@getvitops/create 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +443 -6
  2. package/package.json +2 -3
  3. package/templates/emdash/.agents/skills/promote/SKILL.md +24 -7
  4. package/templates/emdash/.astro/types.d.ts +3 -0
  5. package/templates/emdash/.claude/hooks/session-start.sh +0 -0
  6. package/templates/emdash/.github/workflows/backmerge.yml +18 -0
  7. package/templates/emdash/.github/workflows/deploy-prod.yml +1 -1
  8. package/templates/emdash/.github/workflows/refresh-dev.yml +25 -0
  9. package/templates/emdash/.node-version +1 -0
  10. package/templates/emdash/AGENTS.md +51 -9
  11. package/templates/emdash/README.md +15 -4
  12. package/templates/emdash/SETUP.html +103 -0
  13. package/templates/emdash/astro.config.mjs +47 -4
  14. package/templates/emdash/package.json +15 -11
  15. package/templates/emdash/pnpm-lock.yaml +183 -0
  16. package/templates/emdash/pnpm-workspace.yaml +1 -1
  17. package/templates/emdash/public/_headers +10 -0
  18. package/templates/emdash/scripts/build-dev-wrangler.mjs +6 -0
  19. package/templates/emdash/scripts/refresh-dev.mjs +182 -0
  20. package/templates/emdash/seed/seed.json +104 -5
  21. package/templates/emdash/site.json +77 -2
  22. package/templates/emdash/src/layouts/Layout.astro +7 -1
  23. package/templates/emdash/src/pages/404.astro +5 -0
  24. package/templates/emdash/src/pages/[...slug].astro +15 -2
  25. package/templates/emdash/src/pages/index.astro +15 -2
  26. package/templates/emdash/vite.config.ts +18 -4
  27. package/templates/emdash/wrangler.jsonc +4 -0
  28. package/templates/static/.github/workflows/backmerge.yml +18 -0
  29. package/templates/static/.github/workflows/deploy-prod.yml +1 -1
  30. package/templates/static/.node-version +1 -0
  31. package/templates/static/AGENTS.md +9 -9
  32. package/templates/static/SETUP.html +103 -0
  33. package/templates/static/package.json +6 -6
  34. package/templates/static/pnpm-workspace.yaml +21 -0
  35. package/templates/static/public/_headers +10 -0
  36. package/templates/static/site.json +2 -2
@@ -0,0 +1,182 @@
1
+ // Refreshes the DEV D1 database (and, optionally, the dev media bucket) from
2
+ // PRODUCTION. One direction only: prod is the source of truth for content, dev
3
+ // is a disposable copy used to rehearse code and schema changes against real
4
+ // data (EmDash's own "rehearse changes on a preview environment" pattern).
5
+ //
6
+ // node scripts/refresh-dev.mjs # wipe dev D1, import prod's export
7
+ // node scripts/refresh-dev.mjs --dry # run every check, change nothing
8
+ //
9
+ // Names come from wrangler.jsonc, the same way scripts/build-dev-wrangler.mjs
10
+ // derives them, so the two can't disagree about which resources are "dev":
11
+ //
12
+ // D1 <database_name> → <database_name>-dev (id from $DEV_D1_ID)
13
+ // R2 <bucket_name> → <bucket_name>-dev
14
+ //
15
+ // SAFETY, in the order it is enforced:
16
+ // 1. $DEV_D1_ID must be set, and `wrangler d1 info <name>-dev` must report
17
+ // that same id. A name that resolves to a different database than the id
18
+ // we were told about means one of the two is stale.
19
+ // 2. The dev id must differ from prod's. This script DROPS every table in
20
+ // the target, so pointing it at prod would erase production — the exact
21
+ // thing the dev/prod split exists to prevent.
22
+ // Both checks run before the first write, and `--dry` stops after them.
23
+ //
24
+ // Not copied: anything outside D1/R2. Sessions live in the Astro session store
25
+ // (KV), not D1, so nothing is carried over there. The preview-link secret IS in
26
+ // D1 — set EMDASH_PREVIEW_SECRET on the dev worker so prod preview links don't
27
+ // verify on dev (see SETUP.html).
28
+ //
29
+ // Media is copied only when R2 S3 credentials are present, because wrangler has
30
+ // no bucket-to-bucket sync and rclone needs the S3 API:
31
+ // R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, CLOUDFLARE_ACCOUNT_ID
32
+ // Without them the database is refreshed and media is left alone, with a note —
33
+ // dev then shows broken images for anything uploaded since the last copy.
34
+ //
35
+ // KNOWN LIMIT: `wrangler d1 export` does not support virtual tables, so an FTS5
36
+ // search table breaks the export. EmDash rebuilds its search index on the first
37
+ // search call, so if an export ever fails on one, drop it from prod's schema
38
+ // dump rather than working around it here.
39
+
40
+ import { execFileSync } from 'node:child_process';
41
+ import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
42
+ import { tmpdir } from 'node:os';
43
+ import { join } from 'node:path';
44
+ import { pathToFileURL } from 'node:url';
45
+
46
+ /**
47
+ * Pull the D1 database and R2 bucket names out of a wrangler.jsonc source.
48
+ * Regex rather than a JSONC parser: the template carries no parser, the file
49
+ * is ours, and these two keys are the only thing read. `//` comment lines are
50
+ * stripped first so a commented-out example block can't be picked up.
51
+ */
52
+ export function parseBindingNames(jsonc) {
53
+ const code = jsonc
54
+ .split('\n')
55
+ .filter((line) => !line.trim().startsWith('//'))
56
+ .join('\n');
57
+ const database = /"database_name"\s*:\s*"([^"]+)"/.exec(code)?.[1];
58
+ const bucket = /"bucket_name"\s*:\s*"([^"]+)"/.exec(code)?.[1];
59
+ return { database, bucket };
60
+ }
61
+
62
+ /**
63
+ * Throws unless it is safe to wipe `devId`'s database. Pure, so the one check
64
+ * that stands between this script and production is asserted in a test.
65
+ */
66
+ export function assertSafeTarget({ devName, devId, devReportedId, prodId }) {
67
+ if (!devId) throw new Error('DEV_D1_ID is not set.');
68
+ if (!devName.endsWith('-dev')) throw new Error(`Refusing: "${devName}" is not a -dev database.`);
69
+ if (devReportedId !== devId) {
70
+ throw new Error(
71
+ `DEV_D1_ID (${devId}) does not match "${devName}" (${devReportedId}). One of them is stale.`,
72
+ );
73
+ }
74
+ if (devId === prodId) throw new Error('Refusing: the dev database id is the production id.');
75
+ }
76
+
77
+ /**
78
+ * SQL that empties a database: views, then virtual tables, then ordinary
79
+ * tables. `rows` is `{ name, type, sql }` from sqlite_master. A virtual table
80
+ * owns shadow tables named `<virtual>_*`, which SQLite refuses to drop on their
81
+ * own and removes with the parent, so those are skipped.
82
+ */
83
+ export function dropStatements(rows) {
84
+ const virtual = rows.filter((r) => /^CREATE VIRTUAL TABLE/i.test(r.sql ?? ''));
85
+ const isShadow = (name) => virtual.some((v) => name.startsWith(`${v.name}_`));
86
+ const tables = rows.filter(
87
+ (r) => r.type === 'table' && !virtual.includes(r) && !isShadow(r.name),
88
+ );
89
+ const q = (name) => `"${name.replaceAll('"', '""')}"`;
90
+ return [
91
+ 'PRAGMA defer_foreign_keys = true;',
92
+ ...rows.filter((r) => r.type === 'view').map((r) => `DROP VIEW IF EXISTS ${q(r.name)};`),
93
+ ...virtual.map((r) => `DROP TABLE IF EXISTS ${q(r.name)};`),
94
+ ...tables.map((r) => `DROP TABLE IF EXISTS ${q(r.name)};`),
95
+ ];
96
+ }
97
+
98
+ const wrangler = (args, opts = {}) =>
99
+ execFileSync('pnpm', ['exec', 'wrangler', ...args], { encoding: 'utf8', ...opts });
100
+
101
+ const d1Id = (name) => JSON.parse(wrangler(['d1', 'info', name, '--json'])).uuid;
102
+
103
+ function main() {
104
+ const dry = process.argv.includes('--dry');
105
+ const wranglerConfig = 'wrangler.jsonc';
106
+ if (!existsSync(wranglerConfig))
107
+ throw new Error(`Run from the project root (no ${wranglerConfig}).`);
108
+
109
+ const { database, bucket } = parseBindingNames(readFileSync(wranglerConfig, 'utf8'));
110
+ if (!database) throw new Error(`No D1 database_name found in ${wranglerConfig}.`);
111
+ const devDatabase = `${database}-dev`;
112
+
113
+ assertSafeTarget({
114
+ devName: devDatabase,
115
+ devId: process.env.DEV_D1_ID,
116
+ devReportedId: d1Id(devDatabase),
117
+ prodId: d1Id(database),
118
+ });
119
+ console.log(`OK to refresh ${devDatabase} from ${database}.`);
120
+
121
+ const dir = mkdtempSync(join(tmpdir(), 'refresh-dev-'));
122
+ const dump = join(dir, 'prod.sql');
123
+ const wipe = join(dir, 'wipe.sql');
124
+
125
+ const master = JSON.parse(
126
+ wrangler([
127
+ 'd1',
128
+ 'execute',
129
+ devDatabase,
130
+ '--remote',
131
+ '--json',
132
+ '--command',
133
+ "SELECT name, type, sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' AND name NOT LIKE '\\_cf\\_%' ESCAPE '\\' AND type IN ('table','view')",
134
+ ]),
135
+ )[0].results;
136
+ writeFileSync(wipe, `${dropStatements(master).join('\n')}\n`);
137
+
138
+ if (dry) {
139
+ console.log(`--dry: would drop ${master.length} objects from ${devDatabase} and import prod.`);
140
+ console.log(
141
+ bucket ? `--dry: would copy ${bucket} → ${bucket}-dev.` : '--dry: no R2 bucket found.',
142
+ );
143
+ return;
144
+ }
145
+
146
+ wrangler(['d1', 'export', database, '--remote', '--output', dump], { stdio: 'inherit' });
147
+ wrangler(['d1', 'execute', devDatabase, '--remote', '--file', wipe], { stdio: 'inherit' });
148
+ wrangler(['d1', 'execute', devDatabase, '--remote', '--file', dump], { stdio: 'inherit' });
149
+ console.log(`Imported ${database} into ${devDatabase}.`);
150
+
151
+ const { R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, CLOUDFLARE_ACCOUNT_ID } = process.env;
152
+ if (!bucket) return;
153
+ if (!R2_ACCESS_KEY_ID || !R2_SECRET_ACCESS_KEY || !CLOUDFLARE_ACCOUNT_ID) {
154
+ console.log(
155
+ `Skipped media: R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY not set, ${bucket}-dev left as is.`,
156
+ );
157
+ return;
158
+ }
159
+ // Credentials ride rclone's env-var config, not the command line, so the
160
+ // secret never appears in a process listing.
161
+ const env = {
162
+ ...process.env,
163
+ RCLONE_CONFIG_R2_TYPE: 's3',
164
+ RCLONE_CONFIG_R2_PROVIDER: 'Cloudflare',
165
+ RCLONE_CONFIG_R2_ENDPOINT: `https://${CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`,
166
+ RCLONE_CONFIG_R2_ACCESS_KEY_ID: R2_ACCESS_KEY_ID,
167
+ RCLONE_CONFIG_R2_SECRET_ACCESS_KEY: R2_SECRET_ACCESS_KEY,
168
+ };
169
+ // `sync`, not `copy`: media deleted from prod should disappear from dev too.
170
+ execFileSync('rclone', ['sync', `R2:${bucket}`, `R2:${bucket}-dev`], { env, stdio: 'inherit' });
171
+ console.log(`Synced ${bucket} → ${bucket}-dev.`);
172
+ }
173
+
174
+ // Importable for tests without running; executed when invoked as a script.
175
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
176
+ try {
177
+ main();
178
+ } catch (err) {
179
+ console.error(`ERROR: ${err.message}`);
180
+ process.exit(1);
181
+ }
182
+ }
@@ -36,16 +36,32 @@
36
36
  "name": "primary",
37
37
  "label": "Primary Navigation",
38
38
  "items": [
39
- { "type": "custom", "label": "Home", "url": "/" },
40
- { "type": "custom", "label": "About", "url": "/about" }
39
+ {
40
+ "type": "custom",
41
+ "label": "Home",
42
+ "url": "/"
43
+ },
44
+ {
45
+ "type": "custom",
46
+ "label": "About",
47
+ "url": "/about"
48
+ }
41
49
  ]
42
50
  },
43
51
  {
44
52
  "name": "footer",
45
53
  "label": "Footer",
46
54
  "items": [
47
- { "type": "custom", "label": "Terms & Conditions", "url": "/terms" },
48
- { "type": "custom", "label": "Privacy Policy", "url": "/privacy" }
55
+ {
56
+ "type": "custom",
57
+ "label": "Terms & Conditions",
58
+ "url": "/terms"
59
+ },
60
+ {
61
+ "type": "custom",
62
+ "label": "Privacy Policy",
63
+ "url": "/privacy"
64
+ }
49
65
  ]
50
66
  }
51
67
  ],
@@ -86,6 +102,84 @@
86
102
  }
87
103
  ]
88
104
  },
105
+ {
106
+ "_type": "vitops.hero",
107
+ "_key": "demo-hero",
108
+ "eyebrow": "Vitops blocks",
109
+ "heading": "Build pages from design-system patterns",
110
+ "body": "Every block below renders a framework pattern, so it follows your tokens and works without JavaScript.",
111
+ "primaryLabel": "Open the admin",
112
+ "primaryHref": "/_emdash/admin",
113
+ "secondaryLabel": "Docs",
114
+ "secondaryHref": "https://docs.vitops.ca",
115
+ "layout": "centered"
116
+ },
117
+ {
118
+ "_type": "vitops.cards",
119
+ "_key": "demo-cards",
120
+ "columns": "3",
121
+ "cards": [
122
+ {
123
+ "_key": "c1",
124
+ "title": "Tokens",
125
+ "body": "Colour, type and spacing come from site.json.",
126
+ "href": "https://docs.vitops.ca",
127
+ "linkLabel": "Read more"
128
+ },
129
+ {
130
+ "_key": "c2",
131
+ "title": "Patterns",
132
+ "body": "Cards, carousels and dialogs share one vocabulary."
133
+ },
134
+ {
135
+ "_key": "c3",
136
+ "title": "Accessible",
137
+ "body": "Semantic HTML first, enhanced when JavaScript loads."
138
+ }
139
+ ]
140
+ },
141
+ {
142
+ "_type": "vitops.accordion",
143
+ "_key": "demo-accordion",
144
+ "exclusive": true,
145
+ "items": [
146
+ {
147
+ "_key": "a1",
148
+ "summary": "What is a block?",
149
+ "body": "A reusable section an editor inserts from the slash menu.",
150
+ "open": true
151
+ },
152
+ {
153
+ "_key": "a2",
154
+ "summary": "Can I add my own?",
155
+ "body": "Yes. Pass blocks.extra to the plugin and map a component for it."
156
+ }
157
+ ]
158
+ },
159
+ {
160
+ "_type": "vitops.marquee",
161
+ "_key": "demo-marquee",
162
+ "pauseOnHover": true,
163
+ "reverse": false,
164
+ "items": [
165
+ {
166
+ "_key": "m1",
167
+ "text": "Fast"
168
+ },
169
+ {
170
+ "_key": "m2",
171
+ "text": "Accessible"
172
+ },
173
+ {
174
+ "_key": "m3",
175
+ "text": "Editable"
176
+ },
177
+ {
178
+ "_key": "m4",
179
+ "text": "Yours"
180
+ }
181
+ ]
182
+ },
89
183
  {
90
184
  "_type": "vitops.banner",
91
185
  "_key": "welcome-banner",
@@ -161,7 +255,12 @@
161
255
  "style": "h2",
162
256
  "markDefs": [],
163
257
  "children": [
164
- { "_type": "span", "_key": "terms-use-h-1", "text": "Use of the site", "marks": [] }
258
+ {
259
+ "_type": "span",
260
+ "_key": "terms-use-h-1",
261
+ "text": "Use of the site",
262
+ "marks": []
263
+ }
165
264
  ]
166
265
  },
167
266
  {
@@ -23,8 +23,8 @@
23
23
  }
24
24
  },
25
25
  "roles": {
26
- "neutral": "ink",
27
- "surface": "ink",
26
+ "neutral": { "hue": "ink", "kind": "surface" },
27
+ "surface": { "hue": "ink", "kind": "surface" },
28
28
  "ui-primary": "brand",
29
29
  "brand-primary": "brand",
30
30
  "info": "sky",
@@ -223,6 +223,81 @@
223
223
  "url": "https://dev.my-emdash-site.example.com",
224
224
  "robots": "noindex,nofollow"
225
225
  }
226
+ },
227
+ "icons": {
228
+ "sprite": true,
229
+ "semantic": [
230
+ "menu",
231
+ "close",
232
+ "home",
233
+ "settings",
234
+ "search",
235
+ "add",
236
+ "remove",
237
+ "delete",
238
+ "edit",
239
+ "save",
240
+ "copy",
241
+ "paste",
242
+ "attach",
243
+ "upload",
244
+ "download",
245
+ "share",
246
+ "bookmark",
247
+ "favorite",
248
+ "star",
249
+ "login",
250
+ "logout",
251
+ "email",
252
+ "phone",
253
+ "location",
254
+ "globe",
255
+ "link",
256
+ "clock",
257
+ "calendar",
258
+ "schedule",
259
+ "person",
260
+ "group",
261
+ "file",
262
+ "folder",
263
+ "image",
264
+ "export",
265
+ "import",
266
+ "backup",
267
+ "compress",
268
+ "check",
269
+ "error",
270
+ "warning",
271
+ "info",
272
+ "help",
273
+ "cart",
274
+ "tag",
275
+ "receipt",
276
+ "leaf",
277
+ "tree",
278
+ "seedling",
279
+ "trowel",
280
+ "droplet",
281
+ "snowflake",
282
+ "sun",
283
+ "cloud",
284
+ "grid",
285
+ "list",
286
+ "filter",
287
+ "sort",
288
+ "fullscreen",
289
+ "collapse",
290
+ "building",
291
+ "video",
292
+ "lock",
293
+ "unlock",
294
+ "eye",
295
+ "print",
296
+ "code",
297
+ "palette",
298
+ "lightbulb",
299
+ "lightning"
300
+ ]
226
301
  }
227
302
  }
228
303
  }
@@ -1,8 +1,10 @@
1
1
  ---
2
2
  import VitopsHead from '@getvitops/astro/Head.astro';
3
+ import { resolveOrganizationName } from '@getvitops/astro/routes';
3
4
  import { getMenu, getSiteSettings } from 'emdash';
4
5
  import { createPublicPageContext } from 'emdash/page';
5
6
  import { EmDashHead } from 'emdash/ui';
7
+ import config from '../../site.json' with { type: 'json' };
6
8
  // Design-system CSS, layout-scoped (vitops css.inject: false): only pages
7
9
  // rendered through this layout get it — keeps it out of /_emdash/admin, while
8
10
  // EmDash previews (rendered through this layout) keep it.
@@ -19,7 +21,11 @@ interface Props {
19
21
 
20
22
  const { title, description, image } = Astro.props;
21
23
  const settings = await getSiteSettings();
22
- const siteTitle = settings?.title || 'My EmDash Site';
24
+ // getSiteSettings().title is the editable, client-facing display name (set in
25
+ // the EmDash admin) — it wins whenever it's set. resolveOrganizationName(config)
26
+ // is the fallback rather than a second hardcoded string, so a fresh scaffold
27
+ // shows organization.name (from site.json) before anyone's touched the admin.
28
+ const siteTitle = settings?.title || resolveOrganizationName(config) || 'My EmDash Site';
23
29
  const fullTitle = title ? `${title} — ${siteTitle}` : siteTitle;
24
30
  const siteDescription = settings?.tagline || 'An EmDash website with the Vitops design system';
25
31
 
@@ -1,5 +1,10 @@
1
1
  ---
2
2
  import Layout from '../layouts/Layout.astro';
3
+
4
+ // No per-request data — safe to prerender per AGENTS.md's "Prerender by
5
+ // default" rule. [...slug].astro reaches this via Astro.rewrite('/404'),
6
+ // which resolves a prerendered destination the same as a server-rendered one.
7
+ export const prerender = true;
3
8
  ---
4
9
 
5
10
  <Layout title="Page not found">
@@ -4,17 +4,30 @@ import { PortableText } from 'emdash/ui';
4
4
  import Layout from '../layouts/Layout.astro';
5
5
 
6
6
  // Catch-all: any entry in the `pages` collection renders at its slug.
7
+ //
8
+ // Deliberately NOT prerendered, unlike AGENTS.md's general "prerender by
9
+ // default" rule — see index.astro's comment for why: a live EmDash publish
10
+ // should reach visitors without a redeploy. A page that's known ahead of
11
+ // time and doesn't need that (rarely edited, or fine to lag a build) can
12
+ // move to its own prerendered route with `getStaticPaths()` over the
13
+ // `pages` collection instead of relying on this catch-all.
7
14
  const { slug } = Astro.params;
8
15
  const { entry: page } = slug ? await getEmDashEntry('pages', slug) : { entry: null };
9
16
 
10
17
  if (!page) {
11
18
  return Astro.rewrite('/404');
12
19
  }
20
+
21
+ // `page.data` is typed `unknown` until `.emdash/types.ts` is generated from a
22
+ // running site (`pnpm dev`, then `emdash types`), so a fresh scaffold would fail
23
+ // `astro check`; the cast keeps it passing both before and after, where the
24
+ // generated type is already `string`.
25
+ const title = page.data.title as string | undefined;
13
26
  ---
14
27
 
15
- <Layout title={page.data.title}>
28
+ <Layout title={title}>
16
29
  <article class="rhythm">
17
- <h1>{page.data.title}</h1>
30
+ <h1>{title}</h1>
18
31
  {page.data.content && <PortableText value={page.data.content} />}
19
32
  </article>
20
33
  </Layout>
@@ -6,12 +6,25 @@ import Layout from '../layouts/Layout.astro';
6
6
  // The seeded home page (seed/seed.json → pages/home). Edit it in the admin;
7
7
  // the Vitops blocks from @getvitops/emdash render automatically — EmDash
8
8
  // merges the plugin's block components into <PortableText>.
9
+ //
10
+ // Deliberately NOT prerendered, unlike AGENTS.md's general "prerender by
11
+ // default" rule: this is the one route where the tradeoff that rule names
12
+ // (prerendered = fast, but reflects the database as of the last build) is
13
+ // unacceptable — the whole point of editing in EmDash is that a publish
14
+ // shows up without a redeploy. Add `getStaticPaths()` + `prerender = true`
15
+ // here only if this site's home page genuinely doesn't need that.
9
16
  const { entry: page } = await getEmDashEntry('pages', 'home');
17
+
18
+ // `page.data` is typed `unknown` until `.emdash/types.ts` is generated from a
19
+ // running site (`pnpm dev`, then `emdash types`), so a fresh scaffold would fail
20
+ // `astro check`; the cast keeps it passing both before and after, where the
21
+ // generated type is already `string`.
22
+ const title = page?.data.title as string | undefined;
10
23
  ---
11
24
 
12
- <Layout description={page?.data.title}>
25
+ <Layout description={title}>
13
26
  <section class="rhythm">
14
- <h1>{page?.data.title ?? 'Welcome'}</h1>
27
+ <h1>{title ?? 'Welcome'}</h1>
15
28
  {
16
29
  page?.data.content ? (
17
30
  <PortableText value={page.data.content} />
@@ -11,13 +11,27 @@ export default defineConfig({
11
11
  'vitops lint -i site.json -f tailwind --strict',
12
12
  },
13
13
  fmt: {
14
+ // Explicit, matching the monorepo root's style: unlike `lint`, a nested
15
+ // `fmt` config genuinely takes effect independently here rather than
16
+ // being root-only, so leaving these unset let this file (and anything
17
+ // else oxfmt formats under this directory, e.g. SETUP.html's embedded
18
+ // `<script>`) drift to a different default quote style each time
19
+ // `vp check --fix` ran, breaking `templates.test.ts`'s byte-identical
20
+ // mirror assertion.
21
+ semi: true,
22
+ singleQuote: true,
14
23
  ignorePatterns: ['dist/**', 'node_modules/**', 'src/styles/**', 'public/vitops/**'],
15
24
  },
16
25
  lint: {
17
26
  ignorePatterns: ['dist/**', 'node_modules/**', 'src/styles/**', 'public/vitops/**'],
18
- options: {
19
- typeAware: true,
20
- typeCheck: true,
21
- },
27
+ // `options.typeAware`/`typeCheck` commented out, not deleted: vite-plus 1.0
28
+ // only honours those at the root config, and while this file sits nested
29
+ // inside the getvitops/design-system monorepo (which has its own root
30
+ // vite.config.ts), setting them here is a hard error — "typeAware is only
31
+ // supported in the root config" — rather than the silent no-op it was
32
+ // pre-1.0. Once `vp create @getvitops:emdash` scaffolds this file out into
33
+ // its own project, it *becomes* that project's root config, and
34
+ // uncommenting this is the right move there.
35
+ // options: { typeAware: true, typeCheck: true },
22
36
  },
23
37
  });
@@ -30,6 +30,10 @@
30
30
  "bucket_name": "my-emdash-site-media",
31
31
  },
32
32
  ],
33
+ // Outbound mail for EmDash's magic-link sign-in, invites and recovery
34
+ // (cloudflareEmail() in astro.config.mjs). The binding does nothing until the
35
+ // sender domain is onboarded: `npx vitops email setup send.<your-domain>`.
36
+ "send_email": [{ "name": "EMAIL" }],
33
37
  // Drives scheduled publishing, plugin cron, and maintenance (see
34
38
  // src/worker.ts). Without it, content scheduled in the admin never
35
39
  // publishes on Cloudflare Workers. Local `astro dev` doesn't need it.
@@ -0,0 +1,18 @@
1
+ # Carry changes made directly on `main` (a hotfix) back into `dev`, so the
2
+ # integration branch doesn't silently drift from what is live. Runs on every
3
+ # push to `main`, including each promotion, and opens a PR only when merging
4
+ # main into dev would actually change dev.
5
+ #
6
+ # Deploys via the vitops toolchain's reusable workflow — see
7
+ # https://github.com/getvitops/design-system/blob/main/.github/workflows/site-backmerge.yml
8
+ # for why the PR is left for a human unless BACKMERGE_TOKEN is set.
9
+ name: Back-merge main → dev
10
+
11
+ on:
12
+ push:
13
+ branches: [main]
14
+
15
+ jobs:
16
+ backmerge:
17
+ uses: getvitops/design-system/.github/workflows/site-backmerge.yml@v1
18
+ secrets: inherit
@@ -1,7 +1,7 @@
1
1
  # Deploys `main` to production.
2
2
  # `main` should be branch-protected — it then only advances via the Promote
3
3
  # workflow (promote.yml), so a push here always represents promoted code. The
4
- # `production` GitHub Environment (set up by `scripts/init-github.mjs`) has
4
+ # `production` GitHub Environment (set up by `vitops setup repo`) has
5
5
  # required reviewers restricted to admins, so this PAUSES for approval before
6
6
  # deploying.
7
7
  #
@@ -0,0 +1 @@
1
+ 24
@@ -18,15 +18,15 @@ as Cloudflare Workers static assets (no server runtime, no database).
18
18
  full generated class-vocabulary reference to this file.
19
19
  - **Layout foundations — these are the answer, not a starting point.** Six
20
20
  substitutions carry the structure of nearly every page:
21
- | About to write… | Write instead |
22
- | --- | --- |
23
- | a `wrap`/`wrapper`/`container`/`inner` class | **`centered`** |
24
- | `max-width` + `margin-inline: auto` | **`centered`** |
25
- | `padding-block` on a section | **`region`** |
26
- | margins between headings/paragraphs/lists | **`rhythm`** |
27
- | `display: grid` + `grid-template-columns: repeat(n, …)` | **`subgrid`** |
28
- | `repeat(auto-fit, minmax(…))` | **`grid-auto`** |
29
- | `display: flex` + `gap` for a row of controls | **`cluster`** |
21
+ | About to write… | Write instead |
22
+ | ------------------------------------------------------- | --------------- |
23
+ | a `wrap`/`wrapper`/`container`/`inner` class | **`centered`** |
24
+ | `max-width` + `margin-inline: auto` | **`centered`** |
25
+ | `padding-block` on a section | **`region`** |
26
+ | margins between headings/paragraphs/lists | **`rhythm`** |
27
+ | `display: grid` + `grid-template-columns: repeat(n, …)` | **`subgrid`** |
28
+ | `repeat(auto-fit, minmax(…))` | **`grid-auto`** |
29
+ | `display: flex` + `gap` for a row of controls | **`cluster`** |
30
30
  - Every page is prerendered by default (`output: 'static'`) — there is no
31
31
  server to fall back to, so nothing here can read request-time data
32
32
  (cookies, headers, query params) at render time. If a page genuinely needs