astroidjs 0.1.1 → 0.2.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.
Files changed (154) hide show
  1. package/README.md +240 -5
  2. package/bin/astroid.mjs +185 -9
  3. package/dist/analytics/index.d.ts +37 -0
  4. package/dist/analytics/index.js +108 -0
  5. package/dist/astro/csp.d.ts +64 -0
  6. package/dist/astro/csp.js +173 -0
  7. package/dist/astro/index.d.ts +1 -0
  8. package/dist/astro/index.js +7 -0
  9. package/dist/commerce/adapters.d.ts +60 -0
  10. package/dist/commerce/adapters.js +90 -0
  11. package/dist/commerce/checkout-scaffold.d.ts +42 -0
  12. package/dist/commerce/checkout-scaffold.js +306 -0
  13. package/dist/commerce/checkout.d.ts +72 -0
  14. package/dist/commerce/checkout.js +124 -0
  15. package/dist/commerce/index.d.ts +8 -0
  16. package/dist/commerce/index.js +9 -0
  17. package/dist/commerce/loader.d.ts +71 -0
  18. package/dist/commerce/loader.js +90 -0
  19. package/dist/commerce/mirror.d.ts +67 -0
  20. package/dist/commerce/mirror.js +203 -0
  21. package/dist/commerce/roles.d.ts +38 -0
  22. package/dist/commerce/roles.js +93 -0
  23. package/dist/commerce/secrets.d.ts +74 -0
  24. package/dist/commerce/secrets.js +129 -0
  25. package/dist/commerce/sync.d.ts +86 -0
  26. package/dist/commerce/sync.js +154 -0
  27. package/dist/components/sections.d.ts +577 -0
  28. package/dist/components/sections.js +425 -0
  29. package/dist/config.d.ts +174 -12
  30. package/dist/config.js +43 -1
  31. package/dist/email/index.d.ts +4 -0
  32. package/dist/email/index.js +5 -0
  33. package/dist/email/inquiry.d.ts +33 -0
  34. package/dist/email/inquiry.js +63 -0
  35. package/dist/email/send.d.ts +120 -0
  36. package/dist/email/send.js +196 -0
  37. package/dist/email/templates.d.ts +24 -0
  38. package/dist/email/templates.js +184 -0
  39. package/dist/email/theme.d.ts +24 -0
  40. package/dist/email/theme.js +150 -0
  41. package/dist/errors.d.ts +14 -0
  42. package/dist/errors.js +17 -0
  43. package/dist/index.d.ts +14 -0
  44. package/dist/index.js +14 -0
  45. package/dist/map/index.d.ts +3 -0
  46. package/dist/map/index.js +4 -0
  47. package/dist/map/pmtiles.d.ts +92 -0
  48. package/dist/map/pmtiles.js +130 -0
  49. package/dist/map/scaffold.d.ts +29 -0
  50. package/dist/map/scaffold.js +212 -0
  51. package/dist/map/style.d.ts +58 -0
  52. package/dist/map/style.js +154 -0
  53. package/dist/portal/config.d.ts +26 -0
  54. package/dist/portal/config.js +50 -0
  55. package/dist/portal/guard.d.ts +48 -0
  56. package/dist/portal/guard.js +64 -0
  57. package/dist/portal/index.d.ts +5 -0
  58. package/dist/portal/index.js +6 -0
  59. package/dist/portal/nav.d.ts +26 -0
  60. package/dist/portal/nav.js +35 -0
  61. package/dist/portal/scaffold.d.ts +28 -0
  62. package/dist/portal/scaffold.js +140 -0
  63. package/dist/portal/session.d.ts +36 -0
  64. package/dist/portal/session.js +86 -0
  65. package/dist/portfolio/index.d.ts +1 -0
  66. package/dist/portfolio/index.js +4 -0
  67. package/dist/portfolio/scaffold.d.ts +9 -0
  68. package/dist/portfolio/scaffold.js +93 -0
  69. package/dist/project/actions.d.ts +3 -0
  70. package/dist/project/actions.js +106 -0
  71. package/dist/project/generate.d.ts +15 -0
  72. package/dist/project/generate.js +144 -2
  73. package/dist/project/index.d.ts +2 -0
  74. package/dist/project/index.js +2 -0
  75. package/dist/project/scaffold.d.ts +29 -0
  76. package/dist/project/scaffold.js +140 -0
  77. package/dist/pwa/generate.d.ts +49 -0
  78. package/dist/pwa/generate.js +218 -0
  79. package/dist/pwa/index.d.ts +1 -0
  80. package/dist/pwa/index.js +2 -0
  81. package/dist/queues/consumer.d.ts +29 -0
  82. package/dist/queues/consumer.js +37 -0
  83. package/dist/queues/index.d.ts +4 -0
  84. package/dist/queues/index.js +5 -0
  85. package/dist/queues/messages.d.ts +60 -0
  86. package/dist/queues/messages.js +71 -0
  87. package/dist/queues/scaffold.d.ts +44 -0
  88. package/dist/queues/scaffold.js +204 -0
  89. package/dist/queues/webhook.d.ts +60 -0
  90. package/dist/queues/webhook.js +81 -0
  91. package/dist/realtime/index.d.ts +1 -0
  92. package/dist/realtime/index.js +4 -0
  93. package/dist/realtime/scaffold.d.ts +30 -0
  94. package/dist/realtime/scaffold.js +159 -0
  95. package/dist/schema/collections.d.ts +42 -8
  96. package/dist/schema/collections.js +102 -8
  97. package/dist/schema/generate.js +10 -1
  98. package/dist/secrets.d.ts +54 -0
  99. package/dist/secrets.js +80 -0
  100. package/dist/security/index.d.ts +1 -0
  101. package/dist/security/index.js +2 -0
  102. package/dist/security/rate-rules.d.ts +21 -0
  103. package/dist/security/rate-rules.js +107 -0
  104. package/dist/seo/index.d.ts +3 -0
  105. package/dist/seo/index.js +4 -0
  106. package/dist/seo/resolve.d.ts +68 -0
  107. package/dist/seo/resolve.js +73 -0
  108. package/dist/seo/routes.d.ts +44 -0
  109. package/dist/seo/routes.js +104 -0
  110. package/dist/seo/structured-data.d.ts +51 -0
  111. package/dist/seo/structured-data.js +105 -0
  112. package/dist/status.d.ts +51 -0
  113. package/dist/status.js +113 -0
  114. package/dist/worker/generate.d.ts +18 -10
  115. package/dist/worker/generate.js +325 -37
  116. package/dist/worker/routes.d.ts +1 -1
  117. package/dist/worker/routes.js +42 -0
  118. package/dist/workflow/advance.d.ts +102 -0
  119. package/dist/workflow/advance.js +145 -0
  120. package/dist/workflow/config.d.ts +60 -0
  121. package/dist/workflow/config.js +73 -0
  122. package/dist/workflow/generate.d.ts +22 -0
  123. package/dist/workflow/generate.js +138 -0
  124. package/dist/workflow/index.d.ts +3 -0
  125. package/dist/workflow/index.js +4 -0
  126. package/package.json +21 -5
  127. package/src/components/Editable.astro +33 -9
  128. package/src/components/JustifiedGallery.astro +254 -0
  129. package/src/components/MediaSlot.astro +178 -0
  130. package/src/components/PortalShell.astro +80 -0
  131. package/src/components/RegisterSW.astro +45 -0
  132. package/src/components/Section.astro +101 -35
  133. package/src/components/Sections.astro +64 -0
  134. package/src/components/Seo.astro +57 -0
  135. package/src/components/StageBar.astro +137 -0
  136. package/src/components/StructuredData.astro +33 -0
  137. package/src/components/justify.ts +170 -0
  138. package/src/components/media-meta.ts +174 -0
  139. package/src/components/sections/AboutIntro.astro +46 -0
  140. package/src/components/sections/Banner.astro +31 -0
  141. package/src/components/sections/Contact.astro +22 -9
  142. package/src/components/sections/Cta.astro +33 -10
  143. package/src/components/sections/Faq.astro +50 -0
  144. package/src/components/sections/FeatureGrid.astro +40 -11
  145. package/src/components/sections/Gallery.astro +46 -0
  146. package/src/components/sections/Hero.astro +40 -12
  147. package/src/components/sections/LocationHours.astro +59 -0
  148. package/src/components/sections/Media.astro +44 -0
  149. package/src/components/sections/PricingTiers.astro +79 -0
  150. package/src/components/sections/ProductGrid.astro +73 -0
  151. package/src/components/sections/SplitImage.astro +61 -0
  152. package/src/components/sections/Steps.astro +58 -0
  153. package/src/components/sections/Testimonial.astro +51 -0
  154. package/src/components/sections.ts +452 -67
@@ -0,0 +1,102 @@
1
+ /** The D1 surface this needs. Structural, so a real `D1Database` fits and a
2
+ * test can pass a stub. */
3
+ export interface WorkflowDatabase {
4
+ prepare(query: string): {
5
+ bind(...values: unknown[]): {
6
+ run(): Promise<{
7
+ meta?: {
8
+ changes?: number;
9
+ };
10
+ }>;
11
+ first<T = Record<string, unknown>>(): Promise<T | null>;
12
+ };
13
+ };
14
+ }
15
+ /** Who is advancing the item. */
16
+ export interface WorkflowActor {
17
+ /** Short initials shown in the audit trail. Trimmed, upper-cased, capped. */
18
+ initials: string;
19
+ /** Optional account id, when the pipeline runs behind a portal. */
20
+ userId?: string;
21
+ }
22
+ export type AdvanceResult = {
23
+ ok: true;
24
+ /** The stage now in progress. Equals `stageCount` when the item is done. */
25
+ stage: number;
26
+ complete: boolean;
27
+ } | {
28
+ ok: false;
29
+ /** Maps straight onto an HTTP status, so a route is a one-liner. */
30
+ status: 404 | 409 | 422;
31
+ error: string;
32
+ };
33
+ export interface AdvanceOptions {
34
+ db: WorkflowDatabase;
35
+ /** Table holding the `stage` column. */
36
+ table: string;
37
+ /** Audit table — one row per completed stage. */
38
+ auditTable: string;
39
+ /** Primary key column on `table`. Default `"id"`. */
40
+ idColumn?: string;
41
+ /** Foreign key column on the audit table. Default `"<table singular>_id"`,
42
+ * which is why it's explicit here rather than guessed. */
43
+ auditIdColumn: string;
44
+ /** The item being advanced. */
45
+ id: string | number;
46
+ /**
47
+ * The stage the caller believes is in progress. This is the whole guard: it
48
+ * comes from the page the operator was looking at, so a stale page fails
49
+ * instead of advancing something that already moved.
50
+ */
51
+ expectedStage: number;
52
+ /** Total number of stages. Signing the last one completes the item. */
53
+ stageCount: number;
54
+ actor: WorkflowActor;
55
+ /** Free-form recorded values for this stage, stored as JSON on the audit row. */
56
+ specs?: {
57
+ k: string;
58
+ v: string;
59
+ }[];
60
+ }
61
+ /** Trim/normalize operator initials, or null when there aren't any. */
62
+ export declare function normalizeInitials(value: unknown): string | null;
63
+ /** Drop anything malformed from a caller-supplied spec list and bound it. An
64
+ * operator's notes are untrusted input like any other request body. */
65
+ export declare function normalizeSpecs(value: unknown): {
66
+ k: string;
67
+ v: string;
68
+ }[];
69
+ /**
70
+ * Advance one item by one stage, recording who did it.
71
+ *
72
+ * ```ts
73
+ * const result = await advanceWorkflowStage({
74
+ * db: env.DB, table: "orders", auditTable: "orders_signoffs",
75
+ * auditIdColumn: "order_id", id, expectedStage, stageCount: 4,
76
+ * actor: { initials: "BB" }, specs,
77
+ * });
78
+ * return json(result, result.ok ? 200 : result.status);
79
+ * ```
80
+ *
81
+ * Every failure is a status, never a throw: a conflict is an ordinary outcome
82
+ * of two people working at once, not an exception.
83
+ */
84
+ export declare function advanceWorkflowStage(options: AdvanceOptions): Promise<AdvanceResult>;
85
+ export type OverrideAction = "back" | "skip";
86
+ export interface OverrideOptions extends Omit<AdvanceOptions, "specs" | "expectedStage"> {
87
+ action: OverrideAction;
88
+ /** Override log table. */
89
+ overrideTable: string;
90
+ /** Where the item is now, from the operator's page — the same staleness guard. */
91
+ expectedStage: number;
92
+ /** Optional station/context label recorded with the override. */
93
+ station?: string;
94
+ }
95
+ /**
96
+ * Move an item out of band — back a stage, or skip one — and log it.
97
+ *
98
+ * Sending an item BACK deletes the audit row for the stage being reopened, so
99
+ * "a sign-off exists" keeps meaning "that stage is genuinely done". Leaving it
100
+ * would make the audit trail claim work that was undone.
101
+ */
102
+ export declare function overrideWorkflowStage(options: OverrideOptions): Promise<AdvanceResult>;
@@ -0,0 +1,145 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // The guarded advance — the one piece of a staged pipeline that is genuinely
4
+ // hard to get right.
5
+ //
6
+ // Two operators standing at two stations both press "sign off" on the same job.
7
+ // A read-then-write advance runs the item forward two stages and writes two
8
+ // audit rows, and nobody notices until the numbers stop adding up. The fix is
9
+ // optimistic concurrency: make the write itself assert the stage it expected —
10
+ // `UPDATE … SET stage = ? WHERE id = ? AND stage = ?` — and treat "0 rows
11
+ // changed" as the conflict signal rather than checking first and hoping.
12
+ //
13
+ // ORDERING MATTERS, and the reference gets it wrong. ghostfire's floor route
14
+ // inserts the sign-off row and THEN runs the guarded update, so a double submit
15
+ // writes two audit rows even though only one advance lands. Here the guarded
16
+ // update goes first and the audit row is written only if it actually moved the
17
+ // item — so the audit table can't record work that didn't happen. The unique
18
+ // index the schema generator emits on `(entity_id, stage)` is the belt to that
19
+ // braces.
20
+ /** Longest initials we store. Three is what a floor actually writes. */
21
+ const MAX_INITIALS = 3;
22
+ /** Caps on recorded specs, so one request can't write an unbounded blob. */
23
+ const MAX_SPECS = 24;
24
+ const MAX_SPEC_KEY = 60;
25
+ const MAX_SPEC_VALUE = 200;
26
+ /** Trim/normalize operator initials, or null when there aren't any. */
27
+ export function normalizeInitials(value) {
28
+ const initials = String(value ?? "")
29
+ .trim()
30
+ .toUpperCase()
31
+ .slice(0, MAX_INITIALS);
32
+ return initials || null;
33
+ }
34
+ /** Drop anything malformed from a caller-supplied spec list and bound it. An
35
+ * operator's notes are untrusted input like any other request body. */
36
+ export function normalizeSpecs(value) {
37
+ if (!Array.isArray(value))
38
+ return [];
39
+ return value
40
+ .filter((s) => !!s && typeof s === "object" && typeof s.k === "string" && typeof s.v === "string")
41
+ .map((s) => ({ k: s.k.slice(0, MAX_SPEC_KEY), v: s.v.slice(0, MAX_SPEC_VALUE) }))
42
+ .slice(0, MAX_SPECS);
43
+ }
44
+ /**
45
+ * Advance one item by one stage, recording who did it.
46
+ *
47
+ * ```ts
48
+ * const result = await advanceWorkflowStage({
49
+ * db: env.DB, table: "orders", auditTable: "orders_signoffs",
50
+ * auditIdColumn: "order_id", id, expectedStage, stageCount: 4,
51
+ * actor: { initials: "BB" }, specs,
52
+ * });
53
+ * return json(result, result.ok ? 200 : result.status);
54
+ * ```
55
+ *
56
+ * Every failure is a status, never a throw: a conflict is an ordinary outcome
57
+ * of two people working at once, not an exception.
58
+ */
59
+ export async function advanceWorkflowStage(options) {
60
+ const { db, table, auditTable, idColumn = "id", auditIdColumn, id, expectedStage, stageCount, actor, } = options;
61
+ const initials = normalizeInitials(actor.initials);
62
+ if (!initials)
63
+ return { ok: false, status: 422, error: "Initials are required to sign off." };
64
+ if (!Number.isInteger(expectedStage) || expectedStage < 0 || expectedStage >= stageCount) {
65
+ return { ok: false, status: 422, error: "That stage doesn't exist in this workflow." };
66
+ }
67
+ const next = expectedStage + 1;
68
+ // The guard. `changes === 0` means the row is gone or someone else already
69
+ // moved it — the two cases are told apart below, but only after the write,
70
+ // so there is no window between the check and the update.
71
+ const advanced = await db
72
+ .prepare(`UPDATE ${table} SET stage = ? WHERE ${idColumn} = ? AND stage = ?`)
73
+ .bind(next, id, expectedStage)
74
+ .run();
75
+ if ((advanced.meta?.changes ?? 0) === 0) {
76
+ const current = await db
77
+ .prepare(`SELECT stage FROM ${table} WHERE ${idColumn} = ?`)
78
+ .bind(id)
79
+ .first();
80
+ if (!current)
81
+ return { ok: false, status: 404, error: "That item no longer exists." };
82
+ return {
83
+ ok: false,
84
+ status: 409,
85
+ // Naming the actual stage is what makes this recoverable: the operator
86
+ // refreshes and sees where the job really is, rather than pressing again.
87
+ error: `Someone else already moved this to stage ${current.stage} — refresh and try again.`,
88
+ };
89
+ }
90
+ // Only now, with the advance committed, is the work real enough to record.
91
+ const specs = normalizeSpecs(options.specs);
92
+ await db
93
+ .prepare(`INSERT INTO ${auditTable} (${auditIdColumn}, stage, specs, initials, actor_id, signed_at)` +
94
+ ` VALUES (?, ?, ?, ?, ?, ?)`)
95
+ .bind(id, expectedStage, specs.length > 0 ? JSON.stringify(specs) : null, initials, actor.userId ?? null, Math.floor(Date.now() / 1000))
96
+ .run();
97
+ return { ok: true, stage: next, complete: next >= stageCount };
98
+ }
99
+ /**
100
+ * Move an item out of band — back a stage, or skip one — and log it.
101
+ *
102
+ * Sending an item BACK deletes the audit row for the stage being reopened, so
103
+ * "a sign-off exists" keeps meaning "that stage is genuinely done". Leaving it
104
+ * would make the audit trail claim work that was undone.
105
+ */
106
+ export async function overrideWorkflowStage(options) {
107
+ const { db, table, auditTable, overrideTable, idColumn = "id", auditIdColumn, id, action, expectedStage, stageCount, actor, station, } = options;
108
+ const initials = normalizeInitials(actor.initials);
109
+ if (!initials)
110
+ return { ok: false, status: 422, error: "Initials are required to override." };
111
+ const target = action === "back" ? expectedStage - 1 : expectedStage + 1;
112
+ if (target < 0)
113
+ return { ok: false, status: 422, error: "This is already the first stage." };
114
+ if (target > stageCount)
115
+ return { ok: false, status: 422, error: "This is already complete." };
116
+ const moved = await db
117
+ .prepare(`UPDATE ${table} SET stage = ? WHERE ${idColumn} = ? AND stage = ?`)
118
+ .bind(target, id, expectedStage)
119
+ .run();
120
+ if ((moved.meta?.changes ?? 0) === 0) {
121
+ const current = await db
122
+ .prepare(`SELECT stage FROM ${table} WHERE ${idColumn} = ?`)
123
+ .bind(id)
124
+ .first();
125
+ if (!current)
126
+ return { ok: false, status: 404, error: "That item no longer exists." };
127
+ return {
128
+ ok: false,
129
+ status: 409,
130
+ error: `Someone else already moved this to stage ${current.stage} — refresh and try again.`,
131
+ };
132
+ }
133
+ if (action === "back") {
134
+ await db
135
+ .prepare(`DELETE FROM ${auditTable} WHERE ${auditIdColumn} = ? AND stage = ?`)
136
+ .bind(id, target)
137
+ .run();
138
+ }
139
+ await db
140
+ .prepare(`INSERT INTO ${overrideTable} (${auditIdColumn}, action, initials, station, at)` +
141
+ ` VALUES (?, ?, ?, ?, ?)`)
142
+ .bind(id, action, initials, station ?? null, Math.floor(Date.now() / 1000))
143
+ .run();
144
+ return { ok: true, stage: target, complete: target >= stageCount };
145
+ }
@@ -0,0 +1,60 @@
1
+ /** One stage in the pipeline. */
2
+ export interface WorkflowStage {
3
+ /** Stable key, used in generated code and audit rows. */
4
+ key: string;
5
+ /** What an operator reads. */
6
+ label: string;
7
+ }
8
+ /** One field an operator fills in when signing off a stage. */
9
+ export interface WorkflowField {
10
+ key: string;
11
+ label: string;
12
+ placeholder?: string;
13
+ }
14
+ export interface WorkflowConfig {
15
+ /**
16
+ * Base name for the generated tables and routes — `"orders"` gives an
17
+ * `orders.stage` column, an `orders_signoffs` audit table, and
18
+ * `/api/orders/advance`.
19
+ */
20
+ key: string;
21
+ /** The stages, in order. A bare string is shorthand for `{ key, label }`. */
22
+ stages: readonly (string | WorkflowStage)[];
23
+ /**
24
+ * Per-stage fields recorded on sign-off, keyed by stage key. A stage with no
25
+ * entry records only actor + timestamp.
26
+ *
27
+ * These are the "specs" in the reference — brew ratio, water activity. They
28
+ * are stored as a JSON blob on the audit row rather than as columns, because
29
+ * they are documentation of what happened, not something the pipeline
30
+ * branches on, and every stage wants a different set.
31
+ */
32
+ stationFields?: Record<string, WorkflowField[]>;
33
+ /**
34
+ * Emit an override log table. Every out-of-band move — sending an item back a
35
+ * stage, skipping one — is recorded with the actor's initials. Default true:
36
+ * a pipeline you can override without a trace is one nobody trusts.
37
+ */
38
+ overrides?: boolean;
39
+ }
40
+ /** Normalized stage list. */
41
+ export declare function workflowStages(config: WorkflowConfig): WorkflowStage[];
42
+ /** The audit table's name for a workflow. */
43
+ export declare const workflowAuditTable: (config: WorkflowConfig) => string;
44
+ /** The override log's name, or null when overrides are off. */
45
+ export declare const workflowOverrideTable: (config: WorkflowConfig) => string | null;
46
+ /**
47
+ * Define a staged workflow.
48
+ *
49
+ * ```ts
50
+ * export const fulfillment = defineWorkflow({
51
+ * key: "orders",
52
+ * stages: ["received", "picked", "packed", "shipped"],
53
+ * stationFields: { packed: [{ key: "weight", label: "Package weight" }] },
54
+ * });
55
+ * ```
56
+ *
57
+ * Validated here rather than at generation, so a malformed pipeline fails at
58
+ * config load with a message naming the problem.
59
+ */
60
+ export declare function defineWorkflow(config: WorkflowConfig): WorkflowConfig;
@@ -0,0 +1,73 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // `defineWorkflow` — staged, audited pipelines.
4
+ //
5
+ // The shape this generalizes is ghostfire.coffee's production floor, and the
6
+ // framing correction in #256 is the important part: despite the name "order
7
+ // tracker", it is NOT queue- or Durable-Object-driven. It is a synchronous SSR
8
+ // + D1 state machine — an integer `stage` column advanced by sign-off rows,
9
+ // where "liveness" is an email plus a page reload. That maps to a workflow
10
+ // module, distinct from the queues module and from #71's realtime DO. If live
11
+ // push is wanted later it layers on top; it is not required for the pattern.
12
+ //
13
+ // The domain (coffee production) is site-specific; the mechanism is not.
14
+ // Fulfillment, onboarding, approval chains, and support-ticket flows are all
15
+ // the same four things:
16
+ //
17
+ // 1. an ordered list of stages, and one integer saying which is in progress;
18
+ // 2. exactly one audit row per completed stage — who, when, and what they
19
+ // recorded;
20
+ // 3. an advance that is safe when two operators press the button at once;
21
+ // 4. a per-stage side-effect hook (issue the invoice on packaging).
22
+ import { AstroidConfigError } from "../errors.js";
23
+ /** Normalized stage list. */
24
+ export function workflowStages(config) {
25
+ return config.stages.map((stage) => typeof stage === "string" ? { key: stage, label: stage } : stage);
26
+ }
27
+ /** The audit table's name for a workflow. */
28
+ export const workflowAuditTable = (config) => `${config.key}_signoffs`;
29
+ /** The override log's name, or null when overrides are off. */
30
+ export const workflowOverrideTable = (config) => config.overrides === false ? null : `${config.key}_override_log`;
31
+ /**
32
+ * Define a staged workflow.
33
+ *
34
+ * ```ts
35
+ * export const fulfillment = defineWorkflow({
36
+ * key: "orders",
37
+ * stages: ["received", "picked", "packed", "shipped"],
38
+ * stationFields: { packed: [{ key: "weight", label: "Package weight" }] },
39
+ * });
40
+ * ```
41
+ *
42
+ * Validated here rather than at generation, so a malformed pipeline fails at
43
+ * config load with a message naming the problem.
44
+ */
45
+ export function defineWorkflow(config) {
46
+ if (!config.key || !/^[a-z][a-z0-9_]*$/.test(config.key)) {
47
+ throw new AstroidConfigError(`Workflow \`key\` must be a lowercase identifier (it names generated tables and routes); got ${JSON.stringify(config.key)}`);
48
+ }
49
+ const stages = workflowStages(config);
50
+ // Two stages is the floor: with one there is nothing to advance to, and the
51
+ // whole module is an integer that only ever holds 0.
52
+ if (stages.length < 2) {
53
+ throw new AstroidConfigError(`Workflow ${JSON.stringify(config.key)} needs at least 2 stages; got ${stages.length}`);
54
+ }
55
+ const seen = new Set();
56
+ for (const stage of stages) {
57
+ if (!stage.key || !/^[a-z][a-z0-9_]*$/.test(stage.key)) {
58
+ throw new AstroidConfigError(`Workflow stage keys must be lowercase identifiers; got ${JSON.stringify(stage.key)}`);
59
+ }
60
+ // Duplicates would make `stationFields` ambiguous and an audit row's stage
61
+ // key non-unique — both silent, both awful to debug later.
62
+ if (seen.has(stage.key)) {
63
+ throw new AstroidConfigError(`Workflow ${JSON.stringify(config.key)} has a duplicate stage ${JSON.stringify(stage.key)}`);
64
+ }
65
+ seen.add(stage.key);
66
+ }
67
+ for (const stageKey of Object.keys(config.stationFields ?? {})) {
68
+ if (!seen.has(stageKey)) {
69
+ throw new AstroidConfigError(`Workflow ${JSON.stringify(config.key)} declares stationFields for an unknown stage ${JSON.stringify(stageKey)} (stages: ${[...seen].join(", ")})`);
70
+ }
71
+ }
72
+ return config;
73
+ }
@@ -0,0 +1,22 @@
1
+ import { type WorkflowConfig } from "./config.js";
2
+ /**
3
+ * The Drizzle table sources a workflow adds: the audit table, and the override
4
+ * log when enabled.
5
+ *
6
+ * The `stage` column itself is NOT emitted here — it belongs to the project's
7
+ * own entity table (orders, applications, tickets), which Astroid doesn't own.
8
+ * The returned `stageColumn` is the line to paste into it, which keeps the
9
+ * ownership boundary honest rather than generating a table the project already has.
10
+ */
11
+ export declare function generateWorkflowSchema(config: WorkflowConfig): {
12
+ source: string;
13
+ stageColumn: string;
14
+ };
15
+ /**
16
+ * `src/pages/api/<key>/advance.ts` — the guarded advance route.
17
+ *
18
+ * Thin by design: `advanceWorkflowStage` owns the concurrency guard and the
19
+ * status contract, so what's left here is authentication and the per-stage side
20
+ * effects, which are the project's own business.
21
+ */
22
+ export declare function generateWorkflowRoute(config: WorkflowConfig): string;
@@ -0,0 +1,138 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // Workflow → Drizzle schema + the advance route.
4
+ //
5
+ // The schema half is a pure function of the config (regenerate freely); the
6
+ // route is SCAFFOLD-ONCE, because per-stage side effects — issue the invoice
7
+ // when packaging is signed, email the customer on every move — are the whole
8
+ // reason a project reaches for this module, and regenerating would erase them.
9
+ import { workflowAuditTable, workflowOverrideTable, workflowStages, } from "./config.js";
10
+ /**
11
+ * The Drizzle table sources a workflow adds: the audit table, and the override
12
+ * log when enabled.
13
+ *
14
+ * The `stage` column itself is NOT emitted here — it belongs to the project's
15
+ * own entity table (orders, applications, tickets), which Astroid doesn't own.
16
+ * The returned `stageColumn` is the line to paste into it, which keeps the
17
+ * ownership boundary honest rather than generating a table the project already has.
18
+ */
19
+ export function generateWorkflowSchema(config) {
20
+ const stages = workflowStages(config);
21
+ const audit = workflowAuditTable(config);
22
+ const override = workflowOverrideTable(config);
23
+ // `orders` → `order_id`. Naive de-pluralisation on purpose: it covers the
24
+ // real cases and a project that needs otherwise passes `auditIdColumn`.
25
+ const fk = `${config.key.replace(/s$/, "")}_id`;
26
+ const camel = (s) => s.replace(/_(\w)/g, (_, c) => c.toUpperCase());
27
+ const lines = [
28
+ "// Workflow tables — generated by Astroid from defineWorkflow().",
29
+ "//",
30
+ `// Stage model: \`${config.key}.stage\` is the stage currently IN PROGRESS`,
31
+ `// (0–${stages.length - 1}); ${stages.length} means complete. A stage is done once its`,
32
+ `// \`${audit}\` row exists.`,
33
+ "//",
34
+ `// Add this column to your \`${config.key}\` table:`,
35
+ `// stage: integer("stage").notNull().default(0),`,
36
+ 'import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";',
37
+ "",
38
+ `/** One row per COMPLETED stage — who signed it off, when, and what they recorded. */`,
39
+ `export const ${camel(audit)} = sqliteTable(`,
40
+ ` ${JSON.stringify(audit)},`,
41
+ " {",
42
+ ' id: integer("id").primaryKey({ autoIncrement: true }),',
43
+ ` ${camel(fk)}: text(${JSON.stringify(fk)}).notNull(),`,
44
+ ' /** The stage this row completes (0-based). */',
45
+ ' stage: integer("stage").notNull(),',
46
+ " /** Recorded values for the stage, [{ k, v }] JSON. */",
47
+ ' specs: text("specs", { mode: "json" }).$type<{ k: string; v: string }[]>(),',
48
+ ' initials: text("initials").notNull(),',
49
+ ' /** Account id when the pipeline runs behind a portal. */',
50
+ ' actorId: text("actor_id"),',
51
+ ' signedAt: integer("signed_at", { mode: "timestamp" })',
52
+ " .notNull()",
53
+ " .$defaultFn(() => new Date()),",
54
+ " },",
55
+ " (t) => [",
56
+ ` index(${JSON.stringify(`${audit}_entity_idx`)}).on(t.${camel(fk)}),`,
57
+ " // One sign-off per stage per item. The guarded advance already prevents",
58
+ " // a double move, and this makes a duplicate audit row impossible even if",
59
+ " // a future caller writes one directly.",
60
+ ` uniqueIndex(${JSON.stringify(`${audit}_entity_stage_key`)}).on(t.${camel(fk)}, t.stage),`,
61
+ " ],",
62
+ ");",
63
+ ];
64
+ if (override) {
65
+ lines.push("", "/** Out-of-band moves — sending an item back a stage, or skipping one.", " * A pipeline you can override without a trace is one nobody trusts. */", `export const ${camel(override)} = sqliteTable(`, ` ${JSON.stringify(override)},`, " {", ' id: integer("id").primaryKey({ autoIncrement: true }),', ` ${camel(fk)}: text(${JSON.stringify(fk)}).notNull(),`, ' /** "back" | "skip". */', ' action: text("action").notNull(),', ' initials: text("initials").notNull(),', ' station: text("station"),', ' at: integer("at", { mode: "timestamp" })', " .notNull()", " .$defaultFn(() => new Date()),", " },", ` (t) => [index(${JSON.stringify(`${override}_entity_idx`)}).on(t.${camel(fk)})],`, ");");
66
+ }
67
+ lines.push("", "/** Stage labels, index === stage number. */", `export const ${config.key.toUpperCase()}_STAGES = ${JSON.stringify(stages.map((s) => s.label))} as const;`, "");
68
+ return { source: lines.join("\n"), stageColumn: 'stage: integer("stage").notNull().default(0),' };
69
+ }
70
+ /**
71
+ * `src/pages/api/<key>/advance.ts` — the guarded advance route.
72
+ *
73
+ * Thin by design: `advanceWorkflowStage` owns the concurrency guard and the
74
+ * status contract, so what's left here is authentication and the per-stage side
75
+ * effects, which are the project's own business.
76
+ */
77
+ export function generateWorkflowRoute(config) {
78
+ const stages = workflowStages(config);
79
+ const audit = workflowAuditTable(config);
80
+ const fk = `${config.key.replace(/s$/, "")}_id`;
81
+ return [
82
+ `// Advance one ${config.key} item by a stage.`,
83
+ "//",
84
+ "// Scaffolded once; yours to edit. `advanceWorkflowStage` owns the part that",
85
+ "// is easy to get wrong — the optimistic-concurrency guard, so two operators",
86
+ "// pressing sign-off at the same moment can't run an item forward twice — and",
87
+ "// returns a status rather than throwing, because a conflict is an ordinary",
88
+ "// outcome of two people working at once.",
89
+ "//",
90
+ "// What belongs to you is below the call: the per-stage side effects. The",
91
+ "// reference this generalizes issues the balance invoice when packaging is",
92
+ "// signed and emails the customer on every other move.",
93
+ 'import type { APIRoute } from "astro";',
94
+ 'import { advanceWorkflowStage } from "astroidjs";',
95
+ 'import { env } from "cloudflare:workers";',
96
+ "",
97
+ "export const prerender = false;",
98
+ "",
99
+ `const STAGE_COUNT = ${stages.length};`,
100
+ "",
101
+ "export const POST: APIRoute = async ({ request, locals }) => {",
102
+ " // TODO: gate this. A pipeline advance is a privileged action — check the",
103
+ " // portal session/role here before anything else.",
104
+ " void locals;",
105
+ "",
106
+ " const body = (await request.json().catch(() => null)) as {",
107
+ " id?: string;",
108
+ " stage?: number;",
109
+ " initials?: string;",
110
+ " specs?: { k: string; v: string }[];",
111
+ " } | null;",
112
+ "",
113
+ " if (!body?.id || typeof body.stage !== \"number\") {",
114
+ ' return Response.json({ ok: false, error: "Bad request" }, { status: 400 });',
115
+ " }",
116
+ "",
117
+ " const result = await advanceWorkflowStage({",
118
+ " db: env.DB,",
119
+ ` table: ${JSON.stringify(config.key)},`,
120
+ ` auditTable: ${JSON.stringify(audit)},`,
121
+ ` auditIdColumn: ${JSON.stringify(fk)},`,
122
+ " id: body.id,",
123
+ " expectedStage: body.stage,",
124
+ " stageCount: STAGE_COUNT,",
125
+ ' actor: { initials: body.initials ?? "" },',
126
+ " specs: body.specs,",
127
+ " });",
128
+ "",
129
+ " if (!result.ok) return Response.json(result, { status: result.status });",
130
+ "",
131
+ " // TODO: per-stage side effects go here — `result.stage` is the stage now in",
132
+ " // progress, and `result.complete` is true once the last one is signed.",
133
+ "",
134
+ " return Response.json(result);",
135
+ "};",
136
+ "",
137
+ ].join("\n");
138
+ }
@@ -0,0 +1,3 @@
1
+ export { type AdvanceOptions, type AdvanceResult, advanceWorkflowStage, normalizeInitials, normalizeSpecs, type OverrideAction, type OverrideOptions, overrideWorkflowStage, type WorkflowActor, type WorkflowDatabase, } from "./advance.js";
2
+ export { defineWorkflow, type WorkflowConfig, type WorkflowField, type WorkflowStage, workflowAuditTable, workflowOverrideTable, workflowStages, } from "./config.js";
3
+ export { generateWorkflowRoute, generateWorkflowSchema } from "./generate.js";
@@ -0,0 +1,4 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ export { advanceWorkflowStage, normalizeInitials, normalizeSpecs, overrideWorkflowStage, } from "./advance.js";
3
+ export { defineWorkflow, workflowAuditTable, workflowOverrideTable, workflowStages, } from "./config.js";
4
+ export { generateWorkflowRoute, generateWorkflowSchema } from "./generate.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astroidjs",
3
- "version": "0.1.1",
3
+ "version": "0.2.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",
@@ -27,6 +27,11 @@
27
27
  "import": "./dist/index.js",
28
28
  "default": "./dist/index.js"
29
29
  },
30
+ "./astro": {
31
+ "types": "./dist/astro/index.d.ts",
32
+ "import": "./dist/astro/index.js",
33
+ "default": "./dist/astro/index.js"
34
+ },
30
35
  "./components/*.astro": "./src/components/*.astro",
31
36
  "./components/Collection": {
32
37
  "types": "./src/components/Collection.tsx",
@@ -35,7 +40,16 @@
35
40
  "./components/sections": {
36
41
  "types": "./src/components/sections.ts",
37
42
  "default": "./src/components/sections.ts"
38
- }
43
+ },
44
+ "./components/justify": {
45
+ "types": "./src/components/justify.ts",
46
+ "default": "./src/components/justify.ts"
47
+ },
48
+ "./components/media-meta": {
49
+ "types": "./src/components/media-meta.ts",
50
+ "default": "./src/components/media-meta.ts"
51
+ },
52
+ "./package.json": "./package.json"
39
53
  },
40
54
  "files": [
41
55
  "dist",
@@ -46,8 +60,7 @@
46
60
  "access": "public"
47
61
  },
48
62
  "dependencies": {
49
- "drizzle-orm": "^0.45.0",
50
- "louise-toolkit": "0.14.0"
63
+ "louise-toolkit": "0.16.0"
51
64
  },
52
65
  "peerDependencies": {
53
66
  "solid-js": "^1.9.0"
@@ -58,15 +71,18 @@
58
71
  }
59
72
  },
60
73
  "devDependencies": {
74
+ "@types/node": "^24.13.3",
61
75
  "@typescript/native-preview": "7.0.0-dev.20260707.2",
62
76
  "solid-js": "^1.9.14",
63
- "typescript": "^5.8.0"
77
+ "typescript": "^5.8.0",
78
+ "vitest": "^4.1.10"
64
79
  },
65
80
  "engines": {
66
81
  "node": ">=24.0.0"
67
82
  },
68
83
  "scripts": {
69
84
  "build": "tsgo -p tsconfig.build.json",
85
+ "test": "vp test",
70
86
  "typecheck": "tsgo --noEmit"
71
87
  }
72
88
  }