@louise-toolkit/astro 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -12
- package/dist/actions.d.ts +10 -10
- package/dist/actions.js +13 -13
- package/dist/catalog.d.ts +3 -3
- package/dist/catalog.js +3 -3
- package/dist/content-loader.d.ts +5 -5
- package/dist/content-loader.js +10 -10
- package/dist/form-schema.d.ts +1 -1
- package/dist/form-schema.js +4 -4
- package/dist/index.js +1 -1
- package/dist/middleware.d.ts +18 -18
- package/dist/middleware.js +12 -12
- package/dist/resume.d.ts +2 -2
- package/dist/resume.js +2 -2
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
# @louise-toolkit/astro
|
|
2
2
|
|
|
3
|
-
**Astro adapter for [Louise Toolkit](https://github.com/bowenlabs/louise-toolkit)
|
|
3
|
+
**Astro adapter for [Louise Toolkit](https://github.com/bowenlabs/louise-toolkit)**:
|
|
4
4
|
middleware, Actions, content-layer loaders, and the forms schema bridge.
|
|
5
5
|
|
|
6
|
-
> **Status: pre-1.0, experimental.** The API changes between minor versions
|
|
6
|
+
> **Status: pre-1.0, experimental.** The API changes between minor versions, so
|
|
7
7
|
> pin an exact version if you depend on it.
|
|
8
8
|
|
|
9
9
|
## Why this is a separate package
|
|
10
10
|
|
|
11
11
|
`louise-toolkit` is framework-agnostic, and that claim is enforced rather than
|
|
12
12
|
merely stated: CI fails if the string "astro" appears anywhere in the library's
|
|
13
|
-
source,
|
|
13
|
+
source, whether that's code or prose. Everything that has to import Astro's types lives
|
|
14
14
|
here instead.
|
|
15
15
|
|
|
16
|
-
So the dependency runs one way
|
|
17
|
-
the reverse
|
|
18
|
-
|
|
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
|
+
import this adapter.
|
|
19
19
|
|
|
20
20
|
## Install
|
|
21
21
|
|
|
@@ -29,7 +29,7 @@ top of both, or scaffold one with `pnpm create astroid`.
|
|
|
29
29
|
|
|
30
30
|
## What it gives you
|
|
31
31
|
|
|
32
|
-
**Middleware
|
|
32
|
+
**Middleware**: one factory that mounts Louise's editor routes, session handling,
|
|
33
33
|
and optional rate limiting into an Astro site.
|
|
34
34
|
|
|
35
35
|
```ts
|
|
@@ -47,20 +47,20 @@ Middleware runs before Astro knows which route file answers, so a public route
|
|
|
47
47
|
is declared by path: the toolkit's form and vitals routes are exempt at their
|
|
48
48
|
default paths, and `apiGate: { isPublic: (path) => … }` adds your own.
|
|
49
49
|
|
|
50
|
-
**Actions
|
|
51
|
-
rather than a hand-rolled endpoint. `louiseSaveAction`, `louiseSaveDraftAction
|
|
50
|
+
**Actions**: the editor write path as Astro Actions, so a save is a typed call
|
|
51
|
+
rather than a hand-rolled endpoint. `louiseSaveAction`, `louiseSaveDraftAction`,
|
|
52
52
|
and `louiseSettingsAction` wrap the same primitives the framework exposes, which
|
|
53
53
|
is why an agent writing over MCP and a human clicking in the editor take the
|
|
54
54
|
identical code path.
|
|
55
55
|
|
|
56
|
-
**Content-layer loaders
|
|
56
|
+
**Content-layer loaders**: `louiseLoader` feeds Louise-managed rows into Astro's
|
|
57
57
|
content layer, and `collectionToAstroSchema` derives the Zod schema from the same
|
|
58
58
|
`CollectionConfig` that drives codegen and the editor. One definition, not two
|
|
59
59
|
that drift.
|
|
60
60
|
|
|
61
|
-
**Catalog loader
|
|
61
|
+
**Catalog loader**: `defineCatalogLoader`, for commerce catalogs.
|
|
62
62
|
|
|
63
|
-
**Forms bridge
|
|
63
|
+
**Forms bridge**: `formToAstroSchema`, which derives an Astro-shaped schema from a
|
|
64
64
|
Louise `FormConfig`.
|
|
65
65
|
|
|
66
66
|
## Full exports
|
package/dist/actions.d.ts
CHANGED
|
@@ -5,8 +5,8 @@ import type { EditorRouteEnv } from "louise-toolkit/editor";
|
|
|
5
5
|
import { type SaveDraftDeps } from "louise-toolkit/editor";
|
|
6
6
|
/** The subset of Astro's `ActionError` codes the editor handlers emit. */
|
|
7
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
|
-
*
|
|
8
|
+
/** The shape of Astro's `ActionError` constructor the handlers depend on—injected
|
|
9
|
+
* (see file header) so the toolkit needn't import `astro:actions`. */
|
|
10
10
|
export interface ActionErrorCtor {
|
|
11
11
|
new (opts: {
|
|
12
12
|
code: ActionErrorCode;
|
|
@@ -15,7 +15,7 @@ export interface ActionErrorCtor {
|
|
|
15
15
|
}
|
|
16
16
|
/** The slice of an Astro `ActionAPIContext` the editor handlers read: the
|
|
17
17
|
* middleware-resolved `locals.editor`, plus `cookies` for the D1 bookmark. The
|
|
18
|
-
* Worker `env` is NOT read off the context
|
|
18
|
+
* Worker `env` is NOT read off the context—Astro v6+ removed
|
|
19
19
|
* `Astro.locals.runtime.env`, so it's supplied by the injected
|
|
20
20
|
* {@link EditorActionDeps.getEnv}. A real context (which carries much more)
|
|
21
21
|
* structurally satisfies this. */
|
|
@@ -40,7 +40,7 @@ export interface EditorActionDeps<Env extends EditorRouteEnv = EditorRouteEnv> {
|
|
|
40
40
|
* (set by `createLouiseMiddleware`). A falsy result answers 401. */
|
|
41
41
|
getEditor?: (ctx: EditorActionContext) => unknown;
|
|
42
42
|
/**
|
|
43
|
-
* Resolve the Worker `env` (the D1 binding) for the Action. **Required
|
|
43
|
+
* Resolve the Worker `env` (the D1 binding) for the Action. **Required**—Astro
|
|
44
44
|
* v6+ removed `Astro.locals.runtime.env`, so there is no context field to default
|
|
45
45
|
* to; inject the bindings explicitly, typically by closing over the Cloudflare
|
|
46
46
|
* `env` (the `ctx` argument is available for per-request selection but usually
|
|
@@ -53,7 +53,7 @@ export interface EditorActionDeps<Env extends EditorRouteEnv = EditorRouteEnv> {
|
|
|
53
53
|
*/
|
|
54
54
|
getEnv: (ctx: EditorActionContext) => Env;
|
|
55
55
|
}
|
|
56
|
-
/** The validated `save` input
|
|
56
|
+
/** The validated `save` input—the inline field-save body, same keys the raw
|
|
57
57
|
* route's `SAVE_BODY` uses. `value` stays `unknown`; its non-empty-string check
|
|
58
58
|
* needs the collection config and so lives in `applyFieldSave`. */
|
|
59
59
|
export interface SaveActionInput {
|
|
@@ -63,7 +63,7 @@ export interface SaveActionInput {
|
|
|
63
63
|
value: unknown;
|
|
64
64
|
}
|
|
65
65
|
export interface LouiseSaveActionConfig<Env extends EditorRouteEnv = EditorRouteEnv> extends EditorActionDeps<Env> {
|
|
66
|
-
/** Editable collections keyed by the client's `collection` slug
|
|
66
|
+
/** Editable collections keyed by the client's `collection` slug—the same
|
|
67
67
|
* shape the raw `saveRoute` takes. */
|
|
68
68
|
collections: Record<string, SaveCollectionConfig>;
|
|
69
69
|
/** Rich-HTML sanitizer; defaults to louise-toolkit/security's `sanitizeRichHtml`. */
|
|
@@ -83,8 +83,8 @@ export interface LouiseSaveDraftActionConfig<Env extends EditorRouteEnv = Editor
|
|
|
83
83
|
/**
|
|
84
84
|
* Build the `{ input, handler }` config for the editor `save` Action (the inline
|
|
85
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
|
|
87
|
-
* the raw route's manual `request.json()` + `standardValidate
|
|
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
88
|
* shares the raw route's store path via {@link applyFieldSave}, so a field is
|
|
89
89
|
* validated once and written in exactly one place.
|
|
90
90
|
*/
|
|
@@ -119,8 +119,8 @@ export declare function louiseSettingsAction<Env extends EditorRouteEnv = Editor
|
|
|
119
119
|
* versioned-page draft save). Mirrors {@link louiseSaveAction}, but the input
|
|
120
120
|
* bundles the row `id` with the changed `data` (an Action call has no URL to carry
|
|
121
121
|
* the id). The handler shares the raw `versionsRoute` store path via
|
|
122
|
-
* {@link applySaveDraft}
|
|
123
|
-
* write-buffer
|
|
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
124
|
* `{ buffered: true }` when a write is coalesced into the buffer).
|
|
125
125
|
*/
|
|
126
126
|
export declare function louiseSaveDraftAction<Env extends EditorRouteEnv = EditorRouteEnv>(config: LouiseSaveDraftActionConfig<Env>): {
|
package/dist/actions.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Copyright (c) 2026 BowenLabs. Louise Toolkit is MIT licensed.
|
|
2
2
|
//
|
|
3
|
-
// `louise-toolkit/astro
|
|
3
|
+
// `louise-toolkit/astro`—the editor mutations as Astro Actions (#72): typed,
|
|
4
4
|
// Zod-validated server functions so a site calls `actions.louise.save(...)` /
|
|
5
5
|
// `settings(...)` and gets end-to-end types + automatic input validation, instead
|
|
6
6
|
// of hand-building a `fetch("/api/louise/*")` JSON body and re-parsing it.
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
//
|
|
20
20
|
// The Worker `env` (the D1 binding) is injected via `getEnv`, not read off the
|
|
21
21
|
// context: Astro v6+ removed `Astro.locals.runtime.env`, so a site hands in its
|
|
22
|
-
// bindings
|
|
22
|
+
// bindings—typically `() => env` from `cloudflare:workers`—the same way the
|
|
23
23
|
// core primitives take their bindings by injection (the library never reaches for
|
|
24
24
|
// `cloudflare:workers` itself).
|
|
25
25
|
//
|
|
26
26
|
// Why a factory that returns `{ input, handler }` instead of a ready `defineAction`:
|
|
27
27
|
// `defineAction`/`ActionError` live in Astro's VIRTUAL `astro:actions` module,
|
|
28
|
-
// which only resolves inside an Astro app
|
|
29
|
-
// imports only real `astro/*` subpaths,
|
|
28
|
+
// which only resolves inside an Astro app—a library can't import it (this subpath
|
|
29
|
+
// imports only real `astro/*` subpaths, for example `astro/zod`). So the adapter ships the
|
|
30
30
|
// ingredients and the SITE assembles `defineAction`, and it takes the `ActionError`
|
|
31
31
|
// class by injection so the handler can still throw framework-correct 400/401/404.
|
|
32
32
|
//
|
|
@@ -53,8 +53,8 @@ function statusToCode(status) {
|
|
|
53
53
|
return "BAD_REQUEST";
|
|
54
54
|
}
|
|
55
55
|
/** Resolve an editor Action's deps, filling the default `locals.editor` reader.
|
|
56
|
-
* `getEnv` has no default
|
|
57
|
-
* can't exist
|
|
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
58
|
* action-construction time, rather than a per-request 500 on an `undefined` env. */
|
|
59
59
|
function resolveDeps(deps) {
|
|
60
60
|
if (typeof deps.getEnv !== "function") {
|
|
@@ -68,8 +68,8 @@ function resolveDeps(deps) {
|
|
|
68
68
|
getEnv: deps.getEnv,
|
|
69
69
|
};
|
|
70
70
|
}
|
|
71
|
-
/** Require the (middleware-resolved) editor session
|
|
72
|
-
*
|
|
71
|
+
/** Require the (middleware-resolved) editor session—a missing one is a 401—and
|
|
72
|
+
* return it. CSRF/same-origin is Astro's default for Action POSTs, so only
|
|
73
73
|
* auth is ported. */
|
|
74
74
|
function requireEditor(resolved, ctx) {
|
|
75
75
|
const editor = resolved.getEditor(ctx);
|
|
@@ -78,7 +78,7 @@ function requireEditor(resolved, ctx) {
|
|
|
78
78
|
}
|
|
79
79
|
return editor;
|
|
80
80
|
}
|
|
81
|
-
/** Throw the injected `ActionError` for an `apply*` failure
|
|
81
|
+
/** Throw the injected `ActionError` for an `apply*` failure—`never`, so a
|
|
82
82
|
* `if (!result.ok) throwActionError(...)` narrows the result to its ok branch. */
|
|
83
83
|
function throwActionError(ActionError, status, error) {
|
|
84
84
|
throw new ActionError({ code: statusToCode(status), message: error });
|
|
@@ -86,8 +86,8 @@ function throwActionError(ActionError, status, error) {
|
|
|
86
86
|
/**
|
|
87
87
|
* Build the `{ input, handler }` config for the editor `save` Action (the inline
|
|
88
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
|
|
90
|
-
* the raw route's manual `request.json()` + `standardValidate
|
|
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
91
|
* shares the raw route's store path via {@link applyFieldSave}, so a field is
|
|
92
92
|
* validated once and written in exactly one place.
|
|
93
93
|
*/
|
|
@@ -138,8 +138,8 @@ export function louiseSettingsAction(config) {
|
|
|
138
138
|
* versioned-page draft save). Mirrors {@link louiseSaveAction}, but the input
|
|
139
139
|
* bundles the row `id` with the changed `data` (an Action call has no URL to carry
|
|
140
140
|
* the id). The handler shares the raw `versionsRoute` store path via
|
|
141
|
-
* {@link applySaveDraft}
|
|
142
|
-
* write-buffer
|
|
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
143
|
* `{ buffered: true }` when a write is coalesced into the buffer).
|
|
144
144
|
*/
|
|
145
145
|
export function louiseSaveDraftAction(config) {
|
package/dist/catalog.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { LiveLoader } from "astro/loaders";
|
|
|
2
2
|
type AnyRecord = Record<string, any>;
|
|
3
3
|
/**
|
|
4
4
|
* What a site provides to build a catalog live loader. `Data` is the entry shape
|
|
5
|
-
* (
|
|
5
|
+
* (for example, a display product); `Filter` is the collection query shape.
|
|
6
6
|
*/
|
|
7
7
|
export interface CatalogLoaderConfig<Data extends AnyRecord, Filter extends AnyRecord = Record<string, never>> {
|
|
8
8
|
/** Loader name (Astro convention: the npm package or a stable id). Also the
|
|
@@ -10,7 +10,7 @@ export interface CatalogLoaderConfig<Data extends AnyRecord, Filter extends AnyR
|
|
|
10
10
|
name: string;
|
|
11
11
|
/**
|
|
12
12
|
* Load the (cached) catalog for a collection query, already narrowed to what
|
|
13
|
-
* the query asks for (the site owns its own filtering
|
|
13
|
+
* the query asks for (the site owns its own filtering—category trees, etc.).
|
|
14
14
|
* `fetchedAt` (epoch ms) becomes the `cacheHint.lastModified` so the hint
|
|
15
15
|
* reflects the snapshot's age, not the render time.
|
|
16
16
|
*/
|
|
@@ -21,7 +21,7 @@ export interface CatalogLoaderConfig<Data extends AnyRecord, Filter extends AnyR
|
|
|
21
21
|
/** Resolve a single item by its entry id (slug). `null` → not found (Astro
|
|
22
22
|
* raises `LiveEntryNotFoundError`, which the page can turn into a redirect). */
|
|
23
23
|
loadItem: (id: string) => Promise<Data | null>;
|
|
24
|
-
/** The entry id (slug) for an item
|
|
24
|
+
/** The entry id (slug) for an item—keys the collection and resolves
|
|
25
25
|
* `getLiveEntry("catalog", slug)`. */
|
|
26
26
|
idOf: (item: Data) => string;
|
|
27
27
|
/** `cacheHint` tag for tag-based purges. Default: `name`. */
|
package/dist/catalog.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
// Copyright (c) 2026 BowenLabs. Louise Toolkit is MIT licensed.
|
|
2
2
|
//
|
|
3
|
-
// `louise-toolkit/astro
|
|
3
|
+
// `louise-toolkit/astro`—`defineCatalogLoader`: the shared plumbing for a commerce
|
|
4
4
|
// catalog served as an Astro Live Content Collection. Live collections fetch at
|
|
5
5
|
// request time, so a price/stock edit shows on the next render with no rebuild.
|
|
6
6
|
//
|
|
7
|
-
// Every catalog loader repeats the same boilerplate
|
|
7
|
+
// Every catalog loader repeats the same boilerplate—map items to keyed entries,
|
|
8
8
|
// stamp a `cacheHint` (a tag for future webhook-driven purges, plus the snapshot
|
|
9
9
|
// age as `lastModified`), and wrap a read failure as a loader error instead of a
|
|
10
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
|
|
11
|
+
// read its (cached) catalog, how to resolve one item, and each item's slug—so
|
|
12
12
|
// a Fourthwall site and a Square site share one loader definition and only their
|
|
13
13
|
// `lib/<provider>` reads differ.
|
|
14
14
|
//
|
package/dist/content-loader.d.ts
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
import type { Loader } from "astro/loaders";
|
|
2
2
|
import { z } from "astro/zod";
|
|
3
3
|
import type { CollectionConfig } from "louise-toolkit/content";
|
|
4
|
-
/** A published row as read from D1
|
|
4
|
+
/** A published row as read from D1—a document in the collection's field shape. */
|
|
5
5
|
export type LouiseRow = Record<string, unknown>;
|
|
6
6
|
/**
|
|
7
7
|
* Build an Astro (Zod) schema from a collection's `defineCollection` fields.
|
|
8
|
-
* Unknown/bookkeeping columns (`id`, `status`, timestamps) are dropped
|
|
8
|
+
* Unknown/bookkeeping columns (`id`, `status`, timestamps) are dropped—the
|
|
9
9
|
* schema captures exactly the declared fields, so `getCollection` entry data
|
|
10
10
|
* matches the editor's model.
|
|
11
11
|
*/
|
|
12
12
|
export declare function collectionToAstroSchema(collection: CollectionConfig): z.ZodType;
|
|
13
13
|
export interface LouiseLoaderConfig {
|
|
14
|
-
/** The collection definition (from `defineCollection`)
|
|
14
|
+
/** The collection definition (from `defineCollection`)—drives the schema. */
|
|
15
15
|
collection: CollectionConfig;
|
|
16
16
|
/**
|
|
17
17
|
* Read the published rows to expose, each a document in the collection's field
|
|
18
18
|
* shape. The site owns D1 access (the loader runs at build time, off any
|
|
19
|
-
* binding)
|
|
19
|
+
* binding)—typically the D1 REST API, or a cached snapshot. Only published
|
|
20
20
|
* rows should be returned; drafts never reach `getCollection`.
|
|
21
21
|
*/
|
|
22
22
|
read: () => Promise<LouiseRow[]>;
|
|
@@ -46,7 +46,7 @@ export interface LouiseLoaderConfig {
|
|
|
46
46
|
* };
|
|
47
47
|
* ```
|
|
48
48
|
*
|
|
49
|
-
* then read with `getCollection("pages")` / `getEntry("pages", slug)
|
|
49
|
+
* then read with `getCollection("pages")` / `getEntry("pages", slug)`—typed
|
|
50
50
|
* from the collection's own fields, no hand-written schema.
|
|
51
51
|
*/
|
|
52
52
|
export declare function louiseLoader(config: LouiseLoaderConfig): Loader;
|
package/dist/content-loader.js
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
// Copyright (c) 2026 BowenLabs. Louise Toolkit is MIT licensed.
|
|
2
2
|
//
|
|
3
|
-
// `louise-toolkit/astro
|
|
3
|
+
// `louise-toolkit/astro`—`louiseLoader`: an Astro Content Layer loader that
|
|
4
4
|
// exposes a Louise D1 collection through the native `getCollection()` /
|
|
5
5
|
// `getEntry()` pipeline, with a Zod schema derived from the collection's own
|
|
6
6
|
// `defineCollection` fields (so entry data is typed the same way the editor
|
|
7
7
|
// models it).
|
|
8
8
|
//
|
|
9
9
|
// Content Layer loaders run at BUILD time (in Node, during `astro build`), where
|
|
10
|
-
// Cloudflare bindings don't exist
|
|
10
|
+
// Cloudflare bindings don't exist—so, like `defineCatalogLoader`, the *read*
|
|
11
11
|
// is injected: a site supplies `read()` (typically the D1 REST API at build, or
|
|
12
|
-
// a snapshot) and this owns the rest
|
|
12
|
+
// a snapshot) and this owns the rest—schema mapping, store population, content
|
|
13
13
|
// digests for incremental builds, and fail-safe error handling. The result is a
|
|
14
|
-
// build-time snapshot of published content; rebuild on publish (
|
|
14
|
+
// build-time snapshot of published content; rebuild on publish (for example, a webhook)
|
|
15
15
|
// to refresh it. For request-time freshness, read D1 directly in an SSR page (or
|
|
16
16
|
// use the Live-collection `defineCatalogLoader`).
|
|
17
17
|
//
|
|
@@ -40,7 +40,7 @@ function fieldToZod(field) {
|
|
|
40
40
|
case "relationship":
|
|
41
41
|
return z.number();
|
|
42
42
|
case "checkbox":
|
|
43
|
-
// D1 stores booleans as 0/1
|
|
43
|
+
// D1 stores booleans as 0/1—accept either and normalize to boolean.
|
|
44
44
|
return z.union([z.boolean(), z.number().transform((n) => n !== 0)]);
|
|
45
45
|
case "date":
|
|
46
46
|
// D1 stores dates as integer epochs; a REST/JSON read may hand back a
|
|
@@ -48,10 +48,10 @@ function fieldToZod(field) {
|
|
|
48
48
|
return z.coerce.date();
|
|
49
49
|
case "group":
|
|
50
50
|
// A group flattens to real columns in D1, but the Local API re-nests it on
|
|
51
|
-
// read
|
|
51
|
+
// read—so mirror the config's nested object shape.
|
|
52
52
|
return z.object(mapFields(field.fields));
|
|
53
53
|
// JSON-backed columns (rich text, builder arrays, freeform json) pass through
|
|
54
|
-
// untouched
|
|
54
|
+
// untouched—their inner shape is the site's concern, not the loader's.
|
|
55
55
|
case "richText":
|
|
56
56
|
case "array":
|
|
57
57
|
case "json":
|
|
@@ -64,7 +64,7 @@ function fieldToZod(field) {
|
|
|
64
64
|
function mapFields(fields) {
|
|
65
65
|
const shape = {};
|
|
66
66
|
for (const [key, field] of Object.entries(fields)) {
|
|
67
|
-
// A hasMany relationship lives in a join table
|
|
67
|
+
// A hasMany relationship lives in a join table—no column on this row.
|
|
68
68
|
if (field.type === "relationship" && field.hasMany)
|
|
69
69
|
continue;
|
|
70
70
|
const base = fieldToZod(field);
|
|
@@ -75,7 +75,7 @@ function mapFields(fields) {
|
|
|
75
75
|
}
|
|
76
76
|
/**
|
|
77
77
|
* Build an Astro (Zod) schema from a collection's `defineCollection` fields.
|
|
78
|
-
* Unknown/bookkeeping columns (`id`, `status`, timestamps) are dropped
|
|
78
|
+
* Unknown/bookkeeping columns (`id`, `status`, timestamps) are dropped—the
|
|
79
79
|
* schema captures exactly the declared fields, so `getCollection` entry data
|
|
80
80
|
* matches the editor's model.
|
|
81
81
|
*/
|
|
@@ -103,7 +103,7 @@ function errorMessage(error) {
|
|
|
103
103
|
* };
|
|
104
104
|
* ```
|
|
105
105
|
*
|
|
106
|
-
* then read with `getCollection("pages")` / `getEntry("pages", slug)
|
|
106
|
+
* then read with `getCollection("pages")` / `getEntry("pages", slug)`—typed
|
|
107
107
|
* from the collection's own fields, no hand-written schema.
|
|
108
108
|
*/
|
|
109
109
|
export function louiseLoader(config) {
|
package/dist/form-schema.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "astro/zod";
|
|
2
2
|
import type { FormConfig } from "louise-toolkit/forms";
|
|
3
3
|
/**
|
|
4
|
-
* Build a Zod schema from a `defineForm` definition's fields
|
|
4
|
+
* Build a Zod schema from a `defineForm` definition's fields—the form is the
|
|
5
5
|
* single source of truth for its Astro Action `input`, so the handler receives a
|
|
6
6
|
* typed, validated value and the client infers the same shape. Field-level
|
|
7
7
|
* `validation`/`schema` extras still run in the shared `validateSubmission` pass;
|
package/dist/form-schema.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Copyright (c) 2026 BowenLabs. Louise Toolkit is MIT licensed.
|
|
2
2
|
//
|
|
3
|
-
// `louise-toolkit/astro
|
|
3
|
+
// `louise-toolkit/astro`—`formToAstroSchema`: the forms counterpart to
|
|
4
4
|
// `collectionToAstroSchema`. It maps a `defineForm` definition to a Zod schema
|
|
5
5
|
// so a form can drop straight into an Astro Action's `input`:
|
|
6
6
|
//
|
|
@@ -36,14 +36,14 @@ function formFieldToZod(field) {
|
|
|
36
36
|
return required ? z.coerce.date() : z.coerce.date().optional();
|
|
37
37
|
case "checkbox": {
|
|
38
38
|
// A checkbox may arrive as a real boolean (JSON action), or "on"/"true"/
|
|
39
|
-
// "1"/1 (form-encoded)
|
|
39
|
+
// "1"/1 (form-encoded)—normalize any of them to a boolean.
|
|
40
40
|
const bool = z
|
|
41
41
|
.union([z.boolean(), z.number(), z.string()])
|
|
42
42
|
.transform((v) => v === true || v === 1 || v === "1" || v === "true" || v === "on");
|
|
43
43
|
return required ? bool : bool.optional();
|
|
44
44
|
}
|
|
45
45
|
case "select": {
|
|
46
|
-
// Options double as the allowlist
|
|
46
|
+
// Options double as the allowlist—a value outside them is rejected.
|
|
47
47
|
const select = field.options && field.options.length > 0
|
|
48
48
|
? z.enum([...field.options])
|
|
49
49
|
: z.string();
|
|
@@ -57,7 +57,7 @@ function formFieldToZod(field) {
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
/**
|
|
60
|
-
* Build a Zod schema from a `defineForm` definition's fields
|
|
60
|
+
* Build a Zod schema from a `defineForm` definition's fields—the form is the
|
|
61
61
|
* single source of truth for its Astro Action `input`, so the handler receives a
|
|
62
62
|
* typed, validated value and the client infers the same shape. Field-level
|
|
63
63
|
* `validation`/`schema` extras still run in the shared `validateSubmission` pass;
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Copyright (c) 2026 BowenLabs. Louise Toolkit is MIT licensed.
|
|
2
2
|
//
|
|
3
|
-
// `@louise-toolkit/astro
|
|
3
|
+
// `@louise-toolkit/astro`—optional Astro glue for Louise sites. Framework-specific
|
|
4
4
|
// helpers that import Astro's types live here (never in the framework-agnostic
|
|
5
5
|
// core), so `astro` is an OPTIONAL peer, pulled in only by sites that import
|
|
6
6
|
// this subpath. First inhabitant: the shared middleware factory.
|
package/dist/middleware.d.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import type { APIContext, MiddlewareHandler } from "astro";
|
|
2
2
|
import { type RateLimitBackend, type RateRule } from "louise-toolkit/security";
|
|
3
3
|
export interface LouiseMiddlewareRateLimit {
|
|
4
|
-
/** The site's rate-limit rules
|
|
4
|
+
/** The site's rate-limit rules—the public POST surfaces worth protecting. */
|
|
5
5
|
rules: RateRule[];
|
|
6
6
|
/**
|
|
7
|
-
* Rate-limit backend
|
|
7
|
+
* Rate-limit backend—a KV counter or Cloudflare's native Rate Limiting
|
|
8
8
|
* binding, or a getter that yields one. A getter is resolved per request, so a
|
|
9
9
|
* `cloudflare:workers` `env` binding is read in request scope rather than at
|
|
10
|
-
* module-eval
|
|
11
|
-
* getter that yields a falsy backend (
|
|
12
|
-
* yet) simply skips rate-limiting
|
|
10
|
+
* module-eval—the same reason editor Actions take `getEnv: () => env`. A
|
|
11
|
+
* getter that yields a falsy backend (for example, the KV namespace isn't provisioned
|
|
12
|
+
* yet) simply skips rate-limiting—fail open, consistent with {@link rateLimit}.
|
|
13
13
|
*/
|
|
14
14
|
kv: RateLimitBackend | (() => RateLimitBackend | undefined);
|
|
15
15
|
}
|
|
@@ -18,7 +18,7 @@ export interface LouiseMiddlewareApiGate {
|
|
|
18
18
|
prefix?: string;
|
|
19
19
|
/**
|
|
20
20
|
* Paths under the prefix an anonymous request may still reach, on top of the
|
|
21
|
-
* toolkit's own public routes at their default mounts (forms and vitals
|
|
21
|
+
* toolkit's own public routes at their default mounts (forms and vitals—see
|
|
22
22
|
* `isLouisePublicPath`).
|
|
23
23
|
*
|
|
24
24
|
* A path, not a mark on the route, because middleware runs before it knows
|
|
@@ -29,17 +29,17 @@ export interface LouiseMiddlewareApiGate {
|
|
|
29
29
|
}
|
|
30
30
|
export interface LouiseMiddlewareConfig<TEditor = unknown> {
|
|
31
31
|
/**
|
|
32
|
-
* Resolve the editor session for a request
|
|
33
|
-
*
|
|
32
|
+
* Resolve the editor session for a request—the site wraps its own auth,
|
|
33
|
+
* for example, `resolveEditorSession(await getLouiseAuth(env, origin), request)`. A
|
|
34
34
|
* truthy result is written to `locals.editor` and unlocks edit mode; `null`
|
|
35
|
-
* renders the public page. A thrown error (
|
|
35
|
+
* renders the public page. A thrown error (for example, missing bindings under plain
|
|
36
36
|
* `astro preview`) degrades to public rendering.
|
|
37
37
|
*/
|
|
38
38
|
resolveEditor: (request: Request) => TEditor | null | Promise<TEditor | null>;
|
|
39
39
|
/** Rate-limit the public POST surfaces before any other work. Omit to skip. */
|
|
40
40
|
rateLimit?: LouiseMiddlewareRateLimit;
|
|
41
41
|
/**
|
|
42
|
-
* `style-src` replacement for the response CSP header
|
|
42
|
+
* `style-src` replacement for the response CSP header—the site's allow-list.
|
|
43
43
|
* Astro's `security.csp` hashes inline island styles, which voids the
|
|
44
44
|
* `'unsafe-inline'` the data-driven `style=""` carriers need; this rewrites
|
|
45
45
|
* ONLY `style-src` (script hashes stay verbatim). No-op without a CSP header
|
|
@@ -50,13 +50,13 @@ export interface LouiseMiddlewareConfig<TEditor = unknown> {
|
|
|
50
50
|
* response. Default `true`. */
|
|
51
51
|
securityHeaders?: boolean;
|
|
52
52
|
/**
|
|
53
|
-
* Hosts to keep out of search indexes
|
|
54
|
-
*
|
|
53
|
+
* Hosts to keep out of search indexes—sent as `X-Robots-Tag: noindex`.
|
|
54
|
+
* For example, `(host) => isNoindexHost(host, { prefixes: ["preview."] })`. Set here
|
|
55
55
|
* rather than in a page, because a streamed page's headers are already gone.
|
|
56
56
|
*/
|
|
57
57
|
noindex?: (hostname: string) => boolean;
|
|
58
58
|
/**
|
|
59
|
-
* Extra per-request work after editor resolution, before `next()
|
|
59
|
+
* Extra per-request work after editor resolution, before `next()`—for example,
|
|
60
60
|
* resolve a second session (a shop customer) onto `locals`. Runs inside the
|
|
61
61
|
* same try/catch, so a throw degrades to public rendering.
|
|
62
62
|
*/
|
|
@@ -69,12 +69,12 @@ export interface LouiseMiddlewareConfig<TEditor = unknown> {
|
|
|
69
69
|
* Deliberately separate from `extend`: sessions must be resolved before
|
|
70
70
|
* anything can be authorized against them, and collapsing the two would make
|
|
71
71
|
* that ordering a convention rather than a guarantee. It runs OUTSIDE the
|
|
72
|
-
* `extend` try/catch, because a guard that throws must fail closed
|
|
72
|
+
* `extend` try/catch, because a guard that throws must fail closed—a
|
|
73
73
|
* swallowed error there would serve the protected page.
|
|
74
74
|
*/
|
|
75
75
|
guard?: (context: APIContext) => Response | undefined | Promise<Response | undefined>;
|
|
76
76
|
/**
|
|
77
|
-
* Rewrite the request internally before the page runs
|
|
77
|
+
* Rewrite the request internally before the page runs—return the path to
|
|
78
78
|
* render, or `undefined` to render the requested one. Runs **after**
|
|
79
79
|
* {@link guard}, so policy is still expressed against the URL the visitor
|
|
80
80
|
* actually asked for rather than an internal one.
|
|
@@ -83,7 +83,7 @@ export interface LouiseMiddlewareConfig<TEditor = unknown> {
|
|
|
83
83
|
* `void` and `guard` returns only a `Response`. Astro permits exactly one
|
|
84
84
|
* middleware file, and in a generated one there is nowhere else to put it.
|
|
85
85
|
*
|
|
86
|
-
* The motivating case is host dispatch
|
|
86
|
+
* The motivating case is host dispatch—serving `*.example.com` from one
|
|
87
87
|
* Worker by mapping a subdomain onto an internal path prefix. The middleware
|
|
88
88
|
* stays policy-free: what a host means, and whether an unknown one is a 404,
|
|
89
89
|
* belong to the site.
|
|
@@ -95,7 +95,7 @@ export interface LouiseMiddlewareConfig<TEditor = unknown> {
|
|
|
95
95
|
* },
|
|
96
96
|
* ```
|
|
97
97
|
*
|
|
98
|
-
* The visitor's URL is unchanged
|
|
98
|
+
* The visitor's URL is unchanged—this is an internal rewrite, not a
|
|
99
99
|
* redirect, so `context.url` still reads as the public address and links
|
|
100
100
|
* rendered from it stay correct.
|
|
101
101
|
*/
|
|
@@ -103,7 +103,7 @@ export interface LouiseMiddlewareConfig<TEditor = unknown> {
|
|
|
103
103
|
/**
|
|
104
104
|
* Deny-by-default gate for the editor API (ADR 0012), for routes mounted as
|
|
105
105
|
* framework API routes (`runEditorRoute`) rather than `composeWorker` routes.
|
|
106
|
-
* `true
|
|
106
|
+
* `true`—or an object to change the prefix or add public paths—and every
|
|
107
107
|
* request under `/api/louise` must resolve to an editor, with writes and
|
|
108
108
|
* WebSocket upgrades origin-checked, before any route runs. Gated responses
|
|
109
109
|
* get `Cache-Control: no-store` unless the route set its own. Omit to skip.
|
package/dist/middleware.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Copyright (c) 2026 BowenLabs. Louise Toolkit is MIT licensed.
|
|
2
2
|
//
|
|
3
|
-
// louise-toolkit/astro
|
|
3
|
+
// louise-toolkit/astro—the shared Louise Astro middleware, as a factory. Every
|
|
4
4
|
// Louise site's `middleware.ts` runs the same flow; only the auth wiring, rate
|
|
5
5
|
// rules, and CSP allow-list vary. `createLouiseMiddleware` owns the flow and
|
|
6
6
|
// takes those as config, so a site's middleware collapses to:
|
|
@@ -12,11 +12,11 @@
|
|
|
12
12
|
// cspStyleSrc: "'self' 'unsafe-inline'",
|
|
13
13
|
// });
|
|
14
14
|
//
|
|
15
|
-
// (The brand font is bundled + base64-inlined
|
|
15
|
+
// (The brand font is bundled + base64-inlined—no Google Fonts host to allow.
|
|
16
16
|
// The middleware auto-allows `data:` fonts in the response CSP, so a strict
|
|
17
17
|
// `font-src` needs no manual change for the inlined @font-face.)
|
|
18
18
|
//
|
|
19
|
-
// This subpath is the ONE place Louise touches Astro's types
|
|
19
|
+
// This subpath is the ONE place Louise touches Astro's types—`astro` is an
|
|
20
20
|
// optional peer, pulled in only by sites that import `louise-toolkit/astro`.
|
|
21
21
|
import { allowCspDataFonts, louiseSecurityHeaders, matchRateRule, rateLimit, rewriteCspStyleSrc, } from "louise-toolkit/security";
|
|
22
22
|
import { isLouisePublicPath, LOUISE_API_PREFIX, LOUISE_EDIT_COOKIE, louiseApiGate, underPrefix, } from "louise-toolkit/worker";
|
|
@@ -43,7 +43,7 @@ export function createLouiseMiddleware(config) {
|
|
|
43
43
|
// Resolve the backend only for a matched surface, and per request: a
|
|
44
44
|
// getter defers the `env` binding read to request scope (never
|
|
45
45
|
// module-eval). A falsy backend (binding not yet provisioned) skips
|
|
46
|
-
// limiting
|
|
46
|
+
// limiting—fail open, like `rateLimit` itself.
|
|
47
47
|
const backend = typeof config.rateLimit.kv === "function" ? config.rateLimit.kv() : config.rateLimit.kv;
|
|
48
48
|
if (backend) {
|
|
49
49
|
const ip = context.request.headers.get("cf-connecting-ip") ?? "unknown";
|
|
@@ -65,13 +65,13 @@ export function createLouiseMiddleware(config) {
|
|
|
65
65
|
if (editor) {
|
|
66
66
|
locals.editor = editor;
|
|
67
67
|
// Edit mode is sticky: ?louise enters (sets a cookie), ?louise=off
|
|
68
|
-
// exits. The cookie alone never grants anything
|
|
68
|
+
// exits. The cookie alone never grants anything—the session above is
|
|
69
69
|
// always re-checked, so a stale cookie without a session renders public.
|
|
70
70
|
const param = context.url.searchParams.get("louise");
|
|
71
71
|
if (context.url.searchParams.has("louise") && param !== "off") {
|
|
72
72
|
// `secure` only over https, so plain-http localhost dev still round-trips
|
|
73
|
-
// the toggle. The cookie grants nothing on its own
|
|
74
|
-
// re-verified every request
|
|
73
|
+
// the toggle. The cookie grants nothing on its own—the session above is
|
|
74
|
+
// re-verified every request—so this is hygiene, not a control.
|
|
75
75
|
context.cookies.set(editCookie, "1", {
|
|
76
76
|
path: "/",
|
|
77
77
|
sameSite: "lax",
|
|
@@ -88,14 +88,14 @@ export function createLouiseMiddleware(config) {
|
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
catch {
|
|
91
|
-
// Missing bindings (
|
|
91
|
+
// Missing bindings (for example, plain `astro preview`, an unprovisioned
|
|
92
92
|
// SESSION_SECRET) → public rendering. Auth degrading is fine; what it
|
|
93
93
|
// must NOT do is cancel anything else.
|
|
94
94
|
}
|
|
95
95
|
// The API gate, before extend: it needs only the editor, and a refused
|
|
96
96
|
// request shouldn't pay for the site's extra work. A `resolveEditor` that
|
|
97
97
|
// threw left `locals.editor` null above, so where pages degrade to public
|
|
98
|
-
// the API fails closed
|
|
98
|
+
// the API fails closed—refused, not served anonymously.
|
|
99
99
|
const pathname = context.url.pathname;
|
|
100
100
|
const gatedApi = apiGate !== undefined &&
|
|
101
101
|
underPrefix(pathname, apiPrefix) &&
|
|
@@ -110,7 +110,7 @@ export function createLouiseMiddleware(config) {
|
|
|
110
110
|
return finish(context, denied);
|
|
111
111
|
}
|
|
112
112
|
// extend gets its OWN catch, deliberately separate from auth's. When these
|
|
113
|
-
// shared one, `resolveEditor` throwing (a sentinel SESSION_SECRET
|
|
113
|
+
// shared one, `resolveEditor` throwing (a sentinel SESSION_SECRET—the
|
|
114
114
|
// dormant-until-provisioned state every module is supposed to survive)
|
|
115
115
|
// silently skipped extend too, and everything extend feeds died with it:
|
|
116
116
|
// `locals.tenant` never set, so host dispatch quietly served the ordinary
|
|
@@ -149,7 +149,7 @@ export function createLouiseMiddleware(config) {
|
|
|
149
149
|
const locals = context.locals;
|
|
150
150
|
// content freshness: cached HTML would hide editor edits. Edit-mode pages are
|
|
151
151
|
// per-editor and must be live (`no-store`); public HTML `no-cache` so edits
|
|
152
|
-
// appear without a manual purge. Only HTML
|
|
152
|
+
// appear without a manual purge. Only HTML—hashed `/_astro/*` assets keep
|
|
153
153
|
// their immutable caching (set via `_headers`).
|
|
154
154
|
if ((response.headers.get("content-type") ?? "").includes("text/html")) {
|
|
155
155
|
response.headers.set("Cache-Control", locals.editMode ? "no-store" : "no-cache");
|
|
@@ -157,7 +157,7 @@ export function createLouiseMiddleware(config) {
|
|
|
157
157
|
if (config.cspStyleSrc)
|
|
158
158
|
rewriteCspStyleSrc(response, config.cspStyleSrc);
|
|
159
159
|
// Louise's bundled brand font is an inlined `data:` @font-face (loaded on
|
|
160
|
-
// every edit surface), so guarantee the CSP permits data: fonts
|
|
160
|
+
// every edit surface), so guarantee the CSP permits data: fonts—no-op
|
|
161
161
|
// without a CSP header or when already allowed. Saves consumers a font-src edit.
|
|
162
162
|
allowCspDataFonts(response);
|
|
163
163
|
const noindex = config.noindex?.(context.url.hostname) ?? false;
|
package/dist/resume.d.ts
CHANGED
|
@@ -8,8 +8,8 @@ export interface ResumeReadSession {
|
|
|
8
8
|
}
|
|
9
9
|
/**
|
|
10
10
|
* Open a D1 session anchored at the editor's persisted bookmark, for the
|
|
11
|
-
* edit-mode resume read. On a database without read replication
|
|
12
|
-
* runtime without the Sessions API
|
|
11
|
+
* edit-mode resume read. On a database without read replication—or a
|
|
12
|
+
* runtime without the Sessions API—`client` is the raw binding and nothing
|
|
13
13
|
* changes, so this is safe to wire before replication is on.
|
|
14
14
|
*
|
|
15
15
|
* Edit mode only: a view-mode render should stay session-free and cookie-free,
|
package/dist/resume.js
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
import { D1_BOOKMARK_COOKIE, D1_BOOKMARK_MAX_AGE, d1Bookmark, openD1Session, } from "louise-toolkit/db";
|
|
8
8
|
/**
|
|
9
9
|
* Open a D1 session anchored at the editor's persisted bookmark, for the
|
|
10
|
-
* edit-mode resume read. On a database without read replication
|
|
11
|
-
* runtime without the Sessions API
|
|
10
|
+
* edit-mode resume read. On a database without read replication—or a
|
|
11
|
+
* runtime without the Sessions API—`client` is the raw binding and nothing
|
|
12
12
|
* changes, so this is safe to wire before replication is on.
|
|
13
13
|
*
|
|
14
14
|
* Edit mode only: a view-mode render should stay session-free and cookie-free,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@louise-toolkit/astro",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Astro adapter for louise-toolkit: middleware, Actions, content-layer loaders, and the forms schema bridge.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"astro",
|
|
@@ -35,11 +35,12 @@
|
|
|
35
35
|
"access": "public"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"louise-toolkit": "0.
|
|
38
|
+
"louise-toolkit": "0.31.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@cloudflare/workers-types": "^5.20260829.1",
|
|
42
42
|
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
|
43
|
+
"@vitest/coverage-v8": "4.1.11",
|
|
43
44
|
"astro": "^7.2.9",
|
|
44
45
|
"drizzle-orm": "^0.45.2",
|
|
45
46
|
"typescript": "^5.9.3",
|
|
@@ -59,7 +60,7 @@
|
|
|
59
60
|
"scripts": {
|
|
60
61
|
"build": "tsgo -p tsconfig.build.json",
|
|
61
62
|
"check": "vp check",
|
|
62
|
-
"test": "vp test",
|
|
63
|
+
"test": "vp test --coverage",
|
|
63
64
|
"typecheck": "tsgo --noEmit"
|
|
64
65
|
}
|
|
65
66
|
}
|