This article is co-authored with a generative AI. Facts have been cross-checked against official documentation where possible, but errors may remain. Please verify against primary sources before making any important decisions.

In an earlier post I described an admin console for non-engineers, built on GitHub App + Cloudflare Access. This is a follow-up about making the console add-a-site-without-touching-code ready, and about supporting multiple actions per site (deploy, re-index, backup, …) cleanly.

Where the original design hit a wall

Initially I had src/lib/sites.ts as a TypeScript array with sites written in directly:

export const SITES = [
  {
    id: 'site-a',
    url: 'https://site-a.example.com/',
    action: { type: 'github-workflow', repo: '...', workflow: 'admin.yml' },
  },
  // ...
]

The problems:

  1. Adding a site means a code change, plus an edit to messages.json for i18n
  2. The model assumed one action per site—no clean way to express “deploy” and “re-index” and “backup” on the same site
  3. As open source, users must edit code to use it—forks diverge from upstream and become hard to keep in sync

Collapsing it into a single YAML file

I moved everything—branding, site definitions, action definitions, ja/en text—into one config.yml:

app:
  title:
    ja: 管理コンソール
    en: Admin Console
  homePage:
    heading:
      ja: 管理コンソール
      en: Admin Console
    lead:
      ja: 各サイトのカードを開いて、操作を実行できます。
      en: Open a site card to run operations.

sites:
  - id: site-a
    name:
      ja: サイトA
      en: Site A
    description:
      ja: サイトAの説明
      en: Description
    url: https://site-a.example.com/
    actions:
      - id: deploy
        label:
          ja: デプロイを実行
          en: Deploy
        description:
          ja: ビルドして公開します。
          en: Build and publish.
        type: github-workflow
        repo: your-org/site-a
        workflow: deploy.yml
        ref: main
      - id: es-sync
        label:
          ja: ESインデックス更新
          en: Re-index
        description:
          ja: Elasticsearchを再同期します。
          en: Re-syncs Elasticsearch.
        type: github-workflow
        repo: your-org/site-a
        workflow: es-sync.yml
        ref: main
        inputs:
          - name: dry_run
            type: boolean
            default: false
            label:
              ja: Dry run
              en: Dry run

By editing only this file, an operator can:

  • add a new site
  • add a new action to an existing site
  • rewrite branding text and per-site descriptions
  • extend i18n beyond ja/en (if templates are added)

…all without writing any code.

YAML → TypeScript types, by build script

I want server code in typed TypeScript, so a small pre-build step parses the YAML and emits src/lib/sites.generated.ts:

// scripts/generate-config.js
const yaml = require('js-yaml')
const config = yaml.load(fs.readFileSync('config.yml', 'utf-8'))

const sitesTs = `// AUTO-GENERATED from config.yml. Do not edit by hand.
export type SiteAction =
  | { id: string; type: 'github-workflow'; repo: string; workflow: string; ref: string; inputs?: ActionInput[] }
  | { id: string; type: 'vercel-deploy-hook'; envHookKey: string }

export const SITES = ${JSON.stringify(normalizedSites, null, 2)}

export function getAction(siteId: string, actionId: string): SiteAction | undefined { ... }
`
fs.writeFileSync('src/lib/sites.generated.ts', sitesTs)

The same step generates the i18n message files. UI-internal text (“Loading…”, “Run history” and so on, which users typically don’t customize) lives in src/messages/_template/{ja,en}.json. Site-level text from config.yml is merged in to produce src/messages/{ja,en}.json:

const merged = {
  ...template,
  Common: {
    title: config.app?.title?.[locale] ?? '',
  },
  Sites: Object.fromEntries(
    config.sites.map((s) => [
      s.id,
      {
        name: s.name?.[locale] ?? s.id,
        description: s.description?.[locale] ?? '',
        actions: Object.fromEntries(
          s.actions.map((a) => [
            a.id,
            { label: a.label?.[locale] ?? a.id, description: a.description?.[locale] ?? '' },
          ])
        ),
      },
    ])
  ),
}

Wire npm run config:generate into the dev and build scripts so editing the YAML and seeing the result is one step.

UI for multiple actions per site

Now that sites carry actions[], the UI needed a redesign too.

Home: cards for each site, “Open details” link only. No execute button on the home cards. Inside the card, just a row of badges showing the action labels for that site.

Site detail page: tabs at the top, one panel per action. The active panel shows:

  1. The action’s description
  2. Inputs (e.g., a “dry run” checkbox)
  3. The run button
  4. A run history list on the left
  5. Per-job real-time logs on the right

Switching tabs lets the operator drive different kinds of operations on the same site (deploy / re-index / backup) through a uniform UI.

What the refactor bought

  1. Config separated from code: maintainers only have to look at the YAML, and forks can stay in sync with upstream
  2. Action diversity: any number of actions per site without UI breakage
  3. i18n in one place: text changes live in YAML + the small UI template
  4. Closer to a publishable OSS shape: another org can fork and run by editing config.yml only

Open issues and next steps

YAML schema validation

YAML mistakes show up only at runtime today. Adding Zod for runtime validation, or shipping a JSON Schema so editors can lint inline, would catch typos earlier.

Live reload

YAML changes still require a redeploy. Storing the YAML in something like Cloudflare KV and reading at request time would allow on-the-fly config edits, but the complexity probably isn’t worth it for our cadence.

Per-environment overrides

For “staging should target a different repo,” config.{env}.yml overrides would help. Today there’s only a single YAML.

Summary

If you want a “edit this file and the app updates” experience, code and config have to be physically separated, and TypeScript types are best treated as a generated artifact. A 50-line YAML → types-and-i18n generator is enough to cut the surface that users have to touch by an order of magnitude.

For an OSS admin tool, start with config-driven design from day one. Retrofitting it later is doable but painful; doing it up front costs an afternoon and saves a lot.

References