smoodly 0.0.47 → 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 (55) 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/ops-impl.d.ts +13 -0
  10. package/dist/admin/ops-impl.js +46 -0
  11. package/dist/admin/ops.d.ts +11 -0
  12. package/dist/admin/page-data.d.ts +2 -0
  13. package/dist/admin/shell/AdminApp.js +47 -1
  14. package/dist/admin/shell/EntryForm.js +3 -0
  15. package/dist/admin/shell/GlobalForm.js +5 -1
  16. package/dist/admin/shell/IconRail.d.ts +8 -1
  17. package/dist/admin/shell/IconRail.js +8 -3
  18. package/dist/admin/shell/deploy-tile.d.ts +20 -0
  19. package/dist/admin/shell/deploy-tile.js +35 -0
  20. package/dist/admin/ui/PublishControl.js +3 -1
  21. package/dist/admin/ui/theme.js +12 -0
  22. package/dist/astro/deploy-deps.d.ts +10 -0
  23. package/dist/astro/deploy-deps.js +8 -0
  24. package/dist/astro/index.d.ts +9 -4
  25. package/dist/astro/index.js +75 -15
  26. package/dist/astro/middleware.js +4 -0
  27. package/dist/astro/routes/admin.js +2 -1
  28. package/dist/astro/routes/preview.astro +17 -5
  29. package/dist/astro/routes/static-page.astro +21 -0
  30. package/dist/astro/runtime.d.ts +6 -0
  31. package/dist/astro/runtime.js +4 -1
  32. package/dist/astro/server.d.ts +6 -0
  33. package/dist/astro/server.js +7 -1
  34. package/dist/astro/static.d.ts +36 -0
  35. package/dist/astro/static.js +59 -0
  36. package/dist/cli/load-config.d.ts +3 -0
  37. package/dist/cli/load-config.js +8 -1
  38. package/dist/deploy-state.d.ts +28 -0
  39. package/dist/deploy-state.js +31 -0
  40. package/dist/project.d.ts +3 -0
  41. package/dist/project.js +8 -5
  42. package/dist/published.d.ts +31 -4
  43. package/dist/published.js +35 -7
  44. package/dist/schema-version.d.ts +1 -1
  45. package/dist/schema-version.js +1 -1
  46. package/dist/site.js +2 -2
  47. package/dist/sql-space.d.ts +3 -2
  48. package/dist/sql-space.js +3 -2
  49. package/dist/sql.js +10 -0
  50. package/dist/supabase-published-store.d.ts +14 -3
  51. package/dist/supabase-published-store.js +72 -5
  52. package/dist/version.d.ts +1 -1
  53. package/dist/version.js +1 -1
  54. package/migrations/20261009120000_smoodly_v11.sql +49 -0
  55. 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,
@@ -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
+ }
package/dist/project.d.ts CHANGED
@@ -15,6 +15,9 @@ export declare const PROJECTION_CAP = 100;
15
15
  export type ProjectOptions = {
16
16
  cap?: number;
17
17
  write?: boolean;
18
+ /** `false` (a behind rebuild on a read, spec 2026-10-09 static mode §5): an existing row keeps its publishedAt, so
19
+ * the rebuild is not counted as a publish the static site has not got. Default true: a publish, `npx smoodly rebuild`. */
20
+ stamp?: boolean;
18
21
  };
19
22
  export type Projector = {
20
23
  /** Writes the delivery rows of these pages in every supported locale (a row per visible locale, removed where not visible). Returns the keys it could not fit under the cap, already marked stale. */
package/dist/project.js CHANGED
@@ -127,7 +127,8 @@ export function createProjector(deps) {
127
127
  const missing = ids.filter((id) => !byId.has(id));
128
128
  if (write && missing.length > 0)
129
129
  await deps.stores().published.remove(missing.map((id) => ({ kind: "page", id })));
130
- return project(options?.cap ?? cap, "page", ids.filter((id) => byId.has(id)).map((id) => byId.get(id)), (p) => p.id, (p, locale) => pageRow(a, p, all, locale), (rows) => deps.stores().published.putPages(rows), write);
130
+ const keep = options?.stamp === false;
131
+ return project(options?.cap ?? cap, "page", ids.filter((id) => byId.has(id)).map((id) => byId.get(id)), (p) => p.id, (p, locale) => pageRow(a, p, all, locale), (rows) => deps.stores().published.putPages(rows, { keep }), write);
131
132
  },
132
133
  async entries(ids, options) {
133
134
  const a = assembler.batch();
@@ -143,7 +144,8 @@ export function createProjector(deps) {
143
144
  const write = options?.write !== false;
144
145
  if (write && missing.length > 0)
145
146
  await deps.stores().published.remove(missing);
146
- return project(options?.cap ?? cap, "entry", found, (h) => h.record.id, (h, locale) => entryRow(a, h.schema, h.record, locale), (rows) => deps.stores().published.putEntries(rows), write);
147
+ const keep = options?.stamp === false;
148
+ return project(options?.cap ?? cap, "entry", found, (h) => h.record.id, (h, locale) => entryRow(a, h.schema, h.record, locale), (rows) => deps.stores().published.putEntries(rows, { keep }), write);
147
149
  },
148
150
  async healPage(id, locale, options) {
149
151
  const { pages, published } = deps.stores();
@@ -159,8 +161,9 @@ export function createProjector(deps) {
159
161
  await bestEffort(`removing page ${id} (${locale})`, () => published.remove([{ kind: "page", id, locale }]));
160
162
  return null;
161
163
  }
164
+ // A heal keeps the row's publishedAt: it rebuilds what a publish wrote, it is not one (spec 2026-10-09 static mode §5).
162
165
  if (write)
163
- await bestEffort(`writing page ${id} (${locale})`, () => published.putPages([projected]));
166
+ await bestEffort(`writing page ${id} (${locale})`, () => published.putPages([projected], { keep: true }));
164
167
  return projected.document;
165
168
  },
166
169
  async healEntry(id, locale, options) {
@@ -174,7 +177,7 @@ export function createProjector(deps) {
174
177
  return null;
175
178
  }
176
179
  if (write)
177
- await bestEffort(`writing entry ${id} (${locale})`, () => published.putEntries([row]));
180
+ await bestEffort(`writing entry ${id} (${locale})`, () => published.putEntries([row], { keep: true }));
178
181
  return row.document;
179
182
  },
180
183
  async healGlobal(item, locale, options) {
@@ -190,7 +193,7 @@ export function createProjector(deps) {
190
193
  return null;
191
194
  }
192
195
  if (write)
193
- await bestEffort(`writing global ${item.name} (${locale})`, () => published.putEntries([row]));
196
+ await bestEffort(`writing global ${item.name} (${locale})`, () => published.putEntries([row], { keep: true }));
194
197
  return row.document;
195
198
  },
196
199
  markStale: (keys) => deps.stores().published.markStale(keys),
@@ -66,10 +66,15 @@ export type PublishedKey = {
66
66
  id: string;
67
67
  locale?: string;
68
68
  };
69
+ export type PutOptions = {
70
+ keep?: boolean;
71
+ };
69
72
  export interface PublishedStore {
70
73
  /** Upsert by (id, locale). */
71
- putPages(rows: PublishedPageRow[]): Promise<void>;
72
- putEntries(rows: PublishedEntryRow[]): Promise<void>;
74
+ /** `keep` (a heal, a behind rebuild on read): an existing row keeps its publishedAt, a new row is dated now; without it
75
+ * every row is dated now (a publish, `npx smoodly rebuild`). The static build's count reads publishedAt (spec 2026-10-09 §5). */
76
+ putPages(rows: PublishedPageRow[], options?: PutOptions): Promise<void>;
77
+ putEntries(rows: PublishedEntryRow[], options?: PutOptions): Promise<void>;
73
78
  /** Every locale of the id when `locale` is absent. */
74
79
  remove(keys: PublishedKey[]): Promise<void>;
75
80
  /** The one-call lookup: a reachable page or entry at the address, else null. With `globals` (collection names of
@@ -92,6 +97,18 @@ export interface PublishedStore {
92
97
  behind: number;
93
98
  total: number;
94
99
  }>;
100
+ /** The latest published_at over the space's pages and entries (ms); null when nothing is published. The static build's watermark (spec 2026-10-09 static mode §5). */
101
+ newestPublishedAt(): Promise<number | null>;
102
+ /** Rows published strictly after `since` (ms), pages plus entries: the Deploy tile's "changes waiting". */
103
+ countPublishedAfter(since: number): Promise<number>;
104
+ /** The space's last Deploy press (schema v11, space_deploys); null until the first press. */
105
+ deployRequest(): Promise<{
106
+ at: number;
107
+ by: string | null;
108
+ } | null>;
109
+ requestDeploy(by: string | null, at?: number): Promise<void>;
110
+ /** When a row last went (an unpublish, a delete): a change the count of newer rows cannot see (same row, `removed_at`). */
111
+ lastRemovalAt(): Promise<number | null>;
95
112
  }
96
113
  export declare class MemoryPublishedStore implements PublishedStore {
97
114
  private pages;
@@ -99,13 +116,23 @@ export declare class MemoryPublishedStore implements PublishedStore {
99
116
  private recorded;
100
117
  recordedModel(): Promise<string | null>;
101
118
  recordModel(model: string): Promise<void>;
119
+ private deploy;
120
+ newestPublishedAt(): Promise<number | null>;
121
+ countPublishedAfter(since: number): Promise<number>;
122
+ deployRequest(): Promise<{
123
+ at: number;
124
+ by: string | null;
125
+ } | null>;
126
+ requestDeploy(by: string | null, at?: number): Promise<void>;
102
127
  countBehind(model: string): Promise<{
103
128
  behind: number;
104
129
  total: number;
105
130
  }>;
106
- putPages(rows: PublishedPageRow[]): Promise<void>;
107
- putEntries(rows: PublishedEntryRow[]): Promise<void>;
131
+ putPages(rows: PublishedPageRow[], options?: PutOptions): Promise<void>;
132
+ putEntries(rows: PublishedEntryRow[], options?: PutOptions): Promise<void>;
133
+ private removedAt;
108
134
  remove(keys: PublishedKey[]): Promise<void>;
135
+ lastRemovalAt(): Promise<number | null>;
109
136
  byAddress(locale: string, path: string, globals?: readonly string[]): Promise<PublishedRoute | null>;
110
137
  page(id: string, locale: string): Promise<PublishedPageRow | null>;
111
138
  entry(id: string, locale: string): Promise<PublishedEntryRow | null>;