smoodly 0.0.19 → 0.0.20

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 CHANGED
@@ -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)
@@ -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";
@@ -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,6 +272,8 @@ 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)
@@ -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.
@@ -1,6 +1,14 @@
1
1
  export declare const DEFAULT_ADMIN_PATH = "/admin";
2
- /** One or more `/segment`s: no trailing slash, never "/". */
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
@@ -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
- /** One or more `/segment`s: no trailing slash, never "/". */
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).split("/").every(isSegment);
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 or more /segments of lowercase letters, digits and single hyphens, like "/admin" or "/cms/admin".`);
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
- function assertRootSegments(config) {
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
- assertRootSegments(config);
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoodly",
3
- "version": "0.0.19",
3
+ "version": "0.0.20",
4
4
  "description": "Open-source, code-first CMS for Next.js on Supabase: define a component once, it becomes a typed, editable building block.",
5
5
  "license": "MIT",
6
6
  "repository": {