smoodly 0.0.44 → 0.0.46

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.
Files changed (241) hide show
  1. package/README.md +3 -3
  2. package/dist/addresses.d.ts +6 -7
  3. package/dist/addresses.js +6 -4
  4. package/dist/admin/PublicSans-LICENSE.txt +93 -0
  5. package/dist/admin/auth-config.d.ts +6 -0
  6. package/dist/admin/auth-config.js +1 -0
  7. package/dist/admin/browser/fetch-op.d.ts +10 -0
  8. package/dist/admin/browser/fetch-op.js +44 -0
  9. package/dist/admin/browser/main.d.ts +1 -0
  10. package/dist/admin/browser/main.js +21 -0
  11. package/dist/admin/custom-section.d.ts +1 -1
  12. package/dist/admin/drift.d.ts +1 -1
  13. package/dist/admin/editor/EditorView.js +14 -14
  14. package/dist/admin/editor/autosave.js +1 -1
  15. package/dist/admin/editor/canvas-address.d.ts +2 -0
  16. package/dist/admin/editor/canvas-address.js +8 -0
  17. package/dist/admin/editor/overlay-geometry.d.ts +1 -1
  18. package/dist/admin/editor/protocol.d.ts +18 -4
  19. package/dist/admin/editor/protocol.js +15 -1
  20. package/dist/admin/editor/useCanvasBridge.d.ts +1 -1
  21. package/dist/admin/editor/useCanvasBridge.js +5 -6
  22. package/dist/admin/editor/useInlineSession.js +4 -2
  23. package/dist/admin/files.d.ts +11 -0
  24. package/dist/admin/files.generated.js +2 -0
  25. package/dist/admin/files.js +8 -0
  26. package/dist/admin/forms/useRefOptions.d.ts +2 -2
  27. package/dist/admin/forms/useRefOptions.js +2 -2
  28. package/dist/admin/forms/useRichtextEditor.js +4 -4
  29. package/dist/admin/handler.d.ts +40 -0
  30. package/dist/admin/handler.js +151 -0
  31. package/dist/admin/index.d.ts +1 -1
  32. package/dist/admin/ops-impl.d.ts +7 -1
  33. package/dist/admin/ops-impl.js +110 -33
  34. package/dist/admin/ops.d.ts +3 -2
  35. package/dist/admin/page-data.d.ts +21 -0
  36. package/dist/admin/page-data.js +4 -0
  37. package/dist/admin/page.d.ts +6 -0
  38. package/dist/admin/page.js +35 -0
  39. package/dist/admin/serialize.js +7 -7
  40. package/dist/admin/shell/AdminApp.js +2 -2
  41. package/dist/admin/shell/EntryForm.js +1 -1
  42. package/dist/admin/shell/router.d.ts +3 -16
  43. package/dist/admin/shell/router.js +3 -39
  44. package/dist/admin/shell/session.d.ts +2 -5
  45. package/dist/admin/supabase-auth.js +1 -1
  46. package/dist/admin/tree-ops.d.ts +1 -1
  47. package/dist/admin/ui/fonts.d.ts +12 -0
  48. package/dist/admin/ui/fonts.js +22 -0
  49. package/dist/admin/ui/theme.d.ts +13 -4
  50. package/dist/admin/ui/theme.js +28 -157
  51. package/dist/assemble.d.ts +60 -0
  52. package/dist/assemble.js +359 -0
  53. package/dist/astro/cache.d.ts +34 -0
  54. package/dist/astro/cache.js +69 -0
  55. package/dist/astro/components/Custom.astro +21 -0
  56. package/dist/astro/components/Guarded.astro +24 -0
  57. package/dist/astro/components/Head.astro +26 -0
  58. package/dist/astro/components/Image.astro +20 -0
  59. package/dist/astro/components/RichText.astro +23 -0
  60. package/dist/astro/components/Section.astro +20 -0
  61. package/dist/astro/components/Video.astro +5 -0
  62. package/dist/astro/components/Zone.astro +19 -0
  63. package/dist/astro/components.d.ts +6 -0
  64. package/dist/astro/components.js +7 -0
  65. package/dist/astro/content-loaders.d.ts +40 -0
  66. package/dist/astro/content-loaders.js +100 -0
  67. package/dist/astro/content.d.ts +9 -0
  68. package/dist/astro/content.js +17 -0
  69. package/dist/astro/custom.d.ts +1 -0
  70. package/dist/astro/custom.js +3 -0
  71. package/dist/astro/defaults.d.ts +20 -0
  72. package/dist/astro/defaults.js +21 -0
  73. package/dist/astro/env-files.d.ts +1 -0
  74. package/dist/astro/env-files.js +21 -0
  75. package/dist/astro/head.d.ts +13 -0
  76. package/dist/astro/head.js +19 -0
  77. package/dist/astro/image.d.ts +27 -0
  78. package/dist/astro/image.js +29 -0
  79. package/dist/astro/index.d.ts +43 -0
  80. package/dist/astro/index.js +111 -0
  81. package/dist/astro/locale-paths.d.ts +30 -0
  82. package/dist/astro/locale-paths.js +38 -0
  83. package/dist/astro/middleware.d.ts +1 -0
  84. package/dist/astro/middleware.js +83 -0
  85. package/dist/astro/preview-document.d.ts +1 -0
  86. package/dist/astro/preview-document.js +18 -0
  87. package/dist/astro/preview.d.ts +10 -0
  88. package/dist/astro/preview.js +72 -0
  89. package/dist/astro/richtext.d.ts +15 -0
  90. package/dist/astro/richtext.js +78 -0
  91. package/dist/astro/routes/admin.d.ts +3 -0
  92. package/dist/astro/routes/admin.js +41 -0
  93. package/dist/astro/routes/page.astro +17 -0
  94. package/dist/astro/routes/preview.astro +11 -0
  95. package/dist/astro/runtime.d.ts +32 -0
  96. package/dist/astro/runtime.js +46 -0
  97. package/dist/astro/scope.d.ts +45 -0
  98. package/dist/astro/scope.js +103 -0
  99. package/dist/astro/server.d.ts +14 -0
  100. package/dist/astro/server.js +45 -0
  101. package/dist/astro/shell.d.ts +67 -0
  102. package/dist/astro/shell.js +63 -0
  103. package/dist/astro/style.d.ts +1 -0
  104. package/dist/astro/style.js +11 -0
  105. package/dist/astro/views-check.d.ts +3 -0
  106. package/dist/astro/views-check.js +19 -0
  107. package/dist/astro/zone.d.ts +39 -0
  108. package/dist/astro/zone.js +33 -0
  109. package/dist/bind.d.ts +19 -0
  110. package/dist/bind.js +34 -0
  111. package/dist/bridge/bridge.d.ts +9 -0
  112. package/dist/bridge/bridge.js +107 -0
  113. package/dist/bridge/entry.d.ts +1 -0
  114. package/dist/bridge/entry.js +5 -0
  115. package/dist/bridge/preview-pill.d.ts +18 -0
  116. package/dist/bridge/preview-pill.js +115 -0
  117. package/dist/cli/import.d.ts +3 -0
  118. package/dist/cli/import.js +10 -1
  119. package/dist/cli/load-collections.js +4 -3
  120. package/dist/cli/load-config.d.ts +4 -0
  121. package/dist/cli/load-config.js +26 -0
  122. package/dist/cli/main.d.ts +7 -1
  123. package/dist/cli/main.js +32 -7
  124. package/dist/cli/migrate.d.ts +3 -0
  125. package/dist/cli/migrate.js +8 -1
  126. package/dist/cli/rebuild.d.ts +23 -0
  127. package/dist/cli/rebuild.js +22 -0
  128. package/dist/cli/space-io.d.ts +15 -1
  129. package/dist/cli/space-io.js +14 -0
  130. package/dist/client.js +5 -0
  131. package/dist/config.d.ts +28 -20
  132. package/dist/config.js +71 -75
  133. package/dist/content/content-api.d.ts +1 -1
  134. package/dist/content/node.d.ts +2 -3
  135. package/dist/content/node.js +2 -1
  136. package/dist/crash-message.d.ts +1 -0
  137. package/dist/crash-message.js +8 -0
  138. package/dist/css.d.ts +4 -0
  139. package/dist/css.js +5 -2
  140. package/dist/custom/CustomSection.d.ts +1 -1
  141. package/dist/custom/CustomSection.js +4 -8
  142. package/dist/custom/css.js +2 -2
  143. package/dist/custom/index.d.ts +6 -10
  144. package/dist/custom/index.js +1 -2
  145. package/dist/custom/layout.d.ts +5 -0
  146. package/dist/custom/layout.js +14 -0
  147. package/dist/entry-page.d.ts +3 -18
  148. package/dist/env.js +1 -1
  149. package/dist/fields.js +2 -1
  150. package/dist/global-id.js +2 -2
  151. package/dist/global.d.ts +5 -2
  152. package/dist/global.js +8 -5
  153. package/dist/image/next-config.d.ts +3 -0
  154. package/dist/image/next-config.js +12 -7
  155. package/dist/image/remote-pattern.d.ts +1 -0
  156. package/dist/image/remote-pattern.js +5 -0
  157. package/dist/index.d.ts +15 -17
  158. package/dist/index.js +10 -19
  159. package/dist/marks.d.ts +7 -0
  160. package/dist/marks.js +7 -0
  161. package/dist/next/admin-route.d.ts +7 -0
  162. package/dist/next/admin-route.js +28 -0
  163. package/dist/next/bridge.d.ts +1 -0
  164. package/dist/next/bridge.js +26 -0
  165. package/dist/next/canvas-boundary.d.ts +1 -1
  166. package/dist/next/canvas-slot.d.ts +1 -0
  167. package/dist/next/canvas-slot.js +4 -3
  168. package/dist/next/index.d.ts +2 -0
  169. package/dist/next/index.js +1 -0
  170. package/dist/next/page-renderer.d.ts +5 -1
  171. package/dist/next/page-renderer.js +19 -33
  172. package/dist/next/preview-pill-define.d.ts +1 -0
  173. package/dist/next/preview-pill-define.js +10 -0
  174. package/dist/next/preview-pill.d.ts +0 -3
  175. package/dist/next/preview-pill.js +7 -50
  176. package/dist/next/queries.d.ts +3 -28
  177. package/dist/next/queries.js +3 -90
  178. package/dist/next/reads.d.ts +2 -0
  179. package/dist/next/reads.js +18 -0
  180. package/dist/page-context.d.ts +89 -0
  181. package/dist/page-context.js +8 -0
  182. package/dist/pairing.d.ts +2 -22
  183. package/dist/project.d.ts +69 -0
  184. package/dist/project.js +210 -0
  185. package/dist/published.d.ts +96 -0
  186. package/dist/published.js +125 -0
  187. package/dist/react.d.ts +61 -0
  188. package/dist/react.js +17 -0
  189. package/dist/read-shapes.d.ts +1 -1
  190. package/dist/reads.d.ts +80 -0
  191. package/dist/reads.js +142 -0
  192. package/dist/refs.d.ts +1 -1
  193. package/dist/registration.d.ts +8 -0
  194. package/dist/registration.js +17 -0
  195. package/dist/render.d.ts +5 -83
  196. package/dist/render.js +5 -10
  197. package/dist/resolve.d.ts +1 -1
  198. package/dist/richtext/index.d.ts +6 -2
  199. package/dist/richtext/index.js +3 -2
  200. package/dist/richtext-href.d.ts +4 -0
  201. package/dist/richtext-href.js +17 -0
  202. package/dist/richtext-render.js +1 -14
  203. package/dist/schema-version.d.ts +1 -1
  204. package/dist/schema-version.js +1 -1
  205. package/dist/section-crash.d.ts +2 -2
  206. package/dist/section-crash.js +2 -7
  207. package/dist/section-wrapper.d.ts +3 -10
  208. package/dist/section-wrapper.js +3 -2
  209. package/dist/site.d.ts +38 -14
  210. package/dist/site.js +159 -286
  211. package/dist/smd-key.d.ts +9 -0
  212. package/dist/smd-key.js +1 -0
  213. package/dist/sql-space.d.ts +4 -0
  214. package/dist/sql-space.js +10 -5
  215. package/dist/sql.d.ts +2 -0
  216. package/dist/sql.js +117 -0
  217. package/dist/store.d.ts +1 -1
  218. package/dist/supabase-published-store.d.ts +35 -0
  219. package/dist/supabase-published-store.js +215 -0
  220. package/dist/supabase-store.d.ts +1 -1
  221. package/dist/toolset.d.ts +2 -4
  222. package/dist/toolset.js +6 -4
  223. package/dist/version.d.ts +1 -1
  224. package/dist/version.js +1 -1
  225. package/dist/view-props.d.ts +40 -0
  226. package/dist/view-props.js +1 -0
  227. package/dist/views.d.ts +36 -0
  228. package/dist/views.js +52 -0
  229. package/dist/zones.d.ts +7 -7
  230. package/dist/zones.js +7 -6
  231. package/migrations/20261007120000_smoodly_v8.sql +154 -0
  232. package/migrations/20261007200000_smoodly_v9.sql +30 -0
  233. package/package.json +46 -19
  234. package/dist/admin/next/bridge.d.ts +0 -2
  235. package/dist/admin/next/bridge.js +0 -129
  236. package/dist/admin/next/create-admin.d.ts +0 -19
  237. package/dist/admin/next/create-admin.js +0 -44
  238. package/dist/admin/next/index.d.ts +0 -8
  239. package/dist/admin/next/index.js +0 -8
  240. package/dist/admin/next/op-handler.d.ts +0 -18
  241. package/dist/admin/next/op-handler.js +0 -31
@@ -0,0 +1,83 @@
1
+ // The one middleware the integration adds (spec 2026-10-02 astro cloud fit §7): it decides once whether a
2
+ // request is a verified preview, opens the request scope every read reports its tags to (scope.ts), and
3
+ // when the body is whole — every component has rendered, so every read has been heard — tells Astro's
4
+ // response cache (cache.ts). Astro reads a response's tags the moment the response exists, so waiting
5
+ // for the body is what makes a late read count. A preview response gains the draft marker and, in the
6
+ // canvas, the bridge, when the shell rendered it; a live page gains nothing. Nothing under the preview
7
+ // address is ever stored, and neither is a render a publish overtook or one a read failed in.
8
+ import { defineMiddleware } from "astro:middleware";
9
+ import { loadAdminFiles } from "../admin/files.js";
10
+ import { ADMIN_INTERNAL } from "../admin/page-data.js";
11
+ import { settle } from "./cache.js";
12
+ import { previewDocument } from "./preview-document.js";
13
+ import { DRAFT_COOKIE, isDraftCookie, previewPath } from "./preview.js";
14
+ import { inRequestScope, readsHeader, timingHeader } from "./scope.js";
15
+ import { config, secret } from "./server.js";
16
+ export const onRequest = defineMiddleware(async (context, next) => {
17
+ const { pathname, searchParams } = context.url;
18
+ const admin = config.admin.path;
19
+ if (pathname === admin || pathname.startsWith(`${admin}/`))
20
+ return next();
21
+ const locals = context.locals;
22
+ // The preview route rewrites to the page's own address, which runs the middleware again: the first pass decided.
23
+ if (locals.smoodly)
24
+ return next();
25
+ const preview = previewPath(pathname) !== null;
26
+ const draft = preview && (await isDraftCookie(context.cookies.get(DRAFT_COOKIE)?.value, secret()));
27
+ const state = {
28
+ draft,
29
+ editor: draft && searchParams.get("smoodly-editor") === "1",
30
+ version: (draft && searchParams.get("smoodly-version")) || undefined,
31
+ cacheable: false,
32
+ shell: false,
33
+ };
34
+ locals.smoodly = state;
35
+ const scope = { tags: new Set(), draft, failed: false, reads: 0, ...(process.env.SMOODLY_DEBUG_READS === "1" ? { marks: [] } : {}) };
36
+ const started = Date.now();
37
+ const origin = performance.now();
38
+ let last = origin;
39
+ const stage = (name) => { const now = performance.now(); scope.marks?.push({ name, start: last, end: now }); last = now; };
40
+ // Absent when Astro renders an error page.
41
+ const cache = context.cache;
42
+ const closed = (headers) => {
43
+ headers.set("cache-control", "no-store");
44
+ headers.set("x-robots-tag", "noindex");
45
+ return headers;
46
+ };
47
+ return inRequestScope(scope, async () => {
48
+ const response = await next();
49
+ stage("render");
50
+ // A response that takes no body (a developer's 204 or 304): nothing to read, nothing to cache. Under the
51
+ // preview address it still carries the closed headers.
52
+ if (response.status === 204 || response.status === 304 || response.body === null) {
53
+ if (!preview)
54
+ return response;
55
+ settle(cache, response, { ...state, preview, started, failed: scope.failed }, scope.tags);
56
+ return new Response(null, { status: response.status, statusText: response.statusText, headers: closed(new Headers(response.headers)) });
57
+ }
58
+ if (!response.headers.get("content-type")?.includes("text/html")) {
59
+ if (!preview)
60
+ return response;
61
+ // Whatever a preview address answers, a file or the closed 404, no cache may keep it.
62
+ settle(cache, response, { ...state, preview, started, failed: scope.failed }, scope.tags);
63
+ return new Response(response.body, { status: response.status, statusText: response.statusText, headers: closed(new Headers(response.headers)) });
64
+ }
65
+ const body = await response.text();
66
+ stage("body");
67
+ settle(cache, response, { ...state, preview, started, failed: scope.failed }, scope.tags);
68
+ stage("settle");
69
+ const headers = new Headers(response.headers);
70
+ headers.delete("content-length");
71
+ readsHeader(headers, scope);
72
+ timingHeader(headers, scope, origin);
73
+ if (!preview)
74
+ return new Response(body, { status: response.status, headers });
75
+ closed(headers);
76
+ // Only a page the shell rendered is a canvas. A page file of the project's own over the same address
77
+ // gets no marker and no bridge, so the editor's diagnosis names it as shadowed (canvas-diagnosis.ts).
78
+ if (!draft || !state.shell)
79
+ return new Response(body, { status: response.status, headers });
80
+ const files = await loadAdminFiles();
81
+ return new Response(previewDocument(body, state.editor, `${admin}/${ADMIN_INTERNAL}/${files.hash}/bridge.js`), { status: response.status, headers });
82
+ });
83
+ });
@@ -0,0 +1 @@
1
+ export declare function previewDocument(html: string, editor: boolean, bridgeSrc: string): string;
@@ -0,0 +1,18 @@
1
+ // What a preview response gains (spec 2026-10-02 astro cloud fit §5; DESIGN.md §2, live pages run no
2
+ // Smoodly code): the draft marker the editor's canvas diagnosis looks for, and in the canvas the bridge's
3
+ // one stylesheet rule and its script. Added by the middleware to preview responses only, so the
4
+ // developer's layout holds no Smoodly tag and a live page carries nothing.
5
+ import { BRIDGE_ATTR, DRAFT_META_NAME } from "../admin/editor/canvas-diagnosis.js";
6
+ import { BRIDGE_CSS } from "../marks.js";
7
+ export function previewDocument(html, editor, bridgeSrc) {
8
+ const head = `<meta name="${DRAFT_META_NAME}">${editor ? `<style ${BRIDGE_ATTR}="">${BRIDGE_CSS}</style>` : ""}`;
9
+ // A module script runs after the document is parsed: the bridge needs the body.
10
+ const tail = editor ? `<script type="module" src="${bridgeSrc}"></script>` : "";
11
+ const doctype = /^\s*<!doctype[^>]*>/i.exec(html)?.[0] ?? "";
12
+ const withHead = html.includes("</head>")
13
+ ? html.replace("</head>", () => `${head}</head>`)
14
+ : doctype + head + html.slice(doctype.length);
15
+ if (!tail)
16
+ return withHead;
17
+ return withHead.includes("</body>") ? withHead.replace("</body>", () => `${tail}</body>`) : withHead + tail;
18
+ }
@@ -0,0 +1,10 @@
1
+ export declare const DRAFT_COOKIE = "smoodly-draft";
2
+ export declare const PREVIEW_BASE = "/_smoodly/preview";
3
+ export declare function draftCookieValue(secret: string): Promise<string>;
4
+ /** `verify` compares in constant time. */
5
+ export declare function isDraftCookie(value: string | undefined, secret: string): Promise<boolean>;
6
+ /** The page path a preview address names ("/" for the base itself), or null when `pathname` is not one. */
7
+ export declare function previewPath(pathname: string): string | null;
8
+ /** The page path a preview address may rewrite to: never the admin, never a preview address again (a
9
+ * rewrite into either would serve the admin as a canvas, or loop until Astro answers 508). */
10
+ export declare function previewTarget(pathname: string, adminPath: string): string | null;
@@ -0,0 +1,72 @@
1
+ // The preview address and its key (DESIGN.md Vision, preview per framework; spec 2026-10-02 astro cloud
2
+ // fit §7): Astro serves every draft under PREVIEW_BASE, to a browser that holds the draft cookie. The
3
+ // cookie's value is an HMAC of a fixed message under the site's secret key: the draft operation, which
4
+ // only a signed-in editor can call, is the only thing that can make one. Web Crypto only, so the code
5
+ // runs on any host.
6
+ export const DRAFT_COOKIE = "smoodly-draft";
7
+ export const PREVIEW_BASE = "/_smoodly/preview";
8
+ const encoder = new TextEncoder();
9
+ const MESSAGE = encoder.encode("smoodly-draft:v1");
10
+ const keyOf = (secret) => crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
11
+ export async function draftCookieValue(secret) {
12
+ if (!secret)
13
+ throw new Error("smoodly: the draft cookie needs SMOODLY_SECRET_KEY.");
14
+ const signature = await crypto.subtle.sign("HMAC", await keyOf(secret), MESSAGE);
15
+ return [...new Uint8Array(signature)].map((b) => b.toString(16).padStart(2, "0")).join("");
16
+ }
17
+ /** `verify` compares in constant time. */
18
+ export async function isDraftCookie(value, secret) {
19
+ if (!secret || !value || !/^[0-9a-f]{64}$/.test(value))
20
+ return false;
21
+ const signature = Uint8Array.from(value.match(/../g), (pair) => parseInt(pair, 16));
22
+ return crypto.subtle.verify("HMAC", await keyOf(secret), signature, MESSAGE);
23
+ }
24
+ /** The page path a preview address names ("/" for the base itself), or null when `pathname` is not one. */
25
+ export function previewPath(pathname) {
26
+ if (pathname !== PREVIEW_BASE && !pathname.startsWith(`${PREVIEW_BASE}/`))
27
+ return null;
28
+ const rest = pathname.slice(PREVIEW_BASE.length).replace(/\/+$/, "");
29
+ return rest === "" ? "/" : rest;
30
+ }
31
+ /** astro/dist/core/util/pathname.js validateAndDecodePathname, with its refusals as null. */
32
+ function decodeLikeAstro(pathname) {
33
+ let decoded;
34
+ try {
35
+ decoded = decodeURI(pathname);
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ let iterations = 0;
41
+ while (decoded !== pathname) {
42
+ if (iterations >= 10)
43
+ return null;
44
+ pathname = decoded;
45
+ try {
46
+ decoded = decodeURI(pathname);
47
+ }
48
+ catch {
49
+ break;
50
+ }
51
+ iterations++;
52
+ }
53
+ return decoded;
54
+ }
55
+ /** The page path a preview address may rewrite to: never the admin, never a preview address again (a
56
+ * rewrite into either would serve the admin as a canvas, or loop until Astro answers 508). */
57
+ export function previewTarget(pathname, adminPath) {
58
+ const encoded = previewPath(pathname);
59
+ if (encoded === null)
60
+ return null;
61
+ // Astro's rewrite decodes the address repeatedly (decodeURI until it stops changing, ten times at most)
62
+ // before it matches a route, so the check reads that fully decoded path; an address Astro would refuse
63
+ // (a first decode error, or more than ten levels) is refused. The shell hands in `Astro.url.pathname`,
64
+ // which Astro has already decoded once and collapsed (`//` to `/`), so the repeated decode here is
65
+ // defence in depth. Handed a raw path it would not be enough: a `//admin` target passes the check and
66
+ // the rewrite collapses it to `/admin`.
67
+ const path = decodeLikeAstro(encoded);
68
+ if (path === null)
69
+ return null;
70
+ const under = (base) => path === base || path.startsWith(`${base}/`);
71
+ return under(adminPath) || under(PREVIEW_BASE) ? null : encoded;
72
+ }
@@ -0,0 +1,15 @@
1
+ import type { ResolvedImage } from "../media.ts";
2
+ export type RichTextChunk = {
3
+ kind: "html";
4
+ html: string;
5
+ } | {
6
+ kind: "image";
7
+ image: ResolvedImage;
8
+ caption: string;
9
+ } | {
10
+ kind: "element";
11
+ name: string;
12
+ fields: Record<string, unknown>;
13
+ settings: Record<string, unknown>;
14
+ };
15
+ export declare function richtextChunks(doc: unknown): RichTextChunk[];
@@ -0,0 +1,78 @@
1
+ import { escapeHtml, isResolvedImage, safeHref } from "../richtext-href.js";
2
+ import { ELEMENT_NODE, IMAGE_NODE } from "../richtext-walk.js";
3
+ const asRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? v : {};
4
+ const WRAP = { paragraph: "p", bulletList: "ul", orderedList: "ol", listItem: "li", blockquote: "blockquote" };
5
+ export function richtextChunks(doc) {
6
+ const d = doc;
7
+ if (!d || typeof d !== "object" || d.type !== "doc" || !Array.isArray(d.content))
8
+ return [];
9
+ const out = [];
10
+ let html = "";
11
+ const flush = () => { if (html !== "") {
12
+ out.push({ kind: "html", html });
13
+ html = "";
14
+ } };
15
+ const walk = (node) => {
16
+ if (!node || typeof node !== "object")
17
+ return;
18
+ const children = () => { for (const c of Array.isArray(node.content) ? node.content : [])
19
+ walk(c); };
20
+ const wrap = WRAP[node.type];
21
+ if (wrap) {
22
+ html += `<${wrap}>`;
23
+ children();
24
+ html += `</${wrap}>`;
25
+ return;
26
+ }
27
+ switch (node.type) {
28
+ case "text":
29
+ html += withMarks(escapeHtml(node.text ?? ""), node.marks ?? []);
30
+ return;
31
+ case "heading": {
32
+ const level = Math.min(6, Math.max(1, Math.round(Number(node.attrs?.level ?? 1) || 1)));
33
+ html += `<h${level}>`;
34
+ children();
35
+ html += `</h${level}>`;
36
+ return;
37
+ }
38
+ case "hardBreak":
39
+ html += "<br>";
40
+ return;
41
+ case IMAGE_NODE: {
42
+ const a = (node.attrs ?? {});
43
+ if (!isResolvedImage(a.image))
44
+ return;
45
+ flush();
46
+ out.push({ kind: "image", image: a.image, caption: typeof a.caption === "string" ? a.caption : "" });
47
+ return;
48
+ }
49
+ case ELEMENT_NODE: {
50
+ const a = (node.attrs ?? {});
51
+ if (typeof a.name !== "string")
52
+ return;
53
+ flush();
54
+ out.push({ kind: "element", name: a.name, fields: asRecord(a.fields), settings: asRecord(a.settings) });
55
+ return;
56
+ }
57
+ default: children();
58
+ }
59
+ };
60
+ for (const n of d.content)
61
+ walk(n);
62
+ flush();
63
+ return out;
64
+ }
65
+ /** Marks wrap from the first outward: [bold, italic] → <em><strong>…</strong></em>. */
66
+ function withMarks(inner, marks) {
67
+ return marks.reduce((acc, m) => {
68
+ switch (m.type) {
69
+ case "bold": return `<strong>${acc}</strong>`;
70
+ case "italic": return `<em>${acc}</em>`;
71
+ case "link": {
72
+ const href = safeHref(m.attrs?.href);
73
+ return href === null ? acc : `<a href="${escapeHtml(href)}">${acc}</a>`;
74
+ }
75
+ default: return acc;
76
+ }
77
+ }, inner);
78
+ }
@@ -0,0 +1,3 @@
1
+ import type { APIRoute } from "astro";
2
+ export declare const prerender = false;
3
+ export declare const ALL: APIRoute;
@@ -0,0 +1,41 @@
1
+ // The admin in an Astro project (spec 2026-10-02 astro cloud fit §4), injected by the integration at the
2
+ // admin path: the page, the package's files and the operations endpoint are the framework-free handler's
3
+ // (admin/handler.ts), made once per server. This file adds only what is Astro's: the tags an operation
4
+ // expires go to the response cache together, and draft mode is the draft cookie, scoped to the preview
5
+ // address. The request's own effects ride an AsyncLocalStorage, so one handler serves requests at once.
6
+ import { AsyncLocalStorage } from "node:async_hooks";
7
+ import { createAdminHandler } from "../../admin/handler.js";
8
+ import { expire } from "../cache.js";
9
+ import { DRAFT_COOKIE, PREVIEW_BASE, draftCookieValue } from "../preview.js";
10
+ import { adminDeps, secret } from "../server.js";
11
+ export const prerender = false;
12
+ const current = new AsyncLocalStorage();
13
+ const effects = () => {
14
+ const got = current.getStore();
15
+ if (!got)
16
+ throw new Error("smoodly: an admin operation ran outside its request.");
17
+ return got;
18
+ };
19
+ const secure = (context) => context.url.protocol === "https:" || context.request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim() === "https";
20
+ const handle = createAdminHandler(() => ({
21
+ ...adminDeps(),
22
+ previewBase: PREVIEW_BASE,
23
+ effects: {
24
+ revalidate: (tag) => void effects().expired.add(tag),
25
+ draft: {
26
+ enable: async () => {
27
+ const { context } = effects();
28
+ context.cookies.set(DRAFT_COOKIE, await draftCookieValue(secret()), {
29
+ path: PREVIEW_BASE, httpOnly: true, sameSite: "lax", secure: secure(context),
30
+ });
31
+ },
32
+ disable: () => effects().context.cookies.delete(DRAFT_COOKIE, { path: PREVIEW_BASE }),
33
+ },
34
+ },
35
+ }));
36
+ export const ALL = async (context) => {
37
+ const state = { expired: new Set(), context };
38
+ const response = await current.run(state, () => handle(context.request));
39
+ await expire(context.cache, state.expired);
40
+ return response;
41
+ };
@@ -0,0 +1,17 @@
1
+ ---
2
+ // The page shell (spec 2026-10-02 astro cloud fit §6–§7), injected by the integration at every address a
3
+ // page file of the project's own does not take: the address becomes the project's view for the page's
4
+ // type or for the entry's collection, with the page or the entry and the head as data; a redirect row
5
+ // answers 308, as in Next (302 for the root under a prefixed default) (spec 2026-09-26 redirects §7). A preview arrives here by a rewrite from the
6
+ // preview address, so the view sees the page's real address either way.
7
+ import { views } from "smoodly:views";
8
+ import { shell } from "smoodly/astro/runtime";
9
+
10
+ const result = await shell(Astro.url.pathname, Astro.url.search, Astro.locals.smoodly, views, Astro.cache);
11
+ // An empty 404: Astro renders the project's own 404 page for it, prerendered or not, or its default one.
12
+ if (result.kind === "notFound") return new Response(null, { status: 404 });
13
+ if (result.kind === "redirect") return Astro.redirect(result.location, result.status);
14
+ const View = result.kind === "page" ? views.pages[result.template] : views.entries[result.collection];
15
+ const props = result.kind === "page" ? { page: result.page, zones: result.zones, head: result.head } : { entry: result.entry, head: result.head };
16
+ ---
17
+ <View {...props} />
@@ -0,0 +1,11 @@
1
+ ---
2
+ // The preview address (DESIGN.md Vision, preview per framework): /_smoodly/preview/<the page's path>,
3
+ // open to a browser with the draft cookie and to no other. It rewrites to the page's own address, where
4
+ // the same route a visitor gets renders the draft, so the page sees its real address. The admin and a
5
+ // preview address inside a preview address are no page: a closed 404 for both.
6
+ import { adminPath, previewTarget } from "smoodly/astro/runtime";
7
+
8
+ const target = Astro.locals.smoodly?.draft ? previewTarget(Astro.url.pathname, adminPath) : null;
9
+ if (target === null) return new Response("Not found", { status: 404, headers: { "cache-control": "no-store" } });
10
+ return Astro.rewrite(target + Astro.url.search);
11
+ ---
@@ -0,0 +1,32 @@
1
+ export { EMPTY_ATTR, NODE_ATTR, ZONE_ATTR } from "../marks.ts";
2
+ export { SMD_KEY } from "../smd-key.ts";
3
+ export { DEFAULT_STYLE_VALUES, inlineStyles } from "../styles.ts";
4
+ export { safeCss, scopedCss } from "../css.ts";
5
+ export { PLACEHOLDER_CSS, zoneItems, type ZoneContent, type ZoneItem, type ZoneViews } from "./zone.ts";
6
+ export { CRASHED_ATTR, FIELD_ATTR, ITEM_ATTR } from "../marks.ts";
7
+ export { crashMessage } from "../crash-message.ts";
8
+ export { styleAttribute } from "./style.ts";
9
+ export { richtextChunks, type RichTextChunk } from "./richtext.ts";
10
+ export { CUSTOM_NODE_TYPE } from "../custom/schema.ts";
11
+ export { IMAGE_WIDTHS, imageRequest, imageWidths, placeImage } from "./image.ts";
12
+ export { previewPath, previewTarget } from "./preview.ts";
13
+ export { href } from "./server.ts";
14
+ export type { Shell, SmoodlyLocals } from "./shell.ts";
15
+ import { type RegistryInfo } from "./defaults.ts";
16
+ import { type ViewFiles } from "./views-check.ts";
17
+ export type { PageHead } from "./head.ts";
18
+ export type { ViewFiles } from "./views-check.ts";
19
+ import { type Shell, type SmoodlyLocals } from "./shell.ts";
20
+ /** The shell's decision with the project's bound site: what routes/page.astro calls, with `Astro.cache`.
21
+ * A page read from the database gets its lifetime here, before its view renders. The first call compares
22
+ * the project's view files to the registry and names every disagreement once. */
23
+ export declare function shell(pathname: string, search: string, state: SmoodlyLocals, views: ViewFiles, cache?: unknown): Promise<Shell>;
24
+ /** The registry's titles and settings defaults, for the zone and the richtext renderer. */
25
+ export declare const info: RegistryInfo;
26
+ /** The custom section's rows and layout css, at the project's (already resolved) stacking breakpoint. */
27
+ export declare const customLayoutOf: (rows: unknown, styles: Record<string, unknown>) => {
28
+ list: import("../index.ts").CustomRow[];
29
+ styles: Record<string, unknown>;
30
+ };
31
+ /** Where the admin is mounted, for the preview route to refuse. */
32
+ export declare const adminPath: string;
@@ -0,0 +1,46 @@
1
+ // What the package's own .astro files import (spec 2026-10-02 astro cloud fit §8). They name this entry,
2
+ // `smoodly/astro/runtime`, and never a relative .ts file: tsc compiles this module for dist and leaves an
3
+ // .astro file as it is.
4
+ export { EMPTY_ATTR, NODE_ATTR, ZONE_ATTR } from "../marks.js";
5
+ export { SMD_KEY } from "../smd-key.js";
6
+ export { DEFAULT_STYLE_VALUES, inlineStyles } from "../styles.js";
7
+ export { safeCss, scopedCss } from "../css.js";
8
+ export { PLACEHOLDER_CSS, zoneItems } from "./zone.js";
9
+ export { CRASHED_ATTR, FIELD_ATTR, ITEM_ATTR } from "../marks.js";
10
+ export { crashMessage } from "../crash-message.js";
11
+ export { styleAttribute } from "./style.js";
12
+ export { richtextChunks } from "./richtext.js";
13
+ export { CUSTOM_NODE_TYPE } from "../custom/schema.js";
14
+ export { IMAGE_WIDTHS, imageRequest, imageWidths, placeImage } from "./image.js";
15
+ export { previewPath, previewTarget } from "./preview.js";
16
+ export { href } from "./server.js";
17
+ import { customLayout } from "./custom.js";
18
+ import { registryInfo } from "./defaults.js";
19
+ import { lifetime } from "./cache.js";
20
+ import { astroReads, preload } from "./scope.js";
21
+ import { config, href, routing, site } from "./server.js";
22
+ import { missingViews } from "./views-check.js";
23
+ import { resolveShell } from "./shell.js";
24
+ let viewsChecked = false;
25
+ /** The shell's decision with the project's bound site: what routes/page.astro calls, with `Astro.cache`.
26
+ * A page read from the database gets its lifetime here, before its view renders. The first call compares
27
+ * the project's view files to the registry and names every disagreement once. */
28
+ export async function shell(pathname, search, state, views, cache) {
29
+ if (!viewsChecked) {
30
+ viewsChecked = true;
31
+ const missing = missingViews(config.registry, views);
32
+ if (missing.length > 0)
33
+ console.warn(`smoodly: the views and the config disagree:\n ${missing.join("\n ")}`);
34
+ }
35
+ const shellViews = { page: (template) => Object.prototype.hasOwnProperty.call(views.pages, template), entry: (collection) => Object.prototype.hasOwnProperty.call(views.entries, collection) };
36
+ const result = await resolveShell({ site: site(), config, href, routing, run: astroReads.run, preload }, pathname, search, state, shellViews);
37
+ if (state.cacheable)
38
+ lifetime(cache);
39
+ return result;
40
+ }
41
+ /** The registry's titles and settings defaults, for the zone and the richtext renderer. */
42
+ export const info = registryInfo(config.registry);
43
+ /** The custom section's rows and layout css, at the project's (already resolved) stacking breakpoint. */
44
+ export const customLayoutOf = (rows, styles) => customLayout(rows, styles, config.custom.breakpoint);
45
+ /** Where the admin is mounted, for the preview route to refuse. */
46
+ export const adminPath = config.admin.path;
@@ -0,0 +1,45 @@
1
+ import type { ReadAdapter } from "../reads.ts";
2
+ export type RequestScope = {
3
+ /** The tags of every published read the response has made so far. */
4
+ readonly tags: Set<string>;
5
+ /** The request is a verified preview: reads return drafts. */
6
+ readonly draft: boolean;
7
+ /** A read threw: whatever the page made of it (Astro's live collections hand the error back as a value,
8
+ * a section's guard swallows it) is not the content, so the response is never cached (cache.ts). */
9
+ failed: boolean;
10
+ /** The database calls the response has made (the content client's requests, spec 2026-10-07 delivery model). */
11
+ reads: number;
12
+ /** Under SMOODLY_DEBUG_READS=1 only: when each read and each stage of the response ran, for the Server-Timing header. */
13
+ marks?: {
14
+ name: string;
15
+ start: number;
16
+ end: number;
17
+ }[];
18
+ /** Values the shell's lookup brought along (the layout's globals, schema v9), by unit key: a unit run under one
19
+ * of these keys is answered here, without a read. */
20
+ seeded?: Map<string, {
21
+ value: unknown;
22
+ tags: string[];
23
+ }>;
24
+ };
25
+ /** Seeds the current request's scope with values a read brought along, under the keys the units they stand for
26
+ * would use (`globalUnit`'s for a global item). Outside a scope, nothing. */
27
+ export declare function preload(entries: {
28
+ key: string[];
29
+ tags: string[];
30
+ value: unknown;
31
+ }[]): void;
32
+ /** One database call in the current request, if any. The content client calls it through a global
33
+ * (client.ts), installed once per process here. */
34
+ export declare function countRead(): void;
35
+ /** `x-smoodly-reads: <n>` on a response under SMOODLY_DEBUG_READS=1: how the browser suite holds a live
36
+ * page to one lookup plus its globals. Off by default: a count is not for visitors. */
37
+ export declare function readsHeader(headers: Headers, scope: RequestScope, env?: Record<string, string | undefined>): void;
38
+ /** `server-timing` under SMOODLY_DEBUG_READS=1: every read (its key after the model, the ms it started at,
39
+ * its duration) and the middleware's stages, plus the age of this process, so a cold start is visible. */
40
+ export declare function timingHeader(headers: Headers, scope: RequestScope, origin: number, env?: Record<string, string | undefined>): void;
41
+ /** Runs `fn` with `scope` as the request's: every read it starts, at any depth, finds it. */
42
+ export declare function inRequestScope<T>(scope: RequestScope, fn: () => Promise<T>): Promise<T>;
43
+ export declare function requestScope(): RequestScope | undefined;
44
+ /** Astro's adapter for the read layer. No gate: these routes render on request, and a static build may read. */
45
+ export declare const astroReads: ReadAdapter;
@@ -0,0 +1,103 @@
1
+ var _a;
2
+ // The request scope and Astro's read adapter (spec 2026-10-02 astro cloud fit §6–§7): Astro caches whole
3
+ // responses, so every read a response makes must add its tags, and a developer must never have to. The
4
+ // middleware opens one scope per request (middleware.ts); every unit of the read layer (../reads.ts) run
5
+ // inside it is heard here, wherever in the render it starts. A draft scope reads the draft and tags
6
+ // nothing, because a draft response is never cached. `node:async_hooks` is the one Node module the
7
+ // adapter's server code imports: Astro's own render scope and its Node adapter use the same storage.
8
+ import { AsyncLocalStorage } from "node:async_hooks";
9
+ const seedKey = (key) => key.join("\u0000");
10
+ // A global read during a page render that the lookup did not bring: once per item per process, in development
11
+ // only — a deliberate read on one page type is fine and would only be noise on a server.
12
+ const hinted = new Set();
13
+ function hintUnseeded(key, scope) {
14
+ if (process.env.NODE_ENV === "production" || key[2] !== "global" || !scope.seeded)
15
+ return;
16
+ const name = key[3] ?? "";
17
+ if (hinted.has(name))
18
+ return;
19
+ hinted.add(name);
20
+ console.warn(`smoodly: the global "${name}" was read while rendering a page but is not flagged \`layout: true\`, so every uncached page that reads it costs a database call. Flag it if every page renders it; ignore this if only some pages do.`);
21
+ }
22
+ /** Seeds the current request's scope with values a read brought along, under the keys the units they stand for
23
+ * would use (`globalUnit`'s for a global item). Outside a scope, nothing. */
24
+ export function preload(entries) {
25
+ const scope = storage.getStore();
26
+ if (!scope)
27
+ return;
28
+ scope.seeded ?? (scope.seeded = new Map());
29
+ for (const e of entries)
30
+ scope.seeded.set(seedKey(e.key), { value: e.value, tags: e.tags });
31
+ }
32
+ // One storage per process, however many copies of this module a bundler makes.
33
+ const KEY = Symbol.for("smoodly:request-scope");
34
+ const host = globalThis;
35
+ const storage = (host[KEY] ?? (host[KEY] = new AsyncLocalStorage()));
36
+ /** One database call in the current request, if any. The content client calls it through a global
37
+ * (client.ts), installed once per process here. */
38
+ export function countRead() {
39
+ const scope = storage.getStore();
40
+ if (scope)
41
+ scope.reads++;
42
+ }
43
+ const COUNTER = Symbol.for("smoodly:read-counter");
44
+ (_a = globalThis)[COUNTER] ?? (_a[COUNTER] = countRead);
45
+ /** `x-smoodly-reads: <n>` on a response under SMOODLY_DEBUG_READS=1: how the browser suite holds a live
46
+ * page to one lookup plus its globals. Off by default: a count is not for visitors. */
47
+ export function readsHeader(headers, scope, env = process.env) {
48
+ if (env.SMOODLY_DEBUG_READS === "1")
49
+ headers.set("x-smoodly-reads", String(scope.reads));
50
+ }
51
+ const BOOTED = Date.now();
52
+ /** `server-timing` under SMOODLY_DEBUG_READS=1: every read (its key after the model, the ms it started at,
53
+ * its duration) and the middleware's stages, plus the age of this process, so a cold start is visible. */
54
+ export function timingHeader(headers, scope, origin, env = process.env) {
55
+ if (env.SMOODLY_DEBUG_READS !== "1" || !scope.marks)
56
+ return;
57
+ const parts = scope.marks.map((m, i) => `m${i};desc="${m.name.replace(/["\\]/g, "")}@${Math.round(m.start - origin)}";dur=${Math.round(m.end - m.start)}`);
58
+ parts.push(`boot;dur=${Date.now() - BOOTED}`);
59
+ headers.set("server-timing", parts.join(", "));
60
+ }
61
+ /** Runs `fn` with `scope` as the request's: every read it starts, at any depth, finds it. */
62
+ export function inRequestScope(scope, fn) {
63
+ return storage.run(scope, fn);
64
+ }
65
+ export function requestScope() {
66
+ return storage.getStore();
67
+ }
68
+ /** Astro's adapter for the read layer. No gate: these routes render on request, and a static build may read. */
69
+ export const astroReads = {
70
+ async run(unit) {
71
+ const scope = storage.getStore();
72
+ if (scope && !scope.draft)
73
+ for (const tag of unit.tags)
74
+ scope.tags.add(tag);
75
+ const started = performance.now();
76
+ const seeded = scope && !scope.draft ? scope.seeded?.get(seedKey(unit.key)) : undefined;
77
+ if (seeded) {
78
+ for (const tag of seeded.tags)
79
+ scope.tags.add(tag);
80
+ if (unit.tagsOf)
81
+ for (const tag of unit.tagsOf(seeded.value))
82
+ scope.tags.add(tag);
83
+ scope.marks?.push({ name: `${unit.key.slice(2).join("/")} (seeded)`, start: started, end: performance.now() });
84
+ return seeded.value;
85
+ }
86
+ if (scope && !scope.draft)
87
+ hintUnseeded(unit.key, scope);
88
+ try {
89
+ const value = await unit.read(scope?.draft === true);
90
+ // Tags the value brings (a read by address): added after a successful read only.
91
+ if (scope && !scope.draft && unit.tagsOf)
92
+ for (const tag of unit.tagsOf(value))
93
+ scope.tags.add(tag);
94
+ scope?.marks?.push({ name: unit.key.slice(2).join("/"), start: started, end: performance.now() });
95
+ return value;
96
+ }
97
+ catch (error) {
98
+ if (scope)
99
+ scope.failed = true;
100
+ throw error;
101
+ }
102
+ },
103
+ };
@@ -0,0 +1,14 @@
1
+ import type { AdminHandlerDeps } from "../admin/handler.ts";
2
+ import type { ResolvedConfig } from "../config.ts";
3
+ export declare const config: ResolvedConfig;
4
+ /** The address rule: astro.config's i18n when it is set, else the default locale at the root. */
5
+ export declare const routing: import("./locale-paths.ts").LocaleRouting;
6
+ /** Locale-relative CMS path → site URL, for the operations and the `page` prop. */
7
+ export declare const href: (path: string, locale: string) => string;
8
+ export declare const site: () => import("../site.ts").SmoodlySite;
9
+ /** The draft cookie's key: the server-only key every Smoodly site already holds. */
10
+ export declare const secret: () => string;
11
+ type AdminDeps = Omit<AdminHandlerDeps, "effects" | "previewBase">;
12
+ /** The admin's wiring. The publishable key is asked for here, not on a live page. */
13
+ export declare function adminDeps(): AdminDeps;
14
+ export {};