@louise-toolkit/astro 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BowenLabs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # @louise-toolkit/astro
2
+
3
+ **Astro adapter for [Louise Toolkit](https://github.com/bowenlabs/louise-toolkit)** —
4
+ middleware, Actions, content-layer loaders, and the forms schema bridge.
5
+
6
+ > **Status: pre-1.0, experimental.** The API changes between minor versions —
7
+ > pin an exact version if you depend on it.
8
+
9
+ ## Why this is a separate package
10
+
11
+ `louise-toolkit` is framework-agnostic, and that claim is enforced rather than
12
+ merely stated: CI fails if the string "astro" appears anywhere in the library's
13
+ source, in code or in prose. Everything that has to import Astro's types lives
14
+ here instead.
15
+
16
+ So the dependency runs one way — this package depends on `louise-toolkit`, never
17
+ the reverse — and `astro` is an **optional peer**, pulled in only by sites that
18
+ actually import this adapter.
19
+
20
+ ## Install
21
+
22
+ ```sh
23
+ pnpm add @louise-toolkit/astro louise-toolkit astro
24
+ ```
25
+
26
+ Building a whole site rather than wiring one by hand? Use
27
+ [`astroidjs`](https://www.npmjs.com/package/astroidjs), the opinionated preset on
28
+ top of both, or scaffold one with `pnpm create astroid`.
29
+
30
+ ## What it gives you
31
+
32
+ **Middleware** — one factory that mounts Louise's editor routes, session handling
33
+ and optional rate limiting into an Astro site.
34
+
35
+ ```ts
36
+ // src/middleware.ts
37
+ import { createLouiseMiddleware } from "@louise-toolkit/astro";
38
+
39
+ export const onRequest = createLouiseMiddleware({/* ... */});
40
+ ```
41
+
42
+ **Actions** — the editor write path as Astro Actions, so a save is a typed call
43
+ rather than a hand-rolled endpoint. `louiseSaveAction`, `louiseSaveDraftAction`
44
+ and `louiseSettingsAction` wrap the same primitives the framework exposes, which
45
+ is why an agent writing over MCP and a human clicking in the editor take the
46
+ identical code path.
47
+
48
+ **Content-layer loaders** — `louiseLoader` feeds Louise-managed rows into Astro's
49
+ content layer, and `collectionToAstroSchema` derives the Zod schema from the same
50
+ `CollectionConfig` that drives codegen and the editor. One definition, not two
51
+ that drift.
52
+
53
+ **Catalog loader** — `defineCatalogLoader`, for commerce catalogs.
54
+
55
+ **Forms bridge** — `formToAstroSchema`, deriving an Astro-shaped schema from a
56
+ Louise `FormConfig`.
57
+
58
+ ## Full exports
59
+
60
+ | export | what it is |
61
+ | --------------------------------------------------------------------- | --------------------------------------------- |
62
+ | `createLouiseMiddleware` | mounts editor routes, sessions, rate limiting |
63
+ | `louiseSaveAction` · `louiseSaveDraftAction` · `louiseSettingsAction` | the editor write path as Astro Actions |
64
+ | `louiseLoader` · `collectionToAstroSchema` | content-layer loader and its derived schema |
65
+ | `defineCatalogLoader` | commerce catalog loader |
66
+ | `formToAstroSchema` | `FormConfig` → Astro schema |
67
+
68
+ Types ship alongside each: `LouiseMiddlewareConfig`, `LouiseMiddlewareRateLimit`,
69
+ `EditorActionContext`, `EditorActionDeps`, `LouiseSaveActionConfig`,
70
+ `LouiseSaveDraftActionConfig`, `LouiseSettingsActionConfig`, `SaveActionInput`,
71
+ `SaveDraftActionInput`, `ActionErrorCtor`, `LouiseLoaderConfig`, `LouiseRow`,
72
+ `CatalogLoaderConfig`.
73
+
74
+ ## License
75
+
76
+ MIT © BowenLabs. See [LICENSE](./LICENSE).
@@ -0,0 +1,133 @@
1
+ import { z } from "astro/zod";
2
+ import { type SaveCollectionConfig } from "louise-toolkit/editor";
3
+ import { type SettingsPatchConfig } from "louise-toolkit/editor";
4
+ import type { EditorRouteEnv } from "louise-toolkit/editor";
5
+ import { type SaveDraftDeps } from "louise-toolkit/editor";
6
+ /** The subset of Astro's `ActionError` codes the editor handlers emit. */
7
+ type ActionErrorCode = "BAD_REQUEST" | "UNAUTHORIZED" | "FORBIDDEN" | "NOT_FOUND" | "INTERNAL_SERVER_ERROR";
8
+ /** The shape of Astro's `ActionError` constructor the handlers depend on —
9
+ * injected (see file header) so the toolkit needn't import `astro:actions`. */
10
+ export interface ActionErrorCtor {
11
+ new (opts: {
12
+ code: ActionErrorCode;
13
+ message?: string;
14
+ }): Error;
15
+ }
16
+ /** The slice of an Astro `ActionAPIContext` the editor handlers read: the
17
+ * middleware-resolved `locals.editor`, plus `cookies` for the D1 bookmark. The
18
+ * Worker `env` is NOT read off the context — Astro v6+ removed
19
+ * `Astro.locals.runtime.env`, so it's supplied by the injected
20
+ * {@link EditorActionDeps.getEnv}. A real context (which carries much more)
21
+ * structurally satisfies this. */
22
+ export interface EditorActionContext {
23
+ locals: {
24
+ editor?: unknown;
25
+ };
26
+ /** Astro's `AstroCookies` (structurally). Used to persist the D1 session
27
+ * bookmark for read-your-writes on draft resume (#69). Optional so a bare
28
+ * test context still satisfies this type. */
29
+ cookies?: {
30
+ set(name: string, value: string, options?: Record<string, unknown>): void;
31
+ };
32
+ }
33
+ /** The dependencies every editor Action shares: the injected `ActionError`, an
34
+ * optional reader for the editor session (defaulting to `locals.editor`), and the
35
+ * required `getEnv` that hands in the Worker bindings. */
36
+ export interface EditorActionDeps<Env extends EditorRouteEnv = EditorRouteEnv> {
37
+ /** Astro's `ActionError` class, injected (see file header). */
38
+ ActionError: ActionErrorCtor;
39
+ /** Resolve the editor session from the Action context. Default: `locals.editor`
40
+ * (set by `createLouiseMiddleware`). A falsy result answers 401. */
41
+ getEditor?: (ctx: EditorActionContext) => unknown;
42
+ /**
43
+ * Resolve the Worker `env` (the D1 binding) for the Action. **Required** — Astro
44
+ * v6+ removed `Astro.locals.runtime.env`, so there is no context field to default
45
+ * to; inject the bindings explicitly, typically by closing over the Cloudflare
46
+ * `env` (the `ctx` argument is available for per-request selection but usually
47
+ * unused):
48
+ *
49
+ * ```ts
50
+ * import { env } from "cloudflare:workers";
51
+ * louiseSaveAction({ collections, ActionError, getEnv: () => env });
52
+ * ```
53
+ */
54
+ getEnv: (ctx: EditorActionContext) => Env;
55
+ }
56
+ /** The validated `save` input — the inline field-save body, same keys the raw
57
+ * route's `SAVE_BODY` uses. `value` stays `unknown`; its non-empty-string check
58
+ * needs the collection config and so lives in `applyFieldSave`. */
59
+ export interface SaveActionInput {
60
+ collection: string;
61
+ key: string;
62
+ field: string;
63
+ value: unknown;
64
+ }
65
+ export interface LouiseSaveActionConfig<Env extends EditorRouteEnv = EditorRouteEnv> extends EditorActionDeps<Env> {
66
+ /** Editable collections keyed by the client's `collection` slug — the same
67
+ * shape the raw `saveRoute` takes. */
68
+ collections: Record<string, SaveCollectionConfig>;
69
+ /** Rich-HTML sanitizer; defaults to louise-toolkit/security's `sanitizeRichHtml`. */
70
+ sanitize?: (html: string) => string;
71
+ }
72
+ export interface LouiseSettingsActionConfig<Env extends EditorRouteEnv = EditorRouteEnv> extends EditorActionDeps<Env>, SettingsPatchConfig {
73
+ }
74
+ /** The validated `saveDraft` input: which versioned row (`id`) plus the changed
75
+ * fields (`data`). The route takes `id` from the URL path + the fields as the
76
+ * body; the Action bundles both, since an Action call has no URL. */
77
+ export interface SaveDraftActionInput {
78
+ id: number;
79
+ data: Record<string, unknown>;
80
+ }
81
+ export interface LouiseSaveDraftActionConfig<Env extends EditorRouteEnv = EditorRouteEnv> extends EditorActionDeps<Env>, SaveDraftDeps<Env> {
82
+ }
83
+ /**
84
+ * Build the `{ input, handler }` config for the editor `save` Action (the inline
85
+ * field-save). The site drops the result into `defineAction` (see file header).
86
+ * The `input` schema is validated by Astro *before* the handler runs — replacing
87
+ * the raw route's manual `request.json()` + `standardValidate` — and the handler
88
+ * shares the raw route's store path via {@link applyFieldSave}, so a field is
89
+ * validated once and written in exactly one place.
90
+ */
91
+ export declare function louiseSaveAction<Env extends EditorRouteEnv = EditorRouteEnv>(config: LouiseSaveActionConfig<Env>): {
92
+ input: z.ZodObject<{
93
+ collection: z.ZodString;
94
+ key: z.ZodString;
95
+ field: z.ZodString;
96
+ value: z.ZodUnknown;
97
+ }, z.core.$strip>;
98
+ handler: (input: SaveActionInput, context: EditorActionContext) => Promise<{
99
+ ok: true;
100
+ }>;
101
+ };
102
+ /**
103
+ * Build the `{ input, handler }` config for the editor `settings` Action (the
104
+ * structured settings-panel patch). Mirrors {@link louiseSaveAction}: Astro
105
+ * validates the patch object as `input`, and the handler shares the raw
106
+ * `settingsRoute` store path via {@link applySettingsPatch} (media-strictness on
107
+ * image keys, base-vs-`custom` partition, singleton write). Returns the `ignored`
108
+ * (non-allowlisted) keys so the caller can surface what was dropped.
109
+ */
110
+ export declare function louiseSettingsAction<Env extends EditorRouteEnv = EditorRouteEnv>(config: LouiseSettingsActionConfig<Env>): {
111
+ input: z.ZodRecord<z.ZodString, z.ZodUnknown>;
112
+ handler: (input: Record<string, unknown>, context: EditorActionContext) => Promise<{
113
+ ok: true;
114
+ ignored: string[];
115
+ }>;
116
+ };
117
+ /**
118
+ * Build the `{ input, handler }` config for the editor `saveDraft` Action (the
119
+ * versioned-page draft save). Mirrors {@link louiseSaveAction}, but the input
120
+ * bundles the row `id` with the changed `data` (an Action call has no URL to carry
121
+ * the id). The handler shares the raw `versionsRoute` store path via
122
+ * {@link applySaveDraft} — the concurrent-surface merge base and the #70 KV
123
+ * write-buffer — and returns that path's JSON body (a created `version`, or
124
+ * `{ buffered: true }` when a write is coalesced into the buffer).
125
+ */
126
+ export declare function louiseSaveDraftAction<Env extends EditorRouteEnv = EditorRouteEnv>(config: LouiseSaveDraftActionConfig<Env>): {
127
+ input: z.ZodObject<{
128
+ id: z.ZodNumber;
129
+ data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
130
+ }, z.core.$strip>;
131
+ handler: (input: SaveDraftActionInput, context: EditorActionContext) => Promise<Record<string, unknown>>;
132
+ };
133
+ export {};
@@ -0,0 +1,172 @@
1
+ // Copyright (c) 2026 BowenLabs. Louise Toolkit is MIT licensed.
2
+ //
3
+ // `louise-toolkit/astro` — the editor mutations as Astro Actions (#72): typed,
4
+ // Zod-validated server functions so a site calls `actions.louise.save(...)` /
5
+ // `settings(...)` and gets end-to-end types + automatic input validation, instead
6
+ // of hand-building a `fetch("/api/louise/*")` JSON body and re-parsing it.
7
+ //
8
+ // // site: src/actions/index.ts
9
+ // import { defineAction, ActionError } from "astro:actions";
10
+ // import { env } from "cloudflare:workers";
11
+ // import { louiseSaveAction, louiseSettingsAction } from "louise-toolkit/astro";
12
+ //
13
+ // export const server = {
14
+ // louise: {
15
+ // save: defineAction(louiseSaveAction({ collections, ActionError, getEnv: () => env })),
16
+ // settings: defineAction(louiseSettingsAction({ ...settingsConfig, ActionError, getEnv: () => env })),
17
+ // },
18
+ // };
19
+ //
20
+ // The Worker `env` (the D1 binding) is injected via `getEnv`, not read off the
21
+ // context: Astro v6+ removed `Astro.locals.runtime.env`, so a site hands in its
22
+ // bindings — typically `() => env` from `cloudflare:workers` — the same way the
23
+ // core primitives take their bindings by injection (the library never reaches for
24
+ // `cloudflare:workers` itself).
25
+ //
26
+ // Why a factory that returns `{ input, handler }` instead of a ready `defineAction`:
27
+ // `defineAction`/`ActionError` live in Astro's VIRTUAL `astro:actions` module,
28
+ // which only resolves inside an Astro app — a library can't import it (this subpath
29
+ // imports only real `astro/*` subpaths, e.g. `astro/zod`). So the adapter ships the
30
+ // ingredients and the SITE assembles `defineAction`, and it takes the `ActionError`
31
+ // class by injection so the handler can still throw framework-correct 400/401/404.
32
+ //
33
+ // CSRF: Astro enforces same-origin on Action POSTs by default, so these port only
34
+ // the AUTH guard (a `locals.editor` check). The store logic itself is shared with
35
+ // the raw routes (`applyFieldSave`, `applySettingsPatch`), so nothing is parsed or
36
+ // written twice.
37
+ import { z } from "astro/zod";
38
+ import { D1_BOOKMARK_COOKIE } from "louise-toolkit/db";
39
+ import { applyFieldSave } from "louise-toolkit/editor";
40
+ import { applySettingsPatch } from "louise-toolkit/editor";
41
+ import { applySaveDraft } from "louise-toolkit/editor";
42
+ import { sanitizeRichHtml } from "louise-toolkit/security";
43
+ /** Map an `apply*` HTTP status onto an Astro `ActionError` code. */
44
+ function statusToCode(status) {
45
+ if (status === 401)
46
+ return "UNAUTHORIZED";
47
+ if (status === 403)
48
+ return "FORBIDDEN";
49
+ if (status === 404)
50
+ return "NOT_FOUND";
51
+ if (status >= 500)
52
+ return "INTERNAL_SERVER_ERROR";
53
+ return "BAD_REQUEST";
54
+ }
55
+ /** Resolve an editor Action's deps, filling the default `locals.editor` reader.
56
+ * `getEnv` has no default — Astro v6+ removed `locals.runtime.env`, so a safe one
57
+ * can't exist — and a missing one is a wiring error thrown here, at
58
+ * action-construction time, rather than a per-request 500 on an `undefined` env. */
59
+ function resolveDeps(deps) {
60
+ if (typeof deps.getEnv !== "function") {
61
+ throw new TypeError("louise-toolkit/astro: `getEnv` is required — Astro v6+ removed " +
62
+ "`Astro.locals.runtime.env`, so inject the Worker env explicitly, " +
63
+ 'e.g. `getEnv: () => env` from "cloudflare:workers".');
64
+ }
65
+ return {
66
+ ActionError: deps.ActionError,
67
+ getEditor: deps.getEditor ?? ((ctx) => ctx.locals.editor),
68
+ getEnv: deps.getEnv,
69
+ };
70
+ }
71
+ /** Require the (middleware-resolved) editor session — a missing one is a 401 —
72
+ * and return it. CSRF/same-origin is Astro's default for Action POSTs, so only
73
+ * auth is ported. */
74
+ function requireEditor(resolved, ctx) {
75
+ const editor = resolved.getEditor(ctx);
76
+ if (!editor) {
77
+ throw new resolved.ActionError({ code: "UNAUTHORIZED", message: "Editor session required" });
78
+ }
79
+ return editor;
80
+ }
81
+ /** Throw the injected `ActionError` for an `apply*` failure — `never`, so a
82
+ * `if (!result.ok) throwActionError(...)` narrows the result to its ok branch. */
83
+ function throwActionError(ActionError, status, error) {
84
+ throw new ActionError({ code: statusToCode(status), message: error });
85
+ }
86
+ /**
87
+ * Build the `{ input, handler }` config for the editor `save` Action (the inline
88
+ * field-save). The site drops the result into `defineAction` (see file header).
89
+ * The `input` schema is validated by Astro *before* the handler runs — replacing
90
+ * the raw route's manual `request.json()` + `standardValidate` — and the handler
91
+ * shares the raw route's store path via {@link applyFieldSave}, so a field is
92
+ * validated once and written in exactly one place.
93
+ */
94
+ export function louiseSaveAction(config) {
95
+ const resolved = resolveDeps(config);
96
+ const sanitize = config.sanitize ?? sanitizeRichHtml;
97
+ return {
98
+ input: z.object({
99
+ collection: z.string(),
100
+ key: z.string(),
101
+ field: z.string(),
102
+ value: z.unknown(),
103
+ }),
104
+ handler: async (input, context) => {
105
+ requireEditor(resolved, context);
106
+ const result = await applyFieldSave(resolved.getEnv(context), config.collections, sanitize, input);
107
+ if (!result.ok)
108
+ throwActionError(resolved.ActionError, result.status, result.error);
109
+ return { ok: true };
110
+ },
111
+ };
112
+ }
113
+ /**
114
+ * Build the `{ input, handler }` config for the editor `settings` Action (the
115
+ * structured settings-panel patch). Mirrors {@link louiseSaveAction}: Astro
116
+ * validates the patch object as `input`, and the handler shares the raw
117
+ * `settingsRoute` store path via {@link applySettingsPatch} (media-strictness on
118
+ * image keys, base-vs-`custom` partition, singleton write). Returns the `ignored`
119
+ * (non-allowlisted) keys so the caller can surface what was dropped.
120
+ */
121
+ export function louiseSettingsAction(config) {
122
+ const resolved = resolveDeps(config);
123
+ return {
124
+ // A settings patch is an arbitrary object of allowlisted keys; the allowlist
125
+ // (base columns + `custom` keys) is enforced in `applySettingsPatch`.
126
+ input: z.record(z.string(), z.unknown()),
127
+ handler: async (input, context) => {
128
+ requireEditor(resolved, context);
129
+ const result = await applySettingsPatch(resolved.getEnv(context), config, input);
130
+ if (!result.ok)
131
+ throwActionError(resolved.ActionError, result.status, result.error);
132
+ return { ok: true, ignored: result.ignored };
133
+ },
134
+ };
135
+ }
136
+ /**
137
+ * Build the `{ input, handler }` config for the editor `saveDraft` Action (the
138
+ * versioned-page draft save). Mirrors {@link louiseSaveAction}, but the input
139
+ * bundles the row `id` with the changed `data` (an Action call has no URL to carry
140
+ * the id). The handler shares the raw `versionsRoute` store path via
141
+ * {@link applySaveDraft} — the concurrent-surface merge base and the #70 KV
142
+ * write-buffer — and returns that path's JSON body (a created `version`, or
143
+ * `{ buffered: true }` when a write is coalesced into the buffer).
144
+ */
145
+ export function louiseSaveDraftAction(config) {
146
+ const resolved = resolveDeps(config);
147
+ return {
148
+ input: z.object({
149
+ id: z.number().int(),
150
+ data: z.record(z.string(), z.unknown()),
151
+ }),
152
+ handler: async (input, context) => {
153
+ const editor = requireEditor(resolved, context);
154
+ const result = await applySaveDraft(resolved.getEnv(context), config, editor, input.id, input.data);
155
+ if (!result.ok)
156
+ throwActionError(resolved.ActionError, result.status, result.error);
157
+ // Persist the D1 bookmark so this Action's draft is read-your-writes on the
158
+ // next edit-mode load behind read replication (#69). Mirrors the raw
159
+ // versionsRoute's Set-Cookie; no-op on a non-replicated D1.
160
+ if (result.bookmark) {
161
+ context.cookies?.set(D1_BOOKMARK_COOKIE, result.bookmark, {
162
+ path: "/",
163
+ httpOnly: true,
164
+ sameSite: "lax",
165
+ secure: true,
166
+ maxAge: 60 * 60 * 8,
167
+ });
168
+ }
169
+ return result.body;
170
+ },
171
+ };
172
+ }
@@ -0,0 +1,45 @@
1
+ import type { LiveLoader } from "astro/loaders";
2
+ type AnyRecord = Record<string, any>;
3
+ /**
4
+ * What a site provides to build a catalog live loader. `Data` is the entry shape
5
+ * (e.g. a display product); `Filter` is the collection query shape.
6
+ */
7
+ export interface CatalogLoaderConfig<Data extends AnyRecord, Filter extends AnyRecord = Record<string, never>> {
8
+ /** Loader name (Astro convention: the npm package or a stable id). Also the
9
+ * default `cacheHint` tag. */
10
+ name: string;
11
+ /**
12
+ * Load the (cached) catalog for a collection query, already narrowed to what
13
+ * the query asks for (the site owns its own filtering — category trees, etc.).
14
+ * `fetchedAt` (epoch ms) becomes the `cacheHint.lastModified` so the hint
15
+ * reflects the snapshot's age, not the render time.
16
+ */
17
+ loadCatalog: (filter: Filter | undefined) => Promise<{
18
+ items: Data[];
19
+ fetchedAt?: number | null;
20
+ }>;
21
+ /** Resolve a single item by its entry id (slug). `null` → not found (Astro
22
+ * raises `LiveEntryNotFoundError`, which the page can turn into a redirect). */
23
+ loadItem: (id: string) => Promise<Data | null>;
24
+ /** The entry id (slug) for an item — keys the collection and resolves
25
+ * `getLiveEntry("catalog", slug)`. */
26
+ idOf: (item: Data) => string;
27
+ /** `cacheHint` tag for tag-based purges. Default: `name`. */
28
+ tag?: string;
29
+ }
30
+ /**
31
+ * Build an Astro {@link LiveLoader} for a commerce catalog from a site's cached
32
+ * reads. Register the result in `src/live.config.ts`:
33
+ *
34
+ * ```ts
35
+ * import { defineLiveCollection } from "astro:content";
36
+ * import { catalogLoader } from "./loaders/catalog";
37
+ * export const collections = { catalog: defineLiveCollection({ loader: catalogLoader }) };
38
+ * ```
39
+ *
40
+ * then consume with `getLiveCollection("catalog")` / `getLiveEntry("catalog", slug)`.
41
+ */
42
+ export declare function defineCatalogLoader<Data extends AnyRecord, Filter extends AnyRecord = Record<string, never>>(config: CatalogLoaderConfig<Data, Filter>): LiveLoader<Data, {
43
+ id: string;
44
+ }, Filter>;
45
+ export {};
@@ -0,0 +1,65 @@
1
+ // Copyright (c) 2026 BowenLabs. Louise Toolkit is MIT licensed.
2
+ //
3
+ // `louise-toolkit/astro` — `defineCatalogLoader`: the shared plumbing for a commerce
4
+ // catalog served as an Astro Live Content Collection. Live collections fetch at
5
+ // request time, so a price/stock edit shows on the next render with no rebuild.
6
+ //
7
+ // Every catalog loader repeats the same boilerplate — map items to keyed entries,
8
+ // stamp a `cacheHint` (a tag for future webhook-driven purges, plus the snapshot
9
+ // age as `lastModified`), and wrap a read failure as a loader error instead of a
10
+ // 500. That lives here ONCE. A site injects only what's domain-specific: how to
11
+ // read its (cached) catalog, how to resolve one item, and each item's slug — so
12
+ // a Fourthwall site and a Square site share one loader definition and only their
13
+ // `lib/<provider>` reads differ.
14
+ //
15
+ // `astro` is an OPTIONAL peer (see the entry note); only the `LiveLoader` *type*
16
+ // is imported, and only by sites that pull in this subpath.
17
+ function toError(error, fallback) {
18
+ return error instanceof Error ? error : new Error(fallback);
19
+ }
20
+ /**
21
+ * Build an Astro {@link LiveLoader} for a commerce catalog from a site's cached
22
+ * reads. Register the result in `src/live.config.ts`:
23
+ *
24
+ * ```ts
25
+ * import { defineLiveCollection } from "astro:content";
26
+ * import { catalogLoader } from "./loaders/catalog";
27
+ * export const collections = { catalog: defineLiveCollection({ loader: catalogLoader }) };
28
+ * ```
29
+ *
30
+ * then consume with `getLiveCollection("catalog")` / `getLiveEntry("catalog", slug)`.
31
+ */
32
+ export function defineCatalogLoader(config) {
33
+ const tag = config.tag ?? config.name;
34
+ const cacheHint = (fetchedAt) => ({
35
+ tags: [tag],
36
+ ...(fetchedAt ? { lastModified: new Date(fetchedAt) } : {}),
37
+ });
38
+ return {
39
+ name: config.name,
40
+ async loadCollection({ filter }) {
41
+ try {
42
+ const { items, fetchedAt } = await config.loadCatalog(filter);
43
+ const hint = cacheHint(fetchedAt);
44
+ return {
45
+ entries: items.map((item) => ({ id: config.idOf(item), data: item, cacheHint: hint })),
46
+ cacheHint: hint,
47
+ };
48
+ }
49
+ catch (error) {
50
+ return { error: toError(error, `${config.name} catalog load failed`) };
51
+ }
52
+ },
53
+ async loadEntry({ filter }) {
54
+ try {
55
+ const item = await config.loadItem(filter.id);
56
+ if (!item)
57
+ return undefined;
58
+ return { id: config.idOf(item), data: item, cacheHint: cacheHint(null) };
59
+ }
60
+ catch (error) {
61
+ return { error: toError(error, `${config.name} entry load failed`) };
62
+ }
63
+ },
64
+ };
65
+ }
@@ -0,0 +1,52 @@
1
+ import type { Loader } from "astro/loaders";
2
+ import { z } from "astro/zod";
3
+ import type { CollectionConfig } from "louise-toolkit/content";
4
+ /** A published row as read from D1 — a document in the collection's field shape. */
5
+ export type LouiseRow = Record<string, unknown>;
6
+ /**
7
+ * Build an Astro (Zod) schema from a collection's `defineCollection` fields.
8
+ * Unknown/bookkeeping columns (`id`, `status`, timestamps) are dropped — the
9
+ * schema captures exactly the declared fields, so `getCollection` entry data
10
+ * matches the editor's model.
11
+ */
12
+ export declare function collectionToAstroSchema(collection: CollectionConfig): z.ZodType;
13
+ export interface LouiseLoaderConfig {
14
+ /** The collection definition (from `defineCollection`) — drives the schema. */
15
+ collection: CollectionConfig;
16
+ /**
17
+ * Read the published rows to expose, each a document in the collection's field
18
+ * shape. The site owns D1 access (the loader runs at build time, off any
19
+ * binding) — typically the D1 REST API, or a cached snapshot. Only published
20
+ * rows should be returned; drafts never reach `getCollection`.
21
+ */
22
+ read: () => Promise<LouiseRow[]>;
23
+ /**
24
+ * The entry id (unique key) for a row. Default: `row.slug ?? row.id`, matching
25
+ * how Louise pages are addressed.
26
+ */
27
+ idOf?: (row: LouiseRow) => string | number;
28
+ /** Loader name (Astro convention). Default `louise:<slug>`. */
29
+ name?: string;
30
+ }
31
+ /**
32
+ * A Content Layer loader over a Louise D1 collection. Register it with Astro's
33
+ * `defineCollection`:
34
+ *
35
+ * ```ts
36
+ * // src/content.config.ts
37
+ * import { defineCollection } from "astro:content";
38
+ * import { louiseLoader } from "louise-toolkit/astro";
39
+ * import { pagesCollection } from "./pages-collection";
40
+ * import { readPublishedPages } from "./lib/louise/published-pages";
41
+ *
42
+ * export const collections = {
43
+ * pages: defineCollection({
44
+ * loader: louiseLoader({ collection: pagesCollection, read: readPublishedPages }),
45
+ * }),
46
+ * };
47
+ * ```
48
+ *
49
+ * then read with `getCollection("pages")` / `getEntry("pages", slug)` — typed
50
+ * from the collection's own fields, no hand-written schema.
51
+ */
52
+ export declare function louiseLoader(config: LouiseLoaderConfig): Loader;
@@ -0,0 +1,142 @@
1
+ // Copyright (c) 2026 BowenLabs. Louise Toolkit is MIT licensed.
2
+ //
3
+ // `louise-toolkit/astro` — `louiseLoader`: an Astro Content Layer loader that
4
+ // exposes a Louise D1 collection through the native `getCollection()` /
5
+ // `getEntry()` pipeline, with a Zod schema derived from the collection's own
6
+ // `defineCollection` fields (so entry data is typed the same way the editor
7
+ // models it).
8
+ //
9
+ // Content Layer loaders run at BUILD time (in Node, during `astro build`), where
10
+ // Cloudflare bindings don't exist — so, like `defineCatalogLoader`, the *read*
11
+ // is injected: a site supplies `read()` (typically the D1 REST API at build, or
12
+ // a snapshot) and this owns the rest — schema mapping, store population, content
13
+ // digests for incremental builds, and fail-safe error handling. The result is a
14
+ // build-time snapshot of published content; rebuild on publish (e.g. a webhook)
15
+ // to refresh it. For request-time freshness, read D1 directly in an SSR page (or
16
+ // use the Live-collection `defineCatalogLoader`).
17
+ //
18
+ // `astro` is an OPTIONAL peer (see the entry note): `Loader`/`DataStore` types
19
+ // come from `astro/loaders` and the Zod builder from `astro/zod`, pulled in only
20
+ // by sites that import this subpath.
21
+ import { z } from "astro/zod";
22
+ /**
23
+ * Map one Louise field to its Zod type. The budget is deliberately permissive on
24
+ * read: D1 is the source of truth and the values were already validated on write
25
+ * (the Local API), so this describes shape for `getCollection`'s types rather
26
+ * than re-litigating validity.
27
+ */
28
+ function fieldToZod(field) {
29
+ switch (field.type) {
30
+ case "text":
31
+ case "upload":
32
+ return z.string();
33
+ case "select":
34
+ return field.options.length > 0
35
+ ? z.enum([...field.options])
36
+ : z.string();
37
+ case "number":
38
+ // A `hasMany: false` relationship is a plain integer column (the related
39
+ // row's id); `hasMany: true` has no column and is dropped in `mapFields`.
40
+ case "relationship":
41
+ return z.number();
42
+ case "checkbox":
43
+ // D1 stores booleans as 0/1 — accept either and normalize to boolean.
44
+ return z.union([z.boolean(), z.number().transform((n) => n !== 0)]);
45
+ case "date":
46
+ // D1 stores dates as integer epochs; a REST/JSON read may hand back a
47
+ // number or an ISO string. `coerce` accepts all three.
48
+ return z.coerce.date();
49
+ case "group":
50
+ // A group flattens to real columns in D1, but the Local API re-nests it on
51
+ // read — so mirror the config's nested object shape.
52
+ return z.object(mapFields(field.fields));
53
+ // JSON-backed columns (rich text, builder arrays, freeform json) pass through
54
+ // untouched — their inner shape is the site's concern, not the loader's.
55
+ case "richText":
56
+ case "array":
57
+ case "json":
58
+ return z.unknown();
59
+ default:
60
+ return z.unknown();
61
+ }
62
+ }
63
+ /** Build the `{ key: ZodType }` shape for a set of fields, honoring required. */
64
+ function mapFields(fields) {
65
+ const shape = {};
66
+ for (const [key, field] of Object.entries(fields)) {
67
+ // A hasMany relationship lives in a join table — no column on this row.
68
+ if (field.type === "relationship" && field.hasMany)
69
+ continue;
70
+ const base = fieldToZod(field);
71
+ // Non-required columns are nullable in D1 and may be absent from a row.
72
+ shape[key] = field.required ? base : base.nullable().optional();
73
+ }
74
+ return shape;
75
+ }
76
+ /**
77
+ * Build an Astro (Zod) schema from a collection's `defineCollection` fields.
78
+ * Unknown/bookkeeping columns (`id`, `status`, timestamps) are dropped — the
79
+ * schema captures exactly the declared fields, so `getCollection` entry data
80
+ * matches the editor's model.
81
+ */
82
+ export function collectionToAstroSchema(collection) {
83
+ return z.object(mapFields(collection.fields));
84
+ }
85
+ function errorMessage(error) {
86
+ return error instanceof Error ? error.message : String(error);
87
+ }
88
+ /**
89
+ * A Content Layer loader over a Louise D1 collection. Register it with Astro's
90
+ * `defineCollection`:
91
+ *
92
+ * ```ts
93
+ * // src/content.config.ts
94
+ * import { defineCollection } from "astro:content";
95
+ * import { louiseLoader } from "louise-toolkit/astro";
96
+ * import { pagesCollection } from "./pages-collection";
97
+ * import { readPublishedPages } from "./lib/louise/published-pages";
98
+ *
99
+ * export const collections = {
100
+ * pages: defineCollection({
101
+ * loader: louiseLoader({ collection: pagesCollection, read: readPublishedPages }),
102
+ * }),
103
+ * };
104
+ * ```
105
+ *
106
+ * then read with `getCollection("pages")` / `getEntry("pages", slug)` — typed
107
+ * from the collection's own fields, no hand-written schema.
108
+ */
109
+ export function louiseLoader(config) {
110
+ const slug = config.collection.slug;
111
+ const name = config.name ?? `louise:${slug}`;
112
+ const idOf = config.idOf ?? ((row) => (row.slug ?? row.id));
113
+ return {
114
+ name,
115
+ schema: collectionToAstroSchema(config.collection),
116
+ async load(context) {
117
+ // `parseData`/`generateDigest` are called on `context` rather than
118
+ // destructured: pulling them out unbinds them from the loader context
119
+ // (`typescript/unbound-method`); Astro binds them, but the bound call keeps
120
+ // the intent explicit.
121
+ const { store, logger } = context;
122
+ let rows;
123
+ try {
124
+ rows = await config.read();
125
+ }
126
+ catch (error) {
127
+ // Fail safe: leave the last good store in place rather than wiping the
128
+ // collection to empty on a transient read failure. `logger` is already
129
+ // scoped to the loader name by Astro, so don't re-prefix it.
130
+ logger.error(`read failed, keeping existing entries: ${errorMessage(error)}`);
131
+ return;
132
+ }
133
+ store.clear();
134
+ for (const row of rows) {
135
+ const id = String(idOf(row));
136
+ const data = await context.parseData({ id, data: row });
137
+ store.set({ id, data, digest: context.generateDigest(data) });
138
+ }
139
+ logger.info(`loaded ${rows.length} published ${rows.length === 1 ? "entry" : "entries"}`);
140
+ },
141
+ };
142
+ }
@@ -0,0 +1,13 @@
1
+ import { z } from "astro/zod";
2
+ import type { FormConfig } from "louise-toolkit/forms";
3
+ /**
4
+ * Build a Zod schema from a `defineForm` definition's fields — the form is the
5
+ * single source of truth for its Astro Action `input`, so the handler receives a
6
+ * typed, validated value and the client infers the same shape. Field-level
7
+ * `validation`/`schema` extras still run in the shared `validateSubmission` pass;
8
+ * this describes the field set's structural shape and its built-in type checks.
9
+ *
10
+ * Targets JSON actions (an absent optional field is `undefined`), matching how a
11
+ * typed island calls an action.
12
+ */
13
+ export declare function formToAstroSchema(form: FormConfig): z.ZodType;
@@ -0,0 +1,75 @@
1
+ // Copyright (c) 2026 BowenLabs. Louise Toolkit is MIT licensed.
2
+ //
3
+ // `louise-toolkit/astro` — `formToAstroSchema`: the forms counterpart to
4
+ // `collectionToAstroSchema`. It maps a `defineForm` definition to a Zod schema
5
+ // so a form can drop straight into an Astro Action's `input`:
6
+ //
7
+ // export const server = {
8
+ // inquiry: defineAction({
9
+ // input: formToAstroSchema(inquiryForm),
10
+ // handler: async (input) => { /* input is typed + validated */ },
11
+ // }),
12
+ // };
13
+ //
14
+ // This closes the gap where form actions took raw `FormData` + a hand-written
15
+ // interface + manual coercion: the field set is the single source of truth, and
16
+ // the client gets the inferred input type for free.
17
+ //
18
+ // Like the collection bridge, this lives in `louise-toolkit/astro` and pulls the
19
+ // Zod builder from `astro/zod` (an optional peer), so the framework-agnostic
20
+ // core never takes a Zod dependency.
21
+ import { z } from "astro/zod";
22
+ /** Map one form field to its Zod type, including the type's built-in format
23
+ * check (email/url) and coercion (number/date/checkbox). */
24
+ function formFieldToZod(field) {
25
+ const required = field.required ?? false;
26
+ switch (field.type) {
27
+ case "email":
28
+ return required ? z.email() : z.email().optional();
29
+ case "url":
30
+ return required ? z.url() : z.url().optional();
31
+ case "number":
32
+ // Form values arrive as strings; `coerce` turns "5" into 5.
33
+ return required ? z.coerce.number() : z.coerce.number().optional();
34
+ case "date":
35
+ // Accepts an ISO string, an epoch number, or a Date.
36
+ return required ? z.coerce.date() : z.coerce.date().optional();
37
+ case "checkbox": {
38
+ // A checkbox may arrive as a real boolean (JSON action), or "on"/"true"/
39
+ // "1"/1 (form-encoded) — normalize any of them to a boolean.
40
+ const bool = z
41
+ .union([z.boolean(), z.number(), z.string()])
42
+ .transform((v) => v === true || v === 1 || v === "1" || v === "true" || v === "on");
43
+ return required ? bool : bool.optional();
44
+ }
45
+ case "select": {
46
+ // Options double as the allowlist — a value outside them is rejected.
47
+ const select = field.options && field.options.length > 0
48
+ ? z.enum([...field.options])
49
+ : z.string();
50
+ return required ? select : select.optional();
51
+ }
52
+ default: {
53
+ // text / textarea / tel / file → a plain string (file holds the uploaded
54
+ // asset URL). Required string-likes must be non-empty.
55
+ return required ? z.string().min(1, `${field.label} is required`) : z.string().optional();
56
+ }
57
+ }
58
+ }
59
+ /**
60
+ * Build a Zod schema from a `defineForm` definition's fields — the form is the
61
+ * single source of truth for its Astro Action `input`, so the handler receives a
62
+ * typed, validated value and the client infers the same shape. Field-level
63
+ * `validation`/`schema` extras still run in the shared `validateSubmission` pass;
64
+ * this describes the field set's structural shape and its built-in type checks.
65
+ *
66
+ * Targets JSON actions (an absent optional field is `undefined`), matching how a
67
+ * typed island calls an action.
68
+ */
69
+ export function formToAstroSchema(form) {
70
+ const shape = {};
71
+ for (const [key, field] of Object.entries(form.fields)) {
72
+ shape[key] = formFieldToZod(field);
73
+ }
74
+ return z.object(shape);
75
+ }
@@ -0,0 +1,5 @@
1
+ export { type ActionErrorCtor, type EditorActionContext, type EditorActionDeps, type LouiseSaveActionConfig, type LouiseSaveDraftActionConfig, type LouiseSettingsActionConfig, louiseSaveAction, louiseSaveDraftAction, louiseSettingsAction, type SaveActionInput, type SaveDraftActionInput, } from "./actions.js";
2
+ export { type CatalogLoaderConfig, defineCatalogLoader } from "./catalog.js";
3
+ export { collectionToAstroSchema, louiseLoader, type LouiseLoaderConfig, type LouiseRow, } from "./content-loader.js";
4
+ export { formToAstroSchema } from "./form-schema.js";
5
+ export { createLouiseMiddleware, type LouiseMiddlewareConfig, type LouiseMiddlewareRateLimit, } from "./middleware.js";
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ // Copyright (c) 2026 BowenLabs. Louise Toolkit is MIT licensed.
2
+ //
3
+ // `@louise-toolkit/astro` — optional Astro glue for Louise sites. Framework-specific
4
+ // helpers that import Astro's types live here (never in the framework-agnostic
5
+ // core), so `astro` is an OPTIONAL peer, pulled in only by sites that import
6
+ // this subpath. First inhabitant: the shared middleware factory.
7
+ export { louiseSaveAction, louiseSaveDraftAction, louiseSettingsAction, } from "./actions.js";
8
+ export { defineCatalogLoader } from "./catalog.js";
9
+ export { collectionToAstroSchema, louiseLoader, } from "./content-loader.js";
10
+ export { formToAstroSchema } from "./form-schema.js";
11
+ export { createLouiseMiddleware, } from "./middleware.js";
@@ -0,0 +1,95 @@
1
+ import type { APIContext, MiddlewareHandler } from "astro";
2
+ import { type RateLimitBackend, type RateRule } from "louise-toolkit/security";
3
+ export interface LouiseMiddlewareRateLimit {
4
+ /** The site's rate-limit rules — the public POST surfaces worth protecting. */
5
+ rules: RateRule[];
6
+ /**
7
+ * Rate-limit backend — a KV counter or Cloudflare's native Rate Limiting
8
+ * binding, or a getter that yields one. A getter is resolved per request, so a
9
+ * `cloudflare:workers` `env` binding is read in request scope rather than at
10
+ * module-eval — the same reason editor Actions take `getEnv: () => env`. A
11
+ * getter that yields a falsy backend (e.g. the KV namespace isn't provisioned
12
+ * yet) simply skips rate-limiting — fail open, consistent with {@link rateLimit}.
13
+ */
14
+ kv: RateLimitBackend | (() => RateLimitBackend | undefined);
15
+ }
16
+ export interface LouiseMiddlewareConfig<TEditor = unknown> {
17
+ /**
18
+ * Resolve the editor session for a request — the site wraps its own auth,
19
+ * e.g. `resolveEditorSession(await getLouiseAuth(env, origin), request)`. A
20
+ * truthy result is written to `locals.editor` and unlocks edit mode; `null`
21
+ * renders the public page. A thrown error (e.g. missing bindings under plain
22
+ * `astro preview`) degrades to public rendering.
23
+ */
24
+ resolveEditor: (request: Request) => TEditor | null | Promise<TEditor | null>;
25
+ /** Rate-limit the public POST surfaces before any other work. Omit to skip. */
26
+ rateLimit?: LouiseMiddlewareRateLimit;
27
+ /**
28
+ * `style-src` replacement for the response CSP header — the site's allow-list.
29
+ * Astro's `security.csp` hashes inline island styles, which voids the
30
+ * `'unsafe-inline'` the data-driven `style=""` carriers need; this rewrites
31
+ * ONLY `style-src` (script hashes stay verbatim). No-op without a CSP header
32
+ * (astro dev). Omit to skip.
33
+ */
34
+ cspStyleSrc?: string;
35
+ /** Apply {@link louiseSecurityHeaders} (HSTS, nosniff, referrer, …) to the
36
+ * response. Default `true`. */
37
+ securityHeaders?: boolean;
38
+ /**
39
+ * Extra per-request work after editor resolution, before `next()` — e.g.
40
+ * resolve a second session (a shop customer) onto `locals`. Runs inside the
41
+ * same try/catch, so a throw degrades to public rendering.
42
+ */
43
+ extend?: (context: APIContext) => void | Promise<void>;
44
+ /**
45
+ * Authorize the request after {@link extend} has populated `locals`, and
46
+ * before the page runs. Return a `Response` to short-circuit (a redirect, a
47
+ * 401/403), or `undefined` to continue.
48
+ *
49
+ * Deliberately separate from `extend`: sessions must be resolved before
50
+ * anything can be authorized against them, and collapsing the two would make
51
+ * that ordering a convention rather than a guarantee. It runs OUTSIDE the
52
+ * `extend` try/catch, because a guard that throws must fail closed — a
53
+ * swallowed error there would serve the protected page.
54
+ */
55
+ guard?: (context: APIContext) => Response | undefined | Promise<Response | undefined>;
56
+ /**
57
+ * Rewrite the request internally before the page runs — return the path to
58
+ * render, or `undefined` to render the requested one. Runs **after**
59
+ * {@link guard}, so policy is still expressed against the URL the visitor
60
+ * actually asked for rather than an internal one.
61
+ *
62
+ * This exists because neither existing hook can rewrite: `extend` returns
63
+ * `void` and `guard` returns only a `Response`. Astro permits exactly one
64
+ * middleware file, and in a generated one there is nowhere else to put it.
65
+ *
66
+ * The motivating case is host dispatch — serving `*.example.com` from one
67
+ * Worker by mapping a subdomain onto an internal path prefix. The middleware
68
+ * stays policy-free: what a host means, and whether an unknown one is a 404,
69
+ * belong to the site.
70
+ *
71
+ * ```ts
72
+ * rewrite: (context) => {
73
+ * const tenant = context.locals.tenant;
74
+ * return tenant ? `/t/${tenant.slug}${context.url.pathname}` : undefined;
75
+ * },
76
+ * ```
77
+ *
78
+ * The visitor's URL is unchanged — this is an internal rewrite, not a
79
+ * redirect, so `context.url` still reads as the public address and links
80
+ * rendered from it stay correct.
81
+ */
82
+ rewrite?: (context: APIContext) => string | undefined | Promise<string | undefined>;
83
+ /** Edit-mode cookie name. Default {@link LOUISE_EDIT_COOKIE} (`"louise_edit"`).
84
+ * Change it and the `withEdgeCache` bypass predicate must be told too, or an
85
+ * editor gets served the cached public page. */
86
+ editCookie?: string;
87
+ }
88
+ /**
89
+ * Build the shared Louise Astro middleware: rate-limit → resolve the editor
90
+ * session + sticky `?louise` edit mode → `next()` → content-freshness cache headers
91
+ * + CSP `style-src` rewrite + transport security headers. Sites supply the bits
92
+ * that vary via {@link LouiseMiddlewareConfig} and export the result as
93
+ * `onRequest`.
94
+ */
95
+ export declare function createLouiseMiddleware<TEditor = unknown>(config: LouiseMiddlewareConfig<TEditor>): MiddlewareHandler;
@@ -0,0 +1,139 @@
1
+ // Copyright (c) 2026 BowenLabs. Louise Toolkit is MIT licensed.
2
+ //
3
+ // louise-toolkit/astro — the shared Louise Astro middleware, as a factory. Every
4
+ // Louise site's `middleware.ts` runs the same flow; only the auth wiring, rate
5
+ // rules, and CSP allow-list vary. `createLouiseMiddleware` owns the flow and
6
+ // takes those as config, so a site's middleware collapses to:
7
+ //
8
+ // export const onRequest = createLouiseMiddleware({
9
+ // resolveEditor: (req) =>
10
+ // resolveEditorSession(getLouiseAuth(env, new URL(req.url).origin), req),
11
+ // rateLimit: { rules: RATE_RULES, kv: () => env.RL },
12
+ // cspStyleSrc: "'self' 'unsafe-inline'",
13
+ // });
14
+ //
15
+ // (The brand font is bundled + base64-inlined — no Google Fonts host to allow.
16
+ // The middleware auto-allows `data:` fonts in the response CSP, so a strict
17
+ // `font-src` needs no manual change for the inlined @font-face.)
18
+ //
19
+ // This subpath is the ONE place Louise touches Astro's types — `astro` is an
20
+ // optional peer, pulled in only by sites that import `louise-toolkit/astro`.
21
+ import { allowCspDataFonts, louiseSecurityHeaders, matchRateRule, rateLimit, rewriteCspStyleSrc, } from "louise-toolkit/security";
22
+ import { LOUISE_EDIT_COOKIE } from "louise-toolkit/worker";
23
+ /**
24
+ * Build the shared Louise Astro middleware: rate-limit → resolve the editor
25
+ * session + sticky `?louise` edit mode → `next()` → content-freshness cache headers
26
+ * + CSP `style-src` rewrite + transport security headers. Sites supply the bits
27
+ * that vary via {@link LouiseMiddlewareConfig} and export the result as
28
+ * `onRequest`.
29
+ */
30
+ export function createLouiseMiddleware(config) {
31
+ // The default comes from the same constant `isEditRequest` reads, so the
32
+ // cookie this sets and the predicate that looks for it cannot drift apart.
33
+ const editCookie = config.editCookie ?? LOUISE_EDIT_COOKIE;
34
+ return async (context, next) => {
35
+ // Rate-limit the public, unauthenticated POST surfaces before any other
36
+ // work. Keyed by client IP via a KV counter; `rateLimit` fails open on a KV
37
+ // error so a limiter outage never takes down sign-in or the contact form.
38
+ if (config.rateLimit) {
39
+ const rule = matchRateRule(config.rateLimit.rules, context.request.method, context.url.pathname);
40
+ if (rule) {
41
+ // Resolve the backend only for a matched surface, and per request: a
42
+ // getter defers the `env` binding read to request scope (never
43
+ // module-eval). A falsy backend (binding not yet provisioned) skips
44
+ // limiting — fail open, like `rateLimit` itself.
45
+ const backend = typeof config.rateLimit.kv === "function" ? config.rateLimit.kv() : config.rateLimit.kv;
46
+ if (backend) {
47
+ const ip = context.request.headers.get("cf-connecting-ip") ?? "unknown";
48
+ const { ok, retryAfter } = await rateLimit(backend, `${rule.name}:${ip}`, rule.limit, rule.windowSec);
49
+ if (!ok) {
50
+ return new Response(JSON.stringify({ error: "Too many requests. Please try again shortly." }), {
51
+ status: 429,
52
+ headers: { "content-type": "application/json", "retry-after": String(retryAfter) },
53
+ });
54
+ }
55
+ }
56
+ }
57
+ }
58
+ const locals = context.locals;
59
+ locals.editor = null;
60
+ locals.editMode = false;
61
+ try {
62
+ const editor = await config.resolveEditor(context.request);
63
+ if (editor) {
64
+ locals.editor = editor;
65
+ // Edit mode is sticky: ?louise enters (sets a cookie), ?louise=off
66
+ // exits. The cookie alone never grants anything — the session above is
67
+ // always re-checked, so a stale cookie without a session renders public.
68
+ const param = context.url.searchParams.get("louise");
69
+ if (context.url.searchParams.has("louise") && param !== "off") {
70
+ // `secure` only over https, so plain-http localhost dev still round-trips
71
+ // the toggle. The cookie grants nothing on its own — the session above is
72
+ // re-verified every request — so this is hygiene, not a control.
73
+ context.cookies.set(editCookie, "1", {
74
+ path: "/",
75
+ sameSite: "lax",
76
+ secure: context.url.protocol === "https:",
77
+ });
78
+ locals.editMode = true;
79
+ }
80
+ else if (param === "off") {
81
+ context.cookies.delete(editCookie, { path: "/" });
82
+ }
83
+ else {
84
+ locals.editMode = context.cookies.get(editCookie)?.value === "1";
85
+ }
86
+ }
87
+ }
88
+ catch {
89
+ // Missing bindings (e.g. plain `astro preview`, an unprovisioned
90
+ // SESSION_SECRET) → public rendering. Auth degrading is fine; what it
91
+ // must NOT do is cancel anything else.
92
+ }
93
+ // extend gets its OWN catch, deliberately separate from auth's. When these
94
+ // shared one, `resolveEditor` throwing (a sentinel SESSION_SECRET — the
95
+ // dormant-until-provisioned state every module is supposed to survive)
96
+ // silently skipped extend too, and everything extend feeds died with it:
97
+ // `locals.tenant` never set, so host dispatch quietly served the ordinary
98
+ // site on every tenant subdomain. An unprovisioned editor secret must
99
+ // degrade to "signed out", never to "storefronts don't resolve".
100
+ try {
101
+ await config.extend?.(context);
102
+ }
103
+ catch {
104
+ // extend's own failure still degrades to public rendering.
105
+ }
106
+ // Outside the catch above, on purpose: a guard exists to REFUSE, so an
107
+ // error inside it must not be swallowed into "carry on and render the
108
+ // protected page". Locals are already populated by `extend` at this point.
109
+ const denied = await config.guard?.(context);
110
+ if (denied)
111
+ return denied;
112
+ // After the guard, deliberately: a rewrite changes which page renders, not
113
+ // who may see it, so authorizing against the rewritten path would mean
114
+ // policy written in internal URLs the site never publishes.
115
+ //
116
+ // Outside the try/catch too, for the same reason the guard is: a rewrite
117
+ // that throws must not degrade into rendering the UNREWRITTEN path, which
118
+ // for host dispatch is another tenant's page.
119
+ const rewrite = await config.rewrite?.(context);
120
+ const response = rewrite === undefined ? await next() : await next(rewrite);
121
+ // content freshness: cached HTML would hide editor edits. Edit-mode pages are
122
+ // per-editor and must be live (`no-store`); public HTML `no-cache` so edits
123
+ // appear without a manual purge. Only HTML — hashed `/_astro/*` assets keep
124
+ // their immutable caching (set via `_headers`).
125
+ if ((response.headers.get("content-type") ?? "").includes("text/html")) {
126
+ response.headers.set("Cache-Control", locals.editMode ? "no-store" : "no-cache");
127
+ }
128
+ if (config.cspStyleSrc)
129
+ rewriteCspStyleSrc(response, config.cspStyleSrc);
130
+ // Louise's bundled brand font is an inlined `data:` @font-face (loaded on
131
+ // every edit surface), so guarantee the CSP permits data: fonts — no-op
132
+ // without a CSP header or when already allowed. Saves consumers a font-src edit.
133
+ allowCspDataFonts(response);
134
+ if (config.securityHeaders !== false) {
135
+ louiseSecurityHeaders(response, { hostname: context.url.hostname });
136
+ }
137
+ return response;
138
+ };
139
+ }
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@louise-toolkit/astro",
3
+ "version": "0.1.0",
4
+ "description": "Astro adapter for louise-toolkit: middleware, Actions, content-layer loaders, and the forms schema bridge.",
5
+ "keywords": [
6
+ "astro",
7
+ "cloudflare",
8
+ "cms",
9
+ "louise",
10
+ "louise-toolkit"
11
+ ],
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/bowenlabs/louise-toolkit.git",
16
+ "directory": "packages/louise-astro"
17
+ },
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "type": "module",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "import": "./dist/index.js",
26
+ "default": "./dist/index.js"
27
+ },
28
+ "./package.json": "./package.json"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "dependencies": {
34
+ "louise-toolkit": "0.27.0"
35
+ },
36
+ "devDependencies": {
37
+ "@cloudflare/workers-types": "^5.20260829.1",
38
+ "@typescript/native-preview": "7.0.0-dev.20260707.2",
39
+ "astro": "^7.2.9",
40
+ "drizzle-orm": "^0.45.2",
41
+ "typescript": "^5.9.3",
42
+ "vitest": "^4.1.11"
43
+ },
44
+ "peerDependencies": {
45
+ "astro": "^7.0.6"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "astro": {
49
+ "optional": true
50
+ }
51
+ },
52
+ "scripts": {
53
+ "build": "tsgo -p tsconfig.build.json",
54
+ "check": "vp check",
55
+ "test": "vp test",
56
+ "typecheck": "tsgo --noEmit"
57
+ }
58
+ }