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:
- Adding a site means a code change, plus an edit to messages.json for i18n
- The model assumed one action per site—no clean way to express “deploy” and “re-index” and “backup” on the same site
- 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:
- The action’s description
- Inputs (e.g., a “dry run” checkbox)
- The run button
- A run history list on the left
- 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
- Config separated from code: maintainers only have to look at the YAML, and forks can stay in sync with upstream
- Action diversity: any number of actions per site without UI breakage
- i18n in one place: text changes live in YAML + the small UI template
- Closer to a publishable OSS shape: another org can fork and run by editing
config.ymlonly
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.