smoodly 0.0.45 → 0.0.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/addresses.d.ts +6 -7
- package/dist/addresses.js +6 -4
- package/dist/admin/PublicSans-LICENSE.txt +93 -0
- package/dist/admin/auth-config.d.ts +6 -0
- package/dist/admin/auth-config.js +1 -0
- package/dist/admin/browser/fetch-op.d.ts +10 -0
- package/dist/admin/browser/fetch-op.js +44 -0
- package/dist/admin/browser/main.d.ts +1 -0
- package/dist/admin/browser/main.js +21 -0
- package/dist/admin/custom-section.d.ts +1 -1
- package/dist/admin/drift.d.ts +1 -1
- package/dist/admin/editor/EditorView.js +14 -14
- package/dist/admin/editor/autosave.js +1 -1
- package/dist/admin/editor/canvas-address.d.ts +2 -0
- package/dist/admin/editor/canvas-address.js +8 -0
- package/dist/admin/editor/overlay-geometry.d.ts +1 -1
- package/dist/admin/editor/protocol.d.ts +18 -4
- package/dist/admin/editor/protocol.js +15 -1
- package/dist/admin/editor/useCanvasBridge.d.ts +1 -1
- package/dist/admin/editor/useCanvasBridge.js +5 -6
- package/dist/admin/editor/useInlineSession.js +4 -2
- package/dist/admin/files.d.ts +11 -0
- package/dist/admin/files.generated.js +2 -0
- package/dist/admin/files.js +8 -0
- package/dist/admin/forms/useRefOptions.d.ts +2 -2
- package/dist/admin/forms/useRefOptions.js +2 -2
- package/dist/admin/forms/useRichtextEditor.js +4 -4
- package/dist/admin/handler.d.ts +40 -0
- package/dist/admin/handler.js +151 -0
- package/dist/admin/health.d.ts +4 -0
- package/dist/admin/index.d.ts +1 -1
- package/dist/admin/ops-impl.d.ts +7 -1
- package/dist/admin/ops-impl.js +112 -35
- package/dist/admin/ops.d.ts +3 -2
- package/dist/admin/page-data.d.ts +21 -0
- package/dist/admin/page-data.js +4 -0
- package/dist/admin/page.d.ts +6 -0
- package/dist/admin/page.js +35 -0
- package/dist/admin/serialize.js +7 -7
- package/dist/admin/shell/AdminApp.js +2 -2
- package/dist/admin/shell/ContentHealthPage.js +3 -1
- package/dist/admin/shell/EntryForm.js +1 -1
- package/dist/admin/shell/router.d.ts +3 -16
- package/dist/admin/shell/router.js +3 -39
- package/dist/admin/shell/session.d.ts +2 -5
- package/dist/admin/supabase-auth.js +1 -1
- package/dist/admin/tree-ops.d.ts +1 -1
- package/dist/admin/ui/fonts.d.ts +12 -0
- package/dist/admin/ui/fonts.js +22 -0
- package/dist/admin/ui/theme.d.ts +13 -4
- package/dist/admin/ui/theme.js +28 -157
- package/dist/assemble.d.ts +60 -0
- package/dist/assemble.js +359 -0
- package/dist/astro/cache.d.ts +34 -0
- package/dist/astro/cache.js +69 -0
- package/dist/astro/components/Custom.astro +21 -0
- package/dist/astro/components/Guarded.astro +24 -0
- package/dist/astro/components/Head.astro +26 -0
- package/dist/astro/components/Image.astro +20 -0
- package/dist/astro/components/RichText.astro +23 -0
- package/dist/astro/components/Section.astro +20 -0
- package/dist/astro/components/Video.astro +5 -0
- package/dist/astro/components/Zone.astro +19 -0
- package/dist/astro/components.d.ts +6 -0
- package/dist/astro/components.js +7 -0
- package/dist/astro/content-loaders.d.ts +40 -0
- package/dist/astro/content-loaders.js +100 -0
- package/dist/astro/content.d.ts +9 -0
- package/dist/astro/content.js +17 -0
- package/dist/astro/custom.d.ts +1 -0
- package/dist/astro/custom.js +3 -0
- package/dist/astro/defaults.d.ts +20 -0
- package/dist/astro/defaults.js +21 -0
- package/dist/astro/env-files.d.ts +1 -0
- package/dist/astro/env-files.js +21 -0
- package/dist/astro/head.d.ts +13 -0
- package/dist/astro/head.js +19 -0
- package/dist/astro/image.d.ts +27 -0
- package/dist/astro/image.js +29 -0
- package/dist/astro/index.d.ts +43 -0
- package/dist/astro/index.js +111 -0
- package/dist/astro/locale-paths.d.ts +30 -0
- package/dist/astro/locale-paths.js +38 -0
- package/dist/astro/middleware.d.ts +1 -0
- package/dist/astro/middleware.js +83 -0
- package/dist/astro/preview-document.d.ts +1 -0
- package/dist/astro/preview-document.js +18 -0
- package/dist/astro/preview.d.ts +10 -0
- package/dist/astro/preview.js +72 -0
- package/dist/astro/richtext.d.ts +15 -0
- package/dist/astro/richtext.js +78 -0
- package/dist/astro/routes/admin.d.ts +3 -0
- package/dist/astro/routes/admin.js +41 -0
- package/dist/astro/routes/page.astro +17 -0
- package/dist/astro/routes/preview.astro +11 -0
- package/dist/astro/runtime.d.ts +32 -0
- package/dist/astro/runtime.js +46 -0
- package/dist/astro/scope.d.ts +45 -0
- package/dist/astro/scope.js +103 -0
- package/dist/astro/server.d.ts +14 -0
- package/dist/astro/server.js +45 -0
- package/dist/astro/shell.d.ts +67 -0
- package/dist/astro/shell.js +63 -0
- package/dist/astro/style.d.ts +1 -0
- package/dist/astro/style.js +11 -0
- package/dist/astro/views-check.d.ts +3 -0
- package/dist/astro/views-check.js +19 -0
- package/dist/astro/zone.d.ts +39 -0
- package/dist/astro/zone.js +33 -0
- package/dist/bind.d.ts +19 -0
- package/dist/bind.js +34 -0
- package/dist/bridge/bridge.d.ts +9 -0
- package/dist/bridge/bridge.js +107 -0
- package/dist/bridge/entry.d.ts +1 -0
- package/dist/bridge/entry.js +5 -0
- package/dist/bridge/preview-pill.d.ts +18 -0
- package/dist/bridge/preview-pill.js +115 -0
- package/dist/cli/import.d.ts +3 -0
- package/dist/cli/import.js +10 -1
- package/dist/cli/load-collections.js +4 -3
- package/dist/cli/load-config.d.ts +4 -0
- package/dist/cli/load-config.js +26 -0
- package/dist/cli/main.d.ts +5 -1
- package/dist/cli/main.js +24 -6
- package/dist/cli/migrate.d.ts +13 -0
- package/dist/cli/migrate.js +30 -2
- package/dist/cli/rebuild.d.ts +23 -0
- package/dist/cli/rebuild.js +22 -0
- package/dist/cli/space-io.d.ts +15 -1
- package/dist/cli/space-io.js +14 -0
- package/dist/client.js +5 -0
- package/dist/config.d.ts +28 -20
- package/dist/config.js +71 -75
- package/dist/content/content-api.d.ts +1 -1
- package/dist/content/node.d.ts +2 -3
- package/dist/content/node.js +2 -1
- package/dist/crash-message.d.ts +1 -0
- package/dist/crash-message.js +8 -0
- package/dist/css.d.ts +4 -0
- package/dist/css.js +5 -2
- package/dist/custom/CustomSection.d.ts +1 -1
- package/dist/custom/CustomSection.js +4 -8
- package/dist/custom/css.js +2 -2
- package/dist/custom/index.d.ts +6 -10
- package/dist/custom/index.js +1 -2
- package/dist/custom/layout.d.ts +5 -0
- package/dist/custom/layout.js +14 -0
- package/dist/document-stamp.d.ts +7 -0
- package/dist/document-stamp.js +48 -0
- package/dist/entry-page.d.ts +3 -18
- package/dist/env.js +1 -1
- package/dist/global-id.js +2 -2
- package/dist/global.d.ts +5 -2
- package/dist/global.js +8 -5
- package/dist/image/next-config.d.ts +3 -0
- package/dist/image/next-config.js +12 -7
- package/dist/image/remote-pattern.d.ts +1 -0
- package/dist/image/remote-pattern.js +5 -0
- package/dist/index.d.ts +15 -17
- package/dist/index.js +10 -19
- package/dist/marks.d.ts +7 -0
- package/dist/marks.js +7 -0
- package/dist/next/admin-route.d.ts +7 -0
- package/dist/next/admin-route.js +28 -0
- package/dist/next/bridge.d.ts +1 -0
- package/dist/next/bridge.js +26 -0
- package/dist/next/canvas-boundary.d.ts +1 -1
- package/dist/next/canvas-slot.d.ts +1 -0
- package/dist/next/canvas-slot.js +4 -3
- package/dist/next/index.d.ts +2 -0
- package/dist/next/index.js +1 -0
- package/dist/next/page-renderer.d.ts +5 -1
- package/dist/next/page-renderer.js +19 -33
- package/dist/next/preview-pill-define.d.ts +1 -0
- package/dist/next/preview-pill-define.js +10 -0
- package/dist/next/preview-pill.d.ts +0 -3
- package/dist/next/preview-pill.js +7 -50
- package/dist/next/queries.d.ts +3 -28
- package/dist/next/queries.js +3 -90
- package/dist/next/reads.d.ts +2 -0
- package/dist/next/reads.js +18 -0
- package/dist/page-context.d.ts +89 -0
- package/dist/page-context.js +8 -0
- package/dist/pairing.d.ts +2 -22
- package/dist/project.d.ts +85 -0
- package/dist/project.js +228 -0
- package/dist/published.d.ts +115 -0
- package/dist/published.js +136 -0
- package/dist/react.d.ts +61 -0
- package/dist/react.js +17 -0
- package/dist/read-shapes.d.ts +1 -1
- package/dist/reads.d.ts +80 -0
- package/dist/reads.js +142 -0
- package/dist/refs.d.ts +1 -1
- package/dist/registration.d.ts +8 -0
- package/dist/registration.js +17 -0
- package/dist/render.d.ts +5 -83
- package/dist/render.js +5 -10
- package/dist/resolve.d.ts +1 -1
- package/dist/richtext/index.d.ts +6 -2
- package/dist/richtext/index.js +3 -2
- package/dist/richtext-href.d.ts +4 -0
- package/dist/richtext-href.js +17 -0
- package/dist/richtext-render.js +1 -14
- package/dist/schema-version.d.ts +1 -1
- package/dist/schema-version.js +1 -1
- package/dist/section-crash.d.ts +2 -2
- package/dist/section-crash.js +2 -7
- package/dist/section-wrapper.d.ts +3 -10
- package/dist/section-wrapper.js +3 -2
- package/dist/site.d.ts +40 -14
- package/dist/site.js +199 -284
- package/dist/smd-key.d.ts +9 -0
- package/dist/smd-key.js +1 -0
- package/dist/sql-space.d.ts +5 -0
- package/dist/sql-space.js +11 -5
- package/dist/sql.d.ts +2 -0
- package/dist/sql.js +127 -1
- package/dist/store.d.ts +1 -1
- package/dist/supabase-published-store.d.ts +45 -0
- package/dist/supabase-published-store.js +247 -0
- package/dist/supabase-store.d.ts +1 -1
- package/dist/toolset.d.ts +2 -4
- package/dist/toolset.js +6 -4
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/view-props.d.ts +40 -0
- package/dist/view-props.js +1 -0
- package/dist/views.d.ts +36 -0
- package/dist/views.js +52 -0
- package/dist/zones.d.ts +7 -7
- package/dist/zones.js +7 -6
- package/migrations/20261007120000_smoodly_v8.sql +154 -0
- package/migrations/20261007200000_smoodly_v9.sql +30 -0
- package/migrations/20261008120000_smoodly_v10.sql +47 -0
- package/package.json +46 -19
- package/dist/admin/next/bridge.d.ts +0 -2
- package/dist/admin/next/bridge.js +0 -129
- package/dist/admin/next/create-admin.d.ts +0 -19
- package/dist/admin/next/create-admin.js +0 -44
- package/dist/admin/next/index.d.ts +0 -8
- package/dist/admin/next/index.js +0 -8
- package/dist/admin/next/op-handler.d.ts +0 -18
- package/dist/admin/next/op-handler.js +0 -31
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
let loaded = null;
|
|
2
|
+
export function loadAdminFiles() {
|
|
3
|
+
loaded ?? (loaded = import("./files.generated.js").then((m) => m.adminFiles, () => {
|
|
4
|
+
loaded = null;
|
|
5
|
+
throw new Error("smoodly: the admin's files are not built. In the Smoodly repo run `npm run build:admin -w smoodly`; an installed package ships them.");
|
|
6
|
+
}));
|
|
7
|
+
return loaded;
|
|
8
|
+
}
|
|
@@ -3,8 +3,8 @@ import type { AdminOps } from "../ops.ts";
|
|
|
3
3
|
import type { AdminCollection, AdminRegistry } from "../serialize.ts";
|
|
4
4
|
import type { RefOption } from "./ref-picker.ts";
|
|
5
5
|
export declare function refOptionsOf(records: EntryRecord[], meta: AdminCollection | undefined, registry: AdminRegistry, locale: string): RefOption[];
|
|
6
|
-
/** What the options are read from, by content:
|
|
7
|
-
*
|
|
6
|
+
/** What the options are read from, by content, not identity: while Next compiled the admin the registry
|
|
7
|
+
* was re-serialized on every autosave (a server action re-rendered the route), and a key on its
|
|
8
8
|
* identity emptied the picker to "Loading…" and refetched every target on each save (2026-10-02;
|
|
9
9
|
* the richtext editor's toolset and elements are keyed the same way, 2026-09-26). */
|
|
10
10
|
export declare function refOptionsKey(registry: AdminRegistry, locale: string, targets: readonly string[]): string;
|
|
@@ -13,8 +13,8 @@ export function refOptionsOf(records, meta, registry, locale) {
|
|
|
13
13
|
return row ? { id: e.id, title, status: localeStatusOf(row) } : { id: e.id, title, status: "", absent: true };
|
|
14
14
|
});
|
|
15
15
|
}
|
|
16
|
-
/** What the options are read from, by content:
|
|
17
|
-
*
|
|
16
|
+
/** What the options are read from, by content, not identity: while Next compiled the admin the registry
|
|
17
|
+
* was re-serialized on every autosave (a server action re-rendered the route), and a key on its
|
|
18
18
|
* identity emptied the picker to "Loading…" and refetched every target on each save (2026-10-02;
|
|
19
19
|
* the richtext editor's toolset and elements are keyed the same way, 2026-09-26). */
|
|
20
20
|
export function refOptionsKey(registry, locale, targets) {
|
|
@@ -28,10 +28,10 @@ const EMPTY = { type: "doc", content: [{ type: "paragraph" }] };
|
|
|
28
28
|
const LEVELS = [1, 2, 3, 4, 5, 6];
|
|
29
29
|
const isDoc = (v) => !!v && typeof v === "object" && v.type === "doc";
|
|
30
30
|
export function useRichtextEditor({ value, toolset, onChange, contentClass, onEscape }) {
|
|
31
|
-
// Keyed by content, not identity:
|
|
32
|
-
//
|
|
33
|
-
// again — every autosave
|
|
34
|
-
//
|
|
31
|
+
// Keyed by content, not identity: while Next compiled the admin, the
|
|
32
|
+
// registry was re-serialized whenever the route's server tree rendered
|
|
33
|
+
// again — every autosave did that, the op being a server action that
|
|
34
|
+
// expired the page's cache tag — so `toolset` arrived as a new object with
|
|
35
35
|
// the same content. On identity TipTap rebuilt the editor after each save,
|
|
36
36
|
// dropping its undo history and the focus (found 2026-09-26: cmd-Z did
|
|
37
37
|
// nothing once the first autosave had landed). The elements list is made
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { AssetStore } from "../asset-store.ts";
|
|
2
|
+
import type { ResolvedConfig } from "../config.ts";
|
|
3
|
+
import type { EntryStore } from "../entry-store.ts";
|
|
4
|
+
import type { PresenceConfig } from "../env.ts";
|
|
5
|
+
import type { MemberStore } from "../member-store.ts";
|
|
6
|
+
import type { PageStore } from "../store.ts";
|
|
7
|
+
import type { Projector } from "../project.ts";
|
|
8
|
+
import { type AdminAuth } from "./auth.ts";
|
|
9
|
+
import { type AdminFiles } from "./files.ts";
|
|
10
|
+
import { type AdminEffects } from "./ops-impl.ts";
|
|
11
|
+
import type { AuthConfig } from "./auth-config.ts";
|
|
12
|
+
export type AdminHandlerDeps = {
|
|
13
|
+
config: ResolvedConfig;
|
|
14
|
+
pages: PageStore;
|
|
15
|
+
entries: EntryStore;
|
|
16
|
+
/** The server gate: verifies a session token and reads the member's role. */
|
|
17
|
+
auth: AdminAuth;
|
|
18
|
+
/** What the browser needs to sign in: public values only. */
|
|
19
|
+
signIn: AuthConfig;
|
|
20
|
+
href?: (path: string, locale: string) => string;
|
|
21
|
+
/** The media library; without it the assets ops answer "no media store". */
|
|
22
|
+
assets?: AssetStore;
|
|
23
|
+
/** The admission table; without it members.list answers "no member store". */
|
|
24
|
+
members?: MemberStore;
|
|
25
|
+
/** The site's `site.projector`: a publish writes the delivery rows before it expires the cache. */
|
|
26
|
+
projector: Projector;
|
|
27
|
+
/** The content project's public config for editing presence; absent or failed, presence is off. */
|
|
28
|
+
presence?: () => PresenceConfig | Promise<PresenceConfig>;
|
|
29
|
+
effects?: AdminEffects;
|
|
30
|
+
/** Where drafts are served, when not at the page's own address (page-data.ts). */
|
|
31
|
+
previewBase?: string;
|
|
32
|
+
/** The prebuilt files; default: the ones the package's build wrote. Tests pass their own. */
|
|
33
|
+
files?: AdminFiles;
|
|
34
|
+
};
|
|
35
|
+
/** The request came from a page of this site: its Origin names the host the browser asked for. Behind a
|
|
36
|
+
* proxy that host is X-Forwarded-Host (the first, when a chain appended more), as in Next's own check for
|
|
37
|
+
* server actions. No Origin, no operation: every browser sends one with a POST. */
|
|
38
|
+
export declare function sameOrigin(request: Request): boolean;
|
|
39
|
+
/** `deps` is read on every request, so nothing is read at import and a build with no keys succeeds. */
|
|
40
|
+
export declare function createAdminHandler(deps: () => AdminHandlerDeps): (request: Request) => Promise<Response>;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// packages/smoodly/src/admin/handler.ts
|
|
2
|
+
// The admin's whole server face (spec 2026-10-02 astro cloud fit §4), framework-free: a web Request in, a
|
|
3
|
+
// Response out. GET under the admin path is the HTML page, GET under the reserved segment is one of the
|
|
4
|
+
// package's files, POST to `<admin path>/_smoodly/op` is an operation — gated by the origin check, then
|
|
5
|
+
// by the session (auth.ts). Each framework's adapter supplies the effects (next/admin-route.ts).
|
|
6
|
+
import { mountMismatch } from "../admin-path.js";
|
|
7
|
+
import { gateAdminOp } from "./auth.js";
|
|
8
|
+
import { loadAdminFiles } from "./files.js";
|
|
9
|
+
import { createAdminOps, runAdminOp } from "./ops-impl.js";
|
|
10
|
+
import { ADMIN_INTERNAL } from "./page-data.js";
|
|
11
|
+
import { adminErrorHtml, adminPageHtml } from "./page.js";
|
|
12
|
+
import { serializeAdminConfig } from "./serialize.js";
|
|
13
|
+
const NO_STORE = "no-store";
|
|
14
|
+
const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json; charset=utf-8", "cache-control": NO_STORE } });
|
|
15
|
+
const html = (status, body) => new Response(body, {
|
|
16
|
+
status,
|
|
17
|
+
headers: { "content-type": "text/html; charset=utf-8", "cache-control": NO_STORE, "content-security-policy": "frame-ancestors 'none'" },
|
|
18
|
+
});
|
|
19
|
+
const refused = (code, message) => ({ ok: false, code, message });
|
|
20
|
+
/** The request came from a page of this site: its Origin names the host the browser asked for. Behind a
|
|
21
|
+
* proxy that host is X-Forwarded-Host (the first, when a chain appended more), as in Next's own check for
|
|
22
|
+
* server actions. No Origin, no operation: every browser sends one with a POST. */
|
|
23
|
+
export function sameOrigin(request) {
|
|
24
|
+
return originOf(request).same;
|
|
25
|
+
}
|
|
26
|
+
function originOf(request) {
|
|
27
|
+
const origin = request.headers.get("origin");
|
|
28
|
+
const forwarded = request.headers.get("x-forwarded-host")?.split(",")[0]?.trim();
|
|
29
|
+
const host = forwarded || request.headers.get("host") || new URL(request.url).host;
|
|
30
|
+
let same = false;
|
|
31
|
+
try {
|
|
32
|
+
same = Boolean(origin) && new URL(origin).host === host;
|
|
33
|
+
}
|
|
34
|
+
catch { /* not a URL: not the same */ }
|
|
35
|
+
return { same, origin, host };
|
|
36
|
+
}
|
|
37
|
+
/** An operation's body: a whole page draft fits many times over; more than this is not an operation. */
|
|
38
|
+
const MAX_BODY_BYTES = 4 * 1024 * 1024;
|
|
39
|
+
/** The session token: `Authorization: Bearer <three dot-separated parts>`. Anything else is no token, so
|
|
40
|
+
* junk never costs a call to the auth project. */
|
|
41
|
+
function bearer(request) {
|
|
42
|
+
const match = /^Bearer ([\w-]+\.[\w-]+\.[\w-]+)$/.exec(request.headers.get("authorization") ?? "");
|
|
43
|
+
return match ? match[1] : null;
|
|
44
|
+
}
|
|
45
|
+
const decode = (base64) => Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
|
|
46
|
+
/** `deps` is read on every request, so nothing is read at import and a build with no keys succeeds. */
|
|
47
|
+
export function createAdminHandler(deps) {
|
|
48
|
+
const registries = new WeakMap();
|
|
49
|
+
const registryOf = (config) => {
|
|
50
|
+
let registry = registries.get(config);
|
|
51
|
+
if (!registry)
|
|
52
|
+
registries.set(config, (registry = serializeAdminConfig(config)));
|
|
53
|
+
return registry;
|
|
54
|
+
};
|
|
55
|
+
/** A failure nobody planned for: logged whole; the caller is told only what Smoodly itself said. */
|
|
56
|
+
const failed = (request, e) => {
|
|
57
|
+
console.error("smoodly: the admin failed —", e);
|
|
58
|
+
const message = e instanceof Error && e.message.startsWith("smoodly:") ? e.message : "smoodly: the admin failed — see the server log.";
|
|
59
|
+
return request.method === "POST" ? json(500, { ok: false, code: "internal", message }) : html(500, adminErrorHtml(message));
|
|
60
|
+
};
|
|
61
|
+
async function page(pathname, d) {
|
|
62
|
+
const base = d.config.admin.path;
|
|
63
|
+
const mismatch = mountMismatch(pathname, base);
|
|
64
|
+
if (mismatch)
|
|
65
|
+
return html(500, adminErrorHtml(mismatch));
|
|
66
|
+
// A failed lookup (an unmigrated project, an unreachable database) is logged and turns presence off,
|
|
67
|
+
// so the shell still reaches sign-in and the schema notice.
|
|
68
|
+
let presence = null;
|
|
69
|
+
try {
|
|
70
|
+
presence = (await d.presence?.()) ?? null;
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
console.error("smoodly: editing presence is off —", e instanceof Error ? e.message : e);
|
|
74
|
+
}
|
|
75
|
+
const files = d.files ?? (await loadAdminFiles());
|
|
76
|
+
const data = {
|
|
77
|
+
registry: registryOf(d.config),
|
|
78
|
+
auth: d.signIn,
|
|
79
|
+
presence,
|
|
80
|
+
opUrl: `${base}/${ADMIN_INTERNAL}/op`,
|
|
81
|
+
filesBase: `${base}/${ADMIN_INTERNAL}/${files.hash}`,
|
|
82
|
+
previewBase: d.previewBase ?? "",
|
|
83
|
+
};
|
|
84
|
+
return html(200, adminPageHtml(data));
|
|
85
|
+
}
|
|
86
|
+
/** `<hash>/<name>`. The current build's hash is cached for good; another hash is a tab opened before a
|
|
87
|
+
* deploy, and gets the current file uncached rather than a 404 and a dead admin. */
|
|
88
|
+
async function file(rest, d) {
|
|
89
|
+
const [hash, name, ...more] = rest.split("/");
|
|
90
|
+
const files = d.files ?? (await loadAdminFiles());
|
|
91
|
+
const found = more.length === 0 && name && Object.prototype.hasOwnProperty.call(files.files, name) ? files.files[name] : undefined;
|
|
92
|
+
if (!found)
|
|
93
|
+
return new Response("Not found", { status: 404, headers: { "cache-control": NO_STORE } });
|
|
94
|
+
return new Response(found.encoding === "base64" ? decode(found.body) : found.body, {
|
|
95
|
+
headers: {
|
|
96
|
+
"content-type": found.type,
|
|
97
|
+
"cache-control": hash === files.hash ? "public, max-age=31536000, immutable" : NO_STORE,
|
|
98
|
+
"x-content-type-options": "nosniff",
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/** The order is the defence: the origin, then the token, then the size, then the session — and only a
|
|
103
|
+
* member's body is read and parsed. */
|
|
104
|
+
async function op(request, d) {
|
|
105
|
+
const from = originOf(request);
|
|
106
|
+
if (!from.same) {
|
|
107
|
+
return json(403, refused("unauthorized", `smoodly: operations are accepted from the site's own address only — this request's Origin is ${String(from.origin).slice(0, 200)} and the server was asked at ${from.host}. Behind a proxy, pass the Host header through or set X-Forwarded-Host.`));
|
|
108
|
+
}
|
|
109
|
+
const token = bearer(request);
|
|
110
|
+
if (!token)
|
|
111
|
+
return json(401, refused("unauthorized", "smoodly: sign in to use the admin."));
|
|
112
|
+
if (Number(request.headers.get("content-length") ?? 0) > MAX_BODY_BYTES) {
|
|
113
|
+
return json(413, refused("validation", "smoodly: the operation is larger than 4 MB."));
|
|
114
|
+
}
|
|
115
|
+
const base = { config: d.config, pages: d.pages, entries: d.entries, href: d.href, assets: d.assets, members: d.members, projector: d.projector, effects: d.effects };
|
|
116
|
+
let status = 200;
|
|
117
|
+
// The gate needs the token alone; the call is read once it has admitted the session.
|
|
118
|
+
const result = await gateAdminOp(d.auth, { path: "", args: [], token }, async (_gate, session) => {
|
|
119
|
+
let body;
|
|
120
|
+
try {
|
|
121
|
+
body = await request.json();
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
status = 400;
|
|
125
|
+
return refused("validation", "smoodly: the operation was not JSON.");
|
|
126
|
+
}
|
|
127
|
+
// The token is the header's alone: a `token` in the body is dropped here.
|
|
128
|
+
const sent = typeof body === "object" && body !== null && !Array.isArray(body) ? body : {};
|
|
129
|
+
// Ops are built per call so the verified session rides into every store write as `by` (spec 2026-09-13 history §2).
|
|
130
|
+
return runAdminOp(createAdminOps({ ...base, actor: session }), { path: sent.path, args: sent.args });
|
|
131
|
+
});
|
|
132
|
+
return json(!result.ok && result.code === "unauthorized" ? 401 : status, result);
|
|
133
|
+
}
|
|
134
|
+
return async function handle(request) {
|
|
135
|
+
try {
|
|
136
|
+
const d = deps();
|
|
137
|
+
const { pathname } = new URL(request.url);
|
|
138
|
+
const internal = `${d.config.admin.path}/${ADMIN_INTERNAL}/`;
|
|
139
|
+
const method = request.method.toUpperCase();
|
|
140
|
+
if (method === "POST") {
|
|
141
|
+
return pathname === `${internal}op` ? await op(request, d) : json(404, refused("not_found", "smoodly: operations are posted to the admin's endpoint."));
|
|
142
|
+
}
|
|
143
|
+
if (method !== "GET" && method !== "HEAD")
|
|
144
|
+
return new Response(null, { status: 405, headers: { allow: "GET, HEAD, POST" } });
|
|
145
|
+
return pathname.startsWith(internal) ? await file(pathname.slice(internal.length), d) : await page(pathname, d);
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
return failed(request, e);
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
}
|
package/dist/admin/health.d.ts
CHANGED
|
@@ -13,11 +13,15 @@ export type HealthDocument = {
|
|
|
13
13
|
collection: string | null;
|
|
14
14
|
findings: HealthFinding[];
|
|
15
15
|
};
|
|
16
|
+
import type { DeliveryStatus } from "../project.ts";
|
|
17
|
+
/** `delivery` (DESIGN §3, the document stamp): the delivery rows behind the current stamp, with the recorded one;
|
|
18
|
+
* absent only in a report built without a projector (static tests). */
|
|
16
19
|
export type HealthReport = {
|
|
17
20
|
documents: HealthDocument[];
|
|
18
21
|
collections: {
|
|
19
22
|
name: string;
|
|
20
23
|
count: number;
|
|
21
24
|
}[];
|
|
25
|
+
delivery?: DeliveryStatus;
|
|
22
26
|
};
|
|
23
27
|
export declare function buildHealthReport(registry: AdminRegistry, pages: PageScan[], entries: EntryScan[]): HealthReport;
|
package/dist/admin/index.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export { ensureGlobalItems, initialGlobalFields } from "./global-items.ts";
|
|
|
7
7
|
export { makeClientOps } from "./client-ops.ts";
|
|
8
8
|
export { gateAdminOp, type AdminAuth } from "./auth.ts";
|
|
9
9
|
export { supabaseAdminAuth } from "./supabase-auth.ts";
|
|
10
|
-
export type { AuthConfig } from "./
|
|
10
|
+
export type { AuthConfig } from "./auth-config.ts";
|
|
11
11
|
export { serializeAdminConfig } from "./serialize.ts";
|
|
12
12
|
export type { AdminRegistry, AdminSection, AdminCollection, AdminPageTemplate, AdminGlobal, AdminField } from "./serialize.ts";
|
|
13
13
|
export * from "./tree-ops.ts";
|
package/dist/admin/ops-impl.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { MemberStore } from "../member-store.ts";
|
|
|
5
5
|
import type { PageStore } from "../store.ts";
|
|
6
6
|
import type { AdminOpCall, AdminOps, OpResult } from "./ops.ts";
|
|
7
7
|
import type { AdminSession } from "./auth.ts";
|
|
8
|
+
import type { Projector } from "../project.ts";
|
|
8
9
|
export type AdminEffects = {
|
|
9
10
|
/** Expire one cache tag (`pageTag(id)` / `entryTag(id)`). */
|
|
10
11
|
revalidate?(tag: string): void;
|
|
@@ -26,7 +27,12 @@ export declare function createAdminOps(deps: {
|
|
|
26
27
|
members?: MemberStore;
|
|
27
28
|
/** The verified editor; their email is every write's `by`. Absent for the seed and tests (spec 2026-09-13 history §2). */
|
|
28
29
|
actor?: AdminSession;
|
|
30
|
+
/** Writes the delivery rows (spec 2026-10-07 delivery model §2): the site's `site.projector`. Every op that
|
|
31
|
+
* expires a page's or an entry's tag projects that page or entry first, so the first read after it finds
|
|
32
|
+
* the new document — a draft save excepted (R17). The truth is written before; a projection that throws is
|
|
33
|
+
* logged, its targets marked stale to heal on read, and the op succeeds. */
|
|
34
|
+
projector: Projector;
|
|
29
35
|
}): AdminOps;
|
|
30
|
-
/** Path-dispatch for the one-
|
|
36
|
+
/** Path-dispatch for the one-endpoint seam. Own-property checks only —
|
|
31
37
|
* "constructor.constructor" and prototype paths must not resolve. */
|
|
32
38
|
export declare function runAdminOp(ops: AdminOps, call: AdminOpCall): Promise<OpResult<unknown>>;
|
package/dist/admin/ops-impl.js
CHANGED
|
@@ -125,7 +125,7 @@ function followSlug(fields, descriptors) {
|
|
|
125
125
|
return slug === "" ? fields : { ...fields, slug };
|
|
126
126
|
}
|
|
127
127
|
export function createAdminOps(deps) {
|
|
128
|
-
const { config, pages, entries, effects } = deps;
|
|
128
|
+
const { config, pages, entries, effects, projector } = deps;
|
|
129
129
|
const href = deps.href ?? ((path) => path);
|
|
130
130
|
const by = deps.actor?.email ?? undefined;
|
|
131
131
|
const collectionFields = (name) => config.registry.collections?.find((c) => c.name === name)?.fields ?? null;
|
|
@@ -150,9 +150,9 @@ export function createAdminOps(deps) {
|
|
|
150
150
|
// The serialized registry drift walks (spec 2026-09-27 editor safety §2) — the admin client's shape.
|
|
151
151
|
let adminRegistryMemo = null;
|
|
152
152
|
const adminRegistry = () => (adminRegistryMemo ?? (adminRegistryMemo = serializeAdminConfig(config)));
|
|
153
|
-
const elementDescs = elementSchemas(
|
|
153
|
+
const elementDescs = elementSchemas(config.registry.elements.map((e) => ({ name: e.name, fields: e.fields ?? {}, settings: e.settings })));
|
|
154
154
|
// The custom section (spec 2026-09-14 §6) validates like a section, over its own schema.
|
|
155
|
-
const sectionFields = (type) => (type === CUSTOM_NODE_TYPE ? customSchema(config.custom.toolset) : config.registry.sections
|
|
155
|
+
const sectionFields = (type) => (type === CUSTOM_NODE_TYPE ? customSchema(config.custom.toolset) : config.registry.sections.find((s) => s.name === type));
|
|
156
156
|
const globalItem = (name) => config.registry.globals?.find((s) => s.name === name) ?? null;
|
|
157
157
|
/** One row per registration; null when materialization could not create it. */
|
|
158
158
|
const globalRecord = async (name) => (await entries.list(name))[0] ?? null;
|
|
@@ -164,10 +164,44 @@ export function createAdminOps(deps) {
|
|
|
164
164
|
const snap = await entries.getVersion(name, row.publishedVersionId);
|
|
165
165
|
return snap ? entryDrift(adminRegistry(), name, { fields: snap.fields }) : [];
|
|
166
166
|
};
|
|
167
|
-
|
|
167
|
+
/** A projection is derived work done after the truth is written (spec 2026-10-07 §2 Failure): when it throws, the
|
|
168
|
+
* op still succeeds — the error is logged, the targets are marked stale (best effort) to rebuild on their next
|
|
169
|
+
* read, and the caller expires the tags all the same. */
|
|
170
|
+
const project = async (op, kind, ids) => {
|
|
171
|
+
if (ids.length === 0)
|
|
172
|
+
return;
|
|
173
|
+
try {
|
|
174
|
+
await (kind === "page" ? projector.pages(ids) : projector.entries(ids));
|
|
175
|
+
}
|
|
176
|
+
catch (e) {
|
|
177
|
+
console.error(`smoodly: ${op}: projecting ${kind}s ${ids.join(", ")} failed; their rows are marked stale and rebuild on read.`, e);
|
|
178
|
+
try {
|
|
179
|
+
await projector.markStale(ids.map((id) => ({ kind, id })));
|
|
180
|
+
}
|
|
181
|
+
catch (m) {
|
|
182
|
+
console.error(`smoodly: ${op}: marking ${kind}s ${ids.join(", ")} stale failed too.`, m);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
/** These entries' documents, the entries and pages that embed any of them (transitively), then their tags. Past
|
|
187
|
+
* the projector's cap the rest are marked stale; their tags expire all the same (§2). `project: false` expires
|
|
188
|
+
* the tags only: a save no published document can see (R17). */
|
|
189
|
+
const fanOut = async (roots, options = {}) => {
|
|
190
|
+
const entryIds = new Set();
|
|
191
|
+
const pageIds = new Set();
|
|
192
|
+
for (const root of typeof roots === "string" ? [roots] : roots) {
|
|
193
|
+
const affected = await affectedTargets({ pages, entries }, root);
|
|
194
|
+
for (const id of affected.entryIds)
|
|
195
|
+
entryIds.add(id);
|
|
196
|
+
for (const id of affected.pageIds)
|
|
197
|
+
pageIds.add(id);
|
|
198
|
+
}
|
|
199
|
+
if (options.project !== false) {
|
|
200
|
+
await project("entries", "entry", [...entryIds]);
|
|
201
|
+
await project("entries", "page", [...pageIds]);
|
|
202
|
+
}
|
|
168
203
|
if (!effects?.revalidate)
|
|
169
204
|
return;
|
|
170
|
-
const { pageIds, entryIds } = await affectedTargets({ pages, entries }, entryId);
|
|
171
205
|
for (const id of entryIds)
|
|
172
206
|
effects.revalidate(entryTag(id));
|
|
173
207
|
for (const id of pageIds)
|
|
@@ -198,10 +232,12 @@ export function createAdminOps(deps) {
|
|
|
198
232
|
* the entry fan-out's, seeded with the asset's id — which is no entry, so
|
|
199
233
|
* it gets no tag of its own. */
|
|
200
234
|
const expireAssetUsers = async (assetId) => {
|
|
201
|
-
if (!effects?.revalidate)
|
|
202
|
-
return;
|
|
203
235
|
const { pageIds, entryIds } = await affectedTargets({ pages, entries }, assetId);
|
|
204
236
|
const users = entryIds.filter((id) => id !== assetId);
|
|
237
|
+
await project("assets", "entry", users);
|
|
238
|
+
await project("assets", "page", pageIds);
|
|
239
|
+
if (!effects?.revalidate)
|
|
240
|
+
return;
|
|
205
241
|
for (const id of users)
|
|
206
242
|
effects.revalidate(entryTag(id));
|
|
207
243
|
for (const id of pageIds)
|
|
@@ -221,11 +257,14 @@ export function createAdminOps(deps) {
|
|
|
221
257
|
}
|
|
222
258
|
}
|
|
223
259
|
};
|
|
224
|
-
/**
|
|
225
|
-
|
|
260
|
+
/** Project the pages a tree change touched, then expire their cached units, in order, once each. Every id
|
|
261
|
+
* expires, the ones past the projector's cap (marked stale) included. */
|
|
262
|
+
const expirePages = async (ids) => {
|
|
263
|
+
const unique = [...new Set(ids.filter((x) => typeof x === "string"))];
|
|
264
|
+
await project("pages", "page", unique);
|
|
226
265
|
if (!effects?.revalidate)
|
|
227
266
|
return;
|
|
228
|
-
for (const id of
|
|
267
|
+
for (const id of unique)
|
|
229
268
|
effects.revalidate(pageTag(id));
|
|
230
269
|
};
|
|
231
270
|
/** The tree listing's unit (spec 2026-09-11 §2): every page op that changes what it shows. */
|
|
@@ -243,6 +282,8 @@ export function createAdminOps(deps) {
|
|
|
243
282
|
}
|
|
244
283
|
return out;
|
|
245
284
|
};
|
|
285
|
+
/** The ids under one parent: a move renumbers every one of them (their stored sort). */
|
|
286
|
+
const childIds = async (parentId) => (await pages.listPages()).filter((p) => p.parentId === parentId).map((p) => p.id);
|
|
246
287
|
/** Same walk over one collection's entries. */
|
|
247
288
|
const entrySubtreeIds = async (collection, id) => {
|
|
248
289
|
const all = await entries.list(collection);
|
|
@@ -274,8 +315,8 @@ export function createAdminOps(deps) {
|
|
|
274
315
|
return out;
|
|
275
316
|
};
|
|
276
317
|
const assets = deps.assets;
|
|
277
|
-
const noMedia = () => err("internal", "smoodly: no media store is configured — pass `assets` to
|
|
278
|
-
const noMembers = () => err("internal", "smoodly: no member store is configured — pass `members` to
|
|
318
|
+
const noMedia = () => err("internal", "smoodly: no media store is configured — pass `assets` to the admin's route (createAdminRoute).");
|
|
319
|
+
const noMembers = () => err("internal", "smoodly: no member store is configured — pass `members` to the admin's route (createAdminRoute).");
|
|
279
320
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
280
321
|
/** unauthorized unless the actor may manage members; no actor (scripts, the seed) may. */
|
|
281
322
|
const membersDenied = () => deps.actor && !can(deps.actor.role, "members.manage") ? err("unauthorized", "smoodly: only an admin can manage members.") : null;
|
|
@@ -329,7 +370,7 @@ export function createAdminOps(deps) {
|
|
|
329
370
|
const supportedLocale = (locale) => typeof locale === "string" && config.locales.supported.includes(locale)
|
|
330
371
|
? null
|
|
331
372
|
: err("validation", `smoodly: "${String(locale)}" is not a supported locale.`);
|
|
332
|
-
const openTemplate = (name) => config.registry.pages
|
|
373
|
+
const openTemplate = (name) => config.registry.pages.find((p) => p.kind === "page" && p.name === name);
|
|
333
374
|
const mountGuard = (parent) => {
|
|
334
375
|
const fixed = fixedNodeOf(parent, config);
|
|
335
376
|
return fixed?.collection
|
|
@@ -439,7 +480,7 @@ export function createAdminOps(deps) {
|
|
|
439
480
|
const registration = openTemplate(input.template);
|
|
440
481
|
if (!registration)
|
|
441
482
|
return err("validation", `smoodly: no page type named "${input.template}".`);
|
|
442
|
-
if (registration.
|
|
483
|
+
if (registration.slug !== undefined) {
|
|
443
484
|
return err("validation", `smoodly: page type "${input.template}" is fixed — Smoodly creates that page for you.`);
|
|
444
485
|
}
|
|
445
486
|
}
|
|
@@ -468,6 +509,9 @@ export function createAdminOps(deps) {
|
|
|
468
509
|
locale: input.locale, slug: input.slug, template: input.template, parentId: input.parentId,
|
|
469
510
|
...(input.title !== undefined ? { title: input.title } : {}),
|
|
470
511
|
}, by);
|
|
512
|
+
// A folder is public at once: its row, and its parent's children list. A page starts as a draft.
|
|
513
|
+
if (created.template === null)
|
|
514
|
+
await expirePages([created.id, created.parentId]);
|
|
471
515
|
expireTree();
|
|
472
516
|
return ok(created);
|
|
473
517
|
}),
|
|
@@ -507,7 +551,7 @@ export function createAdminOps(deps) {
|
|
|
507
551
|
}
|
|
508
552
|
const fieldErrors = [];
|
|
509
553
|
// Only the page type's zones (spec 2026-09-27 editor safety §1): a removed zone's nodes never block.
|
|
510
|
-
const shapeZones = Object.keys(openTemplate(record.template)?.
|
|
554
|
+
const shapeZones = Object.keys(openTemplate(record.template)?.zones ?? {});
|
|
511
555
|
for (const zone of shapeZones) {
|
|
512
556
|
const nodes = Array.isArray(draft.zones[zone]) ? draft.zones[zone] : [];
|
|
513
557
|
for (const node of nodes) {
|
|
@@ -524,7 +568,7 @@ export function createAdminOps(deps) {
|
|
|
524
568
|
}
|
|
525
569
|
}
|
|
526
570
|
// The page's own fields (spec 2026-09-10 page fields §2); SEO meta is never validated.
|
|
527
|
-
const pageDescs = openTemplate(record.template)?.
|
|
571
|
+
const pageDescs = openTemplate(record.template)?.fields ?? {};
|
|
528
572
|
for (const fe of validateFields(draft.fields ?? {}, pageDescs, elementDescs)) {
|
|
529
573
|
fieldErrors.push({ field: `page.fields.${fe.field}`, message: fe.message });
|
|
530
574
|
}
|
|
@@ -533,8 +577,8 @@ export function createAdminOps(deps) {
|
|
|
533
577
|
return err("validation", `smoodly: cannot publish "${row.slug}" — fix the listed problems first.`, refused);
|
|
534
578
|
}
|
|
535
579
|
const published = await pages.publish(id, locale, by, token);
|
|
536
|
-
// The parent's cached unit lists its published children.
|
|
537
|
-
expirePages([published.id, published.parentId]);
|
|
580
|
+
// The parent's cached unit lists its published children; every descendant embeds the page in its ancestors.
|
|
581
|
+
await expirePages([...(await subtreeIds(published.id)), published.parentId]);
|
|
538
582
|
expireTree();
|
|
539
583
|
return ok(published);
|
|
540
584
|
}),
|
|
@@ -555,8 +599,8 @@ export function createAdminOps(deps) {
|
|
|
555
599
|
if (fixedNodeOf(record, config))
|
|
556
600
|
return owned(record, "it stays published");
|
|
557
601
|
const off = await pages.unpublish(id, locale, by);
|
|
558
|
-
// The parent's cached unit lists its published children; the tree listing shows status.
|
|
559
|
-
expirePages([off.id, off.parentId]);
|
|
602
|
+
// The parent's cached unit lists its published children, the descendants' ancestors show it; the tree listing shows status.
|
|
603
|
+
await expirePages([...(await subtreeIds(off.id)), off.parentId]);
|
|
560
604
|
expireTree();
|
|
561
605
|
return ok(off);
|
|
562
606
|
}),
|
|
@@ -577,7 +621,7 @@ export function createAdminOps(deps) {
|
|
|
577
621
|
return err("blocked", `smoodly: "${nameOf(record)}" has nothing published in ${locale} — there is no version to restore.`);
|
|
578
622
|
}
|
|
579
623
|
const restored = await pages.discardDraft(id, locale, by, token);
|
|
580
|
-
expirePages([restored.id]); // the draft-mode render of this page changed
|
|
624
|
+
await expirePages([restored.id]); // the draft-mode render of this page changed
|
|
581
625
|
return ok(restored);
|
|
582
626
|
}),
|
|
583
627
|
listVersions: (id, locale) => guard(async () => {
|
|
@@ -607,7 +651,7 @@ export function createAdminOps(deps) {
|
|
|
607
651
|
return err("not_found", `smoodly: no version "${String(versionId)}" of this page in ${locale}.`);
|
|
608
652
|
}
|
|
609
653
|
const restored = await pages.restore(id, locale, versionId, by, token);
|
|
610
|
-
expirePages([restored.id]); // the draft-mode render of this page changed
|
|
654
|
+
await expirePages([restored.id]); // the draft-mode render of this page changed
|
|
611
655
|
return ok(restored);
|
|
612
656
|
}),
|
|
613
657
|
delete: (id) => guard(async () => {
|
|
@@ -623,7 +667,7 @@ export function createAdminOps(deps) {
|
|
|
623
667
|
await pages.deletePage(id);
|
|
624
668
|
// The cached unit is keyed by (locale, path) but tagged by the id
|
|
625
669
|
// found at the first miss; the parent's unit lists its children.
|
|
626
|
-
expirePages([id, record.parentId]);
|
|
670
|
+
await expirePages([id, record.parentId]);
|
|
627
671
|
expireTree();
|
|
628
672
|
return ok(null);
|
|
629
673
|
}),
|
|
@@ -654,7 +698,7 @@ export function createAdminOps(deps) {
|
|
|
654
698
|
const renamed = await pages.rename(id, rest, by, { redirect: redirect ?? true });
|
|
655
699
|
// Every descendant's path changed with this segment; the parent
|
|
656
700
|
// lists this node's title and path.
|
|
657
|
-
expirePages([...(await subtreeIds(id)), record.parentId]);
|
|
701
|
+
await expirePages([...(await subtreeIds(id)), record.parentId]);
|
|
658
702
|
expireTree();
|
|
659
703
|
return ok(renamed);
|
|
660
704
|
}),
|
|
@@ -689,7 +733,8 @@ export function createAdminOps(deps) {
|
|
|
689
733
|
}
|
|
690
734
|
const { redirect, ...dest } = to;
|
|
691
735
|
const moved = await pages.move(id, dest, { redirect: redirect ?? true });
|
|
692
|
-
|
|
736
|
+
// The destination's children are renumbered: their stored sort (the tree's order) changes too.
|
|
737
|
+
await expirePages([...(await subtreeIds(id)), record.parentId, moved.parentId, ...(await childIds(moved.parentId))]);
|
|
693
738
|
expireTree();
|
|
694
739
|
return ok(moved);
|
|
695
740
|
}),
|
|
@@ -707,10 +752,15 @@ export function createAdminOps(deps) {
|
|
|
707
752
|
const registration = openTemplate(template);
|
|
708
753
|
if (!registration)
|
|
709
754
|
return err("validation", `smoodly: no page type named "${template}".`);
|
|
710
|
-
if (registration.
|
|
755
|
+
if (registration.slug !== undefined) {
|
|
711
756
|
return err("validation", `smoodly: page type "${template}" is fixed — Smoodly creates that page for you.`);
|
|
712
757
|
}
|
|
713
758
|
const withTemplate = await pages.setTemplate(id, template, by);
|
|
759
|
+
// The folder becomes an unpublished page: its row goes, and with it its place in the parent's children and
|
|
760
|
+
// the subtree's ancestors. Its own address served nothing before and serves nothing now: no unit to expire.
|
|
761
|
+
const [, ...below] = await subtreeIds(id);
|
|
762
|
+
await project("pages.setTemplate", "page", [id]);
|
|
763
|
+
await expirePages([...below, record.parentId]);
|
|
714
764
|
expireTree();
|
|
715
765
|
return ok(withTemplate);
|
|
716
766
|
}),
|
|
@@ -730,6 +780,9 @@ export function createAdminOps(deps) {
|
|
|
730
780
|
return lands;
|
|
731
781
|
// A new locale is a draft: nothing public changes but the tree listing (spec 2026-09-11 §2).
|
|
732
782
|
const added = await pages.addLocale(id, locale, { from: options.from }, by);
|
|
783
|
+
// A folder's new locale is public at once, in its parent's children too; a page's is a draft.
|
|
784
|
+
if (record.template === null)
|
|
785
|
+
await expirePages([id, record.parentId]);
|
|
733
786
|
expireTree();
|
|
734
787
|
return ok(added);
|
|
735
788
|
}),
|
|
@@ -749,7 +802,7 @@ export function createAdminOps(deps) {
|
|
|
749
802
|
return err("blocked", `smoodly: "${nameOf(record)}" is the site root — it exists in every locale.`);
|
|
750
803
|
const removed = await pages.removeLocale(id, locale);
|
|
751
804
|
// The locale may have been published: its unit and the parent's listing go.
|
|
752
|
-
expirePages([id, record.parentId]);
|
|
805
|
+
await expirePages([id, record.parentId]);
|
|
753
806
|
expireTree();
|
|
754
807
|
return ok(removed);
|
|
755
808
|
}),
|
|
@@ -812,8 +865,9 @@ export function createAdminOps(deps) {
|
|
|
812
865
|
// A published row's slug is its live address and is never versioned, so this
|
|
813
866
|
// save is its publish: the grammar holds here (spec 2026-09-28 ASCII slugs §3).
|
|
814
867
|
const slug = typeof fields.slug === "string" && fields.slug !== "" ? fields.slug : null;
|
|
868
|
+
const before = (await entries.get(collection, id))?.locales[locale];
|
|
815
869
|
if (descriptors.slug !== undefined && (slug === null || !isSegment(slug))) {
|
|
816
|
-
const row =
|
|
870
|
+
const row = before;
|
|
817
871
|
if (row?.status === "published" && row.slug !== null && row.slug !== slug) {
|
|
818
872
|
const message = slug === null ? "Required while the entry is published." : SLUG_GRAMMAR_MESSAGE;
|
|
819
873
|
return err("validation", "Some fields need attention.", [{ field: "slug", message }]);
|
|
@@ -830,7 +884,21 @@ export function createAdminOps(deps) {
|
|
|
830
884
|
}
|
|
831
885
|
throw e;
|
|
832
886
|
}
|
|
833
|
-
|
|
887
|
+
// A save projects only when a published document can see it (R17): a published row whose read is its live
|
|
888
|
+
// row, or a slug change (the slug is never versioned: it is live at once). In a tree collection every
|
|
889
|
+
// descendant's address hangs on the slug, published or not. Any other save is a draft: tags only.
|
|
890
|
+
const after = entry.locales[locale];
|
|
891
|
+
const slugChanged = after !== undefined && after.slug !== (before?.slug ?? null);
|
|
892
|
+
const tree = config.registry.collections?.find((c) => c.name === collection)?.tree !== undefined;
|
|
893
|
+
const below = tree && slugChanged ? (await entrySubtreeIds(collection, id)).slice(1) : [];
|
|
894
|
+
if (after?.status === "published" && (after.publishedVersionId === null || slugChanged)) {
|
|
895
|
+
await fanOut([id, ...below]);
|
|
896
|
+
}
|
|
897
|
+
else {
|
|
898
|
+
await fanOut(id, { project: false });
|
|
899
|
+
if (below.length > 0)
|
|
900
|
+
await fanOut(below);
|
|
901
|
+
}
|
|
834
902
|
expireCollection(collection);
|
|
835
903
|
return ok(entry);
|
|
836
904
|
}),
|
|
@@ -909,8 +977,15 @@ export function createAdminOps(deps) {
|
|
|
909
977
|
move: (collection, id, to) => guard(async () => {
|
|
910
978
|
const { redirect, ...dest } = to;
|
|
911
979
|
const moved = await entries.move(collection, id, dest, { redirect: redirect ?? true });
|
|
980
|
+
// The subtree's addresses changed, and with them every document embedding one of them.
|
|
981
|
+
const subtree = await entrySubtreeIds(collection, id);
|
|
982
|
+
await fanOut(subtree);
|
|
983
|
+
// The destination's children are renumbered: their stored sort (a manual list's order) changes too.
|
|
984
|
+
const inSubtree = new Set(subtree);
|
|
985
|
+
const siblings = (await entries.list(collection)).filter((e) => e.parentId === moved.parentId && !inSubtree.has(e.id)).map((e) => e.id);
|
|
986
|
+
await project("entries.move", "entry", siblings);
|
|
912
987
|
if (effects?.revalidate) {
|
|
913
|
-
for (const entryId of
|
|
988
|
+
for (const entryId of siblings)
|
|
914
989
|
effects.revalidate(entryTag(entryId));
|
|
915
990
|
}
|
|
916
991
|
expireCollection(collection);
|
|
@@ -970,8 +1045,10 @@ export function createAdminOps(deps) {
|
|
|
970
1045
|
if (!record)
|
|
971
1046
|
return err("not_found", `smoodly: global item "${name}" is not materialized — open Globals first.`);
|
|
972
1047
|
const saved = await entries.update(name, record.id, locale, fields, by, token);
|
|
973
|
-
// fanOut expires `global:<name>` itself — this row IS the item's.
|
|
974
|
-
|
|
1048
|
+
// fanOut expires `global:<name>` itself — this row IS the item's. A global has no slug: it projects only when
|
|
1049
|
+
// its published read is the live row (R17); a draft save expires the tags only.
|
|
1050
|
+
const after = saved.locales[locale];
|
|
1051
|
+
await fanOut(record.id, { project: after?.status === "published" && after.publishedVersionId === null });
|
|
975
1052
|
return ok(saved);
|
|
976
1053
|
}),
|
|
977
1054
|
setStatus: (name, locale, status, expected) => guard(async () => {
|
|
@@ -1234,8 +1311,8 @@ export function createAdminOps(deps) {
|
|
|
1234
1311
|
},
|
|
1235
1312
|
health: {
|
|
1236
1313
|
scan: () => guard(async () => {
|
|
1237
|
-
const [trees, rows] = await Promise.all([pages.scanTrees(), entries.scanEntries()]);
|
|
1238
|
-
return ok(buildHealthReport(adminRegistry(), trees, rows));
|
|
1314
|
+
const [trees, rows, delivery] = await Promise.all([pages.scanTrees(), entries.scanEntries(), projector.delivery()]);
|
|
1315
|
+
return ok({ ...buildHealthReport(adminRegistry(), trees, rows), delivery });
|
|
1239
1316
|
}),
|
|
1240
1317
|
},
|
|
1241
1318
|
schema: {
|
|
@@ -1247,7 +1324,7 @@ export function createAdminOps(deps) {
|
|
|
1247
1324
|
},
|
|
1248
1325
|
};
|
|
1249
1326
|
}
|
|
1250
|
-
/** Path-dispatch for the one-
|
|
1327
|
+
/** Path-dispatch for the one-endpoint seam. Own-property checks only —
|
|
1251
1328
|
* "constructor.constructor" and prototype paths must not resolve. */
|
|
1252
1329
|
export async function runAdminOp(ops, call) {
|
|
1253
1330
|
if (!isPlainObject(call) || typeof call.path !== "string" || !Array.isArray(call.args)) {
|