@louise-toolkit/astro 0.3.0 → 0.5.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/dist/actions.d.ts +15 -2
- package/dist/actions.js +14 -2
- package/dist/form-schema.d.ts +1 -1
- package/dist/form-schema.js +19 -7
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/middleware.d.ts +19 -0
- package/dist/middleware.js +15 -0
- package/dist/seo.d.ts +16 -0
- package/dist/seo.js +16 -0
- package/package.json +3 -3
package/dist/actions.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { z } from "astro/zod";
|
|
|
2
2
|
import { type SaveCollectionConfig } from "louise-toolkit/editor";
|
|
3
3
|
import { type SettingsPatchConfig } from "louise-toolkit/editor";
|
|
4
4
|
import type { EditorRouteEnv } from "louise-toolkit/editor";
|
|
5
|
-
import { type SaveDraftDeps } from "louise-toolkit/editor";
|
|
5
|
+
import { type DraftSoftLocks, 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
8
|
/** The shape of Astro's `ActionError` constructor the handlers depend on—injected
|
|
@@ -77,8 +77,13 @@ export interface LouiseSettingsActionConfig<Env extends EditorRouteEnv = EditorR
|
|
|
77
77
|
export interface SaveDraftActionInput {
|
|
78
78
|
id: number;
|
|
79
79
|
data: Record<string, unknown>;
|
|
80
|
+
/** The field revisions the save started from (`SaveDraftOptions.base`). */
|
|
81
|
+
base?: Record<string, string>;
|
|
80
82
|
}
|
|
81
83
|
export interface LouiseSaveDraftActionConfig<Env extends EditorRouteEnv = EditorRouteEnv> extends EditorActionDeps<Env>, SaveDraftDeps<Env> {
|
|
84
|
+
/** The soft-locks a save respects (#572): `realtimeSoftLocks` from
|
|
85
|
+
* `louise-toolkit/realtime`, as `versionsRoute` takes it. */
|
|
86
|
+
softLocks?: DraftSoftLocks<Env>;
|
|
82
87
|
}
|
|
83
88
|
/**
|
|
84
89
|
* Build the `{ input, handler }` config for the editor `save` Action (the inline
|
|
@@ -121,12 +126,20 @@ export declare function louiseSettingsAction<Env extends EditorRouteEnv = Editor
|
|
|
121
126
|
* the id). The handler shares the raw `versionsRoute` store path via
|
|
122
127
|
* {@link applySaveDraft}—the concurrent-surface merge base and the #70 KV
|
|
123
128
|
* write-buffer—and returns that path's JSON body (a created `version`, or
|
|
124
|
-
* `{ buffered: true }` when a write is coalesced into the buffer)
|
|
129
|
+
* `{ buffered: true }` when a write is coalesced into the buffer), with the
|
|
130
|
+
* saved fields' `revs`.
|
|
131
|
+
*
|
|
132
|
+
* A save whose `base` is stale for a field someone else changed returns
|
|
133
|
+
* `{ conflicts }` rather than throwing (#572). An Action's rejection reaches the
|
|
134
|
+
* client only as an error, which can't carry the current values the owner
|
|
135
|
+
* needs to choose between, so the conflict comes back as data. A save that
|
|
136
|
+
* changes a field another editor holds returns `{ locked }` the same way.
|
|
125
137
|
*/
|
|
126
138
|
export declare function louiseSaveDraftAction<Env extends EditorRouteEnv = EditorRouteEnv>(config: LouiseSaveDraftActionConfig<Env>): {
|
|
127
139
|
input: z.ZodObject<{
|
|
128
140
|
id: z.ZodNumber;
|
|
129
141
|
data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
142
|
+
base: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
130
143
|
}, z.core.$strip>;
|
|
131
144
|
handler: (input: SaveDraftActionInput, context: EditorActionContext) => Promise<Record<string, unknown>>;
|
|
132
145
|
};
|
package/dist/actions.js
CHANGED
|
@@ -141,7 +141,14 @@ export function louiseSettingsAction(config) {
|
|
|
141
141
|
* the id). The handler shares the raw `versionsRoute` store path via
|
|
142
142
|
* {@link applySaveDraft}—the concurrent-surface merge base and the #70 KV
|
|
143
143
|
* write-buffer—and returns that path's JSON body (a created `version`, or
|
|
144
|
-
* `{ buffered: true }` when a write is coalesced into the buffer)
|
|
144
|
+
* `{ buffered: true }` when a write is coalesced into the buffer), with the
|
|
145
|
+
* saved fields' `revs`.
|
|
146
|
+
*
|
|
147
|
+
* A save whose `base` is stale for a field someone else changed returns
|
|
148
|
+
* `{ conflicts }` rather than throwing (#572). An Action's rejection reaches the
|
|
149
|
+
* client only as an error, which can't carry the current values the owner
|
|
150
|
+
* needs to choose between, so the conflict comes back as data. A save that
|
|
151
|
+
* changes a field another editor holds returns `{ locked }` the same way.
|
|
145
152
|
*/
|
|
146
153
|
export function louiseSaveDraftAction(config) {
|
|
147
154
|
const resolved = resolveDeps(config);
|
|
@@ -151,10 +158,15 @@ export function louiseSaveDraftAction(config) {
|
|
|
151
158
|
// this schema accepted.
|
|
152
159
|
id: z.number().int().positive(),
|
|
153
160
|
data: z.record(z.string(), z.unknown()),
|
|
161
|
+
base: z.record(z.string(), z.string()).optional(),
|
|
154
162
|
}),
|
|
155
163
|
handler: async (input, context) => {
|
|
156
164
|
const editor = requireEditor(resolved, context);
|
|
157
|
-
const result = await applySaveDraft(resolved.getEnv(context), config, editor, toPageId(input.id), input.data);
|
|
165
|
+
const result = await applySaveDraft(resolved.getEnv(context), config, editor, toPageId(input.id), input.data, { base: input.base, softLocks: config.softLocks });
|
|
166
|
+
if (!result.ok && result.conflicts)
|
|
167
|
+
return { conflicts: result.conflicts };
|
|
168
|
+
if (!result.ok && result.locked)
|
|
169
|
+
return { locked: result.locked };
|
|
158
170
|
if (!result.ok)
|
|
159
171
|
throwActionError(resolved.ActionError, result.status, result.error);
|
|
160
172
|
// Persist the D1 bookmark so this Action's draft is read-your-writes on the
|
package/dist/form-schema.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "astro/zod";
|
|
2
|
-
import type
|
|
2
|
+
import { type FormConfig } from "louise-toolkit/forms";
|
|
3
3
|
/**
|
|
4
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
|
package/dist/form-schema.js
CHANGED
|
@@ -19,18 +19,30 @@
|
|
|
19
19
|
// Zod builder from `astro/zod` (an optional peer), so the framework-agnostic
|
|
20
20
|
// core never takes a Zod dependency.
|
|
21
21
|
import { z } from "astro/zod";
|
|
22
|
+
import { coerceFormValue } from "louise-toolkit/forms";
|
|
23
|
+
/** Coerce the way `formRoute` does, first, so an Action accepts what the route
|
|
24
|
+
* accepts: a URL without a scheme, a number with the locale's separators. A
|
|
25
|
+
* blank value becomes `undefined`, which an optional field allows. */
|
|
26
|
+
function forgiving(field, locale, schema) {
|
|
27
|
+
return z.preprocess((raw) => coerceFormValue(field, raw, { locale }) ?? undefined, schema);
|
|
28
|
+
}
|
|
22
29
|
/** Map one form field to its Zod type, including the type's built-in format
|
|
23
30
|
* check (email/url) and coercion (number/date/checkbox). */
|
|
24
|
-
function formFieldToZod(field) {
|
|
31
|
+
function formFieldToZod(field, locale) {
|
|
25
32
|
const required = field.required ?? false;
|
|
26
33
|
switch (field.type) {
|
|
27
34
|
case "email":
|
|
28
35
|
return required ? z.email() : z.email().optional();
|
|
29
|
-
case "url":
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
case "url": {
|
|
37
|
+
const url = z.url({ error: "Enter a web address, like example.com." });
|
|
38
|
+
return forgiving(field, locale, required ? url : url.optional());
|
|
39
|
+
}
|
|
40
|
+
case "number": {
|
|
41
|
+
// Form values arrive as strings; the shared coercion turns "1,200" into
|
|
42
|
+
// 1200 under the form's locale, and leaves anything else a string.
|
|
43
|
+
const number = z.number({ error: "Enter a number, like 1200." });
|
|
44
|
+
return forgiving(field, locale, required ? number : number.optional());
|
|
45
|
+
}
|
|
34
46
|
case "date":
|
|
35
47
|
// Accepts an ISO string, an epoch number, or a Date.
|
|
36
48
|
return required ? z.coerce.date() : z.coerce.date().optional();
|
|
@@ -69,7 +81,7 @@ function formFieldToZod(field) {
|
|
|
69
81
|
export function formToAstroSchema(form) {
|
|
70
82
|
const shape = {};
|
|
71
83
|
for (const [key, field] of Object.entries(form.fields)) {
|
|
72
|
-
shape[key] = formFieldToZod(field);
|
|
84
|
+
shape[key] = formFieldToZod(field, form.locale);
|
|
73
85
|
}
|
|
74
86
|
return z.object(shape);
|
|
75
87
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -3,4 +3,5 @@ export { type CatalogLoaderConfig, defineCatalogLoader } from "./catalog.js";
|
|
|
3
3
|
export { collectionToAstroSchema, louiseLoader, type LouiseLoaderConfig, type LouiseRow, } from "./content-loader.js";
|
|
4
4
|
export { formToAstroSchema } from "./form-schema.js";
|
|
5
5
|
export { type ResumeReadSession, resumeReadSession } from "./resume.js";
|
|
6
|
+
export { type SeoHeadContext, type SeoHeadInput, seoHead } from "./seo.js";
|
|
6
7
|
export { createLouiseMiddleware, type LouiseMiddlewareConfig, type LouiseMiddlewareRateLimit, } from "./middleware.js";
|
package/dist/index.js
CHANGED
|
@@ -9,4 +9,5 @@ export { defineCatalogLoader } from "./catalog.js";
|
|
|
9
9
|
export { collectionToAstroSchema, louiseLoader, } from "./content-loader.js";
|
|
10
10
|
export { formToAstroSchema } from "./form-schema.js";
|
|
11
11
|
export { resumeReadSession } from "./resume.js";
|
|
12
|
+
export { seoHead } from "./seo.js";
|
|
12
13
|
export { createLouiseMiddleware, } from "./middleware.js";
|
package/dist/middleware.d.ts
CHANGED
|
@@ -113,6 +113,25 @@ export interface LouiseMiddlewareConfig<TEditor = unknown> {
|
|
|
113
113
|
* resolved on every request regardless.
|
|
114
114
|
*/
|
|
115
115
|
apiGate?: boolean | LouiseMiddlewareApiGate;
|
|
116
|
+
/**
|
|
117
|
+
* Where a path moved, for a GET or HEAD that would otherwise answer 404
|
|
118
|
+
* (#574). Return `{ location, status }` to redirect, or `null` to keep the 404.
|
|
119
|
+
* It runs only after the page answered 404, so a live page always wins. The
|
|
120
|
+
* visitor's query string carries over.
|
|
121
|
+
*
|
|
122
|
+
* ```ts
|
|
123
|
+
* redirectFor: (path) => resolvePageRedirect(db(env.DB), pageRedirects, path),
|
|
124
|
+
* ```
|
|
125
|
+
*
|
|
126
|
+
* A lookup that throws keeps the 404.
|
|
127
|
+
*/
|
|
128
|
+
redirectFor?: (pathname: string, context: APIContext) => {
|
|
129
|
+
location: string;
|
|
130
|
+
status: number;
|
|
131
|
+
} | null | Promise<{
|
|
132
|
+
location: string;
|
|
133
|
+
status: number;
|
|
134
|
+
} | null>;
|
|
116
135
|
/** Edit-mode cookie name. Default {@link LOUISE_EDIT_COOKIE} (`"louise_edit"`).
|
|
117
136
|
* Change it and the `withEdgeCache` bypass predicate must be told too, or an
|
|
118
137
|
* editor gets served the cached public page. */
|
package/dist/middleware.js
CHANGED
|
@@ -137,6 +137,21 @@ export function createLouiseMiddleware(config) {
|
|
|
137
137
|
// for host dispatch is another tenant's page.
|
|
138
138
|
const rewrite = await config.rewrite?.(context);
|
|
139
139
|
const response = rewrite === undefined ? await next() : await next(rewrite);
|
|
140
|
+
// A page that moved: answer its old URL with a redirect instead of the 404.
|
|
141
|
+
if (config.redirectFor &&
|
|
142
|
+
response.status === 404 &&
|
|
143
|
+
(context.request.method === "GET" || context.request.method === "HEAD")) {
|
|
144
|
+
// An async wrapper, so a lookup that throws before returning a promise
|
|
145
|
+
// keeps the 404 too.
|
|
146
|
+
const lookup = config.redirectFor;
|
|
147
|
+
const moved = await (async () => lookup(context.url.pathname, context))().catch(() => null);
|
|
148
|
+
if (moved) {
|
|
149
|
+
return finish(context, new Response(null, {
|
|
150
|
+
status: moved.status,
|
|
151
|
+
headers: { location: `${moved.location}${context.url.search}` },
|
|
152
|
+
}));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
140
155
|
// An editor's JSON must not land in a shared cache. A route that chose its
|
|
141
156
|
// own policy keeps it.
|
|
142
157
|
if (gatedApi && !response.headers.has("cache-control")) {
|
package/dist/seo.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type PageHeadInput } from "louise-toolkit/seo";
|
|
2
|
+
/** The part of `Astro` (or an `APIContext`) that `seoHead` reads. */
|
|
3
|
+
export interface SeoHeadContext {
|
|
4
|
+
/** The configured `site`, the canonical origin. */
|
|
5
|
+
site?: URL | undefined;
|
|
6
|
+
url: URL;
|
|
7
|
+
}
|
|
8
|
+
/** Everything `pageHead` takes except what the request supplies. */
|
|
9
|
+
export type SeoHeadInput = Omit<PageHeadInput, "origin" | "path"> & Partial<Pick<PageHeadInput, "origin">>;
|
|
10
|
+
/**
|
|
11
|
+
* The page's head tags as HTML, from `pageHead` and `renderHeadTags`. The
|
|
12
|
+
* origin is `input.origin`, then Astro's configured `site`, then the request's
|
|
13
|
+
* own origin; set `site` in `astro.config` so a preview host or a proxy can't
|
|
14
|
+
* leak into the canonical URL. The path is the request's.
|
|
15
|
+
*/
|
|
16
|
+
export declare function seoHead(context: SeoHeadContext, input: SeoHeadInput): string;
|
package/dist/seo.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Louise Toolkit is MIT licensed.
|
|
2
|
+
//
|
|
3
|
+
// `pageHead` from louise-toolkit/seo, with the origin and path filled in from
|
|
4
|
+
// Astro's request context. The adapter ships no `.astro` components, so a page
|
|
5
|
+
// prints the result itself: `<Fragment set:html={seoHead(Astro, { … })} />`.
|
|
6
|
+
import { pageHead, renderHeadTags } from "louise-toolkit/seo";
|
|
7
|
+
/**
|
|
8
|
+
* The page's head tags as HTML, from `pageHead` and `renderHeadTags`. The
|
|
9
|
+
* origin is `input.origin`, then Astro's configured `site`, then the request's
|
|
10
|
+
* own origin; set `site` in `astro.config` so a preview host or a proxy can't
|
|
11
|
+
* leak into the canonical URL. The path is the request's.
|
|
12
|
+
*/
|
|
13
|
+
export function seoHead(context, input) {
|
|
14
|
+
const origin = input.origin ?? context.site?.origin ?? context.url.origin;
|
|
15
|
+
return renderHeadTags(pageHead({ ...input, origin, path: `${context.url.pathname}${context.url.search}` }));
|
|
16
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@louise-toolkit/astro",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Astro adapter for louise-toolkit: middleware, Actions, content-layer loaders, and the forms schema bridge.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"astro",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"louise",
|
|
10
10
|
"louise-toolkit"
|
|
11
11
|
],
|
|
12
|
-
"homepage": "https://louisetoolkit.
|
|
12
|
+
"homepage": "https://docs.louisetoolkit.org",
|
|
13
13
|
"bugs": "https://github.com/bowenlabs/louise-toolkit/issues",
|
|
14
14
|
"license": "MIT",
|
|
15
15
|
"author": "BowenLabs",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"access": "public"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"louise-toolkit": "0.
|
|
38
|
+
"louise-toolkit": "0.36.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@cloudflare/workers-types": "^5.20260829.1",
|