smoodly 0.0.44 → 0.0.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/index.d.ts +1 -1
- package/dist/admin/ops-impl.d.ts +7 -1
- package/dist/admin/ops-impl.js +110 -33
- 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/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 +7 -1
- package/dist/cli/main.js +32 -7
- package/dist/cli/migrate.d.ts +3 -0
- package/dist/cli/migrate.js +8 -1
- 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/entry-page.d.ts +3 -18
- package/dist/env.js +1 -1
- package/dist/fields.js +2 -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 +69 -0
- package/dist/project.js +210 -0
- package/dist/published.d.ts +96 -0
- package/dist/published.js +125 -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 +38 -14
- package/dist/site.js +159 -286
- package/dist/smd-key.d.ts +9 -0
- package/dist/smd-key.js +1 -0
- package/dist/sql-space.d.ts +4 -0
- package/dist/sql-space.js +10 -5
- package/dist/sql.d.ts +2 -0
- package/dist/sql.js +117 -0
- package/dist/store.d.ts +1 -1
- package/dist/supabase-published-store.d.ts +35 -0
- package/dist/supabase-published-store.js +215 -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/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
package/dist/assemble.js
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
// The assembler (spec 2026-10-07 delivery model §3): the loaded page, entry, global, tree and entry list
|
|
2
|
+
// as the site layer reads them, split out of site.ts so the live reads and the projector that fills the
|
|
3
|
+
// delivery tables share one implementation. Framework-free and React-free, like the site layer.
|
|
4
|
+
import { ARRAY_FIELD_TYPES, entryFieldsIn, schemaOf } from "./entry-store.js";
|
|
5
|
+
import { buildPageTree, chainOf, entryAlternates, entryPathRows, pageAlternates, pagePath, } from "./paths.js";
|
|
6
|
+
import { resolveFields, resolveFieldSets, resolveMedia, resolveTree } from "./resolve.js";
|
|
7
|
+
import { elementSchemas } from "./richtext-walk.js";
|
|
8
|
+
import { globalNodeId, SETTINGS_KEY, STYLES_KEY, CSS_KEY } from "./global.js";
|
|
9
|
+
import { SMD_KEY } from "./smd-key.js";
|
|
10
|
+
import { resolveStyleValues } from "./styles.js";
|
|
11
|
+
import { resolveCustom } from "./custom/index.js";
|
|
12
|
+
import { addressMap, codeAddresses } from "./addresses.js";
|
|
13
|
+
import { fullPath, servedAnchor } from "./anchors.js";
|
|
14
|
+
import { withDefaults } from "./field-defaults.js";
|
|
15
|
+
import { toEntryItem } from "./read-shapes.js";
|
|
16
|
+
import { entrySeo, pageSeo, SEO_KEY } from "./seo.js";
|
|
17
|
+
import { schemaOf as registeredSchema } from "./registration.js";
|
|
18
|
+
/** Field types whose stored value is an object: not filterable (memory and SQL would disagree). */
|
|
19
|
+
const OBJECT_FIELD_TYPES = new Set(["image", "video", "file", "richtext", "object"]);
|
|
20
|
+
/** What the site computes from its options before any read: the registered shapes, the code-owned addresses and the resolved styles. */
|
|
21
|
+
export function siteShape(options) {
|
|
22
|
+
const locales = options.locales;
|
|
23
|
+
const pageSchemas = options.pages.map((p) => registeredSchema(p));
|
|
24
|
+
const homePageSlug = options.homePageSlug ?? "home";
|
|
25
|
+
const styles = resolveStyleValues(options.styles);
|
|
26
|
+
const custom = resolveCustom(options.custom);
|
|
27
|
+
const elements = elementSchemas(options.elements);
|
|
28
|
+
// Code-owned addresses from the site's own registry (spec 2026-09-29 nested paths §3): a code-owned root's path.
|
|
29
|
+
const codeOwned = codeAddresses({ locales, pages: pageSchemas, collections: options.collections });
|
|
30
|
+
const addresses = addressMap(codeOwned);
|
|
31
|
+
const pathOpts = { locales, homePageSlug, addresses };
|
|
32
|
+
/** The same addresses as the path index sees them: an entry's stored part joined to its collection's (spec 2026-09-29 anchored paths §2). */
|
|
33
|
+
const anchors = { addresses, homePageSlug, defaultLocale: locales.default };
|
|
34
|
+
return { locales, pageSchemas, homePageSlug, styles, custom, elements, codeOwned, addresses, pathOpts, anchors };
|
|
35
|
+
}
|
|
36
|
+
/** A template's page-field descriptors (spec 2026-09-10 page fields §3); none for a folder. */
|
|
37
|
+
export function pageFieldsOf(shape, template) {
|
|
38
|
+
if (template === null)
|
|
39
|
+
return {};
|
|
40
|
+
return shape.pageSchemas.find((x) => x.kind === "page" && x.name === template)?.fields ?? {};
|
|
41
|
+
}
|
|
42
|
+
/** The checks `getEntries` makes before any read, in draft mode and outside it: one function both paths call. */
|
|
43
|
+
export function checkEntryQuery(schema, collection, o) {
|
|
44
|
+
// A filter that silently matched nothing would be worse than an error (spec 2026-09-11 §3).
|
|
45
|
+
for (const key of Object.keys(o.where ?? {})) {
|
|
46
|
+
if (key === "slug") {
|
|
47
|
+
throw new Error(`smoodly: getEntries("${collection}"): "slug" is not filterable — where filters fields; find an entry by its path instead.`);
|
|
48
|
+
}
|
|
49
|
+
if (key === SEO_KEY)
|
|
50
|
+
throw new Error(`smoodly: getEntries("${collection}"): "${SEO_KEY}" is reserved — the SEO panel's values are not filterable.`);
|
|
51
|
+
if (!(key in schema.fields))
|
|
52
|
+
throw new Error(`smoodly: getEntries("${collection}"): "${key}" is not a field of the collection.`);
|
|
53
|
+
// The memory adapter's `===` never matches an object while SQL's `@>` matches a subset, so the two would disagree.
|
|
54
|
+
if (OBJECT_FIELD_TYPES.has(schema.fields[key].type ?? "")) {
|
|
55
|
+
throw new Error(`smoodly: getEntries("${collection}"): "${key}" holds an object — filter on a scalar field instead.`);
|
|
56
|
+
}
|
|
57
|
+
// Any-of reads the text form of the whole field (PostgREST's `->>`), which for
|
|
58
|
+
// an array is its JSON — `["a"]` never matches "a" — so refuse rather than match nothing.
|
|
59
|
+
if (Array.isArray(o.where[key]) && ARRAY_FIELD_TYPES.has(schema.fields[key].type ?? "")) {
|
|
60
|
+
throw new Error(`smoodly: getEntries("${collection}"): "${key}" is array-valued — any-of is for scalar fields; pass one value to match entries containing it.`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (o.limit !== undefined && (!Number.isInteger(o.limit) || o.limit < 0)) {
|
|
64
|
+
throw new Error(`smoodly: getEntries("${collection}"): limit must be a non-negative integer.`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** The reads a batch answers once per argument set (a projection asks the same ones for every page). */
|
|
68
|
+
const MEMOIZED = { pages: ["listPages", "fieldsOf"], entries: ["getMany", "pathsIn"], assets: ["getMany"] };
|
|
69
|
+
export function createAssembler(options, shape) {
|
|
70
|
+
const { locales, pageSchemas, styles, custom, elements, pathOpts, anchors } = shape;
|
|
71
|
+
const mounted = options.collections.filter((c) => c.path !== undefined);
|
|
72
|
+
/** The resolver's fetcher, bound to a locale: the merged fields of that
|
|
73
|
+
* locale — published ones (a versioned collection's snapshot) on the
|
|
74
|
+
* public path, the live rows in draft mode. */
|
|
75
|
+
const fetcher = (draft, locale) => (collection, ids) => options.stores().entries.getMany(collection, ids, locale, draft ? undefined : { status: "published" });
|
|
76
|
+
/** The resolver's path lookup, bound to a locale: each EntryRef's address (spec 2026-09-29 content API contract §2). */
|
|
77
|
+
const pathLookup = (locale) => (collection, ids) => options.stores().entries.pathsIn(collection, ids, locale);
|
|
78
|
+
/** The media fetcher (spec 2026-09-08 §9) — absent when the site runs without an asset store. */
|
|
79
|
+
const assetFetcher = () => {
|
|
80
|
+
const assets = options.stores().assets;
|
|
81
|
+
return assets ? (ids) => assets.getMany(ids) : undefined;
|
|
82
|
+
};
|
|
83
|
+
const sectionSettings = (name) => {
|
|
84
|
+
const s = options.sections.find((x) => ("schema" in x ? x.schema : x).name === name);
|
|
85
|
+
return (s && ("schema" in s ? s.schema : s).settings) ?? {};
|
|
86
|
+
};
|
|
87
|
+
/** Visible to the public in a locale: present there, and a folder or a
|
|
88
|
+
* published page. Draft mode sees every present node. Presence is the
|
|
89
|
+
* locale row — nothing falls back (spec 2026-09-06 §3). */
|
|
90
|
+
const visible = (node, locale, draft) => {
|
|
91
|
+
const row = node.locales[locale];
|
|
92
|
+
return row !== undefined && (draft || node.template === null || row.status === "published");
|
|
93
|
+
};
|
|
94
|
+
const hasPage = (node, locale, draft) => {
|
|
95
|
+
const row = node.locales[locale];
|
|
96
|
+
return node.template !== null && row !== undefined && (draft || row.status === "published");
|
|
97
|
+
};
|
|
98
|
+
const pageFields = (template) => pageFieldsOf(shape, template);
|
|
99
|
+
/** The version whose fields a link shows: the draft's in draft mode, else the published one. */
|
|
100
|
+
const versionFor = (node, locale, draft) => {
|
|
101
|
+
const row = node.locales[locale];
|
|
102
|
+
if (!row || node.template === null)
|
|
103
|
+
return null;
|
|
104
|
+
return draft ? row.draftVersionId : row.publishedVersionId;
|
|
105
|
+
};
|
|
106
|
+
/** Raw page fields (no asset resolution) of many nodes in ONE store read
|
|
107
|
+
* (spec 2026-09-10 page fields §3), defaults applied; `{}` for a folder. */
|
|
108
|
+
async function linkFields(nodes, locale, draft) {
|
|
109
|
+
const { pages } = options.stores();
|
|
110
|
+
const wanted = new Map(); // node id → version id
|
|
111
|
+
for (const n of nodes) {
|
|
112
|
+
const v = versionFor(n, locale, draft);
|
|
113
|
+
if (v)
|
|
114
|
+
wanted.set(n.id, v);
|
|
115
|
+
}
|
|
116
|
+
const byVersion = wanted.size > 0 ? await pages.fieldsOf([...new Set(wanted.values())]) : {};
|
|
117
|
+
const out = new Map();
|
|
118
|
+
for (const n of nodes) {
|
|
119
|
+
const v = wanted.get(n.id);
|
|
120
|
+
out.set(n.id, n.template === null ? {} : withDefaults(v ? byVersion[v] : undefined, pageFields(n.template)));
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
/** Only ever called for a node PRESENT in the locale (visible() or an ancestor of one). */
|
|
125
|
+
const link = (node, byId, locale, draft, fields) => ({
|
|
126
|
+
title: node.locales[locale].title,
|
|
127
|
+
path: pagePath(node, byId, locale, pathOpts),
|
|
128
|
+
hasPage: hasPage(node, locale, draft),
|
|
129
|
+
fields: fields.get(node.id) ?? {},
|
|
130
|
+
});
|
|
131
|
+
async function loadPage(id, locale, path, draft, version) {
|
|
132
|
+
const { pages } = options.stores();
|
|
133
|
+
const record = await pages.getPage(id);
|
|
134
|
+
if (!record || record.template === null)
|
|
135
|
+
return null;
|
|
136
|
+
const row = record.locales[locale];
|
|
137
|
+
if (!row)
|
|
138
|
+
return null;
|
|
139
|
+
let raw;
|
|
140
|
+
if (version !== undefined) {
|
|
141
|
+
// History: a stored version of THIS page in THIS locale, and only inside draft mode.
|
|
142
|
+
const stored = draft ? await pages.getVersion(version) : null;
|
|
143
|
+
raw = stored && stored.pageId === id && stored.locale === locale ? stored.tree : null;
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
raw = draft ? await pages.getDraftTree(id, locale) : row.status === "published" ? await pages.getPublishedTree(id, locale) : null;
|
|
147
|
+
}
|
|
148
|
+
if (!raw)
|
|
149
|
+
return null;
|
|
150
|
+
const tree = await resolveTree(raw, {
|
|
151
|
+
sections: options.sections,
|
|
152
|
+
collections: options.collections,
|
|
153
|
+
globals: options.globals,
|
|
154
|
+
elements: options.elements,
|
|
155
|
+
fetch: fetcher(draft, locale), paths: pathLookup(locale),
|
|
156
|
+
assets: assetFetcher(),
|
|
157
|
+
page: { fields: pageFields(record.template) },
|
|
158
|
+
custom,
|
|
159
|
+
});
|
|
160
|
+
const all = await pages.listPages();
|
|
161
|
+
const index = new Map(all.map((n) => [n.id, n]));
|
|
162
|
+
const byId = (x) => index.get(x);
|
|
163
|
+
const chain = chainOf(record, byId);
|
|
164
|
+
const kids = all.filter((n) => n.parentId === id && visible(n, locale, draft));
|
|
165
|
+
const fields = await linkFields([...chain.slice(0, -1), ...kids], locale, draft);
|
|
166
|
+
const ancestors = chain.slice(0, -1).map((n) => link(n, byId, locale, draft, fields));
|
|
167
|
+
const children = kids.map((n) => link(n, byId, locale, draft, fields));
|
|
168
|
+
// Alternates advertise PUBLISHED locales only; the editor (draft) sees every present
|
|
169
|
+
// one. Derived from the tree already listed — no index lookup (spec 2026-09-15 §2).
|
|
170
|
+
const alternates = pageAlternates(record, byId, pathOpts, (l) => draft || record.locales[l]?.status === "published");
|
|
171
|
+
const withSeo = pageSchemas.some((x) => x.kind === "page" && x.name === record.template && x.seo === true);
|
|
172
|
+
return {
|
|
173
|
+
kind: "page",
|
|
174
|
+
locale,
|
|
175
|
+
record,
|
|
176
|
+
template: record.template,
|
|
177
|
+
tree,
|
|
178
|
+
title: row.title,
|
|
179
|
+
path,
|
|
180
|
+
fields: withDefaults(tree.fields, pageFields(record.template)),
|
|
181
|
+
ancestors,
|
|
182
|
+
children,
|
|
183
|
+
locales: alternates,
|
|
184
|
+
...(withSeo ? { seo: pageSeo(tree.meta, row.title) } : {}),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
async function loadEntry(id, locale, path, draft) {
|
|
188
|
+
const { entries } = options.stores();
|
|
189
|
+
for (const schema of mounted) {
|
|
190
|
+
const record = await entries.get(schema.name, id);
|
|
191
|
+
if (!record)
|
|
192
|
+
continue;
|
|
193
|
+
// No row, or a draft row outside draft mode, is a 404 for THIS locale.
|
|
194
|
+
const row = record.locales[locale];
|
|
195
|
+
if (!row)
|
|
196
|
+
return null;
|
|
197
|
+
if (!draft && row.status !== "published")
|
|
198
|
+
return null;
|
|
199
|
+
const raw = draft
|
|
200
|
+
? entryFieldsIn(record, locale)
|
|
201
|
+
: (await entries.getMany(schema.name, [id], locale, { status: "published" }))[id];
|
|
202
|
+
if (!raw)
|
|
203
|
+
return null;
|
|
204
|
+
const fields = await resolveFields(raw, schema.fields, { collections: options.collections, elements: options.elements, fetch: fetcher(draft, locale), paths: pathLookup(locale), assets: assetFetcher() });
|
|
205
|
+
// Alternates advertise PUBLISHED locales only; the editor (draft) sees every present one.
|
|
206
|
+
// One record is in memory (a nested entry's parents are not), so the `paths` index
|
|
207
|
+
// supplies the paths — the same single lookup as before (spec 2026-09-15 §2).
|
|
208
|
+
const known = await entries.pathsOf(schema.name, id);
|
|
209
|
+
const alternates = entryAlternates(record, locales, (l) => draft || record.locales[l]?.status === "published", (l) => known[l] ?? null);
|
|
210
|
+
return {
|
|
211
|
+
kind: "entry", locale, collection: schema.name, entry: { ...record, fields }, path, locales: alternates,
|
|
212
|
+
...(schema.seo ? { seo: entrySeo(fields, schema) } : {}),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
const localeOf = (o) => o?.locale ?? locales.default;
|
|
218
|
+
async function readEntries(collection, o = {}) {
|
|
219
|
+
const schema = schemaOf(options.collections, collection);
|
|
220
|
+
const locale = localeOf(o);
|
|
221
|
+
const draft = o.draft ?? false;
|
|
222
|
+
const { entries } = options.stores();
|
|
223
|
+
checkEntryQuery(schema, collection, o);
|
|
224
|
+
// The store filters, orders and limits over the snapshot shown (spec 2026-09-29 content API contract §5): the
|
|
225
|
+
// locale gate — presence in draft mode, published otherwise — is part of the query, so `limit` counts rows that are really there.
|
|
226
|
+
const rows = await entries.query(collection, {
|
|
227
|
+
locale, draft, where: o.where, order: o.order, limit: o.limit, ids: o.ids,
|
|
228
|
+
...(o.parent !== undefined ? { parent: o.parent } : {}),
|
|
229
|
+
});
|
|
230
|
+
// Paths need every ancestor: a tree collection reads the whole collection for that, a flat one does not.
|
|
231
|
+
const all = schema.tree ? await entries.list(collection) : rows.map((r) => r.record);
|
|
232
|
+
// References and media resolve for the whole list in one pass (§2, §6).
|
|
233
|
+
const sets = rows.map((r) => ({ values: structuredClone(r.fields), descriptors: schema.fields }));
|
|
234
|
+
await resolveFieldSets(sets, { collections: options.collections, elements: options.elements, fetch: fetcher(draft, locale), paths: pathLookup(locale), assets: assetFetcher() });
|
|
235
|
+
const index = new Map(all.map((e) => [e.id, e]));
|
|
236
|
+
const byId = (x) => index.get(x);
|
|
237
|
+
return rows.map(({ record }, i) => {
|
|
238
|
+
const paths = entryPathRows([record], byId, schema.name);
|
|
239
|
+
const pathIn = (l) => {
|
|
240
|
+
const row = paths.find((r) => r.locale === l);
|
|
241
|
+
return row ? fullPath(anchors, row.anchor ?? "", row.path, l) : null;
|
|
242
|
+
};
|
|
243
|
+
const item = toEntryItem(record, sets[i].values, {
|
|
244
|
+
path: pathIn(locale),
|
|
245
|
+
locale,
|
|
246
|
+
locales: entryAlternates(record, locales, (l) => draft || record.locales[l]?.status === "published", pathIn),
|
|
247
|
+
...(schema.seo ? { seo: entrySeo(sets[i].values, schema) } : {}),
|
|
248
|
+
});
|
|
249
|
+
return { record, item };
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
/** A node the public tree leaves out, with its subtree: a record no longer in code (no path, spec
|
|
253
|
+
* 2026-09-29 nested paths §5), and a page where another code address wins, which visitors never
|
|
254
|
+
* reach (anchored paths §4). */
|
|
255
|
+
const hiddenAt = (path, record, byId, locale) => path === null || servedAnchor(anchors, locale, path) !== (chainOf(record, byId)[0].fixed ?? "");
|
|
256
|
+
async function tree(locale, { draft }) {
|
|
257
|
+
const all = await options.stores().pages.listPages();
|
|
258
|
+
const shown = all.filter((n) => visible(n, locale, draft));
|
|
259
|
+
const index = new Map(shown.map((n) => [n.id, n]));
|
|
260
|
+
const fields = await linkFields(shown, locale, draft);
|
|
261
|
+
// Every node the walk reaches is present with a present chain.
|
|
262
|
+
const hidden = (node) => hiddenAt(node.path, node.record, (x) => index.get(x), locale);
|
|
263
|
+
const toItems = (nodes) => nodes.flatMap((node) => hidden(node) ? [] : [{
|
|
264
|
+
id: node.record.id,
|
|
265
|
+
title: node.record.locales[locale].title,
|
|
266
|
+
path: node.path,
|
|
267
|
+
hasPage: hasPage(node.record, locale, draft),
|
|
268
|
+
fields: fields.get(node.record.id) ?? {},
|
|
269
|
+
children: toItems(node.children),
|
|
270
|
+
}]);
|
|
271
|
+
return toItems(buildPageTree(shown, locale, pathOpts));
|
|
272
|
+
}
|
|
273
|
+
async function treeRow(record, all, locale) {
|
|
274
|
+
const index = new Map(all.map((n) => [n.id, n]));
|
|
275
|
+
const byId = (x) => index.get(x);
|
|
276
|
+
const path = pagePath(record, byId, locale, pathOpts);
|
|
277
|
+
const fields = await linkFields([record], locale, false);
|
|
278
|
+
return {
|
|
279
|
+
id: record.id,
|
|
280
|
+
locale,
|
|
281
|
+
parentId: record.parentId,
|
|
282
|
+
sort: record.sort,
|
|
283
|
+
path: hiddenAt(path, record, byId, locale) ? null : path,
|
|
284
|
+
title: record.locales[locale].title,
|
|
285
|
+
hasPage: hasPage(record, locale, false),
|
|
286
|
+
linkFields: fields.get(record.id) ?? {},
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
async function global(item, locale, { draft }) {
|
|
290
|
+
const { entries } = options.stores();
|
|
291
|
+
const [record] = await entries.list(item.name);
|
|
292
|
+
if (!record)
|
|
293
|
+
return null;
|
|
294
|
+
const row = record.locales[locale];
|
|
295
|
+
if (!row || (!draft && row.status !== "published"))
|
|
296
|
+
return null;
|
|
297
|
+
const raw = draft
|
|
298
|
+
? entryFieldsIn(record, locale)
|
|
299
|
+
: (await entries.getMany(item.name, [record.id], locale, { status: "published" }))[record.id];
|
|
300
|
+
if (!raw)
|
|
301
|
+
return null;
|
|
302
|
+
const fields = await resolveFields(raw, item.fields, { collections: options.collections, elements: options.elements, fetch: fetcher(draft, locale), paths: pathLookup(locale), assets: assetFetcher() });
|
|
303
|
+
if (!item.section)
|
|
304
|
+
return { locale, fields };
|
|
305
|
+
const { [SETTINGS_KEY]: settings, [STYLES_KEY]: steps, [CSS_KEY]: css, ...rest } = fields;
|
|
306
|
+
const fetch = assetFetcher();
|
|
307
|
+
const own = settings !== null && typeof settings === "object" ? settings : {};
|
|
308
|
+
if (fetch)
|
|
309
|
+
await resolveMedia(own, sectionSettings(item.section), fetch, elements);
|
|
310
|
+
// The layout node id, the css and the values ride the styles under the reserved key (spec 2026-09-13 §1, 2026-09-14 §2, §4):
|
|
311
|
+
// the wrapper strips it and emits data-smd-node, so the canvas selects the item and the css reaches it.
|
|
312
|
+
const stepsObject = steps !== null && typeof steps === "object" ? steps : {};
|
|
313
|
+
return {
|
|
314
|
+
locale,
|
|
315
|
+
fields: rest,
|
|
316
|
+
settings: own,
|
|
317
|
+
styles: { ...stepsObject, [SMD_KEY]: { node: globalNodeId(item.name), css: typeof css === "string" ? css : undefined, values: styles } },
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
/** A copy whose reads in MEMOIZED answer once per argument set — the promise is shared, so concurrent
|
|
321
|
+
* identical calls make one store call. For the lifetime of the copy only; the base assembler never caches. */
|
|
322
|
+
function batch(primed) {
|
|
323
|
+
const memo = new Map();
|
|
324
|
+
if (primed?.pages)
|
|
325
|
+
memo.set(`pages.listPages:${JSON.stringify([])}`, Promise.resolve(primed.pages));
|
|
326
|
+
const wrap = (store, group) => new Proxy(store, {
|
|
327
|
+
get(target, prop) {
|
|
328
|
+
const value = Reflect.get(target, prop, target);
|
|
329
|
+
if (typeof value !== "function")
|
|
330
|
+
return value;
|
|
331
|
+
if (!MEMOIZED[group].includes(prop))
|
|
332
|
+
return value.bind(target);
|
|
333
|
+
return (...args) => {
|
|
334
|
+
const key = `${group}.${String(prop)}:${JSON.stringify(args)}`;
|
|
335
|
+
let hit = memo.get(key);
|
|
336
|
+
if (!hit)
|
|
337
|
+
memo.set(key, (hit = value.apply(target, args)));
|
|
338
|
+
return hit;
|
|
339
|
+
};
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
return createAssembler({
|
|
343
|
+
...options,
|
|
344
|
+
stores: () => {
|
|
345
|
+
const s = options.stores();
|
|
346
|
+
return { ...s, pages: wrap(s.pages, "pages"), entries: wrap(s.entries, "entries"), ...(s.assets ? { assets: wrap(s.assets, "assets") } : {}) };
|
|
347
|
+
},
|
|
348
|
+
}, shape);
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
page: (id, locale, path, { draft, version }) => loadPage(id, locale, path, draft, version),
|
|
352
|
+
entry: (id, locale, path, { draft }) => loadEntry(id, locale, path, draft),
|
|
353
|
+
global,
|
|
354
|
+
tree,
|
|
355
|
+
entries: readEntries,
|
|
356
|
+
treeRow,
|
|
357
|
+
batch,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type ResponseCache = {
|
|
2
|
+
enabled?: boolean;
|
|
3
|
+
readonly options?: {
|
|
4
|
+
maxAge?: number;
|
|
5
|
+
};
|
|
6
|
+
set(input: false | {
|
|
7
|
+
maxAge?: number;
|
|
8
|
+
tags?: string[];
|
|
9
|
+
}): void;
|
|
10
|
+
invalidate(options: {
|
|
11
|
+
tags: string[];
|
|
12
|
+
}): Promise<void>;
|
|
13
|
+
};
|
|
14
|
+
/** What the middleware knows about a request when its response is whole. */
|
|
15
|
+
export type Settled = {
|
|
16
|
+
/** A verified preview. */
|
|
17
|
+
draft: boolean;
|
|
18
|
+
/** The request is under the preview address, cookie or not. */
|
|
19
|
+
preview: boolean;
|
|
20
|
+
/** The shell rendered a page read from the database and gave it a lifetime. */
|
|
21
|
+
cacheable: boolean;
|
|
22
|
+
/** When the request began, in milliseconds. */
|
|
23
|
+
started: number;
|
|
24
|
+
/** A read the response made threw (scope.ts). */
|
|
25
|
+
failed: boolean;
|
|
26
|
+
};
|
|
27
|
+
/** A cached page lives until a tag expires it: no timer hides a missing tag. One year, in seconds. */
|
|
28
|
+
export declare const MAX_AGE: number;
|
|
29
|
+
/** The shell's page, before its view renders: a view that calls `Astro.cache.set` after this has the last word. */
|
|
30
|
+
export declare function lifetime(cache: ResponseCache | undefined): void;
|
|
31
|
+
export declare function settle(cache: ResponseCache | undefined, response: {
|
|
32
|
+
status: number;
|
|
33
|
+
}, state: Settled, tags: ReadonlySet<string>): void;
|
|
34
|
+
export declare function expire(cache: ResponseCache | undefined, tags: ReadonlySet<string>): Promise<void>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** A cached page lives until a tag expires it: no timer hides a missing tag. One year, in seconds. */
|
|
2
|
+
export const MAX_AGE = 60 * 60 * 24 * 365;
|
|
3
|
+
// When each tag was last expired in this process, kept long enough to outlive any render: a page that
|
|
4
|
+
// read its content before a publish and finished after it must not be stored for a year. One map per
|
|
5
|
+
// process, however many copies of this module a bundler makes.
|
|
6
|
+
const KEY = Symbol.for("smoodly:expired-tags");
|
|
7
|
+
const host = globalThis;
|
|
8
|
+
const expiredAt = (host[KEY] ?? (host[KEY] = new Map()));
|
|
9
|
+
const REMEMBER = 10 * 60000;
|
|
10
|
+
function expiredSince(tags, started) {
|
|
11
|
+
for (const tag of tags) {
|
|
12
|
+
const at = expiredAt.get(tag);
|
|
13
|
+
if (at !== undefined && at >= started)
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
/** The shell's page, before its view renders: a view that calls `Astro.cache.set` after this has the last word. */
|
|
19
|
+
export function lifetime(cache) {
|
|
20
|
+
if (cache?.enabled)
|
|
21
|
+
cache.set({ maxAge: MAX_AGE });
|
|
22
|
+
}
|
|
23
|
+
export function settle(cache, response, state, tags) {
|
|
24
|
+
if (!cache?.enabled)
|
|
25
|
+
return;
|
|
26
|
+
// Astro looks the cache up before any cookie is checked: nothing under the preview address may be stored.
|
|
27
|
+
if (state.draft || state.preview)
|
|
28
|
+
return cache.set(false);
|
|
29
|
+
// A read failed: the page rendered without what it could not read (an empty list, no footer), and a database
|
|
30
|
+
// error must not be served for a year. A section's own throw is no failed read: that page is what the code makes.
|
|
31
|
+
if (state.failed)
|
|
32
|
+
return cache.set(false);
|
|
33
|
+
if (state.cacheable) {
|
|
34
|
+
// The view turned the cache off (`set(false)` drops the lifetime the shell gave): its decision stands.
|
|
35
|
+
if (cache.options?.maxAge === undefined)
|
|
36
|
+
return;
|
|
37
|
+
// No tag would expire it, or it is not the page: it keeps no lifetime.
|
|
38
|
+
if (response.status !== 200 || tags.size === 0)
|
|
39
|
+
return cache.set(false);
|
|
40
|
+
}
|
|
41
|
+
// Nothing of Smoodly's was read (a 404, a page served from code, a route of the developer's own): not ours to cache.
|
|
42
|
+
if (tags.size === 0)
|
|
43
|
+
return;
|
|
44
|
+
// A publish overtook this render: what it read may be the version before.
|
|
45
|
+
if (expiredSince(tags, state.started))
|
|
46
|
+
return cache.set(false);
|
|
47
|
+
// A developer's route that read Smoodly content is tagged, and cached only if it gave itself a lifetime.
|
|
48
|
+
cache.set({ tags: [...tags] });
|
|
49
|
+
}
|
|
50
|
+
export async function expire(cache, tags) {
|
|
51
|
+
if (tags.size === 0)
|
|
52
|
+
return;
|
|
53
|
+
const now = Date.now();
|
|
54
|
+
for (const [tag, at] of expiredAt)
|
|
55
|
+
if (now - at > REMEMBER)
|
|
56
|
+
expiredAt.delete(tag);
|
|
57
|
+
// Noted before the purge, so a render that settles in between is not stored either.
|
|
58
|
+
for (const tag of tags)
|
|
59
|
+
expiredAt.set(tag, now);
|
|
60
|
+
if (!cache?.enabled)
|
|
61
|
+
return;
|
|
62
|
+
try {
|
|
63
|
+
await cache.invalidate({ tags: [...tags] });
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
// The operation succeeded and the write is in the database; a cache that cannot be told stays stale until a later purge or restart.
|
|
67
|
+
console.error(`smoodly: the cache provider failed to invalidate ${[...tags].join(", ")} — the content is saved; the cached pages may be stale.`, error);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
// The custom section for Astro (spec 2026-09-14 §6; the React one is custom/CustomSection.tsx): rows of
|
|
3
|
+
// richtext columns through <Section>, so spacing, width, the node id, the CSS box and selection come free;
|
|
4
|
+
// the layout CSS rides the reserved key (custom.ts). Rows and columns carry the marks inline editing needs.
|
|
5
|
+
// The columns render through <RichText>, so the project's elements reach them with no binding step.
|
|
6
|
+
import { FIELD_ATTR, ITEM_ATTR, customLayoutOf } from "smoodly/astro/runtime";
|
|
7
|
+
import RichText from "./RichText.astro";
|
|
8
|
+
import Section from "./Section.astro";
|
|
9
|
+
|
|
10
|
+
const { rows, styles = {} } = Astro.props;
|
|
11
|
+
const layout = customLayoutOf(rows, styles);
|
|
12
|
+
---
|
|
13
|
+
<Section styles={layout.styles} data-custom="">
|
|
14
|
+
{layout.list.map((row, i) => (
|
|
15
|
+
<div class={row.reverse ? "smd-row smd-row--reverse" : "smd-row"} data-row={i} {...{ [ITEM_ATTR]: `rows.${i}` }}>
|
|
16
|
+
{row.columns.map((doc, j) => (
|
|
17
|
+
<div class="smd-col" {...{ [FIELD_ATTR]: `columns.${j}` }}><RichText doc={doc} /></div>
|
|
18
|
+
))}
|
|
19
|
+
</div>
|
|
20
|
+
))}
|
|
21
|
+
</Section>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
---
|
|
2
|
+
// The section guard for Astro (spec 2026-09-27 section guard §1, §3; DESIGN.md §3): the section renders into
|
|
3
|
+
// a string first, so a throw anywhere inside it — its own code, a nested component, an element in its
|
|
4
|
+
// richtext — is caught here and leaves the section out, not the page. `Astro.slots.render` is the seam
|
|
5
|
+
// (probed 2026-10-06: sync, async and nested throws caught; a scoped style in the slot still reaches the
|
|
6
|
+
// head). Nothing is rethrown: an Astro component cannot 404 or redirect from inside. The placeholder shows
|
|
7
|
+
// in the canvas, and under `astro dev`, so a developer never misses their own crash.
|
|
8
|
+
import { CRASHED_ATTR, NODE_ATTR, PLACEHOLDER_CSS, crashMessage } from "smoodly/astro/runtime";
|
|
9
|
+
|
|
10
|
+
const { id, type, title, editor } = Astro.props;
|
|
11
|
+
let html = "";
|
|
12
|
+
let failed = false;
|
|
13
|
+
let error: unknown;
|
|
14
|
+
try {
|
|
15
|
+
html = await Astro.slots.render("default");
|
|
16
|
+
} catch (e) {
|
|
17
|
+
failed = true;
|
|
18
|
+
error = e;
|
|
19
|
+
console.error(`smoodly: section "${type}" (node ${id}) failed to render and is left out of the page.`, e);
|
|
20
|
+
}
|
|
21
|
+
const placeholder = failed && (editor || import.meta.env.DEV);
|
|
22
|
+
---
|
|
23
|
+
{!failed && <Fragment set:html={html} />}
|
|
24
|
+
{placeholder && <div {...{ [NODE_ATTR]: id, [CRASHED_ATTR]: "section" }} style={PLACEHOLDER_CSS}>{`Section “${title}” failed to render — ${crashMessage(error)}`}</div>}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
// packages/smoodly/src/astro/components/Head.astro
|
|
3
|
+
// The document head Smoodly knows (head.ts): the title, the description, the canonical address, one hreflang
|
|
4
|
+
// link per locale the page is published in, the Open Graph values, and noindex. Next writes the same through
|
|
5
|
+
// smoodlyMetadata. Absolute addresses from `site` in astro.config, else the request's origin. A layout places
|
|
6
|
+
// it inside <head>; `fallback` titles a document Smoodly has no title for (an entry whose collection has no
|
|
7
|
+
// seo). A layout that writes its own <title> leaves `fallback` out and reads `of.title` itself.
|
|
8
|
+
const { of: head, fallback } = Astro.props;
|
|
9
|
+
const origin = (Astro.site ?? Astro.url).origin;
|
|
10
|
+
const absolute = (path: string) => new URL(path, origin).href;
|
|
11
|
+
const title = head.title ?? fallback;
|
|
12
|
+
const canonical = head.canonical ? absolute(head.canonical) : undefined;
|
|
13
|
+
const image = head.image;
|
|
14
|
+
---
|
|
15
|
+
{title && <title>{title}</title>}
|
|
16
|
+
{head.description && <meta name="description" content={head.description} />}
|
|
17
|
+
{canonical && <link rel="canonical" href={canonical} />}
|
|
18
|
+
{Object.entries(head.languages).map(([lang, path]) => <link rel="alternate" hreflang={lang} href={absolute(path as string)} />)}
|
|
19
|
+
{title && <meta property="og:title" content={title} />}
|
|
20
|
+
{head.description && <meta property="og:description" content={head.description} />}
|
|
21
|
+
{canonical && <meta property="og:url" content={canonical} />}
|
|
22
|
+
{image && <meta property="og:image" content={image.url} />}
|
|
23
|
+
{image && <meta property="og:image:width" content={String(image.width)} />}
|
|
24
|
+
{image && <meta property="og:image:height" content={String(image.height)} />}
|
|
25
|
+
{image?.alt && <meta property="og:image:alt" content={image.alt} />}
|
|
26
|
+
{head.noindex && <meta name="robots" content="noindex, nofollow" />}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
// <Image>: a box at the aspect (declared, chosen or natural), the picture inside placed by crop and hotspot,
|
|
3
|
+
// dimensions set so nothing shifts (spec 2026-09-08 media §9; the React one is image/SmoodlyImage.tsx). The
|
|
4
|
+
// bytes come through Astro's image service: `getImage` turns the media URL into the /_image endpoint with a
|
|
5
|
+
// srcset of widths, so a 4 MB upload is served at the size the page asks for. The media host is allowed by the
|
|
6
|
+
// integration (index.ts). An SVG, or `unoptimized`, is the original. Data attributes land on the box.
|
|
7
|
+
import { getImage } from "astro:assets";
|
|
8
|
+
import { imageRequest, placeImage } from "smoodly/astro/runtime";
|
|
9
|
+
|
|
10
|
+
const { image, aspect, sizes, priority, loading, alt, unoptimized, class: className, style, ...data } = Astro.props;
|
|
11
|
+
const placed = placeImage(image, { aspect });
|
|
12
|
+
const request = imageRequest(image, { sizes, unoptimized });
|
|
13
|
+
const transformed = request ? await getImage(request) : null;
|
|
14
|
+
const src = transformed?.src ?? image.url;
|
|
15
|
+
const srcset = transformed?.srcSet.attribute || undefined;
|
|
16
|
+
---
|
|
17
|
+
<span class={className} style={typeof style === "string" && style ? `${placed.box};${style}` : placed.box} {...data}>
|
|
18
|
+
<img src={src} srcset={srcset} sizes={srcset ? sizes : undefined} alt={alt ?? image.alt} width={image.width} height={image.height}
|
|
19
|
+
loading={priority ? "eager" : loading ?? "lazy"} fetchpriority={priority ? "high" : undefined} decoding="async" style={placed.frame} />
|
|
20
|
+
</span>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
// The richtext renderer for Astro (spec 2026-09-08 block nodes; spec 2026-10-02 astro cloud fit §8): TipTap
|
|
3
|
+
// JSON as the React renderer writes it, decided in richtext.ts. Text, marks and block tags come as escaped
|
|
4
|
+
// HTML runs; an image node renders through <Image>; an element node through the project's
|
|
5
|
+
// src/elements/<name>.astro, found by file name, its settings defaults filled as pair() fills them. An
|
|
6
|
+
// element with no file is left out, named on the server.
|
|
7
|
+
import { views } from "smoodly:views";
|
|
8
|
+
import { info, richtextChunks } from "smoodly/astro/runtime";
|
|
9
|
+
import Image from "./Image.astro";
|
|
10
|
+
|
|
11
|
+
const { doc, sizes } = Astro.props;
|
|
12
|
+
const chunks = richtextChunks(doc);
|
|
13
|
+
---
|
|
14
|
+
{chunks.map((chunk) => {
|
|
15
|
+
if (chunk.kind === "html") return <Fragment set:html={chunk.html} />;
|
|
16
|
+
if (chunk.kind === "image") {
|
|
17
|
+
return <figure><Image image={chunk.image} sizes={sizes} loading="lazy" />{chunk.caption !== "" && <figcaption>{chunk.caption}</figcaption>}</figure>;
|
|
18
|
+
}
|
|
19
|
+
// An own name only: `constructor` or `toString` must not find Object's members.
|
|
20
|
+
const View = Object.prototype.hasOwnProperty.call(views.elements, chunk.name) ? views.elements[chunk.name] : undefined;
|
|
21
|
+
if (!View) { console.warn(`smoodly: no src/elements/${chunk.name}.astro for element "${chunk.name}" — skipping.`); return null; }
|
|
22
|
+
return <View {...chunk.fields} settings={{ ...info.element(chunk.name).defaults, ...chunk.settings }} />;
|
|
23
|
+
})}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
// The root wrapper every section view renders through, for Astro (spec 2026-09-14 §2, §4; the React one
|
|
3
|
+
// is section-wrapper.tsx): the real root element, Smoodly's styles set inline, the node id as
|
|
4
|
+
// data-smd-node always (the custom-CSS scope needs it on the live site), the scoped style element, and
|
|
5
|
+
// every other prop spread onto the root.
|
|
6
|
+
import { DEFAULT_STYLE_VALUES, NODE_ATTR, SMD_KEY, inlineStyles, safeCss, scopedCss, styleAttribute } from "smoodly/astro/runtime";
|
|
7
|
+
|
|
8
|
+
const { as: Tag = "section", styles = {}, class: className, style, ...rest } = Astro.props;
|
|
9
|
+
const { [SMD_KEY]: smd, ...steps } = styles;
|
|
10
|
+
const own = typeof style === "object" && style ? style : {};
|
|
11
|
+
const inline = { ...own, ...inlineStyles(steps, smd?.values ?? DEFAULT_STYLE_VALUES) };
|
|
12
|
+
const styleAttr = styleAttribute(typeof style === "string" ? style : undefined, inline);
|
|
13
|
+
const css = smd?.node ? safeCss(smd.css) : "";
|
|
14
|
+
const attrs = { ...rest, ...(smd?.node ? { [NODE_ATTR]: smd.node } : {}) };
|
|
15
|
+
---
|
|
16
|
+
<Tag class={className} style={styleAttr} {...attrs}>
|
|
17
|
+
{smd?.layoutCss && <style set:html={smd.layoutCss} is:inline></style>}
|
|
18
|
+
{css && <style set:html={scopedCss(smd.node, css)} is:inline></style>}
|
|
19
|
+
<slot />
|
|
20
|
+
</Tag>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
---
|
|
2
|
+
// A <video> over the bucket's CDN with its poster and size, no transcoding (spec 2026-09-08 media §9).
|
|
3
|
+
const { video, ...rest } = Astro.props;
|
|
4
|
+
---
|
|
5
|
+
<video src={video.url} poster={video.poster?.url} width={video.width} height={video.height} playsinline {...rest}></video>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
---
|
|
2
|
+
// The zone renderer for Astro (spec 2026-10-02 astro cloud fit §8): each node of the zone through the
|
|
3
|
+
// project's own section view, found by file name (the virtual module smoodly:views), inside the guard;
|
|
4
|
+
// the custom section's view is the package's own. What to render is decided in zone.ts.
|
|
5
|
+
import { views } from "smoodly:views";
|
|
6
|
+
import { CUSTOM_NODE_TYPE, EMPTY_ATTR, NODE_ATTR, PLACEHOLDER_CSS, ZONE_ATTR, info, zoneItems } from "smoodly/astro/runtime";
|
|
7
|
+
import Custom from "./Custom.astro";
|
|
8
|
+
import Guarded from "./Guarded.astro";
|
|
9
|
+
|
|
10
|
+
const { of: content } = Astro.props;
|
|
11
|
+
const items = zoneItems(content, { registered: info.isSection, has: (type) => type === CUSTOM_NODE_TYPE || Object.hasOwn(views.sections, type), about: info.section });
|
|
12
|
+
---
|
|
13
|
+
{items.map((item) => {
|
|
14
|
+
if (item.kind === "empty") return <div {...{ [ZONE_ATTR]: item.zone, [EMPTY_ATTR]: "" }} style="min-height:48px"></div>;
|
|
15
|
+
if (item.kind === "unknown") return <div {...{ [NODE_ATTR]: item.id }} style={PLACEHOLDER_CSS}>Unknown section `{item.type}`</div>;
|
|
16
|
+
if (item.kind === "missing") return <div {...{ [NODE_ATTR]: item.id }} style={PLACEHOLDER_CSS}>No src/sections/{item.type}.astro</div>;
|
|
17
|
+
const View = item.type === CUSTOM_NODE_TYPE ? Custom : views.sections[item.type];
|
|
18
|
+
return <Guarded id={item.id} type={item.type} title={item.title} editor={content.editor}><View {...item.props} /></Guarded>;
|
|
19
|
+
})}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { default as Zone } from "./components/Zone.astro";
|
|
2
|
+
export { default as Section } from "./components/Section.astro";
|
|
3
|
+
export { default as Image } from "./components/Image.astro";
|
|
4
|
+
export { default as Video } from "./components/Video.astro";
|
|
5
|
+
export { default as RichText } from "./components/RichText.astro";
|
|
6
|
+
export { default as Head } from "./components/Head.astro";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// The Astro components a project's views import: `import { Zone, Section, Image } from "smoodly/astro/components"`.
|
|
2
|
+
export { default as Zone } from "./components/Zone.astro";
|
|
3
|
+
export { default as Section } from "./components/Section.astro";
|
|
4
|
+
export { default as Image } from "./components/Image.astro";
|
|
5
|
+
export { default as Video } from "./components/Video.astro";
|
|
6
|
+
export { default as RichText } from "./components/RichText.astro";
|
|
7
|
+
export { default as Head } from "./components/Head.astro";
|