astroidjs 0.1.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.
@@ -0,0 +1,70 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // The editor route plan — which louise-toolkit/editor routes a project needs, in
4
+ // the ONE order that avoids matcher collisions. This is where the "versionsRoute
5
+ // and searchRoute MUST precede pagesRoute" tribal knowledge lives: encoded once,
6
+ // as data, instead of re-derived by hand (and mis-ordered) in every site's
7
+ // worker.ts. The generator turns this plan into source; tests assert the order.
8
+ import { capturesInquiries } from "../schema/framework.js";
9
+ /**
10
+ * The ordered editor route plan for a project. Order is load-bearing: the two
11
+ * routes with `/pages/:id/...` sub-paths (`versions`, `search`) come first, so
12
+ * `pages`' catch-all `/:id` matcher can't swallow them. Inquiry routes are
13
+ * included only when a brand captures inquiries; `seed` is always last.
14
+ */
15
+ export function astroidEditorRoutePlan(config) {
16
+ const routes = [
17
+ {
18
+ name: "versions",
19
+ factory: "versionsRoute",
20
+ note: "Draft/publish + version history for pages. MUST precede pagesRoute — pagesRoute's /:id matcher would otherwise claim /pages/:id/versions and 400 on the non-integer id.",
21
+ },
22
+ {
23
+ name: "search",
24
+ factory: "searchRoute",
25
+ note: "Full-text search over pages (/search + /reindex). Before pagesRoute, whose /:id matcher would else claim those non-integer segments.",
26
+ },
27
+ {
28
+ name: "pages",
29
+ factory: "pagesRoute",
30
+ note: "Page CRUD, including the structured sections column.",
31
+ },
32
+ {
33
+ name: "save",
34
+ factory: "saveRoute",
35
+ note: "Live field saves (title, SEO) — the versioned body stages drafts via versionsRoute instead.",
36
+ },
37
+ {
38
+ name: "settings",
39
+ factory: "settingsRoute",
40
+ note: "Editable site settings (brand, nav, contact, SEO defaults).",
41
+ },
42
+ {
43
+ name: "media",
44
+ factory: "mediaRoute",
45
+ note: "Media library — list, upload, delete, reference checks.",
46
+ },
47
+ {
48
+ name: "editors",
49
+ factory: "editorsRoute",
50
+ note: "Editor roster (the Users panel) over Better Auth's `user` table — a row IS an editor, and the same table is the magic-link allowlist (resolveAdmins).",
51
+ },
52
+ ];
53
+ if (capturesInquiries(config)) {
54
+ routes.push({
55
+ name: "form",
56
+ factory: "formRoute",
57
+ note: "Public inquiry capture (the contact form) + silent spam heuristics.",
58
+ }, {
59
+ name: "inquiries",
60
+ factory: "inquiriesRoute",
61
+ note: "Editor-gated inquiry review, over the same inquiries table.",
62
+ });
63
+ }
64
+ routes.push({
65
+ name: "seed",
66
+ factory: "seedRoute",
67
+ note: "First-run site_settings seed. Last — it's a one-shot bootstrap, not a hot path.",
68
+ });
69
+ return routes;
70
+ }
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "astroidjs",
3
+ "version": "0.1.0",
4
+ "description": "Astroid — an opinionated meta-framework over Louise Toolkit and Astro for building editable, multi-editor sites on Cloudflare Workers.",
5
+ "keywords": [
6
+ "astro",
7
+ "cloudflare-workers",
8
+ "cms",
9
+ "commerce",
10
+ "louise-toolkit",
11
+ "meta-framework"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "BowenLabs",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/bowenlabs/louise-toolkit.git",
18
+ "directory": "packages/astroid"
19
+ },
20
+ "type": "module",
21
+ "bin": {
22
+ "astroid": "./bin/astroid.mjs"
23
+ },
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/index.d.ts",
27
+ "import": "./dist/index.js",
28
+ "default": "./dist/index.js"
29
+ },
30
+ "./components/*.astro": "./src/components/*.astro",
31
+ "./components/Collection": {
32
+ "types": "./src/components/Collection.tsx",
33
+ "default": "./src/components/Collection.tsx"
34
+ },
35
+ "./components/sections": {
36
+ "types": "./src/components/sections.ts",
37
+ "default": "./src/components/sections.ts"
38
+ }
39
+ },
40
+ "files": [
41
+ "dist",
42
+ "bin",
43
+ "src/components"
44
+ ],
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "dependencies": {
49
+ "louise-toolkit": "0.14.0"
50
+ },
51
+ "peerDependencies": {
52
+ "solid-js": "^1.9.0"
53
+ },
54
+ "peerDependenciesMeta": {
55
+ "solid-js": {
56
+ "optional": true
57
+ }
58
+ },
59
+ "devDependencies": {
60
+ "@typescript/native-preview": "7.0.0-dev.20260707.2",
61
+ "solid-js": "^1.9.14",
62
+ "typescript": "^5.8.0"
63
+ },
64
+ "engines": {
65
+ "node": ">=24.0.0"
66
+ },
67
+ "scripts": {
68
+ "build": "tsgo -p tsconfig.build.json",
69
+ "typecheck": "tsgo --noEmit"
70
+ }
71
+ }
@@ -0,0 +1,43 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // `<Collection>` — the typed list primitive (ADR 0003, item 3). It renders a list
4
+ // and hands each item to a render function that is FULLY TYPED, with no
5
+ // hand-written interface: the item type is inferred from `items`, so `item` in the
6
+ // slot carries whatever shape you passed.
7
+ //
8
+ // <Collection items={artworks}>
9
+ // {(art) => <WorkTile title={art.title} price={art.price} />}
10
+ // </Collection>
11
+ //
12
+ // Why a Solid render-prop and not an `.astro` component: Astro slots can't type a
13
+ // per-item `{item}`, but a Solid children-as-function can — this is the only shape
14
+ // that delivers the ADR's "`{item}` in its slot is fully typed". Server-render it
15
+ // by using it inside a Solid island without a `client:*` directive: it emits
16
+ // static HTML and ships no JS. Add `client:load`/`client:visible` only if the list
17
+ // needs interactivity.
18
+ //
19
+ // On the item type + ADR 0003 §6 ("infers from the collection's Zod schema"):
20
+ // Louise's `CollectionConfig` is type-erased (`fields: Record<string,
21
+ // FieldConfig>`), so the item shape can't be recovered from the collection VALUE.
22
+ // Instead you type `items` from your data — per ADR 0001, a Zod-inferred query
23
+ // result — and it flows through to the slot. Same guarantee (a typed `item`),
24
+ // sourced from the data rather than the erased config.
25
+
26
+ import { For, type JSX } from "solid-js";
27
+
28
+ export interface CollectionProps<T> {
29
+ /** The items to render, in order. The item type `T` is inferred from here. */
30
+ items: readonly T[];
31
+ /** Render one item — receives the fully-typed `item` and a reactive `index`. */
32
+ children: (item: T, index: () => number) => JSX.Element;
33
+ /** Rendered when `items` is empty. */
34
+ fallback?: JSX.Element;
35
+ }
36
+
37
+ export function Collection<T>(props: CollectionProps<T>): JSX.Element {
38
+ return (
39
+ <For each={props.items} fallback={props.fallback}>
40
+ {(item, index) => props.children(item, index)}
41
+ </For>
42
+ );
43
+ }
@@ -0,0 +1,71 @@
1
+ ---
2
+ // `<Editable>` — the Astroid inline-edit primitive (ADR 0003, checklist item 1).
3
+ //
4
+ // It owns the `data-louise-*` marker contract so sites stop stamping it by hand:
5
+ // in edit mode it renders your element with the markers the Louise client
6
+ // (`mountLouise` / `mountSections`) looks for to make the field editable in place;
7
+ // out of edit mode it renders the element plain, with zero editing attributes in
8
+ // the public HTML.
9
+ //
10
+ // Two flavors, per which surface the field lives on:
11
+ // • PAGE field — pass `collection` + `key` + `field`; emits
12
+ // `data-louise-field="<collection>:<key>:<field>"` (saved as a
13
+ // versioned draft when the page is mounted with `versionedPageId`).
14
+ // • SECTION field — pass `sfield` (the `<index>.<path>` into the section's data,
15
+ // ADR 0003 §5); emits `data-louise-sfield` (+ `data-louise-multiline`),
16
+ // for the structured `<Section>` editor.
17
+ //
18
+ // Polymorphic via `as` (default `div`) with a typed `...rest` over the div's
19
+ // attributes; edit mode is read from context (`Astro.locals.editMode`) unless the
20
+ // caller passes `edit` (a section knows its own edit flag).
21
+ import type { HTMLAttributes, HTMLTag } from "astro/types";
22
+
23
+ interface Props extends HTMLAttributes<"div"> {
24
+ /** The element to render. Default `"div"`. */
25
+ as?: HTMLTag;
26
+ /** PAGE-field mode: the Louise collection slug (e.g. `"pages"`). */
27
+ collection?: string;
28
+ /** PAGE-field mode: the row key/id the field is on. */
29
+ key?: string | number;
30
+ /** PAGE-field mode: the field name (e.g. `"title"`, `"body"`). */
31
+ field?: string;
32
+ /** SECTION-field mode: the `<index>.<path>` marker for the section editor. */
33
+ sfield?: string;
34
+ /** Rich text (ProseKit) vs a single-line contenteditable. Default `"text"`. */
35
+ type?: "text" | "richtext";
36
+ /** SECTION-field mode: allow newlines in a plain-text field. */
37
+ multiline?: boolean;
38
+ /** Force edit markers on/off. Defaults to `Astro.locals.editMode`. */
39
+ edit?: boolean;
40
+ }
41
+
42
+ const {
43
+ as: Tag = "div",
44
+ collection,
45
+ key,
46
+ field,
47
+ sfield,
48
+ type = "text",
49
+ multiline = false,
50
+ edit,
51
+ ...rest
52
+ } = Astro.props;
53
+
54
+ const editing = edit ?? (Astro.locals as { editMode?: boolean }).editMode ?? false;
55
+ const richText = type === "richtext" ? { "data-louise-type": "richtext" } : {};
56
+
57
+ let markers: Record<string, string> = {};
58
+ if (editing) {
59
+ if (sfield) {
60
+ markers = {
61
+ "data-louise-sfield": sfield,
62
+ ...richText,
63
+ ...(multiline ? { "data-louise-multiline": "" } : {}),
64
+ };
65
+ } else if (collection && key !== undefined && field) {
66
+ markers = { "data-louise-field": `${collection}:${key}:${field}`, ...richText };
67
+ }
68
+ }
69
+ ---
70
+
71
+ <Tag {...rest} {...markers}><slot /></Tag>
@@ -0,0 +1,46 @@
1
+ ---
2
+ // `<Section>` — the section-library dispatcher (ADR 0003, item 2). It's a
3
+ // discriminated union over `SectionKind` (see ./sections.ts): `<Section
4
+ // kind="hero" heading="…" />` requires hero fields and rejects another kind's, and
5
+ // each arm renders the matching section-library component. Bespoke, site-owned
6
+ // sections stay first-class (as the reference site's hand-written ones are) — this
7
+ // is for the catalog sections, not a mandate to route everything through it.
8
+ import type { SectionProps } from "./sections.js";
9
+ import Contact from "./sections/Contact.astro";
10
+ import Cta from "./sections/Cta.astro";
11
+ import FeatureGrid from "./sections/FeatureGrid.astro";
12
+ import Hero from "./sections/Hero.astro";
13
+
14
+ const p = Astro.props as SectionProps;
15
+ ---
16
+
17
+ {
18
+ p.kind === "hero" && (
19
+ <Hero
20
+ heading={p.heading}
21
+ subheading={p.subheading}
22
+ ctaLabel={p.ctaLabel}
23
+ ctaHref={p.ctaHref}
24
+ colorway={p.colorway}
25
+ align={p.align}
26
+ />
27
+ )
28
+ }
29
+ {
30
+ p.kind === "featureGrid" && (
31
+ <FeatureGrid heading={p.heading} items={p.items} colorway={p.colorway} align={p.align} />
32
+ )
33
+ }
34
+ {
35
+ p.kind === "cta" && (
36
+ <Cta
37
+ heading={p.heading}
38
+ body={p.body}
39
+ ctaLabel={p.ctaLabel}
40
+ ctaHref={p.ctaHref}
41
+ colorway={p.colorway}
42
+ align={p.align}
43
+ />
44
+ )
45
+ }
46
+ {p.kind === "contact" && <Contact heading={p.heading} blurb={p.blurb} colorway={p.colorway} align={p.align} />}
@@ -0,0 +1,18 @@
1
+ ---
2
+ // Contact section (ADR 0003, item 4) — a heading + blurb + link to the contact
3
+ // route (the generated worker's contact form lives at /contact).
4
+ import type { ContactFields, SectionBase } from "../sections.js";
5
+ import { colorwayClass } from "../sections.js";
6
+
7
+ type Props = SectionBase & ContactFields;
8
+
9
+ const { heading = "Get in touch", blurb, colorway = "base" } = Astro.props;
10
+ ---
11
+
12
+ <section class:list={["py-16", colorwayClass(colorway)]}>
13
+ <div class="mx-auto max-w-xl px-6 text-center">
14
+ <h2 class="text-3xl font-bold">{heading}</h2>
15
+ {blurb && <p class="mt-2 opacity-80">{blurb}</p>}
16
+ <a class="btn btn-primary mt-6" href="/contact">Contact us</a>
17
+ </div>
18
+ </section>
@@ -0,0 +1,17 @@
1
+ ---
2
+ // Call-to-action band (ADR 0003, item 4). Defaults to the brand colorway.
3
+ import type { CtaFields, SectionBase } from "../sections.js";
4
+ import { alignClass, colorwayClass } from "../sections.js";
5
+
6
+ type Props = SectionBase & CtaFields;
7
+
8
+ const { heading, body, ctaLabel, ctaHref, colorway = "brand", align = "center" } = Astro.props;
9
+ ---
10
+
11
+ <section class:list={["py-16", colorwayClass(colorway)]}>
12
+ <div class:list={["mx-auto flex max-w-3xl flex-col gap-4 px-6", alignClass(align)]}>
13
+ <h2 class="text-3xl font-bold">{heading}</h2>
14
+ {body && <p class="text-lg opacity-90">{body}</p>}
15
+ <a class="btn" href={ctaHref}>{ctaLabel}</a>
16
+ </div>
17
+ </section>
@@ -0,0 +1,26 @@
1
+ ---
2
+ // Feature-grid section (ADR 0003, item 4) — value-prop cards. Declarative
3
+ // props → `Theme` tokens via `colorwayClass`.
4
+ import type { FeatureGridFields, SectionBase } from "../sections.js";
5
+ import { colorwayClass } from "../sections.js";
6
+
7
+ type Props = SectionBase & FeatureGridFields;
8
+
9
+ const { heading, items, colorway = "base" } = Astro.props;
10
+ ---
11
+
12
+ <section class:list={["py-16", colorwayClass(colorway)]}>
13
+ <div class="mx-auto max-w-5xl px-6">
14
+ {heading && <h2 class="mb-8 text-3xl font-bold">{heading}</h2>}
15
+ <div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
16
+ {items.map((item) => (
17
+ <div class="card bg-base-100 text-base-content shadow-sm">
18
+ <div class="card-body">
19
+ <h3 class="card-title">{item.title}</h3>
20
+ <p class="opacity-80">{item.body}</p>
21
+ </div>
22
+ </div>
23
+ ))}
24
+ </div>
25
+ </div>
26
+ </section>
@@ -0,0 +1,23 @@
1
+ ---
2
+ // Hero section (ADR 0003, item 4). Declarative props → `Theme` tokens via the
3
+ // `colorwayClass` / `alignClass` maps — no raw class names in the prop surface, so
4
+ // a brand re-theme flows through without touching markup.
5
+ import type { HeroFields, SectionBase } from "../sections.js";
6
+ import { alignClass, colorwayClass } from "../sections.js";
7
+
8
+ type Props = SectionBase & HeroFields;
9
+
10
+ const { heading, subheading, ctaLabel, ctaHref, colorway = "base", align = "center" } = Astro.props;
11
+ ---
12
+
13
+ <section class:list={["hero py-20", colorwayClass(colorway)]}>
14
+ <div class:list={["hero-content flex-col", alignClass(align)]}>
15
+ <div class="max-w-2xl">
16
+ <h1 class="text-5xl font-bold">{heading}</h1>
17
+ {subheading && <p class="py-6 text-lg opacity-80">{subheading}</p>}
18
+ {ctaLabel && ctaHref && (
19
+ <a class="btn btn-primary" href={ctaHref}>{ctaLabel}</a>
20
+ )}
21
+ </div>
22
+ </div>
23
+ </section>
@@ -0,0 +1,108 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // The typed model behind the section-library primitives (ADR 0003). This is the
4
+ // part the ADR's conventions bite hardest on, and the part TypeScript actually
5
+ // checks: variant props are unions, not `string` (§1); callers describe intent
6
+ // (`colorway="brand"`) and the component owns the token→class mapping (§2); those
7
+ // unions are DERIVED from the token maps with `keyof typeof`, so the type and the
8
+ // implementation can't drift (§3); and `<Section>` is a discriminated union over
9
+ // `SectionKind`, each arm carrying its own field shape (§6).
10
+ //
11
+ // The `.astro` components consume these types. Only the marketing-floor kinds have
12
+ // components today (hero / featureGrid / cta / contact); more arms are added here
13
+ // with their component, so an unimplemented `kind` is a compile error rather than
14
+ // a blank render.
15
+ //
16
+ // Self-contained on purpose: this module ships as SOURCE (the `.astro` next to it
17
+ // import it directly), so it must not reach back into astroid's built `src/*` —
18
+ // only siblings + external packages. `SectionKind` from config.ts is therefore not
19
+ // imported here; the runtime guard takes a plain `string`.
20
+
21
+ /**
22
+ * Colorway → daisyUI surface classes, keyed to the `Theme.colors` set
23
+ * (brand/secondary/tertiary) plus the neutral base. The map is the single source:
24
+ * add a colorway here and {@link Colorway} updates itself (§3).
25
+ */
26
+ export const COLORWAY_CLASS = {
27
+ brand: "bg-primary text-primary-content",
28
+ secondary: "bg-secondary text-secondary-content",
29
+ tertiary: "bg-accent text-accent-content",
30
+ base: "bg-base-100 text-base-content",
31
+ } as const;
32
+ export type Colorway = keyof typeof COLORWAY_CLASS;
33
+
34
+ /** Content alignment → fl/text-alignment utilities. */
35
+ export const ALIGN_CLASS = {
36
+ start: "text-start items-start",
37
+ center: "text-center items-center",
38
+ end: "text-end items-end",
39
+ } as const;
40
+ export type Align = keyof typeof ALIGN_CLASS;
41
+
42
+ /** Shared presentation props every section arm accepts. */
43
+ export interface SectionBase {
44
+ /** Surface colorway — a closed set mapped to daisyUI classes, never a raw class. */
45
+ colorway?: Colorway;
46
+ /** Content alignment. */
47
+ align?: Align;
48
+ }
49
+
50
+ // --- Per-kind field shapes -------------------------------------------------
51
+ // The editable content each section kind renders. Kept minimal + declarative;
52
+ // these are the fields a `sections` JSON entry carries.
53
+
54
+ export interface HeroFields {
55
+ heading: string;
56
+ subheading?: string;
57
+ ctaLabel?: string;
58
+ ctaHref?: string;
59
+ }
60
+
61
+ export interface FeatureGridFields {
62
+ heading?: string;
63
+ items: { title: string; body: string }[];
64
+ }
65
+
66
+ export interface CtaFields {
67
+ heading: string;
68
+ body?: string;
69
+ ctaLabel: string;
70
+ ctaHref: string;
71
+ }
72
+
73
+ export interface ContactFields {
74
+ heading?: string;
75
+ blurb?: string;
76
+ }
77
+
78
+ /**
79
+ * The discriminated union `<Section>` dispatches on (§6). `<Section kind="hero">`
80
+ * requires {@link HeroFields} and rejects another kind's fields; adding a kind
81
+ * means adding an arm here and a component to render it. Only the kinds with a
82
+ * shipped component appear — a subset of the full {@link SectionKind} catalog.
83
+ */
84
+ export type SectionProps =
85
+ | (SectionBase & { kind: "hero" } & HeroFields)
86
+ | (SectionBase & { kind: "featureGrid" } & FeatureGridFields)
87
+ | (SectionBase & { kind: "cta" } & CtaFields)
88
+ | (SectionBase & { kind: "contact" } & ContactFields);
89
+
90
+ /** The section kinds that currently have a section-library component — the `kind`
91
+ * discriminants of {@link SectionProps}, so it can't drift from the union. */
92
+ export type RenderableSectionKind = SectionProps["kind"];
93
+
94
+ /** Type guard: does this section kind have a shipped component? Narrows a plain
95
+ * string (a `sections` entry's `kind` at runtime) to a {@link RenderableSectionKind}. */
96
+ export function isRenderableSection(kind: string): kind is RenderableSectionKind {
97
+ return kind === "hero" || kind === "featureGrid" || kind === "cta" || kind === "contact";
98
+ }
99
+
100
+ /** Resolve a colorway to its class string (default: neutral base). */
101
+ export function colorwayClass(colorway: Colorway = "base"): string {
102
+ return COLORWAY_CLASS[colorway];
103
+ }
104
+
105
+ /** Resolve an alignment to its class string (default: start). */
106
+ export function alignClass(align: Align = "start"): string {
107
+ return ALIGN_CLASS[align];
108
+ }