@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 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
@@ -1,5 +1,5 @@
1
1
  import { z } from "astro/zod";
2
- import type { FormConfig } from "louise-toolkit/forms";
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
@@ -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
- 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();
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";
@@ -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. */
@@ -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.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.com",
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.34.0"
38
+ "louise-toolkit": "0.36.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@cloudflare/workers-types": "^5.20260829.1",