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 +1 -3
- package/dist/cli/cli.js +48 -0
- package/dist/cli/generate-ai-content.js +48 -0
- package/dist/content-reader.d.ts +25 -1
- package/dist/content-reader.js +15 -0
- package/dist/content-store.d.ts +39 -0
- package/dist/content-store.js +62 -3
- package/dist/context.d.ts +14 -4
- package/dist/context.js +15 -4
- package/dist/url-exclusivity-fixtures.d.ts +76 -0
- package/dist/url-exclusivity-fixtures.js +119 -0
- package/dist/url-path-resolver.js +8 -4
- package/package.json +1 -1
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") {
|
package/dist/content-reader.d.ts
CHANGED
|
@@ -11,7 +11,11 @@ export interface ContentReaderOptions {
|
|
|
11
11
|
getBranchContext?: (branch: string) => Promise<BranchContext | null>;
|
|
12
12
|
}
|
|
13
13
|
export interface ReadContentInput {
|
|
14
|
-
/**
|
|
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
|
package/dist/content-reader.js
CHANGED
|
@@ -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) {
|
package/dist/content-store.d.ts
CHANGED
|
@@ -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
|
package/dist/content-store.js
CHANGED
|
@@ -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
|
-
//
|
|
503
|
-
//
|
|
504
|
-
//
|
|
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
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
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
|
|
64
|
-
|
|
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
|
|
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
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
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.
|
|
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": {
|