canopycms 0.0.65 → 0.0.66-int.81

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
@@ -316,9 +316,7 @@ This convention is applied consistently across the API:
316
316
  | `readByUrlPath` | Automatically tries `slug: 'index'` as a fallback when the direct entry doesn't match. Works for all paths including `/`. A path whose last segment is an index slug (any case) skips the direct-entry attempt, so an index entry is reachable only at its collapsed path. |
317
317
  | `buildContentTree` | Default `buildPath` collapses index entries so tree node paths match the URLs consumers would use. |
318
318
 
319
- The round-trip property holds in both directions: for every item from `listEntries`, `readByUrlPath(item.urlPath)` resolves to the same entry, and for an index entry no `.../index` spelling does. (Ordinary entries keep case-insensitive matching on the final slug segment.)
320
-
321
- One known exception, still open: an entry-type name is also resolvable as a URL segment, so an index entry additionally answers at `/<collection>/<entryTypeName>` -- e.g. a root index entry at both `/` and `/home`. Nothing advertises that URL (it is absent from `listEntries` and from static-param generation), so it only matters if you serve a catch-all route. See the `readbyurlpath-entry-type-candidate-phantom-url` task in the CanopyCMS repo.
319
+ The round-trip property holds in both directions: for every item from `listEntries`, `readByUrlPath(item.urlPath)` resolves to the same entry, and for an index entry no `.../index` spelling does, nor does an entry-type name -- `readByUrlPath` only accepts a candidate whose collection segment is an actual collection, so `/<collection>/<entryTypeName>` (e.g. a root index entry answering at both `/` and `/home`) is not a second URL for it. (Ordinary entries keep case-insensitive matching on the final slug segment.)
322
320
 
323
321
  Two different entries can still compute the same `urlPath` — an entry whose slug matches a sibling collection that also has an `index` entry, or two slugs differing only by case. Only one of them can be served, so a production build fails and names them. `findDuplicateUrlPaths` (exported from `canopycms/server`) runs the same scan on demand.
324
322
 
package/dist/cli/cli.js CHANGED
@@ -6264,6 +6264,54 @@ var init_generate_ai_content = __esm({
6264
6264
  }
6265
6265
  return item;
6266
6266
  }
6267
+ /**
6268
+ * Is this path a COLLECTION schema item?
6269
+ *
6270
+ * The non-throwing form of `assertCollection`, reading the same `schemaIndex` -- which is the
6271
+ * point. A caller that gates on this cannot disagree with what `buildPaths` will then do: the
6272
+ * Map is last-wins, so where a subcollection's path collides with a parent's entry-type name
6273
+ * both this and `buildPaths` see the collection. A `find` over the flat schema LIST is
6274
+ * first-wins and would not.
6275
+ *
6276
+ * Type-only, deliberately -- `resolvePath` additionally requires `entries`, but a collection
6277
+ * with subcollections and no entries of its own is legal, and mirroring that stricter test here
6278
+ * would reject something `buildPaths` accepts.
6279
+ *
6280
+ * Exists for `readByUrlPath`'s URL-addressability gate; see `ReadContentInput`'s
6281
+ * `urlAddressableOnly` and the note on `buildPaths`' entry-type branch below.
6282
+ */
6283
+ isCollectionPath(collectionPath) {
6284
+ return this.schemaIndex.get(normalizeFilesystemPath(collectionPath))?.type === "collection";
6285
+ }
6286
+ /**
6287
+ * Does this collection declare `entryTypeName` in its `entries` config?
6288
+ *
6289
+ * Mirrors `parseTypedFilename(filename, collection.entries)`, which is how `listEntries` decides
6290
+ * whether a file on disk is one of the collection's entries at all. `buildPaths`' own directory
6291
+ * scan deliberately does NOT check this -- it matches on slug alone, so that an entry whose type
6292
+ * was renamed out of the schema stays findable and therefore still editable, renameable and
6293
+ * deletable. Only URL resolution consults this, so what enumeration hides is not served.
6294
+ *
6295
+ * Returns FALSE when the collection declares no `entries` at all, which is stricter than
6296
+ * `parseTypedFilename`'s own `if (entryTypes && ...)` guard and deliberately so: the enumerating
6297
+ * surface is not `parseTypedFilename`, it is `listCollectionEntries`, and that returns `[]`
6298
+ * outright for a collection with no `entries`. A collections-only container therefore publishes
6299
+ * nothing, so a URL read that resolved a file sitting in one would be answering where nothing is
6300
+ * advertised -- the exact disagreement this predicate exists to close. Such a file cannot have
6301
+ * been created by the CMS (there is no entry type to create it as); it arrived by hand, by merge
6302
+ * or by retrofit, and it is invisible to the sitemap and to static params either way.
6303
+ *
6304
+ * Returns true for a path that is not a collection at all, because that is rule 1's question,
6305
+ * not this one's -- and under `urlAddressableOnly` rule 1 has already rejected it.
6306
+ */
6307
+ declaresEntryType(collectionPath, entryTypeName) {
6308
+ const item = this.schemaIndex.get(normalizeFilesystemPath(collectionPath));
6309
+ if (item?.type !== "collection")
6310
+ return true;
6311
+ if (!item.entries)
6312
+ return false;
6313
+ return item.entries.some((e) => e.name === entryTypeName);
6314
+ }
6267
6315
  assertCollection(collectionPath) {
6268
6316
  const item = this.assertSchemaItem(collectionPath);
6269
6317
  if (item.type !== "collection") {
@@ -2926,6 +2926,54 @@ var ContentStore = class {
2926
2926
  }
2927
2927
  return item;
2928
2928
  }
2929
+ /**
2930
+ * Is this path a COLLECTION schema item?
2931
+ *
2932
+ * The non-throwing form of `assertCollection`, reading the same `schemaIndex` -- which is the
2933
+ * point. A caller that gates on this cannot disagree with what `buildPaths` will then do: the
2934
+ * Map is last-wins, so where a subcollection's path collides with a parent's entry-type name
2935
+ * both this and `buildPaths` see the collection. A `find` over the flat schema LIST is
2936
+ * first-wins and would not.
2937
+ *
2938
+ * Type-only, deliberately -- `resolvePath` additionally requires `entries`, but a collection
2939
+ * with subcollections and no entries of its own is legal, and mirroring that stricter test here
2940
+ * would reject something `buildPaths` accepts.
2941
+ *
2942
+ * Exists for `readByUrlPath`'s URL-addressability gate; see `ReadContentInput`'s
2943
+ * `urlAddressableOnly` and the note on `buildPaths`' entry-type branch below.
2944
+ */
2945
+ isCollectionPath(collectionPath) {
2946
+ return this.schemaIndex.get(normalizeFilesystemPath(collectionPath))?.type === "collection";
2947
+ }
2948
+ /**
2949
+ * Does this collection declare `entryTypeName` in its `entries` config?
2950
+ *
2951
+ * Mirrors `parseTypedFilename(filename, collection.entries)`, which is how `listEntries` decides
2952
+ * whether a file on disk is one of the collection's entries at all. `buildPaths`' own directory
2953
+ * scan deliberately does NOT check this -- it matches on slug alone, so that an entry whose type
2954
+ * was renamed out of the schema stays findable and therefore still editable, renameable and
2955
+ * deletable. Only URL resolution consults this, so what enumeration hides is not served.
2956
+ *
2957
+ * Returns FALSE when the collection declares no `entries` at all, which is stricter than
2958
+ * `parseTypedFilename`'s own `if (entryTypes && ...)` guard and deliberately so: the enumerating
2959
+ * surface is not `parseTypedFilename`, it is `listCollectionEntries`, and that returns `[]`
2960
+ * outright for a collection with no `entries`. A collections-only container therefore publishes
2961
+ * nothing, so a URL read that resolved a file sitting in one would be answering where nothing is
2962
+ * advertised -- the exact disagreement this predicate exists to close. Such a file cannot have
2963
+ * been created by the CMS (there is no entry type to create it as); it arrived by hand, by merge
2964
+ * or by retrofit, and it is invisible to the sitemap and to static params either way.
2965
+ *
2966
+ * Returns true for a path that is not a collection at all, because that is rule 1's question,
2967
+ * not this one's -- and under `urlAddressableOnly` rule 1 has already rejected it.
2968
+ */
2969
+ declaresEntryType(collectionPath, entryTypeName) {
2970
+ const item = this.schemaIndex.get(normalizeFilesystemPath(collectionPath));
2971
+ if (item?.type !== "collection")
2972
+ return true;
2973
+ if (!item.entries)
2974
+ return false;
2975
+ return item.entries.some((e) => e.name === entryTypeName);
2976
+ }
2929
2977
  assertCollection(collectionPath) {
2930
2978
  const item = this.assertSchemaItem(collectionPath);
2931
2979
  if (item.type !== "collection") {
@@ -11,7 +11,11 @@ export interface ContentReaderOptions {
11
11
  getBranchContext?: (branch: string) => Promise<BranchContext | null>;
12
12
  }
13
13
  export interface ReadContentInput {
14
- /** Resolved schema path (e.g., content/posts or content/home). */
14
+ /**
15
+ * Resolved schema path (e.g., content/posts or content/home). An entry-TYPE path like
16
+ * `content/home` is valid and resolves that singleton -- except under `urlAddressableOnly`
17
+ * below, which rejects it.
18
+ */
15
19
  entryPath: LogicalPath;
16
20
  slug?: Slug;
17
21
  branch?: string;
@@ -21,6 +25,26 @@ export interface ReadContentInput {
21
25
  resolveReferences?: boolean;
22
26
  /** Whether to resolve entry:ID links in body/markdown fields. Defaults to true. */
23
27
  resolveEntryLinks?: boolean;
28
+ /**
29
+ * This read is addressing an entry by its PUBLISHED URL, so accept only what enumeration
30
+ * publishes. Two rules, both off by default:
31
+ *
32
+ * 1. `entryPath` must be a COLLECTION. A published URL is `/<collectionSegments>/<slug>` (or
33
+ * the collapsed collection path, for an index entry), so its non-slug segments are always
34
+ * collection names. An `entryPath` that resolves to an entry-TYPE item instead would be
35
+ * delegated by `ContentStore.buildPaths` to the parent collection -- answering at
36
+ * `/<collection>/<typeName>` and `/<collection>/<typeName>/<slug>`, neither of which any
37
+ * forward surface emits.
38
+ * 2. The resolved entry's type must be one its collection declares, matching the
39
+ * `parseTypedFilename(filename, collection.entries)` check `listEntries` applies. A legacy
40
+ * untyped file has no type token to fail on -- see `declaresEntryType`.
41
+ *
42
+ * Set by `readByUrlPath` and nothing else. Note what it does NOT do: `read({ entryPath:
43
+ * 'content/home' })` and direct `ContentStore` use keep the entry-type delegation, which is a
44
+ * supported API and the only way to address a singleton structurally. Misusing this flag can
45
+ * only make a read stricter, never looser.
46
+ */
47
+ urlAddressableOnly?: boolean;
24
48
  }
25
49
  /**
26
50
  * Structural metadata surfaced alongside a resolved content read. Shared by
@@ -122,6 +122,13 @@ export const createContentReader = (options) => {
122
122
  const readDocument = async (input) => {
123
123
  const { entryPath, slug, branchName, user } = resolveTarget(input);
124
124
  const { context, branchRoot, store } = await resolveStore(branchName);
125
+ // Rule 1 of urlAddressableOnly (see ReadContentInput): a published URL's non-slug segments
126
+ // are collection names, so a candidate landing on an entry-TYPE item can only ever produce a
127
+ // URL enumeration never emits. NO_SCHEMA_ITEM is what assertCollection raises for exactly
128
+ // this condition, and readByUrlPath treats it as a miss and tries the next candidate.
129
+ if (input.urlAddressableOnly && !store.isCollectionPath(entryPath)) {
130
+ throw new ContentStoreError(`Path is not a collection: ${entryPath}`, 'NO_SCHEMA_ITEM');
131
+ }
125
132
  // Get the path WITHOUT reading the file
126
133
  let relativePath;
127
134
  // Absolute filesystem path to the entry file. Surfaced on read() / readByUrlPath()
@@ -151,6 +158,14 @@ export const createContentReader = (options) => {
151
158
  const code = err instanceof ContentStoreError ? err.code : 'VALIDATION';
152
159
  throw new ContentStoreError(message, code);
153
160
  }
161
+ // Rule 2 of urlAddressableOnly (see ReadContentInput): buildPaths' directory scan matches on
162
+ // slug alone, so it happily returns a file whose type token the collection no longer (or
163
+ // never did) declare -- a file listEntries skips. Checked here rather than inside the scan so
164
+ // the write path can still find, edit and rename it; making it unfindable would make the
165
+ // mistake unrecoverable through the editor.
166
+ if (input.urlAddressableOnly && !store.declaresEntryType(entryPath, entryType)) {
167
+ throw new ContentStoreError(`Entry type '${entryType}' is not declared by ${entryPath}`, 'NO_SCHEMA_ITEM');
168
+ }
154
169
  // Check permissions BEFORE reading the file (security)
155
170
  const shouldCheckPermissions = !(isDeployedStatic(services.config) || isBuildMode());
156
171
  if (shouldCheckPermissions) {
@@ -289,6 +289,45 @@ export declare class ContentStore {
289
289
  */
290
290
  getSchemaItems(): IterableIterator<FlatSchemaItem>;
291
291
  private assertSchemaItem;
292
+ /**
293
+ * Is this path a COLLECTION schema item?
294
+ *
295
+ * The non-throwing form of `assertCollection`, reading the same `schemaIndex` -- which is the
296
+ * point. A caller that gates on this cannot disagree with what `buildPaths` will then do: the
297
+ * Map is last-wins, so where a subcollection's path collides with a parent's entry-type name
298
+ * both this and `buildPaths` see the collection. A `find` over the flat schema LIST is
299
+ * first-wins and would not.
300
+ *
301
+ * Type-only, deliberately -- `resolvePath` additionally requires `entries`, but a collection
302
+ * with subcollections and no entries of its own is legal, and mirroring that stricter test here
303
+ * would reject something `buildPaths` accepts.
304
+ *
305
+ * Exists for `readByUrlPath`'s URL-addressability gate; see `ReadContentInput`'s
306
+ * `urlAddressableOnly` and the note on `buildPaths`' entry-type branch below.
307
+ */
308
+ isCollectionPath(collectionPath: LogicalPath): boolean;
309
+ /**
310
+ * Does this collection declare `entryTypeName` in its `entries` config?
311
+ *
312
+ * Mirrors `parseTypedFilename(filename, collection.entries)`, which is how `listEntries` decides
313
+ * whether a file on disk is one of the collection's entries at all. `buildPaths`' own directory
314
+ * scan deliberately does NOT check this -- it matches on slug alone, so that an entry whose type
315
+ * was renamed out of the schema stays findable and therefore still editable, renameable and
316
+ * deletable. Only URL resolution consults this, so what enumeration hides is not served.
317
+ *
318
+ * Returns FALSE when the collection declares no `entries` at all, which is stricter than
319
+ * `parseTypedFilename`'s own `if (entryTypes && ...)` guard and deliberately so: the enumerating
320
+ * surface is not `parseTypedFilename`, it is `listCollectionEntries`, and that returns `[]`
321
+ * outright for a collection with no `entries`. A collections-only container therefore publishes
322
+ * nothing, so a URL read that resolved a file sitting in one would be answering where nothing is
323
+ * advertised -- the exact disagreement this predicate exists to close. Such a file cannot have
324
+ * been created by the CMS (there is no entry type to create it as); it arrived by hand, by merge
325
+ * or by retrofit, and it is invisible to the sitemap and to static params either way.
326
+ *
327
+ * Returns true for a path that is not a collection at all, because that is rule 1's question,
328
+ * not this one's -- and under `urlAddressableOnly` rule 1 has already rejected it.
329
+ */
330
+ declaresEntryType(collectionPath: LogicalPath, entryTypeName: string): boolean;
292
331
  private assertCollection;
293
332
  /**
294
333
  * Lock key for an existing entry, addressed by its permanent content ID
@@ -391,6 +391,54 @@ export class ContentStore {
391
391
  }
392
392
  return item;
393
393
  }
394
+ /**
395
+ * Is this path a COLLECTION schema item?
396
+ *
397
+ * The non-throwing form of `assertCollection`, reading the same `schemaIndex` -- which is the
398
+ * point. A caller that gates on this cannot disagree with what `buildPaths` will then do: the
399
+ * Map is last-wins, so where a subcollection's path collides with a parent's entry-type name
400
+ * both this and `buildPaths` see the collection. A `find` over the flat schema LIST is
401
+ * first-wins and would not.
402
+ *
403
+ * Type-only, deliberately -- `resolvePath` additionally requires `entries`, but a collection
404
+ * with subcollections and no entries of its own is legal, and mirroring that stricter test here
405
+ * would reject something `buildPaths` accepts.
406
+ *
407
+ * Exists for `readByUrlPath`'s URL-addressability gate; see `ReadContentInput`'s
408
+ * `urlAddressableOnly` and the note on `buildPaths`' entry-type branch below.
409
+ */
410
+ isCollectionPath(collectionPath) {
411
+ return this.schemaIndex.get(normalizeFilesystemPath(collectionPath))?.type === 'collection';
412
+ }
413
+ /**
414
+ * Does this collection declare `entryTypeName` in its `entries` config?
415
+ *
416
+ * Mirrors `parseTypedFilename(filename, collection.entries)`, which is how `listEntries` decides
417
+ * whether a file on disk is one of the collection's entries at all. `buildPaths`' own directory
418
+ * scan deliberately does NOT check this -- it matches on slug alone, so that an entry whose type
419
+ * was renamed out of the schema stays findable and therefore still editable, renameable and
420
+ * deletable. Only URL resolution consults this, so what enumeration hides is not served.
421
+ *
422
+ * Returns FALSE when the collection declares no `entries` at all, which is stricter than
423
+ * `parseTypedFilename`'s own `if (entryTypes && ...)` guard and deliberately so: the enumerating
424
+ * surface is not `parseTypedFilename`, it is `listCollectionEntries`, and that returns `[]`
425
+ * outright for a collection with no `entries`. A collections-only container therefore publishes
426
+ * nothing, so a URL read that resolved a file sitting in one would be answering where nothing is
427
+ * advertised -- the exact disagreement this predicate exists to close. Such a file cannot have
428
+ * been created by the CMS (there is no entry type to create it as); it arrived by hand, by merge
429
+ * or by retrofit, and it is invisible to the sitemap and to static params either way.
430
+ *
431
+ * Returns true for a path that is not a collection at all, because that is rule 1's question,
432
+ * not this one's -- and under `urlAddressableOnly` rule 1 has already rejected it.
433
+ */
434
+ declaresEntryType(collectionPath, entryTypeName) {
435
+ const item = this.schemaIndex.get(normalizeFilesystemPath(collectionPath));
436
+ if (item?.type !== 'collection')
437
+ return true;
438
+ if (!item.entries)
439
+ return false;
440
+ return item.entries.some((e) => e.name === entryTypeName);
441
+ }
394
442
  assertCollection(collectionPath) {
395
443
  const item = this.assertSchemaItem(collectionPath);
396
444
  if (item.type !== 'collection') {
@@ -499,9 +547,20 @@ export class ContentStore {
499
547
  const rootWithSep = this.root.endsWith(path.sep) ? this.root : `${this.root}${path.sep}`;
500
548
  // Entry-type items: delegate to their parent collection.
501
549
  // Uses the same {type}.{slug}.{id}.{ext} pattern as all entries.
502
- // NOTE: The API layer always resolves paths via resolvePath(), which returns
503
- // the parent collection directly, so this branch may only fire on direct
504
- // ContentStore usage (e.g., store.read('content/home', '')).
550
+ //
551
+ // This branch is for DIRECT ContentStore usage -- store.read('content/home', ''), and the
552
+ // read({ entryPath: 'content/home' }) API built on it, where the slug defaults to the entry
553
+ // type's own name. The API layer resolves paths via resolvePath(), which returns the parent
554
+ // collection directly and so never lands here.
555
+ //
556
+ // That used to be an observation, and it was WRONG: readByUrlPath reached this branch too,
557
+ // because `resolveUrlPathCandidates` happily produces `content/<typeName>` for the URL
558
+ // `/<typeName>` and the delegation below then answered it with the parent collection's index
559
+ // entry -- a URL no forward surface publishes. It is now enforced rather than assumed:
560
+ // readByUrlPath requires every candidate's entryPath to be a collection (see
561
+ // `isCollectionPath` and `ReadContentInput.urlAddressableOnly`). Narrowing the delegation
562
+ // ITSELF was the wrong fix -- write()/renameEntry()/delete() resolve through here as well,
563
+ // and the by-URL rule has no business constraining them.
505
564
  if (schemaItem.type === 'entry-type') {
506
565
  const parentPath = schemaItem.parentPath || '';
507
566
  const parentCollection = this.schemaIndex.get(parentPath);
package/dist/context.d.ts CHANGED
@@ -76,12 +76,22 @@ export interface CanopyBuildContext {
76
76
  * Read content by URL path, resolving the collection/entry split automatically.
77
77
  *
78
78
  * Tries direct entry match first (last segment = slug, rest = collection path),
79
- * then falls back to index entry (full path = collection, slug = 'index').
79
+ * then falls back to index entry (full path = collection, slug = 'index'). Both attempts
80
+ * require the collection part to be a real collection — see below.
80
81
  * Root path '/' resolves to the content root's index entry.
81
82
  *
82
- * The direct-entry attempt is SKIPPED when the last segment is an index slug (any case), so
83
- * `/x/index` returns null rather than a second URL for the entry that already answers at `/x`.
84
- * A collection literally named `index` is unaffected — the fallback resolves it.
83
+ * Resolves ONLY what `listEntries` publishes — `readByUrlPath(item.urlPath)` reaches the entry,
84
+ * and no other spelling does. Three shapes that used to resolve are therefore null now:
85
+ * `/x/index` (the literal spelling of an index entry's collapsed URL, in any case),
86
+ * `/<collection>/<entryTypeName>` and `/<collection>/<entryTypeName>/<slug>` (an entry-type
87
+ * path is not a collection, so it is not part of any published URL), and an entry whose
88
+ * on-disk type token its collection does not declare (`listEntries` skips those too). A
89
+ * collection literally named `index` is unaffected — the index fallback resolves it.
90
+ *
91
+ * `read({ entryPath: 'content/home' })` is deliberately NOT narrowed: addressing an entry
92
+ * structurally, by its schema path, is a different question from addressing it by its
93
+ * published URL, and it is the only way to reach a singleton without knowing its slug.
94
+ *
85
95
  * Returns null if no content matches the path — including collection URLs that have no
86
96
  * index entry (use buildContentTree for those) and non-entry/invalid paths such as
87
97
  * `/favicon.ico` or Next internals (the slug validator rejects them, treated as a miss).
package/dist/context.js CHANGED
@@ -60,8 +60,12 @@ export function createCanopyContext(options) {
60
60
  const user = await getUser();
61
61
  // Create base content reader
62
62
  const baseReader = createContentReader({ services });
63
- // Wrap reader to inject user automatically, validating strings → branded types at this boundary
64
- const read = async (input) => {
63
+ // Wrap reader to inject user automatically, validating strings → branded types at this
64
+ // boundary. `extra` carries options that are NOT part of the public `read` surface -- today
65
+ // just readByUrlPath's URL-addressability gate. Kept as a separate inner function rather than
66
+ // an optional second parameter on the `CanopyContext['read']`-typed closure so the two call
67
+ // sites stay visible and nobody widens the public API by accident.
68
+ const readWithOptions = async (input, extra) => {
65
69
  const entryPath = createLogicalPath(input.entryPath);
66
70
  let slug;
67
71
  if (input.slug) {
@@ -77,9 +81,11 @@ export function createCanopyContext(options) {
77
81
  branch: input.branch,
78
82
  user,
79
83
  resolveReferences: input.resolveReferences ?? true,
84
+ ...extra,
80
85
  };
81
86
  return baseReader.read(readInput);
82
87
  };
88
+ const read = (input) => readWithOptions(input);
83
89
  const readByUrlPath = async (urlPath, options) => {
84
90
  const contentRoot = services.config.contentRoot || 'content';
85
91
  const candidates = resolveUrlPathCandidates(urlPath, contentRoot);
@@ -93,12 +99,17 @@ export function createCanopyContext(options) {
93
99
  if (!parseSlug(candidate.slug).ok)
94
100
  continue;
95
101
  try {
96
- return await read({
102
+ return await readWithOptions({
97
103
  entryPath: candidate.entryPath,
98
104
  slug: candidate.slug,
99
105
  branch,
100
106
  resolveReferences,
101
- });
107
+ },
108
+ // This is a read BY PUBLISHED URL, so it must accept only what listEntries publishes.
109
+ // See ReadContentInput.urlAddressableOnly for the two rules and why they live in the
110
+ // reader (which holds the branch-correct schema) rather than in the candidate builder
111
+ // (which is pure and schema-free by design).
112
+ { urlAddressableOnly: true });
102
113
  }
103
114
  catch (err) {
104
115
  // Swallow "not found" errors from trying candidate paths, and FORBIDDEN (a denied
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The one-URL-per-entry invariant, as a reusable probe + report.
3
+ *
4
+ * `listEntries` assigns every entry exactly one `urlPath` and documents the round trip through
5
+ * `readByUrlPath` as safe. The reverse direction has repeatedly been the looser of the two: it
6
+ * has answered at URLs no forward surface emits, and those extra URLs were found one at a time,
7
+ * a release apart, by adopters (see
8
+ * `.claude/future-tasks/resolved/url-resolver-index-entry-extra-url.md`, then
9
+ * `resolved/readbyurlpath-entry-type-candidate-phantom-url.md`). This module exists so the whole
10
+ * invariant is asserted at once instead: enumerate, then probe every ADJACENT URL the resolver
11
+ * would try and require it to be a miss.
12
+ *
13
+ * Deliberately free of `vitest` -- this is a plain `src` module in the shape of
14
+ * `operating-mode/deployment-name-fixtures.ts`, so more than one test file can import it without
15
+ * dragging that file's `vi.mock` calls and `describe` blocks along. It therefore ASSERTS NOTHING:
16
+ * it returns a report and the caller does the expecting, which also puts the offending URLs in
17
+ * the assertion message rather than behind a boolean.
18
+ *
19
+ * Scope of the invariant it checks: entries written in the `{type}.{slug}.{id}.{ext}` grammar
20
+ * with a type their collection declares -- i.e. exactly the set `listEntries` can see. A legacy
21
+ * untyped file (`overview.json`) is invisible to enumeration and is deliberately NOT probed here;
22
+ * see `.claude/future-tasks/legacy-untyped-files-url-addressable.md`.
23
+ */
24
+ import { type RootCollectionConfig } from './config/index.js';
25
+ import type { ListEntriesItem } from './content-listing.js';
26
+ import type { CanopyContext } from './context.js';
27
+ import { type DuplicateUrlPath } from './static/index.js';
28
+ /**
29
+ * Every URL adjacent to the published set that the resolver would actually attempt.
30
+ *
31
+ * Three families, each one a shape that HAS resolved at some point in this package's history:
32
+ *
33
+ * 1. `/<collection>/<entryTypeName>` -- `resolveUrlPathCandidates`' index-fallback candidate lands
34
+ * on a registered entry-TYPE schema item, which `ContentStore.buildPaths` delegates to the
35
+ * parent collection with slug `index`, resolving that collection's index entry.
36
+ * 2. `/<collection>/<entryTypeName>/<slug>` -- the same delegation reached through the
37
+ * DIRECT-entry candidate instead, resolving the collection's entry `<slug>`. Wider than family
38
+ * 1: it needs no index entry at all, so it applies to every listed entry.
39
+ * 3. `/<publishedUrl>/index`, and its case variants -- an index entry answering at the literal
40
+ * spelling its collapsed URL replaced.
41
+ *
42
+ * Entry-type names are appended VERBATIM, after `computeEntryUrl` has lowercased the collection
43
+ * part. That asymmetry is real, not sloppiness: `flattenSchema` puts `entryType.name` into the
44
+ * logical path unchanged and `normalizeFilesystemPath` does not lowercase, so only the declared
45
+ * spelling can reach the entry-type schema item at all. A lowercased probe would miss the schema
46
+ * item, return null for the wrong reason, and pass vacuously.
47
+ */
48
+ export declare const buildProbeUrls: (schema: RootCollectionConfig, items: readonly ListEntriesItem[], contentRoot?: string) => string[];
49
+ export interface UrlExclusivityReport {
50
+ /** Every `urlPath` the listing published, sorted. */
51
+ published: string[];
52
+ /** URLs claimed by more than one entry. A precondition failure, not a resolver finding. */
53
+ duplicates: DuplicateUrlPath[];
54
+ /** Published URLs `readByUrlPath` did NOT resolve -- the round trip broken going forward. */
55
+ unresolved: string[];
56
+ /** Published URLs that resolved to some OTHER entry than the one that published them. */
57
+ mismatched: Array<{
58
+ urlPath: string;
59
+ expectedEntryId?: string;
60
+ actualEntryId?: string;
61
+ }>;
62
+ /** Every adjacent URL probed, sorted. */
63
+ probes: string[];
64
+ /** Probes that resolved despite not being published -- the phantom URLs. Must be empty. */
65
+ phantoms: string[];
66
+ }
67
+ /**
68
+ * Enumerate, round-trip, then probe. See `UrlExclusivityReport` for what each field means and
69
+ * `buildProbeUrls` for which URLs are probed.
70
+ *
71
+ * A probe whose LOWERCASED form is published is skipped rather than asserted on: a collection
72
+ * literally named `index` publishes `/docs/index`, which family 3 also generates, and whether
73
+ * `/docs/Index` should resolve is a separate, pre-existing question about collection-path case
74
+ * sensitivity that this invariant does not speak to.
75
+ */
76
+ export declare const collectUrlExclusivityReport: (ctx: Pick<CanopyContext, "listEntries" | "readByUrlPath">, schema: RootCollectionConfig, contentRoot?: string) => Promise<UrlExclusivityReport>;
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The one-URL-per-entry invariant, as a reusable probe + report.
3
+ *
4
+ * `listEntries` assigns every entry exactly one `urlPath` and documents the round trip through
5
+ * `readByUrlPath` as safe. The reverse direction has repeatedly been the looser of the two: it
6
+ * has answered at URLs no forward surface emits, and those extra URLs were found one at a time,
7
+ * a release apart, by adopters (see
8
+ * `.claude/future-tasks/resolved/url-resolver-index-entry-extra-url.md`, then
9
+ * `resolved/readbyurlpath-entry-type-candidate-phantom-url.md`). This module exists so the whole
10
+ * invariant is asserted at once instead: enumerate, then probe every ADJACENT URL the resolver
11
+ * would try and require it to be a miss.
12
+ *
13
+ * Deliberately free of `vitest` -- this is a plain `src` module in the shape of
14
+ * `operating-mode/deployment-name-fixtures.ts`, so more than one test file can import it without
15
+ * dragging that file's `vi.mock` calls and `describe` blocks along. It therefore ASSERTS NOTHING:
16
+ * it returns a report and the caller does the expecting, which also puts the offending URLs in
17
+ * the assertion message rather than behind a boolean.
18
+ *
19
+ * Scope of the invariant it checks: entries written in the `{type}.{slug}.{id}.{ext}` grammar
20
+ * with a type their collection declares -- i.e. exactly the set `listEntries` can see. A legacy
21
+ * untyped file (`overview.json`) is invisible to enumeration and is deliberately NOT probed here;
22
+ * see `.claude/future-tasks/legacy-untyped-files-url-addressable.md`.
23
+ */
24
+ import { flattenSchema } from './config/index.js';
25
+ import { findDuplicateUrlPaths } from './static/index.js';
26
+ import { computeEntryUrl } from './utils/entry-url.js';
27
+ /** Append raw segments to a URL base, without normalizing their case (see buildProbeUrls). */
28
+ const joinUrl = (base, ...segments) => (base === '/' ? '' : base) + segments.map((s) => `/${s}`).join('');
29
+ /**
30
+ * Every URL adjacent to the published set that the resolver would actually attempt.
31
+ *
32
+ * Three families, each one a shape that HAS resolved at some point in this package's history:
33
+ *
34
+ * 1. `/<collection>/<entryTypeName>` -- `resolveUrlPathCandidates`' index-fallback candidate lands
35
+ * on a registered entry-TYPE schema item, which `ContentStore.buildPaths` delegates to the
36
+ * parent collection with slug `index`, resolving that collection's index entry.
37
+ * 2. `/<collection>/<entryTypeName>/<slug>` -- the same delegation reached through the
38
+ * DIRECT-entry candidate instead, resolving the collection's entry `<slug>`. Wider than family
39
+ * 1: it needs no index entry at all, so it applies to every listed entry.
40
+ * 3. `/<publishedUrl>/index`, and its case variants -- an index entry answering at the literal
41
+ * spelling its collapsed URL replaced.
42
+ *
43
+ * Entry-type names are appended VERBATIM, after `computeEntryUrl` has lowercased the collection
44
+ * part. That asymmetry is real, not sloppiness: `flattenSchema` puts `entryType.name` into the
45
+ * logical path unchanged and `normalizeFilesystemPath` does not lowercase, so only the declared
46
+ * spelling can reach the entry-type schema item at all. A lowercased probe would miss the schema
47
+ * item, return null for the wrong reason, and pass vacuously.
48
+ */
49
+ export const buildProbeUrls = (schema, items, contentRoot = 'content') => {
50
+ const collections = flattenSchema(schema, contentRoot).filter((i) => i.type === 'collection');
51
+ const probes = new Set();
52
+ for (const collection of collections) {
53
+ const base = computeEntryUrl(collection.logicalPath, '', contentRoot);
54
+ for (const entryType of collection.entries ?? []) {
55
+ // Family 1.
56
+ probes.add(joinUrl(base, entryType.name));
57
+ // Family 2 -- every listed entry of this collection, under every type name the collection
58
+ // declares (not just the entry's OWN type: the directory scan `buildPaths` runs matches on
59
+ // slug alone, so any declared name reaches any entry).
60
+ for (const item of items) {
61
+ if (String(item.collectionPath) !== String(collection.logicalPath))
62
+ continue;
63
+ probes.add(joinUrl(base, entryType.name, item.slug));
64
+ }
65
+ }
66
+ }
67
+ // Family 3.
68
+ for (const item of items) {
69
+ for (const spelling of ['index', 'Index', 'INDEX']) {
70
+ probes.add(joinUrl(item.urlPath, spelling));
71
+ }
72
+ }
73
+ return [...probes];
74
+ };
75
+ /**
76
+ * Enumerate, round-trip, then probe. See `UrlExclusivityReport` for what each field means and
77
+ * `buildProbeUrls` for which URLs are probed.
78
+ *
79
+ * A probe whose LOWERCASED form is published is skipped rather than asserted on: a collection
80
+ * literally named `index` publishes `/docs/index`, which family 3 also generates, and whether
81
+ * `/docs/Index` should resolve is a separate, pre-existing question about collection-path case
82
+ * sensitivity that this invariant does not speak to.
83
+ */
84
+ export const collectUrlExclusivityReport = async (ctx, schema, contentRoot = 'content') => {
85
+ const items = await ctx.listEntries();
86
+ const unresolved = [];
87
+ const mismatched = [];
88
+ for (const item of items) {
89
+ const result = await ctx.readByUrlPath(item.urlPath);
90
+ if (!result) {
91
+ unresolved.push(item.urlPath);
92
+ continue;
93
+ }
94
+ if (result.meta.entryId !== item.entryId) {
95
+ mismatched.push({
96
+ urlPath: item.urlPath,
97
+ expectedEntryId: item.entryId,
98
+ actualEntryId: result.meta.entryId,
99
+ });
100
+ }
101
+ }
102
+ const publishedLower = new Set(items.map((i) => i.urlPath.toLowerCase()));
103
+ const probes = buildProbeUrls(schema, items, contentRoot);
104
+ const phantoms = [];
105
+ for (const probe of probes) {
106
+ if (publishedLower.has(probe.toLowerCase()))
107
+ continue;
108
+ if (await ctx.readByUrlPath(probe))
109
+ phantoms.push(probe);
110
+ }
111
+ return {
112
+ published: items.map((i) => i.urlPath).sort(),
113
+ duplicates: findDuplicateUrlPaths(items),
114
+ unresolved: unresolved.sort(),
115
+ mismatched,
116
+ probes: [...probes].sort(),
117
+ phantoms: phantoms.sort(),
118
+ };
119
+ };
@@ -34,10 +34,14 @@ export function resolveUrlPathCandidates(urlPath, contentRoot) {
34
34
  // `index` is not a contrived segment either — it is the slug the index convention requires on
35
35
  // disk, so the collision was structural rather than accidental.
36
36
  //
37
- // This closes the `.../index` spelling, NOT every extra URL: candidate 2 below still resolves an
38
- // index entry at `/<collection>/<entryTypeName>`, because that path is a registered entry-TYPE
39
- // schema item which `buildPaths` delegates to the parent collection. Open, and tracked in
40
- // .claude/future-tasks/readbyurlpath-entry-type-candidate-phantom-url.md.
37
+ // This closes the `.../index` spelling only. The other extra URLs an entry answered at —
38
+ // `/<collection>/<entryTypeName>` via candidate 2, and `/<collection>/<entryTypeName>/<slug>`
39
+ // via candidate 1 — are closed DOWNSTREAM instead, by `readByUrlPath` requiring every
40
+ // candidate's `entryPath` to be a collection (`ReadContentInput.urlAddressableOnly`). They have
41
+ // to be: telling those apart needs the branch's schema, and this module is deliberately pure
42
+ // and schema-free so that the candidate shapes stay a fact about URLs rather than about
43
+ // content. The consequence to keep in mind when reading this file: the candidates below are
44
+ // what is ATTEMPTED, not what can resolve.
41
45
  //
42
46
  // Compared case-INSENSITIVELY, through the shared `isIndexSlug`. This function is the one
43
47
  // consumer that sees a raw, un-normalized URL segment — everything downstream lowercases
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "@codemirror/language, @lezer/highlight: workaround — @mdxeditor/editor uses cm6-theme-basic-light which peer-requires these but mdxeditor doesn't declare them as dependencies",
3
3
  "name": "canopycms",
4
- "version": "0.0.65",
4
+ "version": "0.0.66-int.81",
5
5
  "description": "CanopyCMS core package: schema-driven content, branch-aware editing, and editor UI for Next.js.",
6
6
  "license": "MIT",
7
7
  "repository": {