smoodly 0.0.46 → 0.0.48

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 (64) hide show
  1. package/dist/admin/browser/main.js +2 -0
  2. package/dist/admin/client-ops.js +1 -0
  3. package/dist/admin/delivery-mode.d.ts +5 -0
  4. package/dist/admin/delivery-mode.js +20 -0
  5. package/dist/admin/editor/EditorView.js +3 -0
  6. package/dist/admin/files.generated.js +1 -1
  7. package/dist/admin/handler.d.ts +5 -1
  8. package/dist/admin/handler.js +2 -1
  9. package/dist/admin/health.d.ts +4 -0
  10. package/dist/admin/ops-impl.d.ts +13 -0
  11. package/dist/admin/ops-impl.js +48 -2
  12. package/dist/admin/ops.d.ts +11 -0
  13. package/dist/admin/page-data.d.ts +2 -0
  14. package/dist/admin/shell/AdminApp.js +47 -1
  15. package/dist/admin/shell/ContentHealthPage.js +3 -1
  16. package/dist/admin/shell/EntryForm.js +3 -0
  17. package/dist/admin/shell/GlobalForm.js +5 -1
  18. package/dist/admin/shell/IconRail.d.ts +8 -1
  19. package/dist/admin/shell/IconRail.js +8 -3
  20. package/dist/admin/shell/deploy-tile.d.ts +20 -0
  21. package/dist/admin/shell/deploy-tile.js +35 -0
  22. package/dist/admin/ui/PublishControl.js +3 -1
  23. package/dist/admin/ui/theme.js +12 -0
  24. package/dist/astro/deploy-deps.d.ts +10 -0
  25. package/dist/astro/deploy-deps.js +8 -0
  26. package/dist/astro/index.d.ts +9 -4
  27. package/dist/astro/index.js +75 -15
  28. package/dist/astro/middleware.js +4 -0
  29. package/dist/astro/routes/admin.js +2 -1
  30. package/dist/astro/routes/preview.astro +17 -5
  31. package/dist/astro/routes/static-page.astro +21 -0
  32. package/dist/astro/runtime.d.ts +6 -0
  33. package/dist/astro/runtime.js +4 -1
  34. package/dist/astro/server.d.ts +6 -0
  35. package/dist/astro/server.js +7 -1
  36. package/dist/astro/static.d.ts +36 -0
  37. package/dist/astro/static.js +59 -0
  38. package/dist/cli/load-config.d.ts +3 -0
  39. package/dist/cli/load-config.js +8 -1
  40. package/dist/cli/migrate.d.ts +10 -0
  41. package/dist/cli/migrate.js +22 -1
  42. package/dist/cli/space-io.js +3 -3
  43. package/dist/deploy-state.d.ts +28 -0
  44. package/dist/deploy-state.js +31 -0
  45. package/dist/document-stamp.d.ts +7 -0
  46. package/dist/document-stamp.js +48 -0
  47. package/dist/project.d.ts +22 -3
  48. package/dist/project.js +33 -12
  49. package/dist/published.d.ts +50 -4
  50. package/dist/published.js +46 -7
  51. package/dist/schema-version.d.ts +1 -1
  52. package/dist/schema-version.js +1 -1
  53. package/dist/site.d.ts +2 -0
  54. package/dist/site.js +53 -11
  55. package/dist/sql-space.d.ts +4 -2
  56. package/dist/sql-space.js +4 -2
  57. package/dist/sql.js +20 -1
  58. package/dist/supabase-published-store.d.ts +24 -3
  59. package/dist/supabase-published-store.js +104 -5
  60. package/dist/version.d.ts +1 -1
  61. package/dist/version.js +1 -1
  62. package/migrations/20261008120000_smoodly_v10.sql +47 -0
  63. package/migrations/20261009120000_smoodly_v11.sql +49 -0
  64. package/package.json +1 -1
@@ -2,28 +2,36 @@
2
2
  // astro.config mounts the admin, the preview address and the page shell, adds the middleware that
3
3
  // collects cache tags, and supplies the project's config and its views as virtual modules. The project
4
4
  // holds no glue file. This module runs in the build tool, and in a script through createContent; never on the server.
5
- import { join, relative } from "node:path";
5
+ import { mkdir, writeFile } from "node:fs/promises";
6
+ import { dirname, join, relative } from "node:path";
6
7
  import { fileURLToPath } from "node:url";
7
8
  import { DEFAULT_ADMIN_PATH } from "../admin-path.js";
8
9
  import { createSupabaseStores, siteOptions } from "../bind.js";
10
+ import { loadConfig, loadConfigFile } from "../cli/load-config.js";
9
11
  import { createSmoodlyClient } from "../client.js";
10
12
  import { createSmoodlyContent } from "../content/content-api.js";
13
+ import { markerJson, MARKER_PATH } from "../deploy-state.js";
11
14
  import { resolveSmoodlyEnv, siteUrl } from "../env.js";
12
15
  import { mediaRemotePattern } from "../image/remote-pattern.js";
13
16
  import { loadSmoodlyEnv } from "./env-files.js";
14
17
  import { astroRouting, localeHref } from "./locale-paths.js";
15
18
  import { PREVIEW_BASE } from "./preview.js";
19
+ import { hostRedirects, redirectEntries, rootRedirect } from "./static.js";
16
20
  import { createSmoodlySite } from "../site.js";
17
21
  // In the repository this file is TypeScript; in the published package tsc made it JavaScript, and the
18
22
  // .astro files were copied beside it unchanged.
19
23
  const ext = import.meta.url.endsWith(".ts") ? ".ts" : ".js";
20
24
  const here = (path) => fileURLToPath(new URL(path, import.meta.url));
21
- /** The three routes, most specific first; a page file of the project's own wins over the shell for its address. */
22
- export function routes(adminPath) {
25
+ /** The three routes, most specific first; a page file of the project's own wins over the shell for its address.
26
+ * The admin and the preview address are always on demand; the page shell is the prerendered one in static
27
+ * output (spec 2026-10-09 static mode §3) and the on-demand one in server output. */
28
+ export function routes(adminPath, output = "server") {
23
29
  return [
24
- { pattern: `${adminPath}/[...path]`, entrypoint: here(`./routes/admin${ext}`) },
25
- { pattern: `${PREVIEW_BASE}/[...path]`, entrypoint: here("./routes/preview.astro") },
26
- { pattern: "/[...path]", entrypoint: here("./routes/page.astro") },
30
+ { pattern: `${adminPath}/[...path]`, entrypoint: here(`./routes/admin${ext}`), prerender: false },
31
+ { pattern: `${PREVIEW_BASE}/[...path]`, entrypoint: here("./routes/preview.astro"), prerender: false },
32
+ output === "static"
33
+ ? { pattern: "/[...path]", entrypoint: here("./routes/static-page.astro"), prerender: true }
34
+ : { pattern: "/[...path]", entrypoint: here("./routes/page.astro"), prerender: false },
27
35
  ];
28
36
  }
29
37
  /** The views by file name, under the project's source dir (root-relative, "/src" by default): a section's
@@ -36,8 +44,10 @@ export const views = { sections: ${glob("sections")}, elements: ${glob("elements
36
44
  `;
37
45
  }
38
46
  const VIEWS_ID = "\0smoodly:views";
39
- /** `root` is the project's directory; `srcDir` its source dir relative to the root, with a leading slash. */
40
- export function virtualModules(root, configFile, srcDir = "/src") {
47
+ const BUILD_ID = "\0smoodly:build";
48
+ /** `root` is the project's directory; `srcDir` its source dir relative to the root, with a leading slash;
49
+ * `output` astro.config's, which `smoodly:build` tells the preview route and the server. */
50
+ export function virtualModules(root, configFile, srcDir = "/src", output = "server") {
41
51
  return {
42
52
  name: "smoodly:virtual",
43
53
  resolveId(id) {
@@ -45,20 +55,37 @@ export function virtualModules(root, configFile, srcDir = "/src") {
45
55
  return join(root, configFile);
46
56
  if (id === "smoodly:views")
47
57
  return VIEWS_ID;
58
+ if (id === "smoodly:build")
59
+ return BUILD_ID;
48
60
  return undefined;
49
61
  },
50
62
  load(id) {
51
- return id === VIEWS_ID ? viewsModule(srcDir) : undefined;
63
+ if (id === VIEWS_ID)
64
+ return viewsModule(srcDir);
65
+ if (id === BUILD_ID)
66
+ return `export const output = ${JSON.stringify(output)};\n`;
67
+ return undefined;
52
68
  },
53
69
  };
54
70
  }
55
71
  export default function smoodly(options = {}) {
56
72
  const adminPath = options.adminPath ?? DEFAULT_ADMIN_PATH;
73
+ // Closed over by the build hooks: astro.config's output and the project's root, as the setup hook saw them, and
74
+ // the watermark the marker carries.
75
+ let output = "server";
76
+ let root = process.cwd();
77
+ let watermark = null;
78
+ /** The project's stores at build, from its config file (the one `options.config` names) and the env the setup hook loaded. */
79
+ const buildStores = async () => {
80
+ const loaded = options.config ? await loadConfigFile(join(root, options.config), root) : await loadConfig(root);
81
+ return { loaded, ...createSupabaseStores(createSmoodlyClient(resolveSmoodlyEnv(process.env)), loaded) };
82
+ };
57
83
  return {
58
84
  name: "smoodly",
59
85
  hooks: {
60
- "astro:config:setup": ({ config, command, updateConfig, injectRoute, addMiddleware, logger }) => {
61
- const root = fileURLToPath(config.root);
86
+ "astro:config:setup": async ({ config, command, updateConfig, injectRoute, addMiddleware, logger }) => {
87
+ root = fileURLToPath(config.root);
88
+ output = config.output === "static" ? "static" : "server";
62
89
  // Root-relative with a leading slash, as Vite's import.meta.glob reads it; a source dir that is the root itself is "".
63
90
  const rel = relative(root, fileURLToPath(config.srcDir)).split("\\").join("/");
64
91
  const srcDir = rel === "" ? "" : "/" + rel;
@@ -69,12 +96,12 @@ export default function smoodly(options = {}) {
69
96
  logger.warn("smoodly: SMOODLY_URL is not set at build — every image will fail: Astro's /_image serves only hosts in image.remotePatterns, and the media host is taken from SMOODLY_URL here. Set it where `astro build` runs.");
70
97
  updateConfig({
71
98
  image: { remotePatterns: mediaUrl ? [mediaRemotePattern(mediaUrl)] : [] },
72
- vite: { plugins: [virtualModules(root, options.config ?? "smoodly.config.ts", srcDir)] },
99
+ vite: { plugins: [virtualModules(root, options.config ?? "smoodly.config.ts", srcDir, output)] },
73
100
  });
74
- for (const route of routes(adminPath))
75
- injectRoute({ ...route, prerender: false });
101
+ for (const route of routes(adminPath, output))
102
+ injectRoute(route);
76
103
  addMiddleware({ entrypoint: here(`./middleware${ext}`), order: "pre" });
77
- if (!config.cache?.provider) {
104
+ if (output === "server" && !config.cache?.provider) {
78
105
  logger.warn("smoodly: no cache provider in astro.config — every page renders from the database. Add `cache: { provider: memoryCache() }` (from \"astro/config\").");
79
106
  }
80
107
  // astro.config is evaluated before Astro loads .env, so `site: process.env.SITE_URL` there sees only the
@@ -84,9 +111,42 @@ export default function smoodly(options = {}) {
84
111
  siteUrl(process.env);
85
112
  updateConfig({ site: process.env.SITE_URL });
86
113
  }
114
+ else if (!config.site && output === "static") {
115
+ // The Deploy tile reads the build marker from the live address: without one the admin could not open, so the
116
+ // build says so now, where the developer is looking (final review 2026-10-09).
117
+ throw new Error("smoodly: static output needs `site` — the Deploy tile reads the build marker from the live address. Set SITE_URL in .env or where `astro build` runs, or `site` in astro.config.");
118
+ }
87
119
  else if (!config.site) {
88
120
  logger.warn("smoodly: `site` is not set — canonical, hreflang and og:url links are built from each request's own origin, which behind a proxy can be the internal host, and a live page is cached for a year. Set SITE_URL in .env or where `astro build` runs, or `site` in astro.config.");
89
121
  }
122
+ // Static output (spec 2026-10-09 static mode §3): the redirect rows and the prefix-always root become the
123
+ // host's redirects, answered without a function. Read at build only; `astro dev` serves them on demand.
124
+ if (output === "static" && command === "build") {
125
+ const { loaded, pages } = await buildStores();
126
+ const routing = astroRouting(config.i18n, loaded.locales);
127
+ const href = localeHref(loaded.locales, routing);
128
+ const redirects = { ...(await redirectEntries({ pages, config: loaded, href })), ...rootRedirect(routing, href, loaded.locales) };
129
+ updateConfig({ redirects: hostRedirects(config.redirects, redirects) });
130
+ logger.info(`smoodly: static output — ${Object.keys(redirects).length} redirects handed to the host.`);
131
+ }
132
+ },
133
+ // The marker's watermark (spec 2026-10-09 static mode §5) is read BEFORE any page renders: a publish that lands
134
+ // while the build runs is then newer than it and counts as waiting, which it is — the build may not hold it. A
135
+ // row the build itself rebuilds keeps its publishedAt (a heal is not a publish, project.ts), so it never counts.
136
+ "astro:build:start": async () => {
137
+ if (output !== "static")
138
+ return;
139
+ watermark = await (await buildStores()).published.newestPublishedAt();
140
+ },
141
+ // The build marker: beside the pages, the only trace of Smoodly in a static build.
142
+ "astro:build:done": async ({ dir, logger }) => {
143
+ if (output !== "static")
144
+ return;
145
+ const marker = markerJson({ builtAt: Date.now(), publishedThrough: watermark });
146
+ const file = join(fileURLToPath(dir), MARKER_PATH);
147
+ await mkdir(dirname(file), { recursive: true });
148
+ await writeFile(file, marker);
149
+ logger.info(`smoodly: wrote ${MARKER_PATH}.`);
90
150
  },
91
151
  },
92
152
  };
@@ -18,6 +18,10 @@ export const onRequest = defineMiddleware(async (context, next) => {
18
18
  const admin = config.admin.path;
19
19
  if (pathname === admin || pathname.startsWith(`${admin}/`))
20
20
  return next();
21
+ // A page prerendered at build (spec 2026-10-09 static mode §3) has no request, no cookie and no cache: the
22
+ // static shell passes its own state, and the file carries no header of ours.
23
+ if (context.isPrerendered)
24
+ return next();
21
25
  const locals = context.locals;
22
26
  // The preview route rewrites to the page's own address, which runs the middleware again: the first pass decided.
23
27
  if (locals.smoodly)
@@ -7,7 +7,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
7
7
  import { createAdminHandler } from "../../admin/handler.js";
8
8
  import { expire } from "../cache.js";
9
9
  import { DRAFT_COOKIE, PREVIEW_BASE, draftCookieValue } from "../preview.js";
10
- import { adminDeps, secret } from "../server.js";
10
+ import { adminDeps, deployDeps, secret } from "../server.js";
11
11
  export const prerender = false;
12
12
  const current = new AsyncLocalStorage();
13
13
  const effects = () => {
@@ -20,6 +20,7 @@ const secure = (context) => context.url.protocol === "https:" || context.request
20
20
  const handle = createAdminHandler(() => ({
21
21
  ...adminDeps(),
22
22
  previewBase: PREVIEW_BASE,
23
+ deploy: deployDeps(),
23
24
  effects: {
24
25
  revalidate: (tag) => void effects().expired.add(tag),
25
26
  draft: {
@@ -1,11 +1,23 @@
1
1
  ---
2
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";
3
+ // open to a browser with the draft cookie and to no other. The admin and a preview address inside a
4
+ // preview address are no page: a closed 404 for both.
5
+ // Live output: it rewrites to the page's own address, where the same route a visitor gets renders the
6
+ // draft, so the page sees its real address. Static output (spec 2026-10-09 static mode §3): that address
7
+ // is a file, so the preview shell renders the draft here, telling the shell the page's real address; the
8
+ // canvas keeps the preview prefix for the links it follows.
9
+ import { output } from "smoodly:build";
10
+ import { views } from "smoodly:views";
11
+ import { adminPath, previewTarget, shell } from "smoodly/astro/runtime";
7
12
 
8
13
  const target = Astro.locals.smoodly?.draft ? previewTarget(Astro.url.pathname, adminPath) : null;
9
14
  if (target === null) return new Response("Not found", { status: 404, headers: { "cache-control": "no-store" } });
10
- return Astro.rewrite(target + Astro.url.search);
15
+ if (output !== "static") return Astro.rewrite(target + Astro.url.search);
16
+
17
+ const result = await shell(target, Astro.url.search, Astro.locals.smoodly!, views);
18
+ if (result.kind === "notFound") return new Response(null, { status: 404, headers: { "cache-control": "no-store" } });
19
+ if (result.kind === "redirect") return Astro.redirect(result.location, result.status);
20
+ const View = result.kind === "page" ? views.pages[result.template] : views.entries[result.collection];
21
+ const props = result.kind === "page" ? { page: result.page, zones: result.zones, head: result.head } : { entry: result.entry, head: result.head };
11
22
  ---
23
+ <View {...props} />
@@ -0,0 +1,21 @@
1
+ ---
2
+ // The static page shell (spec 2026-10-09 static mode §3), injected in place of page.astro when astro.config
3
+ // says `output: "static"`: every published address prerendered at build from its finished document, through
4
+ // the same shell as the live route. No cache and no request state: the file is the cache, and a visitor's
5
+ // page carries no trace of Smoodly. Drafts are edited through the preview address (routes/preview.astro).
6
+ import { views } from "smoodly:views";
7
+ import { shell, staticPaths } from "smoodly/astro/runtime";
8
+
9
+ export async function getStaticPaths() {
10
+ return staticPaths();
11
+ }
12
+
13
+ const pathname = "/" + (Astro.params.path ?? "").replace(/\/+$/, "");
14
+ const state = { draft: false, editor: false, cacheable: false, shell: false };
15
+ const result = await shell(pathname, "", state, views);
16
+ if (result.kind === "notFound") return new Response(null, { status: 404 });
17
+ if (result.kind === "redirect") return Astro.redirect(result.location, result.status);
18
+ const View = result.kind === "page" ? views.pages[result.template] : views.entries[result.collection];
19
+ const props = result.kind === "page" ? { page: result.page, zones: result.zones, head: result.head } : { entry: result.entry, head: result.head };
20
+ ---
21
+ <View {...props} />
@@ -21,6 +21,12 @@ import { type Shell, type SmoodlyLocals } from "./shell.ts";
21
21
  * A page read from the database gets its lifetime here, before its view renders. The first call compares
22
22
  * the project's view files to the registry and names every disagreement once. */
23
23
  export declare function shell(pathname: string, search: string, state: SmoodlyLocals, views: ViewFiles, cache?: unknown): Promise<Shell>;
24
+ /** getStaticPaths for the static page shell (spec 2026-10-09 static mode §3): every published address of the bound site. */
25
+ export declare const staticPaths: () => Promise<{
26
+ params: {
27
+ path: string | undefined;
28
+ };
29
+ }[]>;
24
30
  /** The registry's titles and settings defaults, for the zone and the richtext renderer. */
25
31
  export declare const info: RegistryInfo;
26
32
  /** The custom section's rows and layout css, at the project's (already resolved) stacking breakpoint. */
@@ -18,7 +18,8 @@ import { customLayout } from "./custom.js";
18
18
  import { registryInfo } from "./defaults.js";
19
19
  import { lifetime } from "./cache.js";
20
20
  import { astroReads, preload } from "./scope.js";
21
- import { config, href, routing, site } from "./server.js";
21
+ import { config, href, published, routing, site } from "./server.js";
22
+ import { staticPaths as listStatic } from "./static.js";
22
23
  import { missingViews } from "./views-check.js";
23
24
  import { resolveShell } from "./shell.js";
24
25
  let viewsChecked = false;
@@ -38,6 +39,8 @@ export async function shell(pathname, search, state, views, cache) {
38
39
  lifetime(cache);
39
40
  return result;
40
41
  }
42
+ /** getStaticPaths for the static page shell (spec 2026-10-09 static mode §3): every published address of the bound site. */
43
+ export const staticPaths = () => listStatic({ published: published(), config, href });
41
44
  /** The registry's titles and settings defaults, for the zone and the richtext renderer. */
42
45
  export const info = registryInfo(config.registry);
43
46
  /** The custom section's rows and layout css, at the project's (already resolved) stacking breakpoint. */
@@ -6,6 +6,12 @@ export declare const routing: import("./locale-paths.ts").LocaleRouting;
6
6
  /** Locale-relative CMS path → site URL, for the operations and the `page` prop. */
7
7
  export declare const href: (path: string, locale: string) => string;
8
8
  export declare const site: () => import("../site.ts").SmoodlySite;
9
+ /** The delivery store: the static shell lists its addresses from it, and the deploy operations read it. */
10
+ export declare const published: () => import("../supabase-published-store.ts").SupabasePublishedStore;
11
+ /** The deploy operations' deps in static output (spec 2026-10-09 static mode §7); undefined on a live site. */
12
+ export declare const deployDeps: () => (import("../admin/ops-impl.ts").DeployDeps & {
13
+ mode: "live" | "static";
14
+ }) | undefined;
9
15
  /** The draft cookie's key: the server-only key every Smoodly site already holds. */
10
16
  export declare const secret: () => string;
11
17
  type AdminDeps = Omit<AdminHandlerDeps, "effects" | "previewBase">;
@@ -1,8 +1,10 @@
1
1
  // The bound stores and site layer of an Astro project (spec 2026-10-02 astro cloud fit §1), built from its
2
2
  // smoodly.config.ts through the virtual module: what the Next starter's server.ts glue does, with no file
3
3
  // in the app. Built on first use, so a build that renders nothing needs no keys.
4
- import { i18n } from "astro:config/server";
4
+ import { i18n, site as configuredSite } from "astro:config/server";
5
+ import { output } from "smoodly:build";
5
6
  import { config as loaded } from "smoodly:config";
7
+ import { deployDepsOf } from "./deploy-deps.js";
6
8
  import { supabaseAdminAuth } from "../admin/supabase-auth.js";
7
9
  import { createSupabaseStores, siteOptions } from "../bind.js";
8
10
  import { createSmoodlyClient } from "../client.js";
@@ -26,6 +28,10 @@ function build() {
26
28
  let cached = null;
27
29
  const bound = () => (cached ?? (cached = build()));
28
30
  export const site = () => bound().site;
31
+ /** The delivery store: the static shell lists its addresses from it, and the deploy operations read it. */
32
+ export const published = () => bound().published;
33
+ /** The deploy operations' deps in static output (spec 2026-10-09 static mode §7); undefined on a live site. */
34
+ export const deployDeps = () => deployDepsOf({ output, site: configuredSite, env: process.env, store: published() });
29
35
  /** The draft cookie's key: the server-only key every Smoodly site already holds. */
30
36
  export const secret = () => smoodlyEnv().secretKey;
31
37
  let admin = null;
@@ -0,0 +1,36 @@
1
+ import type { ResolvedConfig } from "../config.ts";
2
+ import type { PublishedStore } from "../published.ts";
3
+ import type { PageStore } from "../store.ts";
4
+ import { type LocaleRouting } from "./locale-paths.ts";
5
+ type Href = (path: string, locale: string) => string;
6
+ /** Every published page and entry address of every locale, as Astro's getStaticPaths entries. A node with
7
+ * no path (hidden, or a code address wins) and an entry of a collection without addresses are not pages. */
8
+ export declare function staticPaths(deps: {
9
+ published: PublishedStore;
10
+ config: ResolvedConfig;
11
+ href: Href;
12
+ }): Promise<{
13
+ params: {
14
+ path: string | undefined;
15
+ };
16
+ }[]>;
17
+ /** The redirect rows as Astro's `redirects` config: old address → the target's current address, 308 (spec
18
+ * 2026-09-26 redirects §7 at the host). A target with no live path in the locale is left out: a 404 as on demand. */
19
+ export declare function redirectEntries(deps: {
20
+ pages: PageStore;
21
+ config: ResolvedConfig;
22
+ href: Href;
23
+ }): Promise<Record<string, {
24
+ status: 308;
25
+ destination: string;
26
+ }>>;
27
+ /** Astro's `redirects` for the build: Smoodly's entries under the developer's own, so a source astro.config names
28
+ * stays the developer's (final review 2026-10-09). */
29
+ export declare function hostRedirects<D, S>(developer: Record<string, D> | undefined, smoodly: Record<string, S>): Record<string, D | S>;
30
+ /** Under prefix-always the root is nobody's page: the host redirects it to the default locale's home, as the
31
+ * on-demand shell does with a 302 (shell.ts). */
32
+ export declare function rootRedirect(routing: LocaleRouting, href: Href, locales: ResolvedConfig["locales"]): Record<string, {
33
+ status: 302;
34
+ destination: string;
35
+ }>;
36
+ export {};
@@ -0,0 +1,59 @@
1
+ import { redirectsRoot } from "./locale-paths.js";
2
+ /** Astro's `[...path]` param: the root is undefined, otherwise the address without its slashes at either end. */
3
+ const param = (site) => {
4
+ const trimmed = site.replace(/^\/+/, "").replace(/\/+$/, "");
5
+ return trimmed === "" ? undefined : trimmed;
6
+ };
7
+ /** Every published page and entry address of every locale, as Astro's getStaticPaths entries. A node with
8
+ * no path (hidden, or a code address wins) and an entry of a collection without addresses are not pages. */
9
+ export async function staticPaths(deps) {
10
+ const seen = new Set();
11
+ const out = [];
12
+ const add = (site) => {
13
+ const p = param(site);
14
+ if (seen.has(p))
15
+ return;
16
+ seen.add(p);
17
+ out.push({ params: { path: p } });
18
+ };
19
+ const collections = (deps.config.registry.collections ?? []).map((c) => c.name);
20
+ for (const locale of deps.config.locales.supported) {
21
+ for (const row of await deps.published.tree(locale))
22
+ if (row.path !== null)
23
+ add(deps.href(row.path, locale));
24
+ for (const name of collections) {
25
+ for (const row of await deps.published.entries(name, { locale }))
26
+ if (row.path !== null)
27
+ add(deps.href(row.path, locale));
28
+ }
29
+ }
30
+ return out;
31
+ }
32
+ /** Astro reads a redirect's source as a route pattern: brackets make a param, a star or a colon a wildcard on
33
+ * some hosts. A stored path with any of them would redirect more than itself, so it is a literal address or nothing. */
34
+ const literalAddress = (path) => !/[[\]*:]/.test(path);
35
+ /** The redirect rows as Astro's `redirects` config: old address → the target's current address, 308 (spec
36
+ * 2026-09-26 redirects §7 at the host). A target with no live path in the locale is left out: a 404 as on demand. */
37
+ export async function redirectEntries(deps) {
38
+ const out = {};
39
+ for (const locale of deps.config.locales.supported) {
40
+ for (const row of await deps.pages.listRedirects(locale)) {
41
+ if (!literalAddress(row.path))
42
+ continue;
43
+ const current = (await deps.pages.targetPaths({ kind: row.kind, id: row.id }))[locale];
44
+ if (current && literalAddress(current))
45
+ out[deps.href(row.path, locale)] = { status: 308, destination: deps.href(current, locale) };
46
+ }
47
+ }
48
+ return out;
49
+ }
50
+ /** Astro's `redirects` for the build: Smoodly's entries under the developer's own, so a source astro.config names
51
+ * stays the developer's (final review 2026-10-09). */
52
+ export function hostRedirects(developer, smoodly) {
53
+ return { ...smoodly, ...(developer ?? {}) };
54
+ }
55
+ /** Under prefix-always the root is nobody's page: the host redirects it to the default locale's home, as the
56
+ * on-demand shell does with a 302 (shell.ts). */
57
+ export function rootRedirect(routing, href, locales) {
58
+ return redirectsRoot("/", routing) ? { "/": { status: 302, destination: href("/", locales.default) } } : {};
59
+ }
@@ -2,3 +2,6 @@ import type { ResolvedConfig } from "../config.ts";
2
2
  export declare const CONFIG_FILES: string[];
3
3
  export declare const noConfig: (cwd: string) => string;
4
4
  export declare function loadConfig(cwd: string): Promise<ResolvedConfig>;
5
+ /** One named config file (the Astro integration's `config` option names it): the project's tsconfig beside `cwd`
6
+ * (default: the file's folder) resolves its aliases. */
7
+ export declare function loadConfigFile(file: string, cwd?: string): Promise<ResolvedConfig>;
@@ -5,7 +5,7 @@
5
5
  // model §2). The config imports no component (the registration split), so
6
6
  // it loads outside the app. Astro keeps it at the root, Next under smoodly/.
7
7
  import { existsSync } from "node:fs";
8
- import { join } from "node:path";
8
+ import { dirname, join } from "node:path";
9
9
  import { pathToFileURL } from "node:url";
10
10
  import { tsImport } from "tsx/esm/api";
11
11
  export const CONFIG_FILES = ["smoodly.config.ts", "smoodly/config.ts", "src/smoodly/config.ts"];
@@ -14,6 +14,13 @@ export async function loadConfig(cwd) {
14
14
  const file = CONFIG_FILES.map((name) => join(cwd, name)).find((path) => existsSync(path));
15
15
  if (!file)
16
16
  throw new Error(noConfig(cwd));
17
+ return loadConfigFile(file, cwd);
18
+ }
19
+ /** One named config file (the Astro integration's `config` option names it): the project's tsconfig beside `cwd`
20
+ * (default: the file's folder) resolves its aliases. */
21
+ export async function loadConfigFile(file, cwd = dirname(file)) {
22
+ if (!existsSync(file))
23
+ throw new Error(`smoodly: no config file at ${file}.`);
17
24
  const tsconfig = join(cwd, "tsconfig.json");
18
25
  const mod = (await tsImport(pathToFileURL(file).href, {
19
26
  parentURL: import.meta.url,
@@ -29,5 +29,15 @@ export type MigrateDeps = {
29
29
  env?: Record<string, string | undefined>;
30
30
  /** Test seam: replaces the database read behind the key check. */
31
31
  readKey?: (env: Record<string, string | undefined>) => Promise<SchemaRead>;
32
+ /** Test seam: the delivery rows behind the site's document stamp (DESIGN §3); default: the app's config, loaded, against the key's space. */
33
+ delivery?: (env: Record<string, string | undefined>, cwd: string) => Promise<{
34
+ behind: number;
35
+ total: number;
36
+ }>;
32
37
  };
38
+ /** The delivery line of `--check` (DESIGN §3, the document stamp): a count with the one command to run, never a verdict. */
39
+ export declare const deliveryLine: (d: {
40
+ behind: number;
41
+ total: number;
42
+ }) => string;
33
43
  export declare function runMigrate(args: MigrateArgs, out: (line: string) => void, deps?: MigrateDeps): Promise<number>;
@@ -15,6 +15,10 @@ import { resolveSmoodlyEnv } from "../env.js";
15
15
  import { loadAppEnv } from "./space-io.js";
16
16
  import { planIsEmpty, planMigrate } from "../migrations.js";
17
17
  import { loadCollections } from "./load-collections.js";
18
+ import { loadConfig } from "./load-config.js";
19
+ import { siteOptions } from "../bind.js";
20
+ import { documentStamp } from "../document-stamp.js";
21
+ import { SupabasePublishedStore } from "../supabase-published-store.js";
18
22
  export const DEFAULT_COLLECTIONS = "smoodly/collections.ts";
19
23
  /** The Astro starter keeps the setup under src/ (plan 2026-10-06 astro adapter 3); the default follows it when the Next path is absent. */
20
24
  export function collectionsFile(cwd, given, exists = existsSync) {
@@ -47,6 +51,16 @@ export function describePlan(plan, check) {
47
51
  }
48
52
  return lines;
49
53
  }
54
+ /** The delivery line of `--check` (DESIGN §3, the document stamp): a count with the one command to run, never a verdict. */
55
+ export const deliveryLine = (d) => d.behind === 0
56
+ ? `Delivery: every published document is current (${d.total}).`
57
+ : `Delivery: ${d.behind} of ${d.total} documents are behind the current stamp — run npx smoodly rebuild.`;
58
+ async function countDelivery(env, cwd) {
59
+ const config = await loadConfig(cwd);
60
+ const stamp = documentStamp(siteOptions(config, () => { throw new Error("smoodly: the stamp reads no store"); }));
61
+ const db = createSmoodlyClient(resolveSmoodlyEnv(env));
62
+ return new SupabasePublishedStore(db, config.registry.collections ?? []).countBehind(stamp);
63
+ }
50
64
  /** Errors that are a verdict on the env itself, not on the network: they fail the build. */
51
65
  const ENV_VERDICT = /SMOODLY_(SECRET|PUBLISHABLE)_KEY|SMOODLY_URL "/;
52
66
  /** The build's key check (decided 2026-10-02): a key the site would be refused with fails the
@@ -72,8 +86,15 @@ async function checkKey(out, cwd, deps) {
72
86
  out(`Key not checked: ${message}`);
73
87
  return 0;
74
88
  }
75
- if ("version" in read)
89
+ if ("version" in read) {
76
90
  out(keyChecked(read.version));
91
+ try {
92
+ out(deliveryLine(await (deps.delivery ?? countDelivery)(env, cwd)));
93
+ }
94
+ catch (e) {
95
+ out(`Delivery: not checked: ${e instanceof Error ? e.message : String(e)}`);
96
+ }
97
+ }
77
98
  else if (read.missing === "key") {
78
99
  out(secretKeyRefusedMessage);
79
100
  return 1;
@@ -45,10 +45,10 @@ export { readSchemaVersion } from "../schema-read.js";
45
45
  * the site built from the config as the app's glue builds them, then every page and entry. */
46
46
  export async function rebuildSpace(db, config) {
47
47
  const { pages, entries, assets, published } = createSupabaseStores(db, config);
48
- const { projector, cacheKey } = createSmoodlySite(siteOptions(config, () => ({ pages, entries, assets, published })));
48
+ const { projector, stamp } = createSmoodlySite(siteOptions(config, () => ({ pages, entries, assets, published })));
49
49
  const names = [...(config.registry.collections ?? []), ...(config.registry.globals ?? [])].map((c) => c.name);
50
- // The model fingerprint every row now carries: what a rebuild after a deploy wrote.
51
- return { ...(await projectAll({ stores: { pages, entries }, projector, names })), model: cacheKey };
50
+ // The document stamp every row now carries, recorded as the space's current one (DESIGN §3).
51
+ return { ...(await projectAll({ stores: { pages, entries }, projector, names })), model: stamp };
52
52
  }
53
53
  /** The line a rebuild ends with. */
54
54
  export const rebuiltMessage = (counts) => `Rebuilt the delivery tables: ${counts.pages} ${counts.pages === 1 ? "page" : "pages"}, ${counts.entries} ${counts.entries === 1 ? "entry" : "entries"}.`;
@@ -0,0 +1,28 @@
1
+ export declare const MARKER_PATH = "_smoodly/build.json";
2
+ /** A press this old with no newer build is "not landed": the editor looks at the host. */
3
+ export declare const NOT_LANDED_AFTER: number;
4
+ export type BuildMarker = {
5
+ builtAt: number;
6
+ publishedThrough: number | null;
7
+ };
8
+ export declare const markerJson: (m: BuildMarker) => string;
9
+ export declare function parseMarker(text: string): BuildMarker | null;
10
+ export type DeployState = {
11
+ state: "live" | "waiting" | "building" | "not-landed" | "unreachable";
12
+ /** Rows published after the build's watermark. */
13
+ waiting: number;
14
+ builtAt: number | null;
15
+ pressedAt: number | null;
16
+ pressedBy: string | null;
17
+ };
18
+ export declare function deployState(input: {
19
+ marker: BuildMarker | null;
20
+ /** The live address answered (with the marker, or without one). */
21
+ reachable: boolean;
22
+ press: {
23
+ at: number;
24
+ by: string | null;
25
+ } | null;
26
+ waiting: number;
27
+ now: number;
28
+ }): DeployState;
@@ -0,0 +1,31 @@
1
+ // packages/smoodly/src/deploy-state.ts
2
+ // Static mode's Deploy tile (spec 2026-10-09 static mode §5–§6): the build writes a marker beside the
3
+ // pages, the admin reads it from the live address, and the state is pure over the marker, the last
4
+ // press and the count of rows newer than the build's watermark. Framework-free: the integration writes
5
+ // the marker, the operations read it, the tile shows it.
6
+ export const MARKER_PATH = "_smoodly/build.json";
7
+ /** A press this old with no newer build is "not landed": the editor looks at the host. */
8
+ export const NOT_LANDED_AFTER = 10 * 60000;
9
+ export const markerJson = (m) => JSON.stringify({ builtAt: m.builtAt, publishedThrough: m.publishedThrough });
10
+ export function parseMarker(text) {
11
+ try {
12
+ const got = JSON.parse(text);
13
+ if (typeof got?.builtAt !== "number")
14
+ return null;
15
+ const through = got.publishedThrough;
16
+ return { builtAt: got.builtAt, publishedThrough: typeof through === "number" ? through : null };
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ export function deployState(input) {
23
+ const base = { waiting: input.waiting, builtAt: input.marker?.builtAt ?? null, pressedAt: input.press?.at ?? null, pressedBy: input.press?.by ?? null };
24
+ if (!input.reachable)
25
+ return { ...base, state: "unreachable" };
26
+ const press = input.press;
27
+ if (press && (input.marker === null || press.at > input.marker.builtAt)) {
28
+ return { ...base, state: input.now - press.at > NOT_LANDED_AFTER ? "not-landed" : "building" };
29
+ }
30
+ return { ...base, state: input.waiting > 0 ? "waiting" : "live" };
31
+ }
@@ -0,0 +1,7 @@
1
+ import type { SiteOptions } from "./site.ts";
2
+ /** Bumped by hand when the assembler's output changes shape; the upgrading guide names each bump. */
3
+ export declare const DOCUMENT_FORMAT = 1;
4
+ /** The parts of the registry that reach a document: what `cacheKey` hashes, minus the presentation. */
5
+ export declare function documentModel(options: SiteOptions): unknown;
6
+ /** `<format>:<model hash>` — what a delivery row's `model` column holds. */
7
+ export declare function documentStamp(options: SiteOptions): string;