smoodly 0.0.19 → 0.0.21
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 +1 -4
- package/dist/admin/ops-impl.js +19 -9
- package/dist/admin/shell/PagesList.js +3 -3
- package/dist/admin-path.d.ts +9 -1
- package/dist/admin-path.js +11 -3
- package/dist/config.js +13 -6
- package/dist/store.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -307,7 +307,7 @@ Implemented:
|
|
|
307
307
|
subtree plus the old and the new parent; `entries.move` expires the
|
|
308
308
|
entry and its descendants. The Pages list IS that tree — `admin/shell/pages-tree.ts`
|
|
309
309
|
is the pure view model (indent per depth, chevron on parents, the
|
|
310
|
-
derived path as the
|
|
310
|
+
derived path as the URL column, and per row its kind, status, sibling
|
|
311
311
|
position and whether it may take children), rendered by
|
|
312
312
|
`admin/shell/PagesList.tsx` with a row menu over the tree ops (New
|
|
313
313
|
subpage, New subpath, Add page here, Rename…, Delete — reordering is
|
|
@@ -497,9 +497,6 @@ after both are on the registry. From the repo root:
|
|
|
497
497
|
|
|
498
498
|
## Follow-ups (logged 2026-09-17, admin path spec)
|
|
499
499
|
|
|
500
|
-
- A root page slugged like the admin path (or `api`) is unreachable:
|
|
501
|
-
Next's static segment wins over the site catch-all. Refuse it in the
|
|
502
|
-
slug check once `config.admin.path` exists.
|
|
503
500
|
- A robots `Disallow` for the admin path from `next/meta`.
|
|
504
501
|
|
|
505
502
|
## Follow-ups (logged 2026-09-17, reference picker and tags spec)
|
package/dist/admin/ops-impl.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { entryFieldsIn, entryTitleIn } from "../entry-store.js";
|
|
2
2
|
import { distinctTags } from "../tags.js";
|
|
3
3
|
import { ASSET_COLLECTION } from "../media.js";
|
|
4
|
-
import { adminHref } from "../admin-path.js";
|
|
4
|
+
import { adminHref, adminSlugTaken, isAdminSlug } from "../admin-path.js";
|
|
5
5
|
import { affectedTargets, collectionTag, entryTag, pageTag, pagesTreeTag, globalTag } from "../revalidate.js";
|
|
6
6
|
import { ensureFixedNodes, fixedNodeOf } from "./fixed-nodes.js";
|
|
7
7
|
import { ensureGlobalItems } from "./global-items.js";
|
|
@@ -72,13 +72,13 @@ const classify = (e) => {
|
|
|
72
72
|
return err("blocked", message);
|
|
73
73
|
if (message.includes("already taken"))
|
|
74
74
|
return err("conflict", message);
|
|
75
|
-
if (message.includes("already has a
|
|
75
|
+
if (message.includes("already has a page type"))
|
|
76
76
|
return err("conflict", message);
|
|
77
77
|
if (message.includes("exceed the tree depth") || message.includes("has child"))
|
|
78
78
|
return err("blocked", message);
|
|
79
79
|
// The tree guards: editing a folder as a page, moving a node into its
|
|
80
80
|
// own subtree, and the home page's no-children invariant.
|
|
81
|
-
if (message.includes("is a
|
|
81
|
+
if (message.includes("is a path") ||
|
|
82
82
|
message.includes("cannot be moved under itself") ||
|
|
83
83
|
message.includes("cannot have child pages")) {
|
|
84
84
|
return err("blocked", message);
|
|
@@ -226,6 +226,10 @@ export function createAdminOps(deps) {
|
|
|
226
226
|
* its slug: the config's homePageSlug is what routes it. Judged by the
|
|
227
227
|
* DEFAULT locale's slug (paths.ts isHomeNode, the same rule). */
|
|
228
228
|
const isHomeNode = (r) => r.parentId === null && r.locales[def]?.slug === config.homePageSlug;
|
|
229
|
+
/** A ROOT page at the admin's segment can never be opened: Next serves
|
|
230
|
+
* the static admin folder first (spec 2026-09-17 slug guard §4). */
|
|
231
|
+
const adminSlug = (slug) => isAdminSlug(slug, config.admin.path);
|
|
232
|
+
const slugTaken = () => err("validation", adminSlugTaken(config.admin.path));
|
|
229
233
|
const supportedLocale = (locale) => typeof locale === "string" && config.locales.supported.includes(locale)
|
|
230
234
|
? null
|
|
231
235
|
: err("validation", `smoodly: "${String(locale)}" is not a supported locale.`);
|
|
@@ -268,12 +272,14 @@ export function createAdminOps(deps) {
|
|
|
268
272
|
const unsupported = supportedLocale(input.locale);
|
|
269
273
|
if (unsupported)
|
|
270
274
|
return unsupported;
|
|
275
|
+
if (input.parentId === null && adminSlug(input.slug))
|
|
276
|
+
return slugTaken();
|
|
271
277
|
if (input.template !== null) {
|
|
272
278
|
const registration = openTemplate(input.template);
|
|
273
279
|
if (!registration)
|
|
274
|
-
return err("validation", `smoodly: no page
|
|
280
|
+
return err("validation", `smoodly: no page type named "${input.template}".`);
|
|
275
281
|
if (registration.schema.slug !== undefined) {
|
|
276
|
-
return err("validation", `smoodly: page
|
|
282
|
+
return err("validation", `smoodly: page type "${input.template}" is fixed — Smoodly creates that page for you.`);
|
|
277
283
|
}
|
|
278
284
|
}
|
|
279
285
|
if (input.parentId !== null) {
|
|
@@ -318,7 +324,7 @@ export function createAdminOps(deps) {
|
|
|
318
324
|
const draft = record.template === null ? null : await pages.getDraftTree(id, locale);
|
|
319
325
|
if (!draft) {
|
|
320
326
|
return record.template === null
|
|
321
|
-
? err("blocked", `smoodly: "${nameOf(record)}" is a
|
|
327
|
+
? err("blocked", `smoodly: "${nameOf(record)}" is a path — give it a page type first.`)
|
|
322
328
|
: err("blocked", `smoodly: "${row.slug}" has no draft to publish.`);
|
|
323
329
|
}
|
|
324
330
|
const fieldErrors = [];
|
|
@@ -404,7 +410,7 @@ export function createAdminOps(deps) {
|
|
|
404
410
|
if (!record.locales[locale])
|
|
405
411
|
return err("not_found", `smoodly: page "${id}" does not exist in locale "${locale}".`);
|
|
406
412
|
if (record.template === null)
|
|
407
|
-
return err("blocked", `smoodly: "${nameOf(record)}" is a
|
|
413
|
+
return err("blocked", `smoodly: "${nameOf(record)}" is a path — it has no versions.`);
|
|
408
414
|
const version = typeof versionId === "string" ? await pages.getVersion(versionId) : null;
|
|
409
415
|
if (!version || version.pageId !== id || version.locale !== locale) {
|
|
410
416
|
return err("not_found", `smoodly: no version "${String(versionId)}" of this page in ${locale}.`);
|
|
@@ -441,6 +447,8 @@ export function createAdminOps(deps) {
|
|
|
441
447
|
const unsupported = supportedLocale(patch.locale);
|
|
442
448
|
if (unsupported)
|
|
443
449
|
return unsupported;
|
|
450
|
+
if (record.parentId === null && patch.slug !== undefined && adminSlug(patch.slug))
|
|
451
|
+
return slugTaken();
|
|
444
452
|
const renamed = await pages.rename(id, patch, by);
|
|
445
453
|
// Every descendant's path changed with this segment; the parent
|
|
446
454
|
// lists this node's title and path.
|
|
@@ -452,6 +460,8 @@ export function createAdminOps(deps) {
|
|
|
452
460
|
const record = await pages.getPage(id);
|
|
453
461
|
if (!record)
|
|
454
462
|
return err("not_found", `smoodly: no page "${id}".`);
|
|
463
|
+
if (to.parentId === null && Object.values(record.locales).some((row) => adminSlug(row.slug)))
|
|
464
|
+
return slugTaken();
|
|
455
465
|
if (to.parentId !== null) {
|
|
456
466
|
// Code owns a fixed node's URL, never the root's listing order:
|
|
457
467
|
// reparenting is refused, reordering among the root siblings is not.
|
|
@@ -481,9 +491,9 @@ export function createAdminOps(deps) {
|
|
|
481
491
|
}
|
|
482
492
|
const registration = openTemplate(template);
|
|
483
493
|
if (!registration)
|
|
484
|
-
return err("validation", `smoodly: no page
|
|
494
|
+
return err("validation", `smoodly: no page type named "${template}".`);
|
|
485
495
|
if (registration.schema.slug !== undefined) {
|
|
486
|
-
return err("validation", `smoodly: page
|
|
496
|
+
return err("validation", `smoodly: page type "${template}" is fixed — Smoodly creates that page for you.`);
|
|
487
497
|
}
|
|
488
498
|
const withTemplate = await pages.setTemplate(id, template, by);
|
|
489
499
|
expireTree();
|
|
@@ -105,7 +105,7 @@ export function PagesList({ ops, registry }) {
|
|
|
105
105
|
return null;
|
|
106
106
|
if (creatable.length === 1)
|
|
107
107
|
return creatable[0].name;
|
|
108
|
-
return window.prompt(`
|
|
108
|
+
return window.prompt(`Page type? (${creatable.map((p) => p.name).join(", ")})`) || null;
|
|
109
109
|
};
|
|
110
110
|
/** New page and New path (a folder in the tree) create in the SELECTED locale (spec §6). */
|
|
111
111
|
const newNode = (parentId, kind) => {
|
|
@@ -202,8 +202,8 @@ export function PagesList({ ops, registry }) {
|
|
|
202
202
|
const items = menu ? menuItems(menu.row) : [];
|
|
203
203
|
return (_jsxs(_Fragment, { children: [_jsx(ListView, { title: "Pages", titleTrailing: multilingual && _jsx(LocalePicker, { locales: registry.locales.supported, value: locale, onChange: pickLocale }), actions: _jsxs(_Fragment, { children: [_jsx(Button, { variant: "secondary", onClick: () => newNode(null, "folder"), children: "+ New path" }), _jsx(Button, { onClick: () => newNode(null, "page"), disabled: noOpen, title: noOpen ? "Every page registration is fixed — add an open one (a page schema with no `slug`) to create pages here." : undefined, children: "+ New page" })] }), columns: [
|
|
204
204
|
{ label: "Title", width: "minmax(160px,1fr)" },
|
|
205
|
-
{ label: "
|
|
206
|
-
{ label: "
|
|
205
|
+
{ label: "URL", width: "minmax(140px,260px)" },
|
|
206
|
+
{ label: "Page type", width: "minmax(100px,160px)" },
|
|
207
207
|
{ label: "Status", width: "170px" },
|
|
208
208
|
{ label: "", width: "32px" },
|
|
209
209
|
], empty: pages ? "No pages yet." : "Loading…", sortable: { hook: sortable, canDrag: (i) => canDragRow(rows[i]) }, rows: rows.map((row, i) => {
|
package/dist/admin-path.d.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
export declare const DEFAULT_ADMIN_PATH = "/admin";
|
|
2
|
-
/**
|
|
2
|
+
/** `/` plus exactly one segment (spec 2026-09-17 slug guard §1): with one
|
|
3
|
+
* segment only a ROOT slug can collide with the admin, so the guard is a
|
|
4
|
+
* string comparison. */
|
|
3
5
|
export declare function assertAdminPath(path: unknown): asserts path is string;
|
|
6
|
+
/** True when `slug` is the admin's own segment: Next serves the static
|
|
7
|
+
* admin folder before the site catch-all, so a root page, a collection
|
|
8
|
+
* path or a fixed slug that takes it can never be opened. */
|
|
9
|
+
export declare const isAdminSlug: (slug: string, adminPath: string) => boolean;
|
|
10
|
+
/** What an editor reads when a root page would take the admin's segment. */
|
|
11
|
+
export declare const adminSlugTaken: (adminPath: string) => string;
|
|
4
12
|
/** `path` is "" or starts with "/": adminHref("/studio", "/pages") → "/studio/pages". */
|
|
5
13
|
export declare const adminHref: (base: string, path: string) => string;
|
|
6
14
|
/** The message for a shell mounted outside its base — the route folder
|
package/dist/admin-path.js
CHANGED
|
@@ -4,13 +4,21 @@
|
|
|
4
4
|
// all read it from here.
|
|
5
5
|
import { isSegment } from "./paths.js";
|
|
6
6
|
export const DEFAULT_ADMIN_PATH = "/admin";
|
|
7
|
-
/**
|
|
7
|
+
/** `/` plus exactly one segment (spec 2026-09-17 slug guard §1): with one
|
|
8
|
+
* segment only a ROOT slug can collide with the admin, so the guard is a
|
|
9
|
+
* string comparison. */
|
|
8
10
|
export function assertAdminPath(path) {
|
|
9
|
-
const ok = typeof path === "string" && path.startsWith("/") && path.slice(1)
|
|
11
|
+
const ok = typeof path === "string" && path.startsWith("/") && isSegment(path.slice(1));
|
|
10
12
|
if (!ok) {
|
|
11
|
-
throw new Error(`smoodly: admin.path "${String(path)}" is not a valid path — use one
|
|
13
|
+
throw new Error(`smoodly: admin.path "${String(path)}" is not a valid path — use one /segment of lowercase letters, digits and single hyphens, like "/admin" or "/studio".`);
|
|
12
14
|
}
|
|
13
15
|
}
|
|
16
|
+
/** True when `slug` is the admin's own segment: Next serves the static
|
|
17
|
+
* admin folder before the site catch-all, so a root page, a collection
|
|
18
|
+
* path or a fixed slug that takes it can never be opened. */
|
|
19
|
+
export const isAdminSlug = (slug, adminPath) => slug === adminPath.slice(1);
|
|
20
|
+
/** What an editor reads when a root page would take the admin's segment. */
|
|
21
|
+
export const adminSlugTaken = (adminPath) => `smoodly: "${adminPath}" is where the admin lives (admin.path) — pick another slug.`;
|
|
14
22
|
/** `path` is "" or starts with "/": adminHref("/studio", "/pages") → "/studio/pages". */
|
|
15
23
|
export const adminHref = (base, path) => `${base}${path}`;
|
|
16
24
|
/** The message for a shell mounted outside its base — the route folder
|
package/dist/config.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { assertSegment, fixedPageSegments, perLocaleSegments } from "./paths.js";
|
|
2
2
|
import { DEFAULT_MEDIA } from "./media.js";
|
|
3
|
-
import { DEFAULT_ADMIN_PATH, assertAdminPath } from "./admin-path.js";
|
|
3
|
+
import { DEFAULT_ADMIN_PATH, assertAdminPath, isAdminSlug } from "./admin-path.js";
|
|
4
4
|
import { resolveStyleValues, styleDescriptors } from "./styles.js";
|
|
5
5
|
import { resolveCustom } from "./custom/index.js";
|
|
6
6
|
/** `global`: the placement node type and an admin route; `asset`: the refs
|
|
@@ -56,8 +56,9 @@ function assertGlobalSections(config) {
|
|
|
56
56
|
/** Every root-level segment a registration claims, per locale: fixed page
|
|
57
57
|
* slugs and collection paths. Two claims on one (locale, segment) are a
|
|
58
58
|
* conflict — except a fixed page sitting at a collection's segment in
|
|
59
|
-
* EVERY locale of that path, which is the collection's index page.
|
|
60
|
-
|
|
59
|
+
* EVERY locale of that path, which is the collection's index page. The
|
|
60
|
+
* admin's own segment is refused outright (spec 2026-09-17 slug guard §5). */
|
|
61
|
+
function assertRootSegments(config, adminPath) {
|
|
61
62
|
const { default: def, supported } = config.locales;
|
|
62
63
|
// Mounts claim exactly the locales their path names; fixed pages fill an
|
|
63
64
|
// omitted locale from the default one, so the check must fill the same
|
|
@@ -73,6 +74,9 @@ function assertRootSegments(config) {
|
|
|
73
74
|
}
|
|
74
75
|
for (const [locale, segment] of Object.entries(pageSegments(slug))) {
|
|
75
76
|
assertSegment(segment, `page "${p.schema.name}" fixed slug`);
|
|
77
|
+
if (isAdminSlug(segment, adminPath)) {
|
|
78
|
+
throw new Error(`smoodly: page "${p.schema.name}" has the fixed slug "${segment}", where the admin lives — change its slug or admin.path.`);
|
|
79
|
+
}
|
|
76
80
|
const owner = owners.get(`${locale} ${segment}`);
|
|
77
81
|
if (owner) {
|
|
78
82
|
throw new Error(`smoodly: page registrations "${owner}" and "${p.schema.name}" both claim the fixed slug "${segment}" (${locale}).`);
|
|
@@ -94,6 +98,9 @@ function assertRootSegments(config) {
|
|
|
94
98
|
const segments = perLocale(c.path);
|
|
95
99
|
for (const [locale, segment] of Object.entries(segments)) {
|
|
96
100
|
assertSegment(segment, `collection "${c.name}" path`);
|
|
101
|
+
if (isAdminSlug(segment, adminPath)) {
|
|
102
|
+
throw new Error(`smoodly: collection "${c.name}" is mounted at "${adminPath}", where the admin lives — change its path or admin.path.`);
|
|
103
|
+
}
|
|
97
104
|
const other = collectionOwners.get(`${locale} ${segment}`);
|
|
98
105
|
if (other) {
|
|
99
106
|
throw new Error(`smoodly: collections "${other}" and "${c.name}" both claim the root segment "${segment}" (${locale}).`);
|
|
@@ -170,7 +177,9 @@ export function defineConfig(config) {
|
|
|
170
177
|
assertUniqueNames(config);
|
|
171
178
|
assertGlobalSections(config);
|
|
172
179
|
assertToolsetElements(config);
|
|
173
|
-
|
|
180
|
+
const adminPath = config.admin?.path ?? DEFAULT_ADMIN_PATH;
|
|
181
|
+
assertAdminPath(adminPath);
|
|
182
|
+
assertRootSegments(config, adminPath);
|
|
174
183
|
const depth = config.pages?.tree?.depth ?? 1;
|
|
175
184
|
if (!Number.isInteger(depth) || depth < 1)
|
|
176
185
|
throw new Error("smoodly: pages.tree.depth must be a positive integer.");
|
|
@@ -196,8 +205,6 @@ export function defineConfig(config) {
|
|
|
196
205
|
if (!Number.isInteger(media.maxSize) || media.maxSize <= 0) {
|
|
197
206
|
throw new Error("smoodly: media.maxSize must be a positive integer of bytes.");
|
|
198
207
|
}
|
|
199
|
-
const adminPath = config.admin?.path ?? DEFAULT_ADMIN_PATH;
|
|
200
|
-
assertAdminPath(adminPath);
|
|
201
208
|
return {
|
|
202
209
|
...config,
|
|
203
210
|
homePageSlug,
|
package/dist/store.js
CHANGED
|
@@ -31,9 +31,9 @@ export function pathOptions(options) {
|
|
|
31
31
|
export const pageErrors = {
|
|
32
32
|
notFound: (id) => new Error(`smoodly: no page "${id}".`),
|
|
33
33
|
slugTaken: (slug) => new Error(`smoodly: a page with slug "${slug}" already exists under this parent.`),
|
|
34
|
-
folder: (id) => new Error(`smoodly: "${id}" is a
|
|
34
|
+
folder: (id) => new Error(`smoodly: "${id}" is a path — give it a page type first.`),
|
|
35
35
|
nothingPublished: (id, locale) => new Error(`smoodly: page "${id}" has nothing published in locale "${locale}" — there is no version to restore.`),
|
|
36
|
-
hasTemplate: (id) => new Error(`smoodly: "${id}" already has a
|
|
36
|
+
hasTemplate: (id) => new Error(`smoodly: "${id}" already has a page type.`),
|
|
37
37
|
homeChildren: () => new Error("smoodly: the home page cannot have child pages."),
|
|
38
38
|
cycle: () => new Error("smoodly: a page cannot be moved under itself."),
|
|
39
39
|
hasChildren: (id) => new Error(`smoodly: "${id}" has child pages — move or delete them first.`),
|
package/package.json
CHANGED