@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 +21 -0
- package/README.md +76 -0
- package/dist/actions.d.ts +133 -0
- package/dist/actions.js +172 -0
- package/dist/catalog.d.ts +45 -0
- package/dist/catalog.js +65 -0
- package/dist/content-loader.d.ts +52 -0
- package/dist/content-loader.js +142 -0
- package/dist/form-schema.d.ts +13 -0
- package/dist/form-schema.js +75 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +11 -0
- package/dist/middleware.d.ts +95 -0
- package/dist/middleware.js +139 -0
- package/package.json +58 -0
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 {};
|
package/dist/actions.js
ADDED
|
@@ -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 {};
|
package/dist/catalog.js
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|