smoodly 0.0.33 → 0.0.35
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 +845 -1301
- package/dist/addresses.d.ts +42 -0
- package/dist/addresses.js +51 -0
- package/dist/admin/editor/EditorView.js +14 -5
- package/dist/admin/editor/PageSettings.js +2 -1
- package/dist/admin/editor/autosave.d.ts +6 -1
- package/dist/admin/editor/autosave.js +43 -3
- package/dist/admin/editor/useCanvasBridge.js +25 -2
- package/dist/admin/fixed-nodes.d.ts +9 -24
- package/dist/admin/fixed-nodes.js +36 -72
- package/dist/admin/forms/list.js +10 -1
- package/dist/admin/health.js +9 -0
- package/dist/admin/next/create-admin.d.ts +4 -3
- package/dist/admin/next/create-admin.js +14 -3
- package/dist/admin/ops-impl.js +121 -12
- package/dist/admin/redirect-path.d.ts +3 -0
- package/dist/admin/redirect-path.js +9 -0
- package/dist/admin/serialize.d.ts +3 -0
- package/dist/admin/serialize.js +1 -0
- package/dist/admin/shell/AddRedirectModal.js +2 -2
- package/dist/admin/shell/EntryForm.js +3 -2
- package/dist/admin/shell/GlobalForm.js +3 -2
- package/dist/admin/shell/PagesList.js +29 -12
- package/dist/admin/shell/live-url.d.ts +3 -1
- package/dist/admin/shell/live-url.js +5 -2
- package/dist/admin/shell/pages-tree.d.ts +19 -14
- package/dist/admin/shell/pages-tree.js +49 -16
- package/dist/admin/shell/router.d.ts +5 -0
- package/dist/admin/shell/router.js +13 -0
- package/dist/admin/ui/theme.js +1 -0
- package/dist/anchors.d.ts +42 -0
- package/dist/anchors.js +72 -0
- package/dist/cli/export.js +3 -1
- package/dist/cli/import.d.ts +1 -0
- package/dist/cli/import.js +13 -2
- package/dist/cli/space-io.d.ts +1 -2
- package/dist/cli/space-io.js +5 -5
- package/dist/collections.js +5 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +53 -18
- package/dist/current-space.d.ts +4 -0
- package/dist/current-space.js +9 -0
- package/dist/entry-store.js +4 -2
- package/dist/env.d.ts +4 -3
- package/dist/env.js +15 -17
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/next/canvas-slot.d.ts +7 -0
- package/dist/next/canvas-slot.js +9 -0
- package/dist/next/index.d.ts +2 -0
- package/dist/next/index.js +2 -0
- package/dist/next/page-renderer.d.ts +2 -0
- package/dist/next/page-renderer.js +14 -10
- package/dist/next/preview-pill.d.ts +9 -0
- package/dist/next/preview-pill.js +56 -0
- package/dist/next/preview-route.d.ts +1 -0
- package/dist/next/preview-route.js +30 -0
- package/dist/path-index.d.ts +11 -0
- package/dist/path-index.js +52 -25
- package/dist/paths.d.ts +25 -12
- package/dist/paths.js +59 -21
- package/dist/schema-version.d.ts +1 -1
- package/dist/schema-version.js +1 -1
- package/dist/site.d.ts +7 -3
- package/dist/site.js +26 -15
- package/dist/space-transfer.d.ts +2 -0
- package/dist/space-transfer.js +8 -1
- package/dist/sql-space.d.ts +14 -0
- package/dist/sql-space.js +51 -23
- package/dist/sql.d.ts +5 -0
- package/dist/sql.js +51 -9
- package/dist/store.d.ts +15 -1
- package/dist/store.js +27 -6
- package/dist/supabase-entry-store.d.ts +0 -1
- package/dist/supabase-entry-store.js +6 -11
- package/dist/supabase-path-index.d.ts +12 -1
- package/dist/supabase-path-index.js +56 -19
- package/dist/supabase-store.d.ts +2 -0
- package/dist/supabase-store.js +30 -9
- package/migrations/20260929120000_smoodly_v5.sql +56 -0
- package/migrations/20260929135523_smoodly_v6.sql +200 -0
- package/package.json +1 -5
package/dist/env.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readCurrentSpace } from "./current-space.ts";
|
|
1
2
|
export type SmoodlyEnv = {
|
|
2
3
|
/** The content project. */
|
|
3
4
|
url: string;
|
|
@@ -20,6 +21,6 @@ export type ContentConfig = {
|
|
|
20
21
|
key: string;
|
|
21
22
|
spaceId: string;
|
|
22
23
|
};
|
|
23
|
-
/** The
|
|
24
|
-
|
|
25
|
-
export declare function publicContentConfig(env: SmoodlyEnv): ContentConfig
|
|
24
|
+
/** The public config, with the space the database says the secret key acts in —
|
|
25
|
+
* asked, never read out of the key, so it cannot disagree with where rows land. */
|
|
26
|
+
export declare function publicContentConfig(env: SmoodlyEnv, db?: Parameters<typeof readCurrentSpace>[0]): Promise<ContentConfig>;
|
package/dist/env.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// The one config shape (spec 2026-09-04 §4/§6). Self-host sets three
|
|
2
2
|
// names; the cloud sets five. The two defaults are what keep a single
|
|
3
3
|
// code path: a default for an absent value is not a mode branch.
|
|
4
|
-
import {
|
|
4
|
+
import { createSmoodlyClient } from "./client.js";
|
|
5
|
+
import { readCurrentSpace } from "./current-space.js";
|
|
5
6
|
const REQUIRED = ["SMOODLY_URL", "SMOODLY_SECRET_KEY", "SMOODLY_PUBLISHABLE_KEY"];
|
|
6
7
|
export function resolveSmoodlyEnv(env) {
|
|
7
8
|
const missing = REQUIRED.filter((name) => !env[name]);
|
|
@@ -21,21 +22,18 @@ export function resolveSmoodlyEnv(env) {
|
|
|
21
22
|
/** `resolveSmoodlyEnv(process.env)` — call lazily, never at module load,
|
|
22
23
|
* so `next build` without env still succeeds. */
|
|
23
24
|
export const smoodlyEnv = () => resolveSmoodlyEnv(process.env);
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
25
|
+
/** One lookup per process per content project and key: the space never
|
|
26
|
+
* changes for a key. A failed lookup is not cached, so the next call retries. */
|
|
27
|
+
const spaces = new Map();
|
|
28
|
+
/** The public config, with the space the database says the secret key acts in —
|
|
29
|
+
* asked, never read out of the key, so it cannot disagree with where rows land. */
|
|
30
|
+
export function publicContentConfig(env, db) {
|
|
31
|
+
const cacheKey = `${env.url}\n${env.secretKey}`;
|
|
32
|
+
let space = spaces.get(cacheKey);
|
|
33
|
+
if (!space) {
|
|
34
|
+
space = readCurrentSpace(db ?? createSmoodlyClient(env));
|
|
35
|
+
spaces.set(cacheKey, space);
|
|
36
|
+
space.catch(() => spaces.delete(cacheKey));
|
|
34
37
|
}
|
|
35
|
-
|
|
36
|
-
return DEFAULT_SPACE_ID;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
export function publicContentConfig(env) {
|
|
40
|
-
return { url: env.url, key: env.publishableKey, spaceId: spaceIdOfKey(env.secretKey) };
|
|
38
|
+
return space.then((spaceId) => ({ url: env.url, key: env.publishableKey, spaceId }));
|
|
41
39
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -60,6 +60,8 @@ export type { EntryFetcher, ResolveFieldsOptions, AssetFetcher } from "./resolve
|
|
|
60
60
|
export { SEGMENT_RE, isSegment, assertSegment, slugify, joinPath, splitPath, segmentFor, isHomeNode, pagePathIn, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, assertSupportedLocale, unsupportedLocale, pageAlternates, entryAlternates, } from "./paths.ts";
|
|
61
61
|
export type { LocaleSet, PathTarget, PathRow, PageLike, EntryLike, PageTreeNode, PagePathOptions, LocaleAlternate, PageAlternate } from "./paths.ts";
|
|
62
62
|
export { fixedPathFor } from "./localize.ts";
|
|
63
|
+
export { addressMap, assertAddress, codeAddresses, collectionKey, lastPart, ownerCollection, pageKey, type Addresses, type CodeAddress } from "./addresses.ts";
|
|
64
|
+
export { anchorUrl, anchorsFor, candidates, fullPath, pickHit, type AnchorKey, type Anchors } from "./anchors.ts";
|
|
63
65
|
export type { PageStore, PageRecord, PageLocale, PageVersion, PageScan, PageStoreOptions, CreatePageInput, AddLocaleOptions } from "./store.ts";
|
|
64
66
|
export { pathOptions, pageErrors, localeRow, pageRefEdges, DEFAULT_LOCALES } from "./store.ts";
|
|
65
67
|
export type { SmoodlyConfig, ResolvedConfig } from "./config.ts";
|
|
@@ -155,7 +157,7 @@ export declare const smoodly: {
|
|
|
155
157
|
};
|
|
156
158
|
export { NODE_ATTR, ZONE_ATTR, EMPTY_ATTR, BLANK_ATTR, FIELD_ATTR, ITEM_ATTR, EDITOR_ATTR, resolveMarkPath } from "./marks.ts";
|
|
157
159
|
export type { MarkNode, MarkPath, Marks, Scope } from "./marks.ts";
|
|
158
|
-
export { resolveSmoodlyEnv, smoodlyEnv, publicContentConfig
|
|
160
|
+
export { resolveSmoodlyEnv, smoodlyEnv, publicContentConfig } from "./env.ts";
|
|
159
161
|
export type { SmoodlyEnv, ContentConfig } from "./env.ts";
|
|
160
162
|
export { clientOptions, createSmoodlyClient } from "./client.ts";
|
|
161
163
|
export { createSmoodlySite, pageContextOf } from "./site.ts";
|
package/dist/index.js
CHANGED
|
@@ -43,6 +43,8 @@ export { affectedTargets, pageTag, entryTag, globalTag, pagesTreeTag, collection
|
|
|
43
43
|
export { resolveTree, resolveFields, resolveMedia } from "./resolve.js";
|
|
44
44
|
export { SEGMENT_RE, isSegment, assertSegment, slugify, joinPath, splitPath, segmentFor, isHomeNode, pagePathIn, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, assertSupportedLocale, unsupportedLocale, pageAlternates, entryAlternates, } from "./paths.js";
|
|
45
45
|
export { fixedPathFor } from "./localize.js";
|
|
46
|
+
export { addressMap, assertAddress, codeAddresses, collectionKey, lastPart, ownerCollection, pageKey } from "./addresses.js";
|
|
47
|
+
export { anchorUrl, anchorsFor, candidates, fullPath, pickHit } from "./anchors.js";
|
|
46
48
|
export { pathOptions, pageErrors, localeRow, pageRefEdges, DEFAULT_LOCALES } from "./store.js";
|
|
47
49
|
export { DEFAULT_TOOLSET } from "./toolset.js";
|
|
48
50
|
export { collectionOrder, collectionDepth, DEFAULT_ORDER } from "./collections.js";
|
|
@@ -64,6 +66,6 @@ export const smoodly = {
|
|
|
64
66
|
marks,
|
|
65
67
|
};
|
|
66
68
|
export { NODE_ATTR, ZONE_ATTR, EMPTY_ATTR, BLANK_ATTR, FIELD_ATTR, ITEM_ATTR, EDITOR_ATTR, resolveMarkPath } from "./marks.js";
|
|
67
|
-
export { resolveSmoodlyEnv, smoodlyEnv, publicContentConfig
|
|
69
|
+
export { resolveSmoodlyEnv, smoodlyEnv, publicContentConfig } from "./env.js";
|
|
68
70
|
export { clientOptions, createSmoodlyClient } from "./client.js";
|
|
69
71
|
export { createSmoodlySite, pageContextOf } from "./site.js";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// The one Smoodly client module a live page carries (spec 2026-09-29
|
|
3
|
+
// pre-install fixes §1). Next ships every client module a route's server
|
|
4
|
+
// graph imports, rendered or not; this stub is that module, and the bridge
|
|
5
|
+
// and the canvas net load from it lazily — only when the renderer is in
|
|
6
|
+
// editor mode, so a live page downloads neither.
|
|
7
|
+
import dynamic from "next/dynamic";
|
|
8
|
+
export const CanvasBridgeSlot = dynamic(() => import("../admin/next/bridge.js").then((m) => m.EditorBridge));
|
|
9
|
+
export const CanvasNetSlot = dynamic(() => import("./canvas-boundary.js").then((m) => m.CanvasBoundary));
|
package/dist/next/index.d.ts
CHANGED
|
@@ -3,3 +3,5 @@ export type { PageRendererOptions, PathRenderOptions, PageRegistration } from ".
|
|
|
3
3
|
export { metadataFrom } from "./meta.ts";
|
|
4
4
|
export { createSmoodlyQueries } from "./queries.ts";
|
|
5
5
|
export type { SmoodlyQueries, QueryOptions, EntriesQuery, TypedEntryItem, GlobalValue } from "./queries.ts";
|
|
6
|
+
export { previewRoute } from "./preview-route.ts";
|
|
7
|
+
export { previewPill } from "./preview-pill.tsx";
|
package/dist/next/index.js
CHANGED
|
@@ -30,6 +30,8 @@ export type PageRendererOptions = {
|
|
|
30
30
|
/** Locale-relative CMS path → site URL. Default: the path itself. With
|
|
31
31
|
* next-intl: `(path, locale) => getPathname({ locale, href: path })`. */
|
|
32
32
|
href?: (path: string, locale: string) => string;
|
|
33
|
+
/** Where the preview pill's Exit goes (spec 2026-09-29 pre-install fixes §2): the route that exports `previewRoute`. Default `/api/preview`. */
|
|
34
|
+
previewRoute?: string;
|
|
33
35
|
};
|
|
34
36
|
export type PathRenderOptions = {
|
|
35
37
|
locale?: string;
|
|
@@ -1,29 +1,30 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
// The site-route seam (spec 2026-09-05 §5): the two route files call
|
|
3
3
|
// renderSmoodlyPath; everything else — request gating, draft mode, the
|
|
4
|
-
// cache keyed by (locale, path) and tagged by id, 404s, the editor
|
|
5
|
-
// bridge — lives here, over the
|
|
6
|
-
// the app, exactly like
|
|
4
|
+
// cache keyed by (locale, path, target) and tagged by id, 404s, the editor
|
|
5
|
+
// bridge (through the lazy canvas stub) — lives here, over the
|
|
6
|
+
// framework-free site layer. Bound once by the app, exactly like
|
|
7
|
+
// createAdminOpHandler.
|
|
7
8
|
import { Suspense } from "react";
|
|
8
9
|
import { unstable_cache } from "next/cache";
|
|
9
10
|
import { draftMode } from "next/headers";
|
|
10
11
|
import { notFound, permanentRedirect, unstable_rethrow } from "next/navigation";
|
|
11
12
|
import { connection } from "next/server";
|
|
12
|
-
import {
|
|
13
|
-
import { CanvasBoundary } from "./canvas-boundary.js";
|
|
13
|
+
import { CanvasBridgeSlot, CanvasNetSlot } from "./canvas-slot.js";
|
|
14
14
|
import { DRAFT_META_NAME } from "../admin/editor/canvas-diagnosis.js";
|
|
15
15
|
import { fixedPathFor } from "../localize.js";
|
|
16
|
-
import { joinPath } from "../paths.js";
|
|
16
|
+
import { decodeSegment, joinPath } from "../paths.js";
|
|
17
17
|
import { renderPage } from "../render.js";
|
|
18
18
|
import { entryTag, pageTag, globalTag } from "../revalidate.js";
|
|
19
19
|
import { pageContextOf } from "../site.js";
|
|
20
20
|
import { metadataFrom } from "./meta.js";
|
|
21
|
+
import { previewPill } from "./preview-pill.js";
|
|
21
22
|
/** Logged once per renderer when Next's cacheComponents is on (spec 2026-09-27 cache components §2). */
|
|
22
23
|
export const CACHE_COMPONENTS_WARNING = "smoodly: Next's cacheComponents is on, and Smoodly does not support it — a missing page answers 200 with noindex instead of 404, and a redirect answers 200 instead of 308. Turn cacheComponents off in next.config.ts; the Next.js guide's \"Cache Components\" section says why (https://smoodly.io/docs/frameworks/nextjs).";
|
|
23
24
|
/** The canvas net (spec 2026-09-27 section guard §5): Server Components hand a section that failed to the
|
|
24
25
|
* browser, where CanvasBoundary catches it. Defined here, not in the client file: a function exported
|
|
25
26
|
* from a "use client" module is a reference on the server and cannot be called. */
|
|
26
|
-
const canvasWrap = (child, section) => (_jsx(Suspense, { fallback: null, children: _jsx(
|
|
27
|
+
const canvasWrap = (child, section) => (_jsx(Suspense, { fallback: null, children: _jsx(CanvasNetSlot, { id: section.id, title: section.title, children: child }) }));
|
|
27
28
|
export function createPageRenderer(options) {
|
|
28
29
|
const { site } = options;
|
|
29
30
|
const href = options.href ?? ((path) => path);
|
|
@@ -34,7 +35,10 @@ export function createPageRenderer(options) {
|
|
|
34
35
|
async function load(target, locale, path, draft, version) {
|
|
35
36
|
if (draft)
|
|
36
37
|
return site.load(target, locale, path, { draft: true, ...(version !== undefined ? { version } : {}) });
|
|
37
|
-
|
|
38
|
+
// Keyed by what the unit holds, not only where: an address that changes
|
|
39
|
+
// owner — a deploy handing it to a code record, which fires no tag —
|
|
40
|
+
// can never serve the previous occupant (spec 2026-09-29 pre-install fixes §5).
|
|
41
|
+
return unstable_cache(() => site.load(target, locale, path), ["smoodly-site", locale, path, target.kind, target.id], {
|
|
38
42
|
tags: [target.kind === "page" ? pageTag(target.id) : entryTag(target.id)],
|
|
39
43
|
})();
|
|
40
44
|
}
|
|
@@ -115,7 +119,7 @@ export function createPageRenderer(options) {
|
|
|
115
119
|
// The draft marker tells the page editor's canvas diagnosis that Smoodly
|
|
116
120
|
// rendered this response (spec 2026-09-27 shadowed-page detector §3);
|
|
117
121
|
// React hoists the meta into the head. Never outside draft mode.
|
|
118
|
-
return (_jsxs(_Fragment, { children: [draft ? _jsx("meta", { name: DRAFT_META_NAME }) : null, editor ? _jsx(
|
|
122
|
+
return (_jsxs(_Fragment, { children: [draft ? _jsx("meta", { name: DRAFT_META_NAME }) : null, editor ? _jsx(CanvasBridgeSlot, {}) : null, draft && !editor && !version ? previewPill({ route: options.previewRoute }) : null, render(hit, editor, locale)] }));
|
|
119
123
|
}
|
|
120
124
|
async function metadata(pathFor, opts, accept) {
|
|
121
125
|
// generateMetadata runs alongside the page render, so it gates too.
|
|
@@ -137,7 +141,7 @@ export function createPageRenderer(options) {
|
|
|
137
141
|
/** Resolved AFTER connection(), inside page()/metadata(), so a wiring
|
|
138
142
|
* error surfaces as a rejected promise like every other failure. */
|
|
139
143
|
const fixedPath = (Page) => (locale) => fixedPathFor(Page.schema, locale, site.locales, site.homePageSlug);
|
|
140
|
-
const literal = (segments) => () => joinPath(segments);
|
|
144
|
+
const literal = (segments) => () => joinPath(segments.map(decodeSegment));
|
|
141
145
|
/** A global item for a layout: cached per (item, locale) under the
|
|
142
146
|
* global tag outside draft mode, the live draft inside it. */
|
|
143
147
|
async function getSmoodlyGlobal(item, opts) {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type ReactElement } from "react";
|
|
2
|
+
/** The pill's whole client side. `closed` lives in the closure, so a hidden
|
|
3
|
+
* pill stays hidden across client navigations and returns on a reload. */
|
|
4
|
+
export declare const PREVIEW_PILL_SCRIPT: string;
|
|
5
|
+
/** The pill for a route that does not go through the renderer: render it
|
|
6
|
+
* when `(await draftMode()).isEnabled`, never in the editor's canvas. */
|
|
7
|
+
export declare function previewPill(opts?: {
|
|
8
|
+
route?: string;
|
|
9
|
+
}): ReactElement;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// The preview pill (spec 2026-09-29 pre-install fixes §2): while draft mode
|
|
3
|
+
// is on, outside the editor's canvas, a floating pill says the page shows
|
|
4
|
+
// unpublished changes and offers Exit preview. It is an inline script
|
|
5
|
+
// defining a custom element with its styles in a Shadow DOM — no React,
|
|
6
|
+
// no client module, never in the site's bundles (the live-page rule, §1).
|
|
7
|
+
// The renderer emits it only in draft mode, so a live page's HTML is
|
|
8
|
+
// unchanged. A strict Content-Security-Policy blocks the inline script;
|
|
9
|
+
// the Next.js guide says so. The script is a constant: the route rides on
|
|
10
|
+
// the element, so nothing is interpolated into code.
|
|
11
|
+
import { createElement } from "react";
|
|
12
|
+
const CSS = ":host{all:initial}:host([hidden]){display:none}" +
|
|
13
|
+
".pill{position:fixed;bottom:16px;z-index:2147483647;display:flex;align-items:center;gap:8px;padding:6px 6px 6px 14px;" +
|
|
14
|
+
"border-radius:999px;background:#151727;color:#fff;font:500 13px/1.2 system-ui,-apple-system,sans-serif;" +
|
|
15
|
+
"box-shadow:0 4px 16px rgba(0,0,0,.25);cursor:grab;user-select:none;touch-action:none}" +
|
|
16
|
+
".pill[data-pos=left]{left:16px}.pill[data-pos=center]{left:50%;transform:translateX(-50%)}.pill[data-pos=right]{right:16px}" +
|
|
17
|
+
".pill.dragging{cursor:grabbing}" +
|
|
18
|
+
"a{color:#151727;background:#fff;border-radius:999px;padding:5px 10px;text-decoration:none;font-weight:600}" +
|
|
19
|
+
"button{all:unset;cursor:pointer;width:24px;height:24px;border-radius:999px;display:grid;place-items:center;color:#fff;opacity:.7}" +
|
|
20
|
+
"button:hover,button:focus-visible{opacity:1;outline:2px solid #fff}";
|
|
21
|
+
const HTML = '<div class="pill" role="region" aria-label="Smoodly preview">' +
|
|
22
|
+
"<span>Preview \\u00b7 unpublished changes</span>" +
|
|
23
|
+
'<a href="#">Exit preview</a>' +
|
|
24
|
+
'<button type="button" aria-label="Hide until reload">\\u00d7</button>' +
|
|
25
|
+
"</div>";
|
|
26
|
+
/** The pill's whole client side. `closed` lives in the closure, so a hidden
|
|
27
|
+
* pill stays hidden across client navigations and returns on a reload. */
|
|
28
|
+
export const PREVIEW_PILL_SCRIPT = "(function(){" +
|
|
29
|
+
'if(customElements.get("smoodly-preview-pill"))return;' +
|
|
30
|
+
'var KEY="smoodly-pill-position",closed=false;' +
|
|
31
|
+
'function load(){try{var v=localStorage.getItem(KEY);return v==="left"||v==="right"?v:"center"}catch(e){return"center"}}' +
|
|
32
|
+
"function save(v){try{localStorage.setItem(KEY,v)}catch(e){}}" +
|
|
33
|
+
"class Pill extends HTMLElement{connectedCallback(){" +
|
|
34
|
+
"if(closed){this.hidden=true;return}" +
|
|
35
|
+
"if(this.shadowRoot)return;" +
|
|
36
|
+
'var root=this.attachShadow({mode:"open"}),self=this;' +
|
|
37
|
+
`root.innerHTML="<style>${CSS}</style>${HTML.replace(/"/g, '\\"')}";` +
|
|
38
|
+
'var pill=root.querySelector(".pill"),link=root.querySelector("a"),close=root.querySelector("button");' +
|
|
39
|
+
'pill.setAttribute("data-pos",load());' +
|
|
40
|
+
'link.addEventListener("click",function(e){e.preventDefault();var route=self.getAttribute("data-route")||"/api/preview";' +
|
|
41
|
+
'location.href=route+(route.indexOf("?")<0?"?":"&")+"disable&path="+encodeURIComponent(location.pathname+location.search)});' +
|
|
42
|
+
'close.addEventListener("click",function(){closed=true;self.hidden=true});' +
|
|
43
|
+
"var start=null;" +
|
|
44
|
+
'pill.addEventListener("pointerdown",function(e){if(e.target.closest("a,button"))return;start=e.clientX;pill.setPointerCapture(e.pointerId);pill.classList.add("dragging")});' +
|
|
45
|
+
'pill.addEventListener("pointermove",function(e){if(start!==null)pill.style.translate=(e.clientX-start)+"px 0"});' +
|
|
46
|
+
'pill.addEventListener("pointerup",function(e){if(start===null)return;start=null;pill.classList.remove("dragging");pill.style.translate="";' +
|
|
47
|
+
'var x=e.clientX/innerWidth,pos=x<1/3?"left":x>2/3?"right":"center";pill.setAttribute("data-pos",pos);save(pos)});' +
|
|
48
|
+
'pill.addEventListener("pointercancel",function(){if(start===null)return;start=null;pill.classList.remove("dragging");pill.style.translate=""});' +
|
|
49
|
+
"}}" +
|
|
50
|
+
'customElements.define("smoodly-preview-pill",Pill);' +
|
|
51
|
+
"})();";
|
|
52
|
+
/** The pill for a route that does not go through the renderer: render it
|
|
53
|
+
* when `(await draftMode()).isEnabled`, never in the editor's canvas. */
|
|
54
|
+
export function previewPill(opts) {
|
|
55
|
+
return (_jsxs(_Fragment, { children: [_jsx("script", { dangerouslySetInnerHTML: { __html: PREVIEW_PILL_SCRIPT } }), createElement("smoodly-preview-pill", { "data-route": opts?.route ?? "/api/preview" })] }));
|
|
56
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function previewRoute(request: Request): Promise<Response>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// The draft-mode switch every starter mounts at app/api/preview/route.ts
|
|
2
|
+
// (spec 2026-09-29 pre-install fixes §2). Turning drafts OFF is harmless,
|
|
3
|
+
// so `?disable` needs no secret — the preview pill's Exit links here.
|
|
4
|
+
// Turning them ON needs SMOODLY_PREVIEW_SECRET and fails closed when it is
|
|
5
|
+
// unset. Either way it returns to a same-origin path.
|
|
6
|
+
import { draftMode } from "next/headers";
|
|
7
|
+
import { redirect } from "next/navigation";
|
|
8
|
+
export async function previewRoute(request) {
|
|
9
|
+
const url = new URL(request.url);
|
|
10
|
+
const draft = await draftMode();
|
|
11
|
+
if (url.searchParams.has("disable")) {
|
|
12
|
+
draft.disable();
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
const secret = process.env.SMOODLY_PREVIEW_SECRET;
|
|
16
|
+
if (!secret || url.searchParams.get("secret") !== secret)
|
|
17
|
+
return new Response("Invalid secret", { status: 401 });
|
|
18
|
+
draft.enable();
|
|
19
|
+
}
|
|
20
|
+
redirect(sameOriginPath(url.searchParams.get("path") ?? "/", url));
|
|
21
|
+
}
|
|
22
|
+
/** The path as this origin resolves it, or "/" when it would leave the site. A browser's URL parser strips
|
|
23
|
+
* tab, CR and LF before it resolves a Location, so "/\t/evil.test" is "//evil.test" to it: resolve the
|
|
24
|
+
* way it does and compare origins, and refuse control characters and backslashes outright. */
|
|
25
|
+
function sameOriginPath(raw, base) {
|
|
26
|
+
if (!raw.startsWith("/") || /[\u0000-\u001f\u007f\\]/.test(raw))
|
|
27
|
+
return "/";
|
|
28
|
+
const to = new URL(raw, base);
|
|
29
|
+
return to.origin === base.origin ? to.pathname + to.search + to.hash : "/";
|
|
30
|
+
}
|
package/dist/path-index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { PathRow, PathTarget } from "./paths.ts";
|
|
2
|
+
import { type Anchors } from "./anchors.ts";
|
|
2
3
|
export type { PathRow, PathTarget } from "./paths.ts";
|
|
3
4
|
/** A rewrite target; `redirectLocales` names the locales whose old live rows stay as redirects (none = drop them). */
|
|
4
5
|
export type RewriteTarget = PathTarget & {
|
|
@@ -27,14 +28,24 @@ export interface PathIndex {
|
|
|
27
28
|
export declare const pathTakenError: (locale: string, path: string) => Error;
|
|
28
29
|
export declare const pathLiveError: (locale: string, path: string) => Error;
|
|
29
30
|
export declare class MemoryPathIndex implements PathIndex {
|
|
31
|
+
private anchors;
|
|
30
32
|
private rows;
|
|
33
|
+
/** Held by reference: the config's addresses, read on every call (spec 2026-09-29 anchored paths §2). */
|
|
34
|
+
constructor(anchors?: Anchors);
|
|
35
|
+
/** A stored row as callers see it: the full URL, or nothing when its anchor has no address. */
|
|
36
|
+
private shown;
|
|
37
|
+
private shownAll;
|
|
31
38
|
rewrite(targets: RewriteTarget[], rows: PathRow[]): Promise<void>;
|
|
32
39
|
remove(targets: PathTarget[]): Promise<void>;
|
|
40
|
+
private hit;
|
|
33
41
|
resolve(locale: string, path: string): Promise<PathHit | null>;
|
|
34
42
|
pathsOf(target: PathTarget): Promise<Record<string, string>>;
|
|
35
43
|
redirectsOf(target: PathTarget): Promise<PathRow[]>;
|
|
44
|
+
/** Every live URL in the locale; an editor's row under a code address is left out, since visitors never reach it (spec §4). */
|
|
36
45
|
listLive(locale: string): Promise<PathRow[]>;
|
|
37
46
|
listRedirects(locale: string): Promise<PathRow[]>;
|
|
47
|
+
/** A redirect by hand stays absolute (anchor ''): refused where the URL resolves live; a redirect there is replaced. */
|
|
38
48
|
addRedirect(row: PathRow): Promise<void>;
|
|
49
|
+
/** Deletes the redirect rows a URL reaches, under any candidate anchor; a live row there is left alone. */
|
|
39
50
|
removeRedirect(locale: string, path: string): Promise<void>;
|
|
40
51
|
}
|
package/dist/path-index.js
CHANGED
|
@@ -1,66 +1,93 @@
|
|
|
1
|
+
import { candidates, fullPath, pickHit, sameUrlKeys } from "./anchors.js";
|
|
1
2
|
const sameTarget = (a, b) => a.kind === b.kind && a.id === b.id;
|
|
2
|
-
const key = (locale, path) => `${locale} ${path}`;
|
|
3
3
|
export const pathTakenError = (locale, path) => new Error(`smoodly: the path "${path}" (${locale}) is already taken.`);
|
|
4
4
|
export const pathLiveError = (locale, path) => new Error(`smoodly: "${path}" (${locale}) is a live address.`);
|
|
5
|
-
const
|
|
5
|
+
const key = (locale, anchor, path) => `${locale} ${anchor} ${path}`;
|
|
6
|
+
const stored = (r) => ({ kind: r.kind, id: r.id, locale: r.locale, anchor: r.anchor ?? "", path: r.path, redirect: false });
|
|
6
7
|
export class MemoryPathIndex {
|
|
7
|
-
|
|
8
|
+
/** Held by reference: the config's addresses, read on every call (spec 2026-09-29 anchored paths §2). */
|
|
9
|
+
constructor(anchors = { addresses: {}, homePageSlug: "home", defaultLocale: "en" }) {
|
|
10
|
+
this.anchors = anchors;
|
|
8
11
|
this.rows = [];
|
|
9
12
|
}
|
|
13
|
+
/** A stored row as callers see it: the full URL, or nothing when its anchor has no address. */
|
|
14
|
+
shown(r) {
|
|
15
|
+
const path = fullPath(this.anchors, r.anchor, r.path, r.locale);
|
|
16
|
+
return path === null ? null : { kind: r.kind, id: r.id, locale: r.locale, path };
|
|
17
|
+
}
|
|
18
|
+
shownAll(rows) {
|
|
19
|
+
return rows.map((r) => this.shown(r)).filter((r) => r !== null);
|
|
20
|
+
}
|
|
10
21
|
async rewrite(targets, rows) {
|
|
22
|
+
const incoming = rows.map(stored);
|
|
11
23
|
const ofTarget = (r) => targets.find((t) => sameTarget(t, r));
|
|
12
|
-
|
|
24
|
+
// Every key a new row's full URL answers to: live wins a redirect there under any anchor (a redirect by
|
|
25
|
+
// hand has none), and an old row at the same full URL is the same address, not an old one.
|
|
26
|
+
const newKeys = new Set(incoming.flatMap((r) => sameUrlKeys(this.anchors, r.locale, r.anchor, r.path).map((k) => key(r.locale, k.anchor, k.path))));
|
|
13
27
|
// 1. redirect rows at the new live addresses go, whoever they point at (live wins, rename-back reclaims)
|
|
14
|
-
let kept = this.rows.filter((r) => !(r.redirect && newKeys.has(key(r.locale, r.path))));
|
|
28
|
+
let kept = this.rows.filter((r) => !(r.redirect && newKeys.has(key(r.locale, r.anchor, r.path))));
|
|
15
29
|
// 2. the targets' old live rows: flipped to redirects in the named locales unless the address stays live
|
|
16
30
|
kept = kept.map((r) => {
|
|
17
31
|
const t = !r.redirect ? ofTarget(r) : undefined;
|
|
18
|
-
return t && t.redirectLocales?.includes(r.locale) && !newKeys.has(key(r.locale, r.path)) ? { ...r, redirect: true } : r;
|
|
32
|
+
return t && t.redirectLocales?.includes(r.locale) && !newKeys.has(key(r.locale, r.anchor, r.path)) ? { ...r, redirect: true } : r;
|
|
19
33
|
});
|
|
20
|
-
// 3. the targets' remaining live rows go — and so do their redirect rows in a locale the
|
|
21
|
-
|
|
22
|
-
const liveLocales = new Set(rows.map((r) => `${r.kind} ${r.id} ${r.locale}`));
|
|
34
|
+
// 3. the targets' remaining live rows go — and so do their redirect rows in a locale the target has no new live row in
|
|
35
|
+
const liveLocales = new Set(incoming.map((r) => `${r.kind} ${r.id} ${r.locale}`));
|
|
23
36
|
kept = kept.filter((r) => !ofTarget(r) || (r.redirect && liveLocales.has(`${r.kind} ${r.id} ${r.locale}`)));
|
|
24
|
-
// 4. the new live rows: a collision now is with another target's live row
|
|
25
|
-
const taken = new Set(kept.map((r) => key(r.locale, r.path)));
|
|
26
|
-
for (const
|
|
27
|
-
const k = key(
|
|
37
|
+
// 4. the new live rows: a collision now is with another target's live row under the same anchor
|
|
38
|
+
const taken = new Set(kept.map((r) => key(r.locale, r.anchor, r.path)));
|
|
39
|
+
for (const r of incoming) {
|
|
40
|
+
const k = key(r.locale, r.anchor, r.path);
|
|
28
41
|
if (taken.has(k))
|
|
29
|
-
throw pathTakenError(
|
|
42
|
+
throw pathTakenError(r.locale, fullPath(this.anchors, r.anchor, r.path, r.locale) ?? r.path);
|
|
30
43
|
taken.add(k);
|
|
31
44
|
}
|
|
32
|
-
this.rows = [...kept, ...
|
|
45
|
+
this.rows = [...kept, ...incoming];
|
|
33
46
|
}
|
|
34
47
|
async remove(targets) {
|
|
35
48
|
this.rows = this.rows.filter((r) => !targets.some((t) => sameTarget(t, r)));
|
|
36
49
|
}
|
|
50
|
+
hit(locale, url) {
|
|
51
|
+
const { keys, coded } = candidates(this.anchors, locale, url);
|
|
52
|
+
return pickHit(this.rows.filter((r) => r.locale === locale), keys, coded);
|
|
53
|
+
}
|
|
37
54
|
async resolve(locale, path) {
|
|
38
|
-
const row = this.
|
|
55
|
+
const row = this.hit(locale, path);
|
|
39
56
|
return row ? { kind: row.kind, id: row.id, redirect: row.redirect } : null;
|
|
40
57
|
}
|
|
41
58
|
async pathsOf(target) {
|
|
42
59
|
const out = {};
|
|
43
|
-
for (const r of this.rows)
|
|
44
|
-
|
|
45
|
-
out[r.locale] = r.path;
|
|
60
|
+
for (const r of this.shownAll(this.rows.filter((r) => !r.redirect && sameTarget(r, target))))
|
|
61
|
+
out[r.locale] = r.path;
|
|
46
62
|
return out;
|
|
47
63
|
}
|
|
48
64
|
async redirectsOf(target) {
|
|
49
|
-
return this.rows.filter((r) => r.redirect && sameTarget(r, target))
|
|
65
|
+
return this.shownAll(this.rows.filter((r) => r.redirect && sameTarget(r, target)));
|
|
50
66
|
}
|
|
67
|
+
/** Every live URL in the locale; an editor's row under a code address is left out, since visitors never reach it (spec §4). */
|
|
51
68
|
async listLive(locale) {
|
|
52
|
-
|
|
69
|
+
const served = (r) => !(r.anchor === "" && candidates(this.anchors, locale, r.path).coded);
|
|
70
|
+
return this.shownAll(this.rows.filter((r) => !r.redirect && r.locale === locale && served(r))).sort((a, b) => a.path.localeCompare(b.path));
|
|
53
71
|
}
|
|
54
72
|
async listRedirects(locale) {
|
|
55
|
-
return this.rows.filter((r) => r.redirect && r.locale === locale).
|
|
73
|
+
return this.shownAll(this.rows.filter((r) => r.redirect && r.locale === locale)).sort((a, b) => a.path.localeCompare(b.path));
|
|
56
74
|
}
|
|
75
|
+
/** A redirect by hand stays absolute (anchor ''): refused where the URL resolves live; a redirect there is replaced. */
|
|
57
76
|
async addRedirect(row) {
|
|
58
|
-
const existing = this.
|
|
77
|
+
const existing = this.hit(row.locale, row.path);
|
|
59
78
|
if (existing && !existing.redirect)
|
|
60
79
|
throw pathLiveError(row.locale, row.path);
|
|
61
|
-
|
|
80
|
+
// An editor's page hidden under a code address still holds its row: a redirect never stacks on it.
|
|
81
|
+
if (this.rows.some((r) => !r.redirect && r.locale === row.locale && r.anchor === "" && r.path === row.path))
|
|
82
|
+
throw pathLiveError(row.locale, row.path);
|
|
83
|
+
// Every redirect the URL reaches goes, under any anchor: the new one replaces them all.
|
|
84
|
+
const { keys } = candidates(this.anchors, row.locale, row.path);
|
|
85
|
+
const replaced = (r) => r.redirect && r.locale === row.locale && keys.some((k) => k.anchor === r.anchor && k.path === r.path);
|
|
86
|
+
this.rows = [...this.rows.filter((r) => !replaced(r)), { ...stored({ ...row, anchor: "" }), redirect: true }];
|
|
62
87
|
}
|
|
88
|
+
/** Deletes the redirect rows a URL reaches, under any candidate anchor; a live row there is left alone. */
|
|
63
89
|
async removeRedirect(locale, path) {
|
|
64
|
-
|
|
90
|
+
const { keys } = candidates(this.anchors, locale, path);
|
|
91
|
+
this.rows = this.rows.filter((r) => !(r.redirect && r.locale === locale && keys.some((k) => k.anchor === r.anchor && k.path === r.path)));
|
|
65
92
|
}
|
|
66
93
|
}
|
package/dist/paths.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Addresses } from "./addresses.ts";
|
|
1
2
|
export declare const SEGMENT_RE: RegExp;
|
|
2
3
|
export declare const isSegment: (s: string) => boolean;
|
|
3
4
|
export declare function assertSegment(s: string, what: string): void;
|
|
@@ -24,18 +25,28 @@ export declare function perLocaleSegments(value: string | Record<string, string>
|
|
|
24
25
|
* (spec 2026-09-06 §3). The boot collision check (config.ts), the
|
|
25
26
|
* materializer (admin/fixed-nodes.ts) and the fixed path derivation
|
|
26
27
|
* (localize.ts) all fill through here, or a collision passes boot and
|
|
27
|
-
* only surfaces at materialization. Collection
|
|
28
|
-
*
|
|
28
|
+
* only surfaces at materialization. Collection paths fill the same way
|
|
29
|
+
* (addresses.ts): their entries take the default path in a locale the
|
|
30
|
+
* path omits (collectionSegment), so the address is claimed there too. */
|
|
29
31
|
export declare function fixedPageSegments(slug: string | Record<string, string>, locales: LocaleSet): Record<string, string>;
|
|
30
32
|
export type PathTarget = {
|
|
31
33
|
kind: "page" | "entry";
|
|
32
34
|
id: string;
|
|
33
35
|
};
|
|
36
|
+
/** A URL row: `anchor` names the code address it hangs under (spec 2026-09-29 anchored paths §1), absent = the editors' tree;
|
|
37
|
+
* `path` is the part editors made. Read back from the index, `path` is the full URL and `anchor` is absent. */
|
|
34
38
|
export type PathRow = PathTarget & {
|
|
35
39
|
locale: string;
|
|
36
40
|
path: string;
|
|
41
|
+
anchor?: string;
|
|
37
42
|
};
|
|
38
43
|
export declare const joinPath: (segments: string[]) => string;
|
|
44
|
+
/** A request's segment or a typed address's, as stored: percent-decoded and NFC-composed, so
|
|
45
|
+
* a pasted `%C3%A4`, a typed `ä` and a decomposed `a\u0308` are one address. Next hands the
|
|
46
|
+
* catch-all its segments still encoded. A stray `%` that is no escape stays as it is, and so
|
|
47
|
+
* does a segment holding an encoded slash: decoded, it would become two segments and make
|
|
48
|
+
* `/a%2Fb` an alias of `/a/b`. Slugs are ASCII, so this changes nothing a slug path holds. */
|
|
49
|
+
export declare function decodeSegment(segment: string): string;
|
|
39
50
|
export declare const splitPath: (path: string) => string[];
|
|
40
51
|
/** The node's segment in one locale. An absent locale is an error, never
|
|
41
52
|
* a fallback: the stores' parent rule guarantees a present node has a
|
|
@@ -49,6 +60,8 @@ export declare function assertDepth(depth: number, max: number, what: string): v
|
|
|
49
60
|
export type PageLike = {
|
|
50
61
|
id: string;
|
|
51
62
|
parentId: string | null;
|
|
63
|
+
/** The owner key when code owns the record (spec 2026-09-29 nested paths §3); absent or null = an editor's page. */
|
|
64
|
+
fixed?: string | null;
|
|
52
65
|
/** The locales the node exists in, each with the node's OWN segment there. Presence IS the key (spec 2026-09-06 §3). */
|
|
53
66
|
locales: Record<string, {
|
|
54
67
|
slug: string;
|
|
@@ -57,18 +70,22 @@ export type PageLike = {
|
|
|
57
70
|
export type PagePathOptions = {
|
|
58
71
|
locales: LocaleSet;
|
|
59
72
|
homePageSlug: string;
|
|
73
|
+
addresses?: Addresses;
|
|
60
74
|
};
|
|
61
75
|
/** Home is judged by identity: the root node whose DEFAULT-locale slug is
|
|
62
76
|
* homePageSlug serves "/" in every locale it exists in. */
|
|
63
77
|
export declare const isHomeNode: (node: PageLike, opts: PagePathOptions) => boolean;
|
|
64
78
|
/** The node's path in one locale: `/` for the home node, else the
|
|
65
|
-
* locale's ancestor chain joined.
|
|
79
|
+
* locale's ancestor chain joined. A code-owned root's path is its
|
|
80
|
+
* address (spec 2026-09-29). THE derivation — pagePathRows,
|
|
66
81
|
* buildPageTree and the site layer all call this. */
|
|
67
82
|
export declare function pagePath(node: PageLike, byId: (id: string) => PageLike | undefined, locale: string, opts: PagePathOptions): string;
|
|
68
83
|
/** The same path, or null when the node or an ancestor is absent from the
|
|
69
|
-
* locale — what the Pages list shows as a dimmed "not in fi" row
|
|
84
|
+
* locale — what the Pages list shows as a dimmed "not in fi" row — or
|
|
85
|
+
* when its code-owned root has no address there. */
|
|
70
86
|
export declare function pagePathIn(node: PageLike, byId: (id: string) => PageLike | undefined, locale: string, opts: PagePathOptions): string | null;
|
|
71
|
-
/** One row per locale the target EXISTS in
|
|
87
|
+
/** One row per locale the target EXISTS in (spec 2026-09-29 anchored paths §3): under a code-owned root, the root's
|
|
88
|
+
* owner key and the chain below it (`''` for the root itself); in the editors' tree, no anchor and the full path. */
|
|
72
89
|
export declare function pagePathRows(targets: PageLike[], byId: (id: string) => PageLike | undefined, opts: PagePathOptions): PathRow[];
|
|
73
90
|
export type EntryLike = {
|
|
74
91
|
id: string;
|
|
@@ -91,13 +108,9 @@ export declare const slugOf: (fields: Record<string, unknown>) => string | null;
|
|
|
91
108
|
/** The entry's segment in one locale: its row's slug. An absent locale
|
|
92
109
|
* or a slug-less row is null — no URL there, never a fallback. */
|
|
93
110
|
export declare function entrySegment(entry: EntryLike, locale: string): string | null;
|
|
94
|
-
/** One row per locale the target EXISTS in, where the whole chain has a
|
|
95
|
-
*
|
|
96
|
-
|
|
97
|
-
* simply yields no URL for the subtree in that locale. */
|
|
98
|
-
export declare function entryPathRows(targets: EntryLike[], byId: (id: string) => EntryLike | undefined, collection: {
|
|
99
|
-
path?: string | Record<string, string>;
|
|
100
|
-
}, set: LocaleSet): PathRow[];
|
|
111
|
+
/** One row per locale the target EXISTS in, where the whole chain has a slug there, under the collection's anchor
|
|
112
|
+
* (spec 2026-09-29 anchored paths §1): written whether or not the collection has a `path`, which the index reads. */
|
|
113
|
+
export declare function entryPathRows(targets: EntryLike[], byId: (id: string) => EntryLike | undefined, collection: string): PathRow[];
|
|
101
114
|
/** The locales a node is published in — the ones whose old addresses may
|
|
102
115
|
* stay as redirects (spec 2026-09-26 redirects §2: only published rows). */
|
|
103
116
|
export declare function publishedLocales(node: {
|