astroidjs 0.18.0 → 0.19.0

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.
package/README.md CHANGED
@@ -35,17 +35,16 @@ project:** every site Astroid targets serves a single brand from a single deploy
35
35
  so the config describes one brand, not an array. What actually multiplexes is
36
36
  _editors_ (Louise's org plugin) and _audiences_ (a gated portal beside the public
37
37
  site)—both options on the one brand. The vocabulary is drawn from the real
38
- sites Astroid targets: a storefront (coracle.coffee), a wholesale front
39
- (ghostfire.coffee), an artist portfolio (themidwestartist.com), and a plain
40
- marketing baseline (louise-web).
38
+ sites Astroid was built from: a storefront, a wholesale front, an artist
39
+ portfolio, and a plain marketing baseline.
41
40
 
42
41
  ```ts
43
42
  import { defineAstroid } from "astroidjs";
44
43
 
45
44
  export default defineAstroid({
46
- key: "coracle",
45
+ key: "example",
47
46
  archetype: "storefront",
48
- theme: { name: "Coracle Coffee", colors: { brand: "#1f6f78" } },
47
+ theme: { name: "Example Organization", colors: { brand: "#5b4bff" } },
49
48
  sections: ["hero", "banner", "productGrid", "locationHours", "contact"],
50
49
  commerce: { provider: "square" },
51
50
  deploy: { platform: "cloudflare" },
@@ -56,9 +55,9 @@ A portfolio with a gated client area, for contrast:
56
55
 
57
56
  ```ts
58
57
  export default defineAstroid({
59
- key: "megbowen",
58
+ key: "example-studio",
60
59
  archetype: "portfolio",
61
- theme: { name: "Meg Bowen Studio", colors: { brand: "#2b2b2b" } },
60
+ theme: { name: "Example Studio", colors: { brand: "#2b2b2b" } },
62
61
  sections: ["hero", "gallery", "aboutIntro", "contact"],
63
62
  portal: { enabled: true },
64
63
  // The masters ARE the product here: 40 MB camera files upload once and only
package/bin/astroid.mjs CHANGED
@@ -17,7 +17,7 @@
17
17
 
18
18
  import { execFileSync, spawn, spawnSync } from "node:child_process";
19
19
  import { randomBytes } from "node:crypto";
20
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
20
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
21
21
  import { createRequire } from "node:module";
22
22
  import { dirname, isAbsolute, join, resolve } from "node:path";
23
23
  import { createInterface } from "node:readline/promises";
@@ -87,6 +87,22 @@ async function loadConfig(cwd, explicit) {
87
87
  return { config, path };
88
88
  }
89
89
 
90
+ /**
91
+ * The scaffold files with each migration placed in the `DB` binding's
92
+ * `migrations_dir` and numbered past the site's own migrations. Without this a
93
+ * site whose migrations live elsewhere got files Wrangler never applied.
94
+ */
95
+ async function scaffoldFilesFor(cwd, files) {
96
+ const { astroidMigrationsDir, resolveAstroidScaffoldPaths } = await import(GENERATORS_URL);
97
+ const wranglerPath = join(cwd, "wrangler.jsonc");
98
+ const migrationsDir = astroidMigrationsDir(
99
+ existsSync(wranglerPath) ? readFileSync(wranglerPath, "utf8") : null,
100
+ );
101
+ const dirPath = join(cwd, migrationsDir);
102
+ const existing = existsSync(dirPath) ? readdirSync(dirPath) : [];
103
+ return resolveAstroidScaffoldPaths(files, { migrationsDir, existing });
104
+ }
105
+
90
106
  // --- commands --------------------------------------------------------------
91
107
  async function cmdGenerate(cwd, flags, { quiet = false } = {}) {
92
108
  const {
@@ -123,7 +139,7 @@ async function cmdGenerate(cwd, flags, { quiet = false } = {}) {
123
139
  // regenerated a project that couldn't resolve its own imports. Completing the
124
140
  // config change is what makes "one typed config" true.
125
141
  const created = [];
126
- for (const file of generateAstroidScaffoldFiles(config)) {
142
+ for (const file of await scaffoldFilesFor(cwd, generateAstroidScaffoldFiles(config))) {
127
143
  const abs = join(cwd, file.path);
128
144
  const exists = existsSync(abs);
129
145
  if (file.apply === "append-once") {
@@ -196,7 +212,7 @@ async function cmdDoctor(cwd, flags) {
196
212
  // that names a module whose seam was never written produces a project that
197
213
  // cannot resolve its own imports. This is precisely the state that used to
198
214
  // report "healthy" with two warnings and exit 0.
199
- for (const file of generateAstroidScaffoldFiles(config)) {
215
+ for (const file of await scaffoldFilesFor(cwd, generateAstroidScaffoldFiles(config))) {
200
216
  if (file.apply === "append-once") continue; // accumulated, not owned—see below
201
217
  if (existsSync(join(cwd, file.path))) ok(`${file.path} present`);
202
218
  else err(`${file.path} is missing (required by your config) — run \`astroid generate\`.`);
@@ -2,9 +2,9 @@
2
2
  //
3
3
  // Provider → `CatalogItem` normalizers.
4
4
  //
5
- // This is the file the whole module exists for. themidwestartist.com's loader
6
- // says it outright: coracle runs the same helper over Square, "only the
7
- // content/repo reads differ—issue: repo drift." Two sites, one intent, two
5
+ // This is the file the whole module exists for. One client site's loader says it
6
+ // outright: another site runs the same helper over Square, and only the content
7
+ // and repository reads differ, so the copies drift. Two sites, one intent, two
8
8
  // hand-written translations that drifted apart. The translation is mechanical,
9
9
  // so it belongs here once.
10
10
  //
@@ -3,7 +3,7 @@
3
3
  // Server-authoritative checkout.
4
4
  //
5
5
  // A cart arrives from the browser, so every number in it is a claim, not a fact.
6
- // The rule this encodes—taken from coracle.coffee's working checkout—is that
6
+ // The rule this encodes—taken from a client site's working checkout—is that
7
7
  // the client's price is a **staleness check**, never an input to the charge:
8
8
  // look the price up server-side, and if it disagrees with what the customer was
9
9
  // shown, refuse rather than silently charging a different amount. Refusing is
@@ -12,7 +12,7 @@
12
12
  // What the sites did NOT agree on is how much to store, and it turns out to be
13
13
  // one primitive with two settings rather than two designs:
14
14
  //
15
- // mirror: pulled + owned columns both live in D1 (themidwestartist.com).
15
+ // mirror: pulled + owned columns both live in D1.
16
16
  // Reads are one local query. The catalog can be stale between syncs.
17
17
  // overlay: only the owned columns live in D1, keyed by the provider's id.
18
18
  // The catalog is read live from the provider and joined at read
@@ -20,9 +20,10 @@
20
20
  // (cache accordingly).
21
21
  // live: Astroid manages NO catalog table at all: the catalog is read live
22
22
  // from the provider (cached), and any owner-side overlay table is
23
- // the SITE's own (coracle.coffee's `product_display_meta`, declared
24
- // in schema.site.ts and joined in the site's loader). Use when the
25
- // existing overlay shape predates Astroid and must be preserved 1:1.
23
+ // the SITE's own (for example, a `product_display_meta` table
24
+ // declared in schema.site.ts and joined in the site's loader).
25
+ // Use when the existing overlay shape predates Astroid and must
26
+ // be preserved 1:1.
26
27
  //
27
28
  // `overlay` is just `mirror` with an empty pulled set; `live` emits neither table
28
29
  // nor migration. One generator serves all three and a project switches by one word.
@@ -9,9 +9,9 @@
9
9
  // happens to do both. A single `CommerceProvider` abstraction that assumed
10
10
  // catalog + checkout would therefore have a permanent hole wherever Stripe sits.
11
11
  //
12
- // That's not hypothetical. themidwestartist.com runs Stripe for **invoicing**
13
- // (commissions, originals) alongside Fourthwall for the **storefront**
14
- // (merch)—two providers, one site, each doing the half it can do.
12
+ // A site that invoices through one provider (commissions, originals) and runs
13
+ // its storefront through another (merch) needs exactly this: two providers,
14
+ // one site, each doing the half it can do.
15
15
  //
16
16
  // So a project assigns providers to roles, and Astroid validates the assignment
17
17
  // against what each provider's client can actually serve.
package/dist/config.d.ts CHANGED
@@ -8,8 +8,8 @@ import type { PwaConfig } from "./pwa/generate.js";
8
8
  * The starting shape the front-end takes. Not a fork—each archetype is a preset
9
9
  * of defaults (which sections/modules are on, nav shape) that the site then tunes.
10
10
  * `marketing` = the lean brochure floor (louise-web, no commerce); `storefront` =
11
- * DTC shop (coracle); `wholesale` = B2B/private-label (ghostfire); `portfolio` =
12
- * gallery + prints + client portal (megbowen).
11
+ * DTC shop; `wholesale` = B2B/private-label; `portfolio` = gallery + prints +
12
+ * client portal.
13
13
  */
14
14
  export type Archetype = "marketing" | "storefront" | "wholesale" | "portfolio";
15
15
  /**
@@ -56,7 +56,7 @@ export declare const ASTROID_ARCHETYPE_SECTIONS: Record<Archetype, SectionKind[]
56
56
  * deploy and notice the absence.
57
57
  *
58
58
  * They are removed rather than left as TODOs. `orderTracking` in particular has
59
- * a real implementation waiting—`src/workflow/` is the ghostfire order tracker,
59
+ * a real implementation waiting—`src/workflow/` is a client site's order tracker,
60
60
  * generalized—but it is reached through `defineWorkflow`, not this flag, and
61
61
  * pretending otherwise is what made the flag misleading. Re-add each one in the
62
62
  * change that wires it.
@@ -152,8 +152,9 @@ export interface CommerceConfig {
152
152
  storefront?: CommerceProvider;
153
153
  /**
154
154
  * Invoices for work that isn't a catalog item—commissions, originals.
155
- * Independent of `storefront`: themidwestartist.com runs Stripe here and
156
- * Fourthwall as the storefront, because neither can do the other's job.
155
+ * Independent of `storefront`: a site can invoice through one provider (say,
156
+ * Stripe) and run its storefront through another (say, Fourthwall), because
157
+ * neither can do the other's job.
157
158
  */
158
159
  invoicing?: CommerceProvider;
159
160
  /**
@@ -161,7 +162,7 @@ export interface CommerceConfig {
161
162
  * counter or a market stall rather than through the site's own cart.
162
163
  *
163
164
  * Separate from `storefront` because they are genuinely different jobs and a
164
- * site commonly runs both. themidwestartist.com sells print-on-demand merch
165
+ * site commonly runs both. An artist's site might sell print-on-demand merch
165
166
  * through Fourthwall (`storefront`) while originals and self-stocked prints
166
167
  * live in Square (`pos`) across several shops and galleries—one catalog per
167
168
  * rail, neither able to do the other's job.
@@ -323,9 +324,9 @@ export interface TenancyConfig {
323
324
  * sign-in) instead of JSON, and every data load on that host silently fails
324
325
  * while the same code works on the apex.
325
326
  *
326
- * That is not hypothetical—it is why this default exists (found on
327
- * themidwestartist.com's studio, where the whole admin app loaded and then
328
- * fetched nothing).
327
+ * That is not hypothetical—it is why this default exists (found on a client
328
+ * site's studio host, where the whole admin app loaded and then fetched
329
+ * nothing).
329
330
  *
330
331
  * Set `[]` to rewrite everything, or add prefixes for other host-agnostic
331
332
  * surfaces (`/_actions`, `/webhooks`). Matching is prefix-based on a path
@@ -407,8 +408,8 @@ export interface SettingsConfig {
407
408
  * on top of (or, with `columns: []`, instead of) Astroid's base columns. The
408
409
  * generated `settingsRoute` + Action accept these; the Settings panel writes
409
410
  * them through the `settingsExtension` groups a site supplies to
410
- * `mountSettings`. A site with a rich settings shape (coracle's footer columns,
411
- * hours table, ui strings, shop/order config) lists their top-level keys here.
411
+ * `mountSettings`. A site with a rich settings shape (footer columns, an hours
412
+ * table, UI strings, shop/order config) lists their top-level keys here.
412
413
  */
413
414
  customKeys?: string[];
414
415
  /** Extra media-library image keys beyond the base logo/favicon/OG defaults—*
@@ -456,7 +457,22 @@ export interface PagesConfig {
456
457
  * clamping a title, or filling a new page's defaults.
457
458
  */
458
459
  hooks?: boolean;
460
+ /**
461
+ * Reserved slugs this site serves as pages. `contact` and `login` are
462
+ * reserved because a new scaffold has file routes there, and `work` because
463
+ * a portfolio has its gallery there. A site without that file, whose page at
464
+ * the path comes from its own catch-all route, lists the slug here so the
465
+ * Pages route accepts it. Only those three can be allowed
466
+ * ({@link ASTROID_SCAFFOLD_ROUTE_SLUGS}): the platform serves the other
467
+ * reserved slugs, such as `api` and `sitemap.xml`, before any page.
468
+ */
469
+ allowSlugs?: string[];
459
470
  }
471
+ /**
472
+ * The reserved slugs that come from a scaffolded file route rather than the
473
+ * platform, so a site without that file can allow them with `pages.allowSlugs`.
474
+ */
475
+ export declare const ASTROID_SCAFFOLD_ROUTE_SLUGS: readonly string[];
460
476
  export interface StatusConfig {
461
477
  /**
462
478
  * Add the site's own checks to the public status route, from the
@@ -484,7 +500,7 @@ export interface DeployConfig {
484
500
  export interface AstroidConfig {
485
501
  /**
486
502
  * Stable project slug—the worker/D1/R2 base name and default subdomain (for example,
487
- * `"coracle"`). Required and non-empty; it drives the generated binding names.
503
+ * `"example"`). Required and non-empty; it drives the generated binding names.
488
504
  */
489
505
  key: string;
490
506
  /** Hostnames this site serves (prod + preview), for custom-domain routes. */
@@ -505,7 +521,7 @@ export interface AstroidConfig {
505
521
  * A site-provided section catalog that REPLACES the built-in one for
506
522
  * SERVER-side validation + sanitization of `pages.sections` (the generated
507
523
  * pages route + versions route). A site with bespoke section designs—its own
508
- * `.astro` components and field defs (coracle's 13 sections)—registers them
524
+ * `.astro` components and field defs (a dozen or more sections)—registers them
509
525
  * here so writes to its custom `_type`s validate instead of 422-ing against the
510
526
  * built-in vocabulary. The on-canvas editor already uses the site's catalog
511
527
  * (its `mountSections` call passes it); this closes the server half so both
@@ -561,7 +577,8 @@ export interface AstroidConfig {
561
577
  * Force the contact form + `inquiries` table on or off. Omit to detect from
562
578
  * the config (a `contact` section, or a wholesale-inquiry module). Set `true`
563
579
  * when a bespoke section captures inquiries under a name Astroid can't see
564
- * (coracle's custom `contactForm`); set `false` to suppress it entirely.
580
+ * (for example, a custom `contactForm` section); set `false` to suppress it
581
+ * entirely.
565
582
  */
566
583
  inquiries?: boolean;
567
584
  /** Installable-app settings. Only read when `modules` includes `"pwa"`. */
package/dist/config.js CHANGED
@@ -8,8 +8,8 @@
8
8
  // the Louise wiring (worker routes, middleware, Drizzle schema, theme tokens) a
9
9
  // site would otherwise hand-write per repo.
10
10
  //
11
- // ONE brand per project. Every site Astroid targets (coracle.coffee,
12
- // ghostfire.coffee, themidwestartist.com, louise-web) serves a single brand from a
11
+ // ONE brand per project. Every site Astroid targets (the client sites its
12
+ // patterns came from, and louise-web) serves a single brand from a
13
13
  // single deploy. The axis that genuinely multiplexes is *editors* (Louise's org
14
14
  // plugin, #100) and *audiences*—a gated portal alongside the public site, or a
15
15
  // per-merchant storefront on its own subdomain (`tenancy`)—not brands. So all
@@ -22,9 +22,9 @@
22
22
  // a second project.
23
23
  //
24
24
  // The vocabulary below is not invented: `Archetype`, `SectionKind`, and
25
- // `ModuleKind` are extracted from the real sites Astroid targets—a storefront
26
- // (coracle), a wholesale front (ghostfire), an artist portfolio (megbowen), and a
27
- // plain marketing baseline (louise-web).
25
+ // `ModuleKind` are extracted from the real sites Astroid targets—a storefront,
26
+ // a wholesale front, an artist portfolio, and a plain marketing baseline
27
+ // (louise-web).
28
28
  import { assertAuthIsolation } from "./auth/index.js";
29
29
  import { assertCommerceRoles } from "./commerce/roles.js";
30
30
  import { AstroidConfigError } from "./errors.js";
@@ -55,6 +55,11 @@ export const ASTROID_ARCHETYPE_SECTIONS = {
55
55
  wholesale: ["hero", "featureGrid", "aboutIntro", "contact"],
56
56
  portfolio: ["hero", "gallery", "aboutIntro", "contact"],
57
57
  };
58
+ /**
59
+ * The reserved slugs that come from a scaffolded file route rather than the
60
+ * platform, so a site without that file can allow them with `pages.allowSlugs`.
61
+ */
62
+ export const ASTROID_SCAFFOLD_ROUTE_SLUGS = ["contact", "login", "work"];
58
63
  /**
59
64
  * Define an Astroid project. An identity function in the shape of Astro's
60
65
  * `defineConfig`: it returns the config verbatim with full type-checking +
@@ -64,9 +69,9 @@ export const ASTROID_ARCHETYPE_SECTIONS = {
64
69
  *
65
70
  * ```ts
66
71
  * export default defineAstroid({
67
- * key: "coracle",
72
+ * key: "example",
68
73
  * archetype: "storefront",
69
- * theme: { name: "Coracle Coffee", colors: { brand: "#1f6f78" } },
74
+ * theme: { name: "Example Organization", colors: { brand: "#5b4bff" } },
70
75
  * sections: ["hero", "banner", "productGrid", "locationHours", "contact"],
71
76
  * commerce: { provider: "square" },
72
77
  * deploy: { platform: "cloudflare" },
@@ -109,6 +114,20 @@ function assertCrons(config) {
109
114
  seen.set(expression, "another entry in `crons`");
110
115
  }
111
116
  }
117
+ /**
118
+ * `pages.allowSlugs` may only name a slug reserved for a scaffolded file route.
119
+ * Allowing `api` or `sitemap.xml` would let an editor save a page nobody can
120
+ * reach, which is the silent failure the reserved list exists to prevent.
121
+ */
122
+ function assertAllowSlugs(config) {
123
+ for (const slug of config.pages?.allowSlugs ?? []) {
124
+ if (!ASTROID_SCAFFOLD_ROUTE_SLUGS.includes(slug)) {
125
+ throw new AstroidConfigError(`\`pages.allowSlugs\` can't allow "${slug}". Only the slugs reserved for a scaffolded ` +
126
+ `file route can be allowed (${ASTROID_SCAFFOLD_ROUTE_SLUGS.join(", ")}); the ` +
127
+ "platform serves the others before any page.");
128
+ }
129
+ }
130
+ }
112
131
  /**
113
132
  * The tenancy misconfigurations that fail late, or not at all.
114
133
  *
@@ -211,6 +230,7 @@ export function defineAstroid(config) {
211
230
  // an answer. Fail loudly, at config load, naming the workaround.
212
231
  assertCrons(config);
213
232
  assertTenancy(config);
233
+ assertAllowSlugs(config);
214
234
  if (config.portal?.gated) {
215
235
  throw new AstroidConfigError("`portal.gated` is not implemented: it is accepted but wires no guard, so the site " +
216
236
  "would be fully public while appearing gated. Remove it, and gate the whole site by " +
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // Role-gated routing for the portal.
4
4
  //
5
- // coracle and ghostfire independently built the same thing: a declarative table
5
+ // Two client sites independently built the same thing: a declarative table
6
6
  // of `prefix → roles`, walked once per request. Declarative rather than a guard
7
7
  // call inside each page, because a guard you have to remember to write is a
8
8
  // guard someone eventually forgets—and the page that forgets it is the one
@@ -1,6 +1,7 @@
1
1
  export * from "./generate.js";
2
2
  export * from "./actions.js";
3
3
  export * from "./scaffold.js";
4
+ export * from "./migrations.js";
4
5
  export * from "./seed.js";
5
6
  export * from "./previews.js";
6
7
  export * from "./release.js";
@@ -5,6 +5,7 @@
5
5
  export * from "./generate.js";
6
6
  export * from "./actions.js";
7
7
  export * from "./scaffold.js";
8
+ export * from "./migrations.js";
8
9
  export * from "./seed.js";
9
10
  export * from "./previews.js";
10
11
  export * from "./release.js";
@@ -0,0 +1,26 @@
1
+ import type { ScaffoldFile } from "./scaffold.js";
2
+ /**
3
+ * The `DB` binding's `migrations_dir` from `wrangler.jsonc`, or Wrangler's
4
+ * default `migrations` when the binding sets none or the file is missing.
5
+ * Returned as written, without a trailing slash.
6
+ */
7
+ export declare function astroidMigrationsDir(wrangler: string | null | undefined): string;
8
+ /**
9
+ * Resolve each scaffold file's path against the site's migrations directory.
10
+ * Files that aren't migrations pass through unchanged. For a migration:
11
+ *
12
+ * - **Already there under any number:** the path is that file's, so a caller
13
+ * that skips existing files skips it. A site that copied `page_redirects`
14
+ * in by hand as `0009_page_redirects.sql` keeps that file and gets no second.
15
+ * - **Default number free and past the newest migration:** the default, so a
16
+ * standard project numbers exactly as before.
17
+ * - **Otherwise:** the next number after the newest migration, at the same
18
+ * width. A migration never takes a number the site already uses, and never
19
+ * sorts before one that already ran.
20
+ *
21
+ * `existing` is the file names in `migrationsDir`, in any order.
22
+ */
23
+ export declare function resolveAstroidScaffoldPaths(files: ScaffoldFile[], { migrationsDir, existing }: {
24
+ migrationsDir: string;
25
+ existing: string[];
26
+ }): ScaffoldFile[];
@@ -0,0 +1,78 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // Where a scaffolded D1 migration lands in a site. The scaffold names each
4
+ // migration with a default path (`migrations/0004_page_redirects.sql`), but a
5
+ // site can point its `DB` binding at another directory with `migrations_dir`,
6
+ // and it may already number its own migrations past the default. Wrangler
7
+ // applies only the directory in `migrations_dir` and tracks each file by name,
8
+ // so a migration written anywhere else never runs, and one written onto a
9
+ // number the site already uses leaves two files claiming that number.
10
+ import { parseJsonc } from "./previews.js";
11
+ /** Wrangler's own default when a D1 binding sets no `migrations_dir`. */
12
+ const DEFAULT_MIGRATIONS_DIR = "migrations";
13
+ /** `0004_page_redirects.sql` → number `"0004"`, name `page_redirects`. */
14
+ const MIGRATION_FILE = /^(\d+)_(.+)\.sql$/;
15
+ /**
16
+ * The `DB` binding's `migrations_dir` from `wrangler.jsonc`, or Wrangler's
17
+ * default `migrations` when the binding sets none or the file is missing.
18
+ * Returned as written, without a trailing slash.
19
+ */
20
+ export function astroidMigrationsDir(wrangler) {
21
+ if (!wrangler)
22
+ return DEFAULT_MIGRATIONS_DIR;
23
+ let parsed;
24
+ try {
25
+ parsed = parseJsonc(wrangler);
26
+ }
27
+ catch {
28
+ return DEFAULT_MIGRATIONS_DIR;
29
+ }
30
+ const dir = (parsed.d1_databases ?? []).find((d) => d.binding === "DB")?.migrations_dir;
31
+ return dir ? dir.replace(/\/+$/, "") : DEFAULT_MIGRATIONS_DIR;
32
+ }
33
+ /**
34
+ * Resolve each scaffold file's path against the site's migrations directory.
35
+ * Files that aren't migrations pass through unchanged. For a migration:
36
+ *
37
+ * - **Already there under any number:** the path is that file's, so a caller
38
+ * that skips existing files skips it. A site that copied `page_redirects`
39
+ * in by hand as `0009_page_redirects.sql` keeps that file and gets no second.
40
+ * - **Default number free and past the newest migration:** the default, so a
41
+ * standard project numbers exactly as before.
42
+ * - **Otherwise:** the next number after the newest migration, at the same
43
+ * width. A migration never takes a number the site already uses, and never
44
+ * sorts before one that already ran.
45
+ *
46
+ * `existing` is the file names in `migrationsDir`, in any order.
47
+ */
48
+ export function resolveAstroidScaffoldPaths(files, { migrationsDir, existing }) {
49
+ const taken = new Map();
50
+ const byName = new Map();
51
+ for (const file of existing) {
52
+ const match = MIGRATION_FILE.exec(file);
53
+ if (!match)
54
+ continue;
55
+ taken.set(Number(match[1]), file);
56
+ byName.set(match[2], file);
57
+ }
58
+ let newest = taken.size ? Math.max(...taken.keys()) : -1;
59
+ return files.map((file) => {
60
+ if (!file.migration)
61
+ return file;
62
+ const base = file.path.slice(file.path.lastIndexOf("/") + 1);
63
+ const match = MIGRATION_FILE.exec(base);
64
+ if (!match)
65
+ return { ...file, path: `${migrationsDir}/${base}` };
66
+ const [, digits, name] = match;
67
+ const present = byName.get(name);
68
+ if (present)
69
+ return { ...file, path: `${migrationsDir}/${present}` };
70
+ const wanted = Number(digits);
71
+ const number = !taken.has(wanted) && wanted > newest ? wanted : newest + 1;
72
+ const filename = `${String(number).padStart(digits.length, "0")}_${name}.sql`;
73
+ taken.set(number, filename);
74
+ byName.set(name, filename);
75
+ newest = Math.max(newest, number);
76
+ return { ...file, path: `${migrationsDir}/${filename}` };
77
+ });
78
+ }
@@ -18,6 +18,12 @@ export interface ScaffoldFile {
18
18
  * Without it a re-run would append a duplicate every time.
19
19
  */
20
20
  marker?: string;
21
+ /**
22
+ * A D1 migration. `path` is its default, `migrations/NNNN_name.sql`; the CLI
23
+ * places it in the `DB` binding's `migrations_dir` and renumbers it past the
24
+ * site's own migrations with {@link resolveAstroidScaffoldPaths}.
25
+ */
26
+ migration?: true;
21
27
  }
22
28
  /**
23
29
  * `migrations/0004_page_redirects.sql`: the table that keeps a renamed page's
@@ -160,14 +160,25 @@ export function generateAstroidScaffoldFiles(config) {
160
160
  // scaffolded a `products` table into src/schema.ts that no migration ever
161
161
  // created, and the first sync wrote nothing while reporting success.
162
162
  const catalogSql = generateCatalogMigrationSql(config);
163
- if (catalogSql)
164
- files.push({ path: "migrations/0003_catalog.sql", contents: catalogSql });
163
+ if (catalogSql) {
164
+ files.push({ path: "migrations/0003_catalog.sql", contents: catalogSql, migration: true });
165
+ }
165
166
  // --- louise-toolkit 0.35's two schema changes -----------------------------
166
167
  // Numbered after the catalog's 0003, and written into an existing site by
167
168
  // `astroid generate` because a missing scaffold file is always written. The
168
169
  // alt update is a no-op on a fresh database. Wrangler tracks migrations by
169
- // filename, so a site that already has its own 0004 keeps both.
170
- files.push({ path: "migrations/0004_page_redirects.sql", contents: ASTROID_PAGE_REDIRECTS_MIGRATION }, { path: "migrations/0005_media_alt_undecided.sql", contents: ASTROID_MEDIA_ALT_MIGRATION });
170
+ // filename. The CLI moves each into the site's `migrations_dir` and past
171
+ // the site's own numbers (see migrations.ts), so a site that already has its
172
+ // own 0004 gets the next free number instead of a second 0004.
173
+ files.push({
174
+ path: "migrations/0004_page_redirects.sql",
175
+ contents: ASTROID_PAGE_REDIRECTS_MIGRATION,
176
+ migration: true,
177
+ }, {
178
+ path: "migrations/0005_media_alt_undecided.sql",
179
+ contents: ASTROID_MEDIA_ALT_MIGRATION,
180
+ migration: true,
181
+ });
171
182
  // --- the CWV beacon -------------------------------------------------------
172
183
  // A static file under public/, so it is same-origin and covered by
173
184
  // `script-src 'self'`—an inline script carrying generated content could not
@@ -69,6 +69,12 @@ export interface AstroidPagesHooks {
69
69
  * with a 422 instead.
70
70
  */
71
71
  export declare const ASTROID_RESERVED_SLUGS: readonly string[];
72
+ /**
73
+ * The reserved slugs for this config: {@link ASTROID_RESERVED_SLUGS}, plus the
74
+ * file routes a module scaffolds, minus any in `pages.allowSlugs`. A
75
+ * portfolio's gallery is `src/pages/work.astro`.
76
+ */
77
+ export declare function astroidReservedSlugs(config: AstroidConfig): string[];
72
78
  export declare function astroidPagesWriteHooks(config: AstroidConfig, site?: AstroidPagesHooks): {
73
79
  sanitize: (html: string) => string;
74
80
  transform: (data: Record<string, unknown>, ctx: AstroidPagesWriteContext) => Promise<Record<string, unknown>>;
@@ -47,7 +47,7 @@ const pageMediaBase = astroidMediaBase;
47
47
  * The section catalog a `pages` write is validated + sanitized against: the
48
48
  * site's own (`config.sectionCatalog`) when it registered bespoke sections, else
49
49
  * Astroid's built-in vocabulary. This is what lets a site with its own section
50
- * designs (coracle's 13) keep the same write contract as a stock Astroid site.
50
+ * designs keep the same write contract as a stock Astroid site.
51
51
  */
52
52
  function resolveSectionCatalog(config) {
53
53
  return config.sectionCatalog ?? astroidSectionCatalog;
@@ -109,9 +109,21 @@ export const ASTROID_RESERVED_SLUGS = [
109
109
  "_astro",
110
110
  "api",
111
111
  "cdn-cgi",
112
+ // The scaffold's own file routes, which every site has.
113
+ "contact",
114
+ "login",
112
115
  "robots.txt",
113
116
  "sitemap.xml",
114
117
  ];
118
+ /**
119
+ * The reserved slugs for this config: {@link ASTROID_RESERVED_SLUGS}, plus the
120
+ * file routes a module scaffolds, minus any in `pages.allowSlugs`. A
121
+ * portfolio's gallery is `src/pages/work.astro`.
122
+ */
123
+ export function astroidReservedSlugs(config) {
124
+ const allowed = new Set(config.pages?.allowSlugs ?? []);
125
+ return [...ASTROID_RESERVED_SLUGS, ...(config.archetype === "portfolio" ? ["work"] : [])].filter((slug) => !allowed.has(slug));
126
+ }
115
127
  export function astroidPagesWriteHooks(config, site = {}) {
116
128
  return {
117
129
  // `body` is a richField, so it goes through pagesRoute's own sanitize seam—with
@@ -127,7 +139,7 @@ export function astroidPagesWriteHooks(config, site = {}) {
127
139
  await site.validate?.(data, ctx);
128
140
  await assertAstroidPageSections(config, data, ctx.operation);
129
141
  },
130
- reservedSlugs: [...ASTROID_RESERVED_SLUGS, ...(site.reservedSlugs ?? [])],
142
+ reservedSlugs: [...astroidReservedSlugs(config), ...(site.reservedSlugs ?? [])],
131
143
  };
132
144
  }
133
145
  export function astroidPagesCollection(config) {
@@ -10,7 +10,7 @@
10
10
  * (the worker route plan). */
11
11
  export function capturesInquiries(config) {
12
12
  // Explicit override wins—a site whose inquiry surface is a bespoke section
13
- // (coracle's custom `contactForm`) can't be detected from the built-in
13
+ // (a custom `contactForm`, say) can't be detected from the built-in
14
14
  // vocabulary, so it says so directly.
15
15
  if (typeof config.inquiries === "boolean")
16
16
  return config.inquiries;
@@ -10,7 +10,7 @@
10
10
  // expected—`UPDATE … SET stage = ? WHERE id = ? AND stage = ?`—and treat "0 rows
11
11
  // changed" as the conflict signal rather than checking first and hoping.
12
12
  //
13
- // ORDERING MATTERS, and the reference gets it wrong. ghostfire's floor route
13
+ // ORDERING MATTERS, and the reference gets it wrong. Its floor route
14
14
  // inserts the sign-off row and THEN runs the guarded update, so a double submit
15
15
  // writes two audit rows even though only one advance lands. Here the guarded
16
16
  // update goes first and the audit row is written only if it actually moved the
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // `defineWorkflow`—staged, audited pipelines.
4
4
  //
5
- // The shape this generalizes is ghostfire.coffee's production floor, and the
5
+ // The shape this generalizes is a client site's production floor, and the
6
6
  // framing correction in #256 is the important part: despite the name "order
7
7
  // tracker", it is NOT queue- or Durable-Object-driven. It is a synchronous SSR
8
8
  // + D1 state machine—an integer `stage` column advanced by sign-off rows,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astroidjs",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Astroid — an opinionated meta-framework over Louise Toolkit and Astro for building editable, multi-editor sites on Cloudflare Workers.",
5
5
  "keywords": [
6
6
  "astro",
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  // `<StageBar>`—a pipeline's progress as a segmented bar.
3
3
  //
4
- // Generalized from ghostfire's six-stage floor tracker, which hard-coded the
4
+ // Generalized from a client site's six-stage floor tracker, which hard-coded the
5
5
  // segment count, the corner radii, the brand hex codes, and a mascot image.
6
6
  // Here the stage list drives everything and the colours are theme tokens, so a
7
7
  // four-stage onboarding flow and an eight-stage production line both work and
@@ -9,7 +9,7 @@
9
9
  //
10
10
  // So the whole page is resolved in ONE bounded `IN (...)` lookup before anything
11
11
  // renders, and the result is threaded down as `mediaMeta`. This is the pattern
12
- // ghostfire's `Sections.astro` arrived at independently, generalized.
12
+ // a client site's `Sections.astro` arrived at independently, generalized.
13
13
  //
14
14
  // The collection step is SCHEMA-DRIVEN: it walks the catalog looking for fields
15
15
  // of `type: "image"` rather than hardcoding field names. That's what keeps it