smoodly 0.0.4 → 0.0.6
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 +408 -34
- package/dist/admin/client-ops.js +2 -2
- package/dist/admin/editor/EditorView.d.ts +3 -2
- package/dist/admin/editor/EditorView.js +133 -80
- package/dist/admin/editor/PageSettings.d.ts +13 -0
- package/dist/admin/editor/PageSettings.js +40 -0
- package/dist/admin/fixed-nodes.d.ts +23 -0
- package/dist/admin/fixed-nodes.js +83 -0
- package/dist/admin/forms/FieldWidget.d.ts +2 -1
- package/dist/admin/forms/FieldWidget.js +16 -16
- package/dist/admin/index.d.ts +2 -1
- package/dist/admin/index.js +1 -0
- package/dist/admin/next/create-admin.js +7 -2
- package/dist/admin/next/op-handler.d.ts +1 -0
- package/dist/admin/next/op-handler.js +7 -2
- package/dist/admin/ops-impl.d.ts +4 -1
- package/dist/admin/ops-impl.js +190 -48
- package/dist/admin/ops.d.ts +39 -11
- package/dist/admin/serialize.d.ts +10 -1
- package/dist/admin/serialize.js +13 -2
- package/dist/admin/shell/AdminApp.js +36 -74
- package/dist/admin/shell/EntryForm.js +34 -10
- package/dist/admin/shell/ErrorPane.d.ts +4 -0
- package/dist/admin/shell/ErrorPane.js +9 -0
- package/dist/admin/shell/ListView.d.ts +8 -5
- package/dist/admin/shell/ListView.js +12 -11
- package/dist/admin/shell/LoginView.js +6 -2
- package/dist/admin/shell/PagesList.d.ts +6 -0
- package/dist/admin/shell/PagesList.js +148 -0
- package/dist/admin/shell/base.d.ts +1 -0
- package/dist/admin/shell/base.js +4 -0
- package/dist/admin/shell/pages-tree.d.ts +39 -0
- package/dist/admin/shell/pages-tree.js +65 -0
- package/dist/admin/ui/format.d.ts +1 -0
- package/dist/admin/ui/format.js +25 -0
- package/dist/admin/ui/primitives.d.ts +89 -0
- package/dist/admin/ui/primitives.js +115 -0
- package/dist/admin/ui/theme.d.ts +5 -0
- package/dist/admin/ui/theme.js +396 -0
- package/dist/collections.d.ts +23 -2
- package/dist/collections.js +19 -0
- package/dist/config.d.ts +11 -1
- package/dist/config.js +71 -13
- package/dist/entry-page.d.ts +16 -0
- package/dist/entry-page.js +6 -0
- package/dist/entry-store.d.ts +94 -7
- package/dist/entry-store.js +175 -26
- package/dist/index.d.ts +24 -8
- package/dist/index.js +14 -3
- package/dist/localize.d.ts +19 -0
- package/dist/localize.js +42 -0
- package/dist/next/index.d.ts +2 -2
- package/dist/next/index.js +1 -1
- package/dist/next/meta.d.ts +0 -6
- package/dist/next/meta.js +0 -11
- package/dist/next/page-renderer.d.ts +26 -18
- package/dist/next/page-renderer.js +79 -56
- package/dist/page.d.ts +10 -3
- package/dist/page.js +12 -2
- package/dist/pairing.d.ts +7 -6
- package/dist/path-index.d.ts +16 -0
- package/dist/path-index.js +33 -0
- package/dist/paths.d.ts +80 -0
- package/dist/paths.js +110 -0
- package/dist/render.d.ts +34 -5
- package/dist/render.js +9 -2
- package/dist/resolve.d.ts +8 -0
- package/dist/resolve.js +19 -7
- package/dist/revalidate.d.ts +10 -2
- package/dist/revalidate.js +13 -8
- package/dist/site.d.ts +85 -0
- package/dist/site.js +164 -0
- package/dist/sql-space.d.ts +1 -1
- package/dist/sql-space.js +2 -0
- package/dist/sql.d.ts +1 -1
- package/dist/sql.js +92 -15
- package/dist/store.d.ts +106 -30
- package/dist/store.js +242 -66
- package/dist/supabase-entry-store.d.ts +17 -3
- package/dist/supabase-entry-store.js +198 -22
- package/dist/supabase-path-index.d.ts +11 -0
- package/dist/supabase-path-index.js +52 -0
- package/dist/supabase-store.d.ts +46 -16
- package/dist/supabase-store.js +322 -110
- package/package.json +5 -4
- package/dist/admin/page-path.d.ts +0 -1
- package/dist/admin/page-path.js +0 -6
- package/dist/admin/shell/pages-list.d.ts +0 -22
- package/dist/admin/shell/pages-list.js +0 -31
|
@@ -1,36 +1,44 @@
|
|
|
1
1
|
import type { ComponentType, ReactElement } from "react";
|
|
2
2
|
import type { Metadata } from "next";
|
|
3
|
-
import type { CollectionSchema } from "../collections.ts";
|
|
4
|
-
import type { EntryStore } from "../entry-store.ts";
|
|
5
3
|
import type { Descriptor } from "../fields.ts";
|
|
6
4
|
import { type PairedPage } from "../render.tsx";
|
|
7
|
-
import type
|
|
8
|
-
/** A registered section, as
|
|
5
|
+
import { type SmoodlySite } from "../site.ts";
|
|
6
|
+
/** A registered section, as renderPage's registry needs it. */
|
|
9
7
|
type SectionEntry = ComponentType<any> & {
|
|
10
8
|
schema: {
|
|
11
9
|
name: string;
|
|
12
10
|
fields: Record<string, Descriptor>;
|
|
13
11
|
};
|
|
14
12
|
};
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
13
|
+
/** A page shape or an entry page — anything in `config.registry.pages`. */
|
|
14
|
+
export type PageRegistration = ((props: any) => ReactElement) & {
|
|
15
|
+
schema: {
|
|
16
|
+
kind: string;
|
|
17
|
+
name: string;
|
|
18
|
+
collection?: string;
|
|
20
19
|
};
|
|
20
|
+
};
|
|
21
|
+
export type PageRendererOptions = {
|
|
22
|
+
site: SmoodlySite;
|
|
21
23
|
sections: SectionEntry[];
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
|
|
24
|
+
pages: ReadonlyArray<PageRegistration>;
|
|
25
|
+
/** Locale-relative CMS path → site URL. Default: the path itself. With
|
|
26
|
+
* next-intl: `(path, locale) => getPathname({ locale, href: path })`. */
|
|
27
|
+
href?: (path: string, locale: string) => string;
|
|
25
28
|
};
|
|
26
|
-
export type
|
|
27
|
-
|
|
29
|
+
export type PathRenderOptions = {
|
|
30
|
+
locale?: string;
|
|
28
31
|
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
|
29
32
|
};
|
|
30
33
|
export declare function createPageRenderer(options: PageRendererOptions): {
|
|
31
|
-
|
|
32
|
-
smoodlyMetadata
|
|
33
|
-
|
|
34
|
-
})
|
|
34
|
+
renderSmoodlyPath: (segments: string[], opts?: PathRenderOptions) => Promise<ReactElement<unknown, string | import("react").JSXElementConstructor<any>>>;
|
|
35
|
+
smoodlyMetadata: (segments: string[], opts?: {
|
|
36
|
+
locale?: string;
|
|
37
|
+
}) => Promise<Metadata>;
|
|
38
|
+
/** A fixed registration rendered from the developer's own route file. */
|
|
39
|
+
renderSmoodlyPage: <Z extends Record<string, unknown>>(Page: PairedPage<Z>, opts?: PathRenderOptions) => Promise<ReactElement<unknown, string | import("react").JSXElementConstructor<any>>>;
|
|
40
|
+
smoodlyPageMetadata: <Z extends Record<string, unknown>>(Page: PairedPage<Z>, opts?: {
|
|
41
|
+
locale?: string;
|
|
42
|
+
}) => Promise<Metadata>;
|
|
35
43
|
};
|
|
36
44
|
export {};
|
|
@@ -4,66 +4,89 @@ import { draftMode } from "next/headers";
|
|
|
4
4
|
import { notFound } from "next/navigation";
|
|
5
5
|
import { connection } from "next/server";
|
|
6
6
|
import { EditorBridge } from "../admin/next/bridge.js";
|
|
7
|
+
import { fixedPathFor } from "../localize.js";
|
|
8
|
+
import { joinPath } from "../paths.js";
|
|
7
9
|
import { renderPage } from "../render.js";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
+
import { entryTag, pageTag } from "../revalidate.js";
|
|
11
|
+
import { pageContextOf } from "../site.js";
|
|
12
|
+
import { metadataFrom } from "./meta.js";
|
|
10
13
|
export function createPageRenderer(options) {
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
: null;
|
|
27
|
-
},
|
|
28
|
-
// Prefixed so it never collides with the surviving catch-all's own
|
|
29
|
-
// ["resolved-page", slug] cache in the same app.
|
|
30
|
-
["smoodly-resolved-page", slug], { tags: [tag(slug)] })();
|
|
31
|
-
async function load(slug, draft) {
|
|
32
|
-
if (!draft)
|
|
33
|
-
return cachedPublished(slug);
|
|
34
|
-
const { pages } = options.stores();
|
|
35
|
-
const record = await pages.getPage(slug);
|
|
36
|
-
const tree = record ? await pages.getDraftTree(slug) : null;
|
|
37
|
-
return record && tree ? { template: record.template, tree: await resolved(tree) } : null;
|
|
14
|
+
const { site } = options;
|
|
15
|
+
const href = options.href ?? ((path) => path);
|
|
16
|
+
const shapes = new Map(options.pages.filter((p) => p.schema.kind === "page").map((p) => [p.schema.name, p]));
|
|
17
|
+
const entryPages = new Map(options.pages.filter((p) => p.schema.kind === "entryPage" && p.schema.collection).map((p) => [p.schema.collection, p]));
|
|
18
|
+
/** locate (uncached: the id it returns IS the cache tag) then load —
|
|
19
|
+
* cached per (locale, path) outside draft mode. */
|
|
20
|
+
async function load(locale, path, draft) {
|
|
21
|
+
const target = await site.locate(locale, path);
|
|
22
|
+
if (!target)
|
|
23
|
+
return null;
|
|
24
|
+
if (draft)
|
|
25
|
+
return site.load(target, locale, path, { draft: true });
|
|
26
|
+
return unstable_cache(() => site.load(target, locale, path), ["smoodly-site", locale, path], {
|
|
27
|
+
tags: [target.kind === "page" ? pageTag(target.id) : entryTag(target.id)],
|
|
28
|
+
})();
|
|
38
29
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
30
|
+
function render(hit, editor, locale) {
|
|
31
|
+
if (hit.kind === "page") {
|
|
32
|
+
const Page = shapes.get(hit.template);
|
|
33
|
+
if (!Page) {
|
|
34
|
+
console.warn(`smoodly: no page registration named "${hit.template}" — the record at ${hit.path} is a 404.`);
|
|
35
|
+
notFound();
|
|
36
|
+
}
|
|
37
|
+
const page = pageContextOf(hit, (path) => href(path, locale));
|
|
38
|
+
return renderPage(Page, hit.tree, { sections: options.sections }, { editor, page });
|
|
39
|
+
}
|
|
40
|
+
const View = entryPages.get(hit.collection);
|
|
41
|
+
if (!View) {
|
|
42
|
+
console.warn(`smoodly: no entry page for collection "${hit.collection}" — the entry at ${hit.path} is a 404.`);
|
|
43
|
+
notFound();
|
|
44
|
+
}
|
|
45
|
+
return _jsx(View, { entry: hit.entry, path: hit.path });
|
|
46
|
+
}
|
|
47
|
+
function alternates(hit) {
|
|
48
|
+
if (site.locales.supported.length < 2)
|
|
49
|
+
return {};
|
|
50
|
+
const languages = Object.fromEntries(Object.entries(hit.paths).map(([locale, path]) => [locale, href(path, locale)]));
|
|
51
|
+
return { alternates: { languages } };
|
|
52
|
+
}
|
|
53
|
+
async function page(pathFor, opts, accept) {
|
|
54
|
+
// A CMS route is request-rendered by construction: without this a
|
|
55
|
+
// route with no dynamic segment would be prerendered against the
|
|
56
|
+
// database at build time (DESIGN.md §3, OQ #19).
|
|
57
|
+
await connection();
|
|
58
|
+
const locale = opts?.locale ?? site.locales.default;
|
|
59
|
+
const path = pathFor(locale);
|
|
60
|
+
const { isEnabled: draft } = await draftMode();
|
|
61
|
+
const hit = await load(locale, path, draft);
|
|
62
|
+
if (!hit || !accept(hit))
|
|
63
|
+
notFound();
|
|
64
|
+
const editor = draft && (await opts?.searchParams)?.["smoodly-editor"] === "1";
|
|
65
|
+
return (_jsxs(_Fragment, { children: [editor ? _jsx(EditorBridge, {}) : null, render(hit, editor, locale)] }));
|
|
44
66
|
}
|
|
67
|
+
async function metadata(pathFor, opts, accept) {
|
|
68
|
+
// generateMetadata runs alongside the page render, so it gates too.
|
|
69
|
+
await connection();
|
|
70
|
+
const locale = opts?.locale ?? site.locales.default;
|
|
71
|
+
const path = pathFor(locale);
|
|
72
|
+
const { isEnabled: draft } = await draftMode();
|
|
73
|
+
const hit = await load(locale, path, draft);
|
|
74
|
+
if (!hit || !accept(hit))
|
|
75
|
+
return {};
|
|
76
|
+
return { ...(hit.kind === "page" ? metadataFrom(hit.tree.meta) : {}), ...alternates(hit) };
|
|
77
|
+
}
|
|
78
|
+
const any = () => true;
|
|
79
|
+
/** The escape hatch may only render ITS OWN registration's record. */
|
|
80
|
+
const only = (Page) => (hit) => hit.kind === "page" && hit.template === Page.schema.name;
|
|
81
|
+
/** Resolved AFTER connection(), inside page()/metadata(), so a wiring
|
|
82
|
+
* error surfaces as a rejected promise like every other failure. */
|
|
83
|
+
const fixedPath = (Page) => (locale) => fixedPathFor(Page.schema, locale, site.locales, site.homePageSlug);
|
|
84
|
+
const literal = (segments) => () => joinPath(segments);
|
|
45
85
|
return {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const slug = pageSlugFor(Page.schema, opts?.slug);
|
|
52
|
-
const { isEnabled: draft } = await draftMode();
|
|
53
|
-
const tree = forRegistration(await load(slug, draft), Page.schema.name);
|
|
54
|
-
if (!tree)
|
|
55
|
-
notFound();
|
|
56
|
-
const editor = draft && (await opts?.searchParams)?.["smoodly-editor"] === "1";
|
|
57
|
-
return (_jsxs(_Fragment, { children: [editor ? _jsx(EditorBridge, {}) : null, renderPage(Page, tree, { sections: options.sections }, { editor })] }));
|
|
58
|
-
},
|
|
59
|
-
async smoodlyMetadata(Page, opts) {
|
|
60
|
-
// generateMetadata runs alongside the page render, so it must gate
|
|
61
|
-
// on the request too or a build could still reach the database here.
|
|
62
|
-
await connection();
|
|
63
|
-
const slug = pageSlugFor(Page.schema, opts?.slug);
|
|
64
|
-
const { isEnabled: draft } = await draftMode();
|
|
65
|
-
const tree = forRegistration(await load(slug, draft), Page.schema.name);
|
|
66
|
-
return metadataFrom(tree?.meta);
|
|
67
|
-
},
|
|
86
|
+
renderSmoodlyPath: (segments, opts) => page(literal(segments), opts, any),
|
|
87
|
+
smoodlyMetadata: (segments, opts) => metadata(literal(segments), opts, any),
|
|
88
|
+
/** A fixed registration rendered from the developer's own route file. */
|
|
89
|
+
renderSmoodlyPage: (Page, opts) => page(fixedPath(Page), opts, only(Page)),
|
|
90
|
+
smoodlyPageMetadata: (Page, opts) => metadata(fixedPath(Page), opts, only(Page)),
|
|
68
91
|
};
|
|
69
92
|
}
|
package/dist/page.d.ts
CHANGED
|
@@ -6,8 +6,10 @@ export type PageSchema<Zones extends Record<string, unknown> = Record<string, un
|
|
|
6
6
|
/** Fixed: this registration owns exactly ONE page record, at this slug,
|
|
7
7
|
* served by one route file. Absent = open: editors create many.
|
|
8
8
|
* The route file serving "/" must declare the config's `homePageSlug`
|
|
9
|
-
* (default "home") — that is how the admin maps its record to "/".
|
|
10
|
-
|
|
9
|
+
* (default "home") — that is how the admin maps its record to "/".
|
|
10
|
+
* A per-locale object lets each locale carry its own translated
|
|
11
|
+
* segment; the default locale's entry is required (see `fixedSlug`). */
|
|
12
|
+
slug?: string | Record<string, string>;
|
|
11
13
|
zones: Record<keyof Zones & string, ZonePolicy>;
|
|
12
14
|
meta: Record<string, unknown>;
|
|
13
15
|
/** phantom: zone names for Props inference */
|
|
@@ -16,7 +18,12 @@ export type PageSchema<Zones extends Record<string, unknown> = Record<string, un
|
|
|
16
18
|
export declare function pageSchema<Zones extends Record<string, object>>(input: {
|
|
17
19
|
name: string;
|
|
18
20
|
title: string;
|
|
19
|
-
slug?: string
|
|
21
|
+
slug?: string | Record<string, string>;
|
|
20
22
|
zones: Zones;
|
|
21
23
|
meta?: Record<string, unknown>;
|
|
22
24
|
}): PageSchema<Zones>;
|
|
25
|
+
/** The default-locale segment of a fixed slug — what the admin lists and
|
|
26
|
+
* what today's root-level lookups use. */
|
|
27
|
+
export declare function fixedSlug(schema: {
|
|
28
|
+
slug?: string | Record<string, string>;
|
|
29
|
+
}, defaultLocale: string): string | undefined;
|
package/dist/page.js
CHANGED
|
@@ -21,8 +21,11 @@ export function pageSchema(input) {
|
|
|
21
21
|
// A fixed slug is decided by `slug !== undefined` at every other hop, but
|
|
22
22
|
// the renderer's path lookup is truthiness-based: an empty string would read
|
|
23
23
|
// as fixed to the admin and blow up in the route. Reject it at construction.
|
|
24
|
-
if (input.slug !== undefined
|
|
25
|
-
|
|
24
|
+
if (input.slug !== undefined) {
|
|
25
|
+
const values = typeof input.slug === "string" ? [input.slug] : Object.values(input.slug);
|
|
26
|
+
if (values.length === 0 || values.some((v) => typeof v !== "string" || v.trim() === "")) {
|
|
27
|
+
throw new Error(`smoodly: page "${input.name}" declares an invalid slug — a fixed slug must be a non-empty string or a per-locale object of them.`);
|
|
28
|
+
}
|
|
26
29
|
}
|
|
27
30
|
return {
|
|
28
31
|
kind: "page",
|
|
@@ -33,3 +36,10 @@ export function pageSchema(input) {
|
|
|
33
36
|
meta: input.meta ?? {},
|
|
34
37
|
};
|
|
35
38
|
}
|
|
39
|
+
/** The default-locale segment of a fixed slug — what the admin lists and
|
|
40
|
+
* what today's root-level lookups use. */
|
|
41
|
+
export function fixedSlug(schema, defaultLocale) {
|
|
42
|
+
if (schema.slug === undefined)
|
|
43
|
+
return undefined;
|
|
44
|
+
return typeof schema.slug === "string" ? schema.slug : schema.slug[defaultLocale];
|
|
45
|
+
}
|
package/dist/pairing.d.ts
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import type { ComponentType, ReactElement } from "react";
|
|
2
2
|
import type { BuilderMap, ElementSchema, NamespaceValues, SectionSchema } from "./schema.ts";
|
|
3
|
-
import type { ZoneContent } from "./render.tsx";
|
|
4
|
-
/** What the view receives. A page view gets its zones
|
|
5
|
-
* view gets field values flat and styles
|
|
6
|
-
* The page branch keys on `kind` — an
|
|
7
|
-
* section schemas too, since a type
|
|
8
|
-
* `{ __zones?: X }`. */
|
|
3
|
+
import type { PageContext, ZoneContent } from "./render.tsx";
|
|
4
|
+
/** What the view receives. A page view gets its zones and where it sits
|
|
5
|
+
* in the tree; a section/element view gets field values flat and styles
|
|
6
|
+
* complete (defaults filled). The page branch keys on `kind` — an
|
|
7
|
+
* optional-only check would match section schemas too, since a type
|
|
8
|
+
* lacking `__zones` still extends `{ __zones?: X }`. */
|
|
9
9
|
export type Props<S> = S extends {
|
|
10
10
|
kind: "page";
|
|
11
11
|
__zones?: infer Z extends Record<string, unknown>;
|
|
12
12
|
} ? {
|
|
13
13
|
zones: Record<keyof Z & string, ZoneContent>;
|
|
14
|
+
page: PageContext;
|
|
14
15
|
} : S extends {
|
|
15
16
|
__f?: infer F extends BuilderMap;
|
|
16
17
|
__s?: infer St extends BuilderMap;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { PathRow, PathTarget } from "./paths.ts";
|
|
2
|
+
export type { PathRow, PathTarget } from "./paths.ts";
|
|
3
|
+
export interface PathIndex {
|
|
4
|
+
rewrite(targets: PathTarget[], rows: PathRow[]): Promise<void>;
|
|
5
|
+
remove(targets: PathTarget[]): Promise<void>;
|
|
6
|
+
resolve(locale: string, path: string): Promise<PathTarget | null>;
|
|
7
|
+
pathsOf(target: PathTarget): Promise<Record<string, string>>;
|
|
8
|
+
}
|
|
9
|
+
export declare const pathTakenError: (locale: string, path: string) => Error;
|
|
10
|
+
export declare class MemoryPathIndex implements PathIndex {
|
|
11
|
+
private rows;
|
|
12
|
+
rewrite(targets: PathTarget[], rows: PathRow[]): Promise<void>;
|
|
13
|
+
remove(targets: PathTarget[]): Promise<void>;
|
|
14
|
+
resolve(locale: string, path: string): Promise<PathTarget | null>;
|
|
15
|
+
pathsOf(target: PathTarget): Promise<Record<string, string>>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const sameTarget = (a, b) => a.kind === b.kind && a.id === b.id;
|
|
2
|
+
const key = (locale, path) => `${locale} ${path}`;
|
|
3
|
+
export const pathTakenError = (locale, path) => new Error(`smoodly: the path "${path}" (${locale}) is already taken.`);
|
|
4
|
+
export class MemoryPathIndex {
|
|
5
|
+
constructor() {
|
|
6
|
+
this.rows = [];
|
|
7
|
+
}
|
|
8
|
+
async rewrite(targets, rows) {
|
|
9
|
+
const kept = this.rows.filter((r) => !targets.some((t) => sameTarget(t, r)));
|
|
10
|
+
const taken = new Set(kept.map((r) => key(r.locale, r.path)));
|
|
11
|
+
for (const row of rows) {
|
|
12
|
+
const k = key(row.locale, row.path);
|
|
13
|
+
if (taken.has(k))
|
|
14
|
+
throw pathTakenError(row.locale, row.path);
|
|
15
|
+
taken.add(k);
|
|
16
|
+
}
|
|
17
|
+
this.rows = [...kept, ...rows.map((r) => ({ ...r }))];
|
|
18
|
+
}
|
|
19
|
+
async remove(targets) {
|
|
20
|
+
this.rows = this.rows.filter((r) => !targets.some((t) => sameTarget(t, r)));
|
|
21
|
+
}
|
|
22
|
+
async resolve(locale, path) {
|
|
23
|
+
const row = this.rows.find((r) => r.locale === locale && r.path === path);
|
|
24
|
+
return row ? { kind: row.kind, id: row.id } : null;
|
|
25
|
+
}
|
|
26
|
+
async pathsOf(target) {
|
|
27
|
+
const out = {};
|
|
28
|
+
for (const r of this.rows)
|
|
29
|
+
if (sameTarget(r, target))
|
|
30
|
+
out[r.locale] = r.path;
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
}
|
package/dist/paths.d.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export declare const SEGMENT_RE: RegExp;
|
|
2
|
+
export declare const isSegment: (s: string) => boolean;
|
|
3
|
+
export declare function assertSegment(s: string, what: string): void;
|
|
4
|
+
export type LocaleSet = {
|
|
5
|
+
default: string;
|
|
6
|
+
supported: string[];
|
|
7
|
+
};
|
|
8
|
+
/** A per-locale segment map: a string applies to every supported locale; an object is copied as declared. */
|
|
9
|
+
export declare function perLocaleSegments(value: string | Record<string, string>, supported: string[]): Record<string, string>;
|
|
10
|
+
export type PathTarget = {
|
|
11
|
+
kind: "page" | "entry";
|
|
12
|
+
id: string;
|
|
13
|
+
};
|
|
14
|
+
export type PathRow = PathTarget & {
|
|
15
|
+
locale: string;
|
|
16
|
+
path: string;
|
|
17
|
+
};
|
|
18
|
+
export declare const joinPath: (segments: string[]) => string;
|
|
19
|
+
export declare const splitPath: (path: string) => string[];
|
|
20
|
+
type SlugI18n = Record<string, {
|
|
21
|
+
slug?: string;
|
|
22
|
+
}> | null | undefined;
|
|
23
|
+
/** The locale's own segment, or the default locale's (per-field fallback). */
|
|
24
|
+
export declare function segmentFor(node: {
|
|
25
|
+
slug: string;
|
|
26
|
+
i18n?: SlugI18n;
|
|
27
|
+
}, locale: string, set: LocaleSet): string;
|
|
28
|
+
/** null = every supported locale; otherwise the intersection. */
|
|
29
|
+
export declare function localesOf(node: {
|
|
30
|
+
locales?: string[] | null;
|
|
31
|
+
}, set: LocaleSet): string[];
|
|
32
|
+
export declare function chainOf<T extends {
|
|
33
|
+
id: string;
|
|
34
|
+
parentId: string | null;
|
|
35
|
+
}>(node: T, byId: (id: string) => T | undefined): T[];
|
|
36
|
+
export declare function assertDepth(depth: number, max: number, what: string): void;
|
|
37
|
+
export type PageLike = {
|
|
38
|
+
id: string;
|
|
39
|
+
parentId: string | null;
|
|
40
|
+
slug: string;
|
|
41
|
+
i18n: Record<string, {
|
|
42
|
+
slug?: string;
|
|
43
|
+
title?: string;
|
|
44
|
+
}> | null;
|
|
45
|
+
locales: string[] | null;
|
|
46
|
+
};
|
|
47
|
+
export type PagePathOptions = {
|
|
48
|
+
locales: LocaleSet;
|
|
49
|
+
homePageSlug: string;
|
|
50
|
+
};
|
|
51
|
+
/** The node's path in one locale: `/` for the home node, else the
|
|
52
|
+
* localized ancestor chain joined. THE derivation — pagePathRows,
|
|
53
|
+
* buildPageTree and the site layer all call this. */
|
|
54
|
+
export declare function pagePath(node: PageLike, byId: (id: string) => PageLike | undefined, locale: string, opts: PagePathOptions): string;
|
|
55
|
+
export declare function pagePathRows(targets: PageLike[], byId: (id: string) => PageLike | undefined, opts: PagePathOptions): PathRow[];
|
|
56
|
+
export type EntryLike = {
|
|
57
|
+
id: string;
|
|
58
|
+
parentId: string | null;
|
|
59
|
+
fields: Record<string, unknown>;
|
|
60
|
+
i18n: Record<string, Record<string, unknown>> | null;
|
|
61
|
+
locales: string[] | null;
|
|
62
|
+
};
|
|
63
|
+
export declare function collectionSegment(path: string | Record<string, string> | undefined, locale: string, set: LocaleSet): string | null;
|
|
64
|
+
/** The "claims a URL" predicate (§1): a slug is only a real segment when
|
|
65
|
+
* it is a non-empty string — absent, null, or `""` all mean "no URL".
|
|
66
|
+
* The sibling index predicate in sql.ts (`entries_sibling_slug_key`)
|
|
67
|
+
* mirrors this rule exactly, so Postgres and memory agree on which
|
|
68
|
+
* entries can collide. */
|
|
69
|
+
export declare const slugOf: (fields: Record<string, unknown>) => string | null;
|
|
70
|
+
export declare function entrySegment(entry: EntryLike, locale: string, set: LocaleSet): string | null;
|
|
71
|
+
export declare function entryPathRows(targets: EntryLike[], byId: (id: string) => EntryLike | undefined, collection: {
|
|
72
|
+
path?: string | Record<string, string>;
|
|
73
|
+
}, set: LocaleSet): PathRow[];
|
|
74
|
+
export type PageTreeNode<T extends PageLike> = {
|
|
75
|
+
record: T;
|
|
76
|
+
path: string;
|
|
77
|
+
children: PageTreeNode<T>[];
|
|
78
|
+
};
|
|
79
|
+
export declare function buildPageTree<T extends PageLike>(records: T[], locale: string, opts: PagePathOptions): PageTreeNode<T>[];
|
|
80
|
+
export {};
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// The one place a URL is derived (spec 2026-09-05 §1, §4). Pure and
|
|
2
|
+
// framework-free: both store adapters and the tree query call these, so
|
|
3
|
+
// the `paths` index and every derived `path` value agree by construction.
|
|
4
|
+
export const SEGMENT_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
5
|
+
export const isSegment = (s) => SEGMENT_RE.test(s);
|
|
6
|
+
export function assertSegment(s, what) {
|
|
7
|
+
if (typeof s !== "string" || !isSegment(s)) {
|
|
8
|
+
throw new Error(`smoodly: ${what} "${s}" is not a valid slug — use lowercase letters, digits and single hyphens.`);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/** A per-locale segment map: a string applies to every supported locale; an object is copied as declared. */
|
|
12
|
+
export function perLocaleSegments(value, supported) {
|
|
13
|
+
return typeof value === "string" ? Object.fromEntries(supported.map((l) => [l, value])) : { ...value };
|
|
14
|
+
}
|
|
15
|
+
export const joinPath = (segments) => (segments.length === 0 ? "/" : "/" + segments.join("/"));
|
|
16
|
+
export const splitPath = (path) => path.split("/").filter((s) => s.length > 0);
|
|
17
|
+
/** The locale's own segment, or the default locale's (per-field fallback). */
|
|
18
|
+
export function segmentFor(node, locale, set) {
|
|
19
|
+
if (locale === set.default)
|
|
20
|
+
return node.slug;
|
|
21
|
+
const own = node.i18n?.[locale]?.slug;
|
|
22
|
+
return typeof own === "string" && own.length > 0 ? own : node.slug;
|
|
23
|
+
}
|
|
24
|
+
/** null = every supported locale; otherwise the intersection. */
|
|
25
|
+
export function localesOf(node, set) {
|
|
26
|
+
if (!node.locales)
|
|
27
|
+
return [...set.supported];
|
|
28
|
+
return set.supported.filter((l) => node.locales.includes(l));
|
|
29
|
+
}
|
|
30
|
+
export function chainOf(node, byId) {
|
|
31
|
+
const chain = [node];
|
|
32
|
+
let current = node;
|
|
33
|
+
while (current.parentId !== null) {
|
|
34
|
+
const parent = byId(current.parentId);
|
|
35
|
+
if (!parent)
|
|
36
|
+
throw new Error(`smoodly: broken parent chain at "${node.id}".`);
|
|
37
|
+
chain.unshift(parent);
|
|
38
|
+
current = parent;
|
|
39
|
+
}
|
|
40
|
+
return chain;
|
|
41
|
+
}
|
|
42
|
+
export function assertDepth(depth, max, what) {
|
|
43
|
+
if (depth > max)
|
|
44
|
+
throw new Error(`smoodly: ${what} would exceed the tree depth (${max}).`);
|
|
45
|
+
}
|
|
46
|
+
const isHome = (node, homePageSlug) => node.parentId === null && node.slug === homePageSlug;
|
|
47
|
+
/** The node's path in one locale: `/` for the home node, else the
|
|
48
|
+
* localized ancestor chain joined. THE derivation — pagePathRows,
|
|
49
|
+
* buildPageTree and the site layer all call this. */
|
|
50
|
+
export function pagePath(node, byId, locale, opts) {
|
|
51
|
+
return isHome(node, opts.homePageSlug)
|
|
52
|
+
? "/"
|
|
53
|
+
: joinPath(chainOf(node, byId).map((n) => segmentFor(n, locale, opts.locales)));
|
|
54
|
+
}
|
|
55
|
+
export function pagePathRows(targets, byId, opts) {
|
|
56
|
+
const rows = [];
|
|
57
|
+
for (const target of targets) {
|
|
58
|
+
for (const locale of localesOf(target, opts.locales)) {
|
|
59
|
+
rows.push({ kind: "page", id: target.id, locale, path: pagePath(target, byId, locale, opts) });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return rows;
|
|
63
|
+
}
|
|
64
|
+
export function collectionSegment(path, locale, set) {
|
|
65
|
+
if (path === undefined)
|
|
66
|
+
return null;
|
|
67
|
+
if (typeof path === "string")
|
|
68
|
+
return path;
|
|
69
|
+
return path[locale] ?? path[set.default] ?? null;
|
|
70
|
+
}
|
|
71
|
+
/** The "claims a URL" predicate (§1): a slug is only a real segment when
|
|
72
|
+
* it is a non-empty string — absent, null, or `""` all mean "no URL".
|
|
73
|
+
* The sibling index predicate in sql.ts (`entries_sibling_slug_key`)
|
|
74
|
+
* mirrors this rule exactly, so Postgres and memory agree on which
|
|
75
|
+
* entries can collide. */
|
|
76
|
+
export const slugOf = (fields) => {
|
|
77
|
+
const slug = fields.slug;
|
|
78
|
+
return typeof slug === "string" && slug.length > 0 ? slug : null;
|
|
79
|
+
};
|
|
80
|
+
export function entrySegment(entry, locale, set) {
|
|
81
|
+
const own = locale === set.default ? undefined : entry.i18n?.[locale]?.slug;
|
|
82
|
+
return typeof own === "string" && own.length > 0 ? own : slugOf(entry.fields);
|
|
83
|
+
}
|
|
84
|
+
export function entryPathRows(targets, byId, collection, set) {
|
|
85
|
+
const rows = [];
|
|
86
|
+
for (const target of targets) {
|
|
87
|
+
const chain = chainOf(target, byId);
|
|
88
|
+
for (const locale of localesOf(target, set)) {
|
|
89
|
+
const base = collectionSegment(collection.path, locale, set);
|
|
90
|
+
if (base === null)
|
|
91
|
+
continue;
|
|
92
|
+
const segments = chain.map((e) => entrySegment(e, locale, set));
|
|
93
|
+
if (segments.some((s) => s === null))
|
|
94
|
+
continue;
|
|
95
|
+
rows.push({ kind: "entry", id: target.id, locale, path: joinPath([base, ...segments]) });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return rows;
|
|
99
|
+
}
|
|
100
|
+
export function buildPageTree(records, locale, opts) {
|
|
101
|
+
const byId = (id) => records.find((r) => r.id === id);
|
|
102
|
+
const build = (parentId) => records
|
|
103
|
+
.filter((r) => r.parentId === parentId)
|
|
104
|
+
.map((r) => ({
|
|
105
|
+
record: r,
|
|
106
|
+
path: pagePath(r, byId, locale, opts),
|
|
107
|
+
children: build(r.id),
|
|
108
|
+
}));
|
|
109
|
+
return build(null);
|
|
110
|
+
}
|
package/dist/render.d.ts
CHANGED
|
@@ -16,6 +16,34 @@ export type PageTree = {
|
|
|
16
16
|
zones: Record<string, TreeNode[]>;
|
|
17
17
|
meta?: Record<string, unknown>;
|
|
18
18
|
};
|
|
19
|
+
/** Where a rendered page sits in the tree — what a view needs for
|
|
20
|
+
* breadcrumbs and sub-navigation without a query (spec 2026-09-05 §5).
|
|
21
|
+
* `path` is the locale-relative CMS path (what the content API returns);
|
|
22
|
+
* `url` is that path through the app's `href` seam — `/fi/yritys` under
|
|
23
|
+
* next-intl, `/yritys` without it — so a page shape links the same way
|
|
24
|
+
* in both layouts. */
|
|
25
|
+
export type PageLink = {
|
|
26
|
+
title: string;
|
|
27
|
+
path: string;
|
|
28
|
+
hasPage: boolean;
|
|
29
|
+
};
|
|
30
|
+
export type PageContextLink = PageLink & {
|
|
31
|
+
url: string;
|
|
32
|
+
};
|
|
33
|
+
export type PageContext = {
|
|
34
|
+
id: string;
|
|
35
|
+
title: string;
|
|
36
|
+
path: string;
|
|
37
|
+
url: string;
|
|
38
|
+
/** Root first, parent last. */
|
|
39
|
+
ancestors: ReadonlyArray<PageContextLink>;
|
|
40
|
+
children: ReadonlyArray<PageContextLink>;
|
|
41
|
+
};
|
|
42
|
+
/** The `page` prop of a view rendered outside the site (a test, a preview
|
|
43
|
+
* of a shape): a real object, so a view never guards an optional prop, but
|
|
44
|
+
* no position in the tree. Frozen: one singleton shared by every such
|
|
45
|
+
* render, so a view that pushed onto it would leak into the next. */
|
|
46
|
+
export declare const EMPTY_PAGE_CONTEXT: PageContext;
|
|
19
47
|
type AnyPaired = ComponentType<any> & {
|
|
20
48
|
schema: {
|
|
21
49
|
name: string;
|
|
@@ -30,19 +58,20 @@ export type ZoneContent = {
|
|
|
30
58
|
export declare function Zone({ of: content }: {
|
|
31
59
|
of: ZoneContent;
|
|
32
60
|
}): import("react").JSX.Element | null;
|
|
33
|
-
export type
|
|
61
|
+
export type PageViewProps<Zones extends Record<string, unknown>> = {
|
|
34
62
|
zones: Record<keyof Zones & string, ZoneContent>;
|
|
35
|
-
|
|
63
|
+
page: PageContext;
|
|
64
|
+
};
|
|
65
|
+
export type PairedPage<Zones extends Record<string, unknown>> = ((props: PageViewProps<Zones>) => ReactElement) & {
|
|
36
66
|
schema: PageSchema<Zones>;
|
|
37
67
|
};
|
|
38
|
-
export declare function page<Zones extends Record<string, unknown>>(schema: PageSchema<Zones>, View: ComponentType<
|
|
39
|
-
zones: Record<keyof Zones & string, ZoneContent>;
|
|
40
|
-
}>): PairedPage<Zones>;
|
|
68
|
+
export declare function page<Zones extends Record<string, unknown>>(schema: PageSchema<Zones>, View: ComponentType<PageViewProps<Zones>>): PairedPage<Zones>;
|
|
41
69
|
export type Registry = {
|
|
42
70
|
sections?: AnyPaired[];
|
|
43
71
|
elements?: AnyPaired[];
|
|
44
72
|
};
|
|
45
73
|
export declare function renderPage<Zones extends Record<string, unknown>>(Page: PairedPage<Zones>, tree: PageTree, registry: Registry, options?: {
|
|
46
74
|
editor?: boolean;
|
|
75
|
+
page?: PageContext;
|
|
47
76
|
}): ReactElement;
|
|
48
77
|
export {};
|
package/dist/render.js
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
/** The `page` prop of a view rendered outside the site (a test, a preview
|
|
3
|
+
* of a shape): a real object, so a view never guards an optional prop, but
|
|
4
|
+
* no position in the tree. Frozen: one singleton shared by every such
|
|
5
|
+
* render, so a view that pushed onto it would leak into the next. */
|
|
6
|
+
export const EMPTY_PAGE_CONTEXT = Object.freeze({
|
|
7
|
+
id: "", title: "", path: "/", url: "/", ancestors: Object.freeze([]), children: Object.freeze([]),
|
|
8
|
+
});
|
|
2
9
|
export function Zone({ of: content }) {
|
|
3
10
|
// A malformed tree (bad data, a client that skipped isPageTree) must not
|
|
4
11
|
// crash the render — treat a non-array nodes as empty rather than throw.
|
|
@@ -23,7 +30,7 @@ export function Zone({ of: content }) {
|
|
|
23
30
|
export function page(schema, View) {
|
|
24
31
|
const Component = ((props) => {
|
|
25
32
|
const V = View;
|
|
26
|
-
return _jsx(V, { zones: props.zones });
|
|
33
|
+
return _jsx(V, { zones: props.zones, page: props.page });
|
|
27
34
|
});
|
|
28
35
|
Component.schema = schema;
|
|
29
36
|
return Component;
|
|
@@ -37,5 +44,5 @@ export function renderPage(Page, tree, registry, options) {
|
|
|
37
44
|
zone,
|
|
38
45
|
{ name: zone, nodes: tree.zones?.[zone] ?? [], components, editor: options?.editor ?? false },
|
|
39
46
|
]));
|
|
40
|
-
return _jsx(Page, { zones: zones });
|
|
47
|
+
return _jsx(Page, { zones: zones, page: options?.page ?? EMPTY_PAGE_CONTEXT });
|
|
41
48
|
}
|
package/dist/resolve.d.ts
CHANGED
|
@@ -19,4 +19,12 @@ type ResolveOptions = {
|
|
|
19
19
|
maxDepth?: number;
|
|
20
20
|
};
|
|
21
21
|
export declare function resolveTree(tree: PageTree, options: ResolveOptions): Promise<PageTree>;
|
|
22
|
+
export type ResolveFieldsOptions = {
|
|
23
|
+
collections: CollectionSchema<any>[];
|
|
24
|
+
fetch: EntryFetcher;
|
|
25
|
+
maxDepth?: number;
|
|
26
|
+
};
|
|
27
|
+
/** One entry's fields, refs hydrated — the entry page's counterpart of
|
|
28
|
+
* resolveTree. Same rules, same batching, same cycle cut. */
|
|
29
|
+
export declare function resolveFields(fields: Record<string, unknown>, descriptors: Record<string, Descriptor>, options: ResolveFieldsOptions): Promise<Record<string, unknown>>;
|
|
22
30
|
export {};
|