@louise-toolkit/astro 0.2.7 → 0.4.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/README.md CHANGED
@@ -44,8 +44,8 @@ Pass `apiGate: true` to deny the editor API by default: every request under
44
44
  writes and WebSocket upgrades origin-checked. This is the same gate as
45
45
  `composeWorker({ gate })` (ADR 0012), for routes mounted as Astro API routes.
46
46
  Middleware runs before Astro knows which route file answers, so a public route
47
- is declared by path: the toolkit's form and vitals routes are exempt at their
48
- default paths, and `apiGate: { isPublic: (path) => … }` adds your own.
47
+ is declared by path: the toolkit's form, vitals, and status routes are exempt
48
+ at their default paths, and `apiGate: { isPublic: (path) => … }` adds your own.
49
49
 
50
50
  **Actions**: the editor write path as Astro Actions, so a save is a typed call
51
51
  rather than a hand-rolled endpoint. `louiseSaveAction`, `louiseSaveDraftAction`,
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
@@ -35,6 +35,7 @@
35
35
  // the raw routes (`applyFieldSave`, `applySettingsPatch`), so nothing is parsed or
36
36
  // written twice.
37
37
  import { z } from "astro/zod";
38
+ import { toPageId } from "louise-toolkit/content";
38
39
  import { D1_BOOKMARK_COOKIE } from "louise-toolkit/db";
39
40
  import { applyFieldSave } from "louise-toolkit/editor";
40
41
  import { applySettingsPatch } from "louise-toolkit/editor";
@@ -140,18 +141,32 @@ export function louiseSettingsAction(config) {
140
141
  * the id). The handler shares the raw `versionsRoute` store path via
141
142
  * {@link applySaveDraft}—the concurrent-surface merge base and the #70 KV
142
143
  * 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
+ * `{ 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.
144
152
  */
145
153
  export function louiseSaveDraftAction(config) {
146
154
  const resolved = resolveDeps(config);
147
155
  return {
148
156
  input: z.object({
149
- id: z.number().int(),
157
+ // Positive, like every row ID, so `toPageId` below can't throw on input
158
+ // this schema accepted.
159
+ id: z.number().int().positive(),
150
160
  data: z.record(z.string(), z.unknown()),
161
+ base: z.record(z.string(), z.string()).optional(),
151
162
  }),
152
163
  handler: async (input, context) => {
153
164
  const editor = requireEditor(resolved, context);
154
- const result = await applySaveDraft(resolved.getEnv(context), config, editor, 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 };
155
170
  if (!result.ok)
156
171
  throwActionError(resolved.ActionError, result.status, result.error);
157
172
  // Persist the D1 bookmark so this Action's draft is read-your-writes on the
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";
@@ -18,8 +18,8 @@ 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—see
22
- * `isLouisePublicPath`).
21
+ * toolkit's own public routes at their default mounts (forms, vitals, and
22
+ * status—see `isLouisePublicPath`).
23
23
  *
24
24
  * A path, not a mark on the route, because middleware runs before it knows
25
25
  * which route file will answer: a route can't declare itself public here the
@@ -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.2.7",
3
+ "version": "0.4.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.33.0"
38
+ "louise-toolkit": "0.35.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@cloudflare/workers-types": "^5.20260829.1",