canopycms 0.0.65 → 0.0.66-int.82

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.
Files changed (60) hide show
  1. package/README.md +1 -3
  2. package/dist/ai/generate.d.ts +13 -0
  3. package/dist/ai/generate.js +9 -3
  4. package/dist/ai/to-plain-text.js +4 -0
  5. package/dist/ai/types.d.ts +20 -1
  6. package/dist/api/admin-branch-health.js +13 -12
  7. package/dist/api/branch.js +2 -2
  8. package/dist/branch-health.js +1 -1
  9. package/dist/branch-metadata-file.d.ts +58 -0
  10. package/dist/branch-metadata-file.js +65 -0
  11. package/dist/branch-metadata.d.ts +2 -26
  12. package/dist/branch-metadata.js +6 -33
  13. package/dist/branch-registry.js +4 -2
  14. package/dist/build/generate-ai-content.js +103 -0
  15. package/dist/cli/cli.js +432 -317
  16. package/dist/cli/generate-ai-content.js +264 -160
  17. package/dist/content-id-index.js +22 -0
  18. package/dist/content-listing.js +3 -0
  19. package/dist/content-reader.d.ts +25 -1
  20. package/dist/content-reader.js +15 -0
  21. package/dist/content-store.d.ts +50 -1
  22. package/dist/content-store.js +102 -4
  23. package/dist/context.d.ts +36 -4
  24. package/dist/context.js +15 -4
  25. package/dist/editor/admin/SystemHealthPanel.js +2 -2
  26. package/dist/entry-schema.d.ts +22 -0
  27. package/dist/git-manager.d.ts +9 -8
  28. package/dist/git-manager.js +35 -9
  29. package/dist/github-service.d.ts +1 -1
  30. package/dist/github-service.js +1 -1
  31. package/dist/paths/branch-name.d.ts +1 -1
  32. package/dist/paths/branch-name.js +1 -1
  33. package/dist/schema/index.d.ts +1 -1
  34. package/dist/schema/index.js +1 -1
  35. package/dist/schema/meta-loader.js +1 -1
  36. package/dist/services.d.ts +22 -0
  37. package/dist/types.d.ts +1 -1
  38. package/dist/url-exclusivity-fixtures.d.ts +76 -0
  39. package/dist/url-exclusivity-fixtures.js +119 -0
  40. package/dist/url-path-resolver.js +8 -4
  41. package/dist/utils/content-write-lock.d.ts +2 -1
  42. package/dist/utils/content-write-lock.js +2 -1
  43. package/dist/utils/error.d.ts +17 -0
  44. package/dist/utils/error.js +17 -0
  45. package/dist/utils/git.d.ts +1 -1
  46. package/dist/utils/git.js +1 -1
  47. package/dist/utils/occ-json-write.js +1 -1
  48. package/dist/worker/cms-worker.d.ts +22 -288
  49. package/dist/worker/cms-worker.js +118 -1995
  50. package/dist/worker/git-sync.d.ts +166 -0
  51. package/dist/worker/git-sync.js +554 -0
  52. package/dist/worker/history-rewrite.d.ts +129 -0
  53. package/dist/worker/history-rewrite.js +216 -0
  54. package/dist/worker/rebase.d.ts +171 -0
  55. package/dist/worker/rebase.js +859 -0
  56. package/dist/worker/task-runner.d.ts +93 -0
  57. package/dist/worker/task-runner.js +529 -0
  58. package/dist/worker/worker-context.d.ts +133 -0
  59. package/dist/worker/worker-context.js +1 -0
  60. package/package.json +1 -1
@@ -1,3 +1,25 @@
1
+ /**
2
+ * The ContentId -> file path index.
3
+ *
4
+ * Every entry file carries a stable id in its filename (`<type>.<slug>.<id>.md`),
5
+ * and this index is what makes an id-based lookup cheap instead of a directory
6
+ * walk. `ContentStore` owns an instance and consults it on every id-addressed
7
+ * read.
8
+ *
9
+ * Coherency is the whole problem here, and it is cross-process: two Lambdas and
10
+ * the worker can all mutate one branch on shared storage. The design is an
11
+ * on-disk generation marker (`content-index-generation.ts`) plus an in-process
12
+ * registry (`content-index-registry.ts`) — two files whose names read as
13
+ * near-synonyms and do unrelated jobs. Read
14
+ * ../../../docs/concurrency.md before changing any of the three.
15
+ *
16
+ * Also here: the filename-grammar helpers (`extractIdFromFilename`,
17
+ * `extractEntryTypeFromFilename`, `extractSlugFromFilename`). Note that this
18
+ * grammar is LOOSER than what `parseSlug` accepts, which is the gap
19
+ * `static/`'s `assertRoutableSlugs` exists to catch at build time.
20
+ *
21
+ * Module map: ./AGENTS.md.
22
+ */
1
23
  import fs from 'node:fs/promises';
2
24
  import path from 'node:path';
3
25
  import { isValidId } from './id.js';
@@ -44,6 +44,9 @@ export const readEntryData = async (filePath, format, bodyFieldName = 'body') =>
44
44
  if (format === 'yaml') {
45
45
  return asRecord(yamlParse(raw));
46
46
  }
47
+ // Read-only here, and the result is copied immediately below before anything
48
+ // is written into it -- see that comment for the hazard being avoided.
49
+ // eslint-disable-next-line no-restricted-syntax
47
50
  const parsed = matter(raw);
48
51
  // Copy before writing the body in. gray-matter keeps a PROCESS-GLOBAL cache keyed by file
49
52
  // content and hands every caller the same `data` object instance, so mutating it in place
@@ -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) {
@@ -130,7 +130,17 @@ export interface ContentStoreOptions {
130
130
  * non-default content root would otherwise get an index built from a
131
131
  * directory that does not exist: empty, so every ID-based lookup (reference
132
132
  * resolution, entry links, order cleanup, rename) silently misses while
133
- * path-based reads keep working. Defaults to 'content'.
133
+ * path-based reads keep working.
134
+ *
135
+ * Defaults to 'content'.
136
+ *
137
+ * ENFORCED BY LINT, not by the type. Omitting it is silent and data-shaped
138
+ * when wrong -- nothing throws, nothing logs, and only ID-addressed lookups
139
+ * miss -- so every production construction must pass it. That is checked by
140
+ * the `no-restricted-syntax` rule on `new ContentStore(...)` in
141
+ * eslint.config.mjs, which is scoped to non-test sources: making the field
142
+ * required in the TYPE would have forced the argument on 61 test call sites
143
+ * that legitimately want the default, for no gain in the 11 production ones.
134
144
  */
135
145
  contentRootName?: string;
136
146
  /**
@@ -289,6 +299,45 @@ export declare class ContentStore {
289
299
  */
290
300
  getSchemaItems(): IterableIterator<FlatSchemaItem>;
291
301
  private assertSchemaItem;
302
+ /**
303
+ * Is this path a COLLECTION schema item?
304
+ *
305
+ * The non-throwing form of `assertCollection`, reading the same `schemaIndex` -- which is the
306
+ * point. A caller that gates on this cannot disagree with what `buildPaths` will then do: the
307
+ * Map is last-wins, so where a subcollection's path collides with a parent's entry-type name
308
+ * both this and `buildPaths` see the collection. A `find` over the flat schema LIST is
309
+ * first-wins and would not.
310
+ *
311
+ * Type-only, deliberately -- `resolvePath` additionally requires `entries`, but a collection
312
+ * with subcollections and no entries of its own is legal, and mirroring that stricter test here
313
+ * would reject something `buildPaths` accepts.
314
+ *
315
+ * Exists for `readByUrlPath`'s URL-addressability gate; see `ReadContentInput`'s
316
+ * `urlAddressableOnly` and the note on `buildPaths`' entry-type branch below.
317
+ */
318
+ isCollectionPath(collectionPath: LogicalPath): boolean;
319
+ /**
320
+ * Does this collection declare `entryTypeName` in its `entries` config?
321
+ *
322
+ * Mirrors `parseTypedFilename(filename, collection.entries)`, which is how `listEntries` decides
323
+ * whether a file on disk is one of the collection's entries at all. `buildPaths`' own directory
324
+ * scan deliberately does NOT check this -- it matches on slug alone, so that an entry whose type
325
+ * was renamed out of the schema stays findable and therefore still editable, renameable and
326
+ * deletable. Only URL resolution consults this, so what enumeration hides is not served.
327
+ *
328
+ * Returns FALSE when the collection declares no `entries` at all, which is stricter than
329
+ * `parseTypedFilename`'s own `if (entryTypes && ...)` guard and deliberately so: the enumerating
330
+ * surface is not `parseTypedFilename`, it is `listCollectionEntries`, and that returns `[]`
331
+ * outright for a collection with no `entries`. A collections-only container therefore publishes
332
+ * nothing, so a URL read that resolved a file sitting in one would be answering where nothing is
333
+ * advertised -- the exact disagreement this predicate exists to close. Such a file cannot have
334
+ * been created by the CMS (there is no entry type to create it as); it arrived by hand, by merge
335
+ * or by retrofit, and it is invisible to the sitemap and to static params either way.
336
+ *
337
+ * Returns true for a path that is not a collection at all, because that is rule 1's question,
338
+ * not this one's -- and under `urlAddressableOnly` rule 1 has already rejected it.
339
+ */
340
+ declaresEntryType(collectionPath: LogicalPath, entryTypeName: string): boolean;
292
341
  private assertCollection;
293
342
  /**
294
343
  * Lock key for an existing entry, addressed by its permanent content ID
@@ -1,3 +1,32 @@
1
+ /**
2
+ * ContentStore — the authoritative read/write boundary for entry content.
3
+ *
4
+ * Everything that creates, reads, updates, renames or deletes an entry file goes
5
+ * through here. `api/content.ts` is its HTTP front door.
6
+ *
7
+ * ORIENTATION — this is one of seven `content-*` modules, and picking the wrong
8
+ * one costs more time than reading this comment:
9
+ *
10
+ * content-store.ts (this file) the write boundary, plus path-and-id resolution
11
+ * content-reader.ts branch-aware read facade over this store
12
+ * content-listing.ts batch listing (`listEntries`) for adopters
13
+ * content-tree.ts the adopter-facing navigable tree
14
+ * content-id-index.ts the id -> path index this store consults
15
+ * content-index-registry.ts IN-PROCESS cache invalidation registry
16
+ * content-index-generation.ts ON-DISK generation marker <- near-homonym of the line above,
17
+ * unrelated job; check which one you want
18
+ *
19
+ * SHAPE — the CRUD/path/lock core is genuinely interwoven (`buildPaths` alone has
20
+ * ~25 call sites in this file), but two clusters are only loosely attached and are the ones
21
+ * to read in isolation: reference resolution (`resolveReferences` and friends, at
22
+ * the end of the file) and ID-index coherency (`idIndex`, `recordOwnMutation`,
23
+ * `refreshIndexForSuspiciousLookup`).
24
+ *
25
+ * The load-bearing rules are documented at the point of the rule, not here — see
26
+ * in particular `ContentStoreOptions.contentRootName`, the `[SLUG]` guard in
27
+ * `write()`, and the read-path note on `parseSlug`. Those comments are
28
+ * authoritative. Module map: ./AGENTS.md.
29
+ */
1
30
  import fs from 'node:fs/promises';
2
31
  import path from 'node:path';
3
32
  import matter from 'gray-matter';
@@ -391,6 +420,54 @@ export class ContentStore {
391
420
  }
392
421
  return item;
393
422
  }
423
+ /**
424
+ * Is this path a COLLECTION schema item?
425
+ *
426
+ * The non-throwing form of `assertCollection`, reading the same `schemaIndex` -- which is the
427
+ * point. A caller that gates on this cannot disagree with what `buildPaths` will then do: the
428
+ * Map is last-wins, so where a subcollection's path collides with a parent's entry-type name
429
+ * both this and `buildPaths` see the collection. A `find` over the flat schema LIST is
430
+ * first-wins and would not.
431
+ *
432
+ * Type-only, deliberately -- `resolvePath` additionally requires `entries`, but a collection
433
+ * with subcollections and no entries of its own is legal, and mirroring that stricter test here
434
+ * would reject something `buildPaths` accepts.
435
+ *
436
+ * Exists for `readByUrlPath`'s URL-addressability gate; see `ReadContentInput`'s
437
+ * `urlAddressableOnly` and the note on `buildPaths`' entry-type branch below.
438
+ */
439
+ isCollectionPath(collectionPath) {
440
+ return this.schemaIndex.get(normalizeFilesystemPath(collectionPath))?.type === 'collection';
441
+ }
442
+ /**
443
+ * Does this collection declare `entryTypeName` in its `entries` config?
444
+ *
445
+ * Mirrors `parseTypedFilename(filename, collection.entries)`, which is how `listEntries` decides
446
+ * whether a file on disk is one of the collection's entries at all. `buildPaths`' own directory
447
+ * scan deliberately does NOT check this -- it matches on slug alone, so that an entry whose type
448
+ * was renamed out of the schema stays findable and therefore still editable, renameable and
449
+ * deletable. Only URL resolution consults this, so what enumeration hides is not served.
450
+ *
451
+ * Returns FALSE when the collection declares no `entries` at all, which is stricter than
452
+ * `parseTypedFilename`'s own `if (entryTypes && ...)` guard and deliberately so: the enumerating
453
+ * surface is not `parseTypedFilename`, it is `listCollectionEntries`, and that returns `[]`
454
+ * outright for a collection with no `entries`. A collections-only container therefore publishes
455
+ * nothing, so a URL read that resolved a file sitting in one would be answering where nothing is
456
+ * advertised -- the exact disagreement this predicate exists to close. Such a file cannot have
457
+ * been created by the CMS (there is no entry type to create it as); it arrived by hand, by merge
458
+ * or by retrofit, and it is invisible to the sitemap and to static params either way.
459
+ *
460
+ * Returns true for a path that is not a collection at all, because that is rule 1's question,
461
+ * not this one's -- and under `urlAddressableOnly` rule 1 has already rejected it.
462
+ */
463
+ declaresEntryType(collectionPath, entryTypeName) {
464
+ const item = this.schemaIndex.get(normalizeFilesystemPath(collectionPath));
465
+ if (item?.type !== 'collection')
466
+ return true;
467
+ if (!item.entries)
468
+ return false;
469
+ return item.entries.some((e) => e.name === entryTypeName);
470
+ }
394
471
  assertCollection(collectionPath) {
395
472
  const item = this.assertSchemaItem(collectionPath);
396
473
  if (item.type !== 'collection') {
@@ -499,9 +576,20 @@ export class ContentStore {
499
576
  const rootWithSep = this.root.endsWith(path.sep) ? this.root : `${this.root}${path.sep}`;
500
577
  // Entry-type items: delegate to their parent collection.
501
578
  // 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', '')).
579
+ //
580
+ // This branch is for DIRECT ContentStore usage -- store.read('content/home', ''), and the
581
+ // read({ entryPath: 'content/home' }) API built on it, where the slug defaults to the entry
582
+ // type's own name. The API layer resolves paths via resolvePath(), which returns the parent
583
+ // collection directly and so never lands here.
584
+ //
585
+ // That used to be an observation, and it was WRONG: readByUrlPath reached this branch too,
586
+ // because `resolveUrlPathCandidates` happily produces `content/<typeName>` for the URL
587
+ // `/<typeName>` and the delegation below then answered it with the parent collection's index
588
+ // entry -- a URL no forward surface publishes. It is now enforced rather than assumed:
589
+ // readByUrlPath requires every candidate's entryPath to be a collection (see
590
+ // `isCollectionPath` and `ReadContentInput.urlAddressableOnly`). Narrowing the delegation
591
+ // ITSELF was the wrong fix -- write()/renameEntry()/delete() resolve through here as well,
592
+ // and the by-URL rule has no business constraining them.
505
593
  if (schemaItem.type === 'entry-type') {
506
594
  const parentPath = schemaItem.parentPath || '';
507
595
  const parentCollection = this.schemaIndex.get(parentPath);
@@ -697,12 +785,22 @@ export class ContentStore {
697
785
  };
698
786
  }
699
787
  else {
788
+ // eslint-disable-next-line no-restricted-syntax -- see the copy below
700
789
  const parsed = matter(raw);
701
790
  doc = {
702
791
  collection: schemaItem.logicalPath,
703
792
  collectionName: schemaItem.name,
704
793
  format: format,
705
- data: parsed.data ?? {},
794
+ // Copy, never the object gray-matter hands back: its cache is
795
+ // process-global and keyed by file content, so every caller parsing the
796
+ // same bytes gets the SAME `data` instance (the hazard content-listing.ts
797
+ // documents at length). Assigning it straight into `doc.data` was safe
798
+ // only by accident -- `resolveReferencesInData` spreads its input, so the
799
+ // shared object got replaced a few lines down. But that only happens when
800
+ // resolution runs: with `resolveReferences: false` the caller was handed
801
+ // the shared instance itself, and one mutation would poison every later
802
+ // parse of those bytes.
803
+ data: { ...(parsed.data ?? {}) },
706
804
  body: parsed.content,
707
805
  bodyFieldName: findBodyFieldName(fields),
708
806
  relativePath,
package/dist/context.d.ts CHANGED
@@ -1,3 +1,25 @@
1
+ /**
2
+ * `createCanopyContext` — the per-REQUEST content facade.
3
+ *
4
+ * Binds a user and a branch to the long-lived `CanopyServices` (see
5
+ * `services.ts`) and exposes the read surface page code and API handlers
6
+ * actually call: `read`, `readByUrlPath`, listing and tree helpers. This is the
7
+ * layer where authorization is applied, so a read that bypasses it bypasses
8
+ * path ACLs.
9
+ *
10
+ * PHASE-AWARE: at build time (`isBuildMode`) or for a static deployment
11
+ * (`isDeployedStatic`) it authorizes as `STATIC_DEPLOY_USER` instead of a real
12
+ * user. Note that this changes WHO the read is authorized as, not WHERE it reads
13
+ * from — in dev mode every read resolves to the branch clone under
14
+ * `.canopy-dev/content-branches/`, during `next build` exactly as during
15
+ * `next dev`. See DEVELOPING.md's "Dev Content Sync" section; this has cost
16
+ * several people real time.
17
+ *
18
+ * The Next.js adapter wraps this in React `cache()` for per-request memoization
19
+ * (`canopycms-next/src/context-wrapper.ts`).
20
+ *
21
+ * Module map: ./AGENTS.md.
22
+ */
1
23
  import type { CanopyUser } from './user.js';
2
24
  import type { CanopyServices } from './services.js';
3
25
  import type { ContentReadMeta } from './content-reader.js';
@@ -76,12 +98,22 @@ export interface CanopyBuildContext {
76
98
  * Read content by URL path, resolving the collection/entry split automatically.
77
99
  *
78
100
  * Tries direct entry match first (last segment = slug, rest = collection path),
79
- * then falls back to index entry (full path = collection, slug = 'index').
101
+ * then falls back to index entry (full path = collection, slug = 'index'). Both attempts
102
+ * require the collection part to be a real collection — see below.
80
103
  * Root path '/' resolves to the content root's index entry.
81
104
  *
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.
105
+ * Resolves ONLY what `listEntries` publishes — `readByUrlPath(item.urlPath)` reaches the entry,
106
+ * and no other spelling does. Three shapes that used to resolve are therefore null now:
107
+ * `/x/index` (the literal spelling of an index entry's collapsed URL, in any case),
108
+ * `/<collection>/<entryTypeName>` and `/<collection>/<entryTypeName>/<slug>` (an entry-type
109
+ * path is not a collection, so it is not part of any published URL), and an entry whose
110
+ * on-disk type token its collection does not declare (`listEntries` skips those too). A
111
+ * collection literally named `index` is unaffected — the index fallback resolves it.
112
+ *
113
+ * `read({ entryPath: 'content/home' })` is deliberately NOT narrowed: addressing an entry
114
+ * structurally, by its schema path, is a different question from addressing it by its
115
+ * published URL, and it is the only way to reach a singleton without knowing its slug.
116
+ *
85
117
  * Returns null if no content matches the path — including collection URLs that have no
86
118
  * index entry (use buildContentTree for those) and non-entry/invalid paths such as
87
119
  * `/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
@@ -37,7 +37,7 @@ function formatAgeMs(ms) {
37
37
  return `${totalDays}d ${totalHours % 24}h`;
38
38
  }
39
39
  /**
40
- * [LOW-2] Whether the Purge button should be disabled for a corrupt-metadata
40
+ * Whether the Purge button should be disabled for a corrupt-metadata
41
41
  * or orphan row, and the tooltip explaining why.
42
42
  * - Base branch: never purgeable -- the server already 400s
43
43
  * ('The base branch directory can never be purged', see
@@ -214,7 +214,7 @@ function BranchesTab({ health }) {
214
214
  function BranchHealthRow({ entry, onMarkMerged, onRepair, onPurge, }) {
215
215
  if (entry.kind === 'healthy' && entry.branch) {
216
216
  const b = entry.branch;
217
- // [LOW-3] Mirror rebaseActiveBranches' skip logic (worker/cms-worker.ts):
217
+ // Mirror the rebase loop's skip logic (worker/rebase.ts):
218
218
  // the worker rebases every branch except 'submitted'/'approved' (under an
219
219
  // active PR) and 'archived' (already merged). Stated as an exclusion list
220
220
  // rather than `status === 'editing'` so that a status added later shows its
@@ -1,3 +1,25 @@
1
+ /**
2
+ * The entry-schema authoring API — the surface ADOPTERS write against.
3
+ *
4
+ * `defineEntrySchema`, `defineInlineFieldGroup`, `defineNestedFieldGroup` and
5
+ * `defineSeoFieldGroup` are how a host app declares its content model in
6
+ * TypeScript, with the field list inferring the entry's data type. Changing a
7
+ * signature here is an adopter-facing breaking change, not an internal one.
8
+ *
9
+ * This is the type-level half of the schema story. The runtime half — loading
10
+ * `.collection.json`, resolving it, and validating content against it — lives in
11
+ * `schema/` and `validation/`.
12
+ *
13
+ * `RESOLVED_REFERENCE_KEYS`/`buildResolvedReference` define the shape a resolved
14
+ * reference takes; `validation/entry-validator.ts`'s `normalizeReferenceValues`
15
+ * is the inverse, and the two must agree.
16
+ *
17
+ * `DEFAULT_SEO_FIELD_NAMES` is imported from `static/seo.ts` rather than
18
+ * redeclared, so the schema this module emits and the fields that module reads
19
+ * cannot drift apart.
20
+ *
21
+ * Module map: ./AGENTS.md.
22
+ */
1
23
  import type { ComponentType } from 'react';
2
24
  /** Structural constraint for fields that can be inferred by TypeFromEntrySchema. */
3
25
  type InferableField = {
@@ -2,8 +2,9 @@ import { type SimpleGitOptions, type StatusResult } from 'simple-git';
2
2
  import type { OperatingMode } from './operating-mode/index.js';
3
3
  /**
4
4
  * Exported for GitManager's own use (see `this.git.env(...)` above),
5
- * worker/cms-worker.ts's push-rejection-classified GitHub calls
6
- * (`pushBranchToGitHub`, `syncGit`'s fetch/`pushSettingsBranches` instance),
5
+ * the worker's push-rejection-classified GitHub calls
6
+ * (`pushBranchToGitHub` in worker/task-runner.ts, `syncGit`'s
7
+ * fetch/`pushSettingsBranches` instance in worker/git-sync.ts),
7
8
  * and tests.
8
9
  */
9
10
  export declare function gitChildEnv(overrides: Record<string, string>): Record<string, string>;
@@ -16,8 +17,8 @@ export declare function gitNetworkChildEnv(): Record<string, string>;
16
17
  * `remote.git`'s `refs/heads/*` is NOT a throwaway mirror: it's the
17
18
  * deployment's local origin. `GitManager.push()` writes editor work into it
18
19
  * (`target:target`), branch-workspace clones are cloned FROM it, and the CMS
19
- * worker itself pushes it on to GitHub (`pushBranchToGitHub`,
20
- * `pushSettingsBranches` in worker/cms-worker.ts). A fetch that force-writes
20
+ * worker itself pushes it on to GitHub (`pushBranchToGitHub` in
21
+ * worker/task-runner.ts, `pushSettingsBranches` in worker/git-sync.ts). A fetch that force-writes
21
22
  * GitHub's refs straight into `refs/heads/*` (the old
22
23
  * `+refs/heads/*:refs/heads/*` refspec) can therefore destroy work that
23
24
  * reached `remote.git` but not GitHub yet: with `--prune`, a branch pushed
@@ -30,10 +31,10 @@ export declare function gitNetworkChildEnv(): Record<string, string>;
30
31
  * Fetching into this remote-tracking namespace instead makes `--prune`/`+`
31
32
  * safe again -- they now only ever affect GitHub's-view-of-the-world refs,
32
33
  * never the local heads other code depends on. `reconcileTrackedBranches()`
33
- * (worker/cms-worker.ts) is what subsequently, and non-destructively, brings
34
+ * (worker/git-sync.ts) is what subsequently, and non-destructively, brings
34
35
  * `refs/heads/*` toward what's tracked here.
35
36
  *
36
- * Lives here (not worker/cms-worker.ts, where this constant originated) so
37
+ * Lives here (not the worker, where this constant originated) so
37
38
  * `GitManager.bareRemoteHasBranch` -- which must recognize this namespace
38
39
  * too, since it's what a branch pushed by another CanopyCMS deployment (or
39
40
  * pushed directly to GitHub) shows up in before/without ever gaining a local
@@ -172,7 +173,7 @@ export declare class GitManager {
172
173
  * guard, which also needs the tracking namespace: `syncGit()` fetches
173
174
  * GitHub into `GITHUB_TRACKING_REF_PREFIX*` rather than `refs/heads/*`
174
175
  * directly (see that constant's doc comment above), and
175
- * `reconcileTrackedBranches()` (worker/cms-worker.ts) only
176
+ * `reconcileTrackedBranches()` (worker/git-sync.ts) only
176
177
  * non-destructively brings `refs/heads/*` toward what's tracked there — so
177
178
  * a branch that another CanopyCMS deployment sharing this repo (or a
178
179
  * direct push to GitHub) just created can sit in the tracking namespace
@@ -229,7 +230,7 @@ export declare class GitManager {
229
230
  * "absent" deterministic instead of parsing update-ref's locale-dependent
230
231
  * failure text, and its captured SHA is passed to `update-ref -d` as the
231
232
  * expected old value -- same pattern as reconcileTrackedBranches'
232
- * guarded updates (worker/cms-worker.ts): a concurrent Lambda push
233
+ * guarded updates (worker/git-sync.ts): a concurrent Lambda push
233
234
  * re-creating/moving this branch between the read and the delete makes
234
235
  * update-ref throw (surfaced as the caller's best-effort warning) instead
235
236
  * of silently deleting a commit that was just pushed. No
@@ -1,3 +1,28 @@
1
+ /**
2
+ * Git operations for branch workspaces.
3
+ *
4
+ * This file is effectively TWO modules sharing a class name, split cleanly by
5
+ * line number:
6
+ *
7
+ * Everything from `cloneRepo` down to `initializeWorkspace` is `static` —
8
+ * workspace PROVISIONING (also ensureLocalSimulatedRemote, bareRemoteHasBranch,
9
+ * deleteBareRemoteHead, findGitRoot, resolveRemoteUrl). These share no instance
10
+ * state; the class is acting as a namespace.
11
+ * Everything from `status()` onward is an INSTANCE method — per-repo operations
12
+ * on one already-provisioned workspace (checkoutBranch, pullBase,
13
+ * rebaseOntoBase, add/commit/push, ...), needing
14
+ * `repoPath`/`baseBranch`/`remote`.
15
+ *
16
+ * `status()` is the dividing line. If you are here to change provisioning, nothing
17
+ * after it concerns you, and vice versa.
18
+ *
19
+ * Every git invocation is argv-based with `--end-of-options`, and `gitChildEnv`
20
+ * forces `LC_ALL=C`/`LANG=C` so git's own message text stays English — several
21
+ * callers classify errors by matching it (see `utils/git.ts`'s
22
+ * `isNonFastForwardRejection`). Do not remove that.
23
+ *
24
+ * Module map: ./AGENTS.md. Locking rules: ../../../docs/concurrency.md.
25
+ */
1
26
  import fs from 'node:fs/promises';
2
27
  import path from 'node:path';
3
28
  import { simpleGit, } from 'simple-git';
@@ -44,8 +69,9 @@ const GIT_ENV_PASSTHROUGH = /^(PATH|HOME|USER|LANG|LC_[A-Z]+|TZ|TMPDIR|GIT_(AUTH
44
69
  const FORCE_C_LOCALE = { LC_ALL: 'C', LANG: 'C' };
45
70
  /**
46
71
  * Exported for GitManager's own use (see `this.git.env(...)` above),
47
- * worker/cms-worker.ts's push-rejection-classified GitHub calls
48
- * (`pushBranchToGitHub`, `syncGit`'s fetch/`pushSettingsBranches` instance),
72
+ * the worker's push-rejection-classified GitHub calls
73
+ * (`pushBranchToGitHub` in worker/task-runner.ts, `syncGit`'s
74
+ * fetch/`pushSettingsBranches` instance in worker/git-sync.ts),
49
75
  * and tests.
50
76
  */
51
77
  export function gitChildEnv(overrides) {
@@ -100,8 +126,8 @@ const remoteInitLocks = new Map();
100
126
  * `remote.git`'s `refs/heads/*` is NOT a throwaway mirror: it's the
101
127
  * deployment's local origin. `GitManager.push()` writes editor work into it
102
128
  * (`target:target`), branch-workspace clones are cloned FROM it, and the CMS
103
- * worker itself pushes it on to GitHub (`pushBranchToGitHub`,
104
- * `pushSettingsBranches` in worker/cms-worker.ts). A fetch that force-writes
129
+ * worker itself pushes it on to GitHub (`pushBranchToGitHub` in
130
+ * worker/task-runner.ts, `pushSettingsBranches` in worker/git-sync.ts). A fetch that force-writes
105
131
  * GitHub's refs straight into `refs/heads/*` (the old
106
132
  * `+refs/heads/*:refs/heads/*` refspec) can therefore destroy work that
107
133
  * reached `remote.git` but not GitHub yet: with `--prune`, a branch pushed
@@ -114,10 +140,10 @@ const remoteInitLocks = new Map();
114
140
  * Fetching into this remote-tracking namespace instead makes `--prune`/`+`
115
141
  * safe again -- they now only ever affect GitHub's-view-of-the-world refs,
116
142
  * never the local heads other code depends on. `reconcileTrackedBranches()`
117
- * (worker/cms-worker.ts) is what subsequently, and non-destructively, brings
143
+ * (worker/git-sync.ts) is what subsequently, and non-destructively, brings
118
144
  * `refs/heads/*` toward what's tracked here.
119
145
  *
120
- * Lives here (not worker/cms-worker.ts, where this constant originated) so
146
+ * Lives here (not the worker, where this constant originated) so
121
147
  * `GitManager.bareRemoteHasBranch` -- which must recognize this namespace
122
148
  * too, since it's what a branch pushed by another CanopyCMS deployment (or
123
149
  * pushed directly to GitHub) shows up in before/without ever gaining a local
@@ -388,7 +414,7 @@ export class GitManager {
388
414
  * guard, which also needs the tracking namespace: `syncGit()` fetches
389
415
  * GitHub into `GITHUB_TRACKING_REF_PREFIX*` rather than `refs/heads/*`
390
416
  * directly (see that constant's doc comment above), and
391
- * `reconcileTrackedBranches()` (worker/cms-worker.ts) only
417
+ * `reconcileTrackedBranches()` (worker/git-sync.ts) only
392
418
  * non-destructively brings `refs/heads/*` toward what's tracked there — so
393
419
  * a branch that another CanopyCMS deployment sharing this repo (or a
394
420
  * direct push to GitHub) just created can sit in the tracking namespace
@@ -485,7 +511,7 @@ export class GitManager {
485
511
  * "absent" deterministic instead of parsing update-ref's locale-dependent
486
512
  * failure text, and its captured SHA is passed to `update-ref -d` as the
487
513
  * expected old value -- same pattern as reconcileTrackedBranches'
488
- * guarded updates (worker/cms-worker.ts): a concurrent Lambda push
514
+ * guarded updates (worker/git-sync.ts): a concurrent Lambda push
489
515
  * re-creating/moving this branch between the read and the delete makes
490
516
  * update-ref throw (surfaced as the caller's best-effort warning) instead
491
517
  * of silently deleting a commit that was just pushed. No
@@ -965,7 +991,7 @@ export class GitManager {
965
991
  // Merge the just-fetched tip (pinned to a SHA), not <remote>/<base>:
966
992
  // workspaces are cloned --single-branch, so the remote-tracking ref for
967
993
  // any branch other than the cloned one never exists (same fix as the
968
- // worker's rebase loop — see cms-worker.ts), and FETCH_HEAD itself is a
994
+ // worker's rebase loop — see worker/rebase.ts), and FETCH_HEAD itself is a
969
995
  // shared mutable file repointed by any other fetch in this clone.
970
996
  const fetchedTip = (await this.git.revparse(['FETCH_HEAD'])).trim();
971
997
  try {
@@ -16,7 +16,7 @@ export declare const shouldRetrySecondaryRateLimit: (retryAfter: number, retryCo
16
16
  /**
17
17
  * Create an Octokit instance with the throttling plugin attached, so it
18
18
  * proactively respects GitHub's `retry-after` guidance on rate limits
19
- * instead of failing immediately (see cms-worker.ts isPermanentTaskFailure
19
+ * instead of failing immediately (see worker/task-runner.ts's isPermanentTaskFailure
20
20
  * for the safety net this doesn't cover: exhausted plugin retries and
21
21
  * errors the plugin never sees, like non-403 network failures).
22
22
  */
@@ -25,7 +25,7 @@ export const shouldRetrySecondaryRateLimit = (retryAfter, retryCount) => retryCo
25
25
  /**
26
26
  * Create an Octokit instance with the throttling plugin attached, so it
27
27
  * proactively respects GitHub's `retry-after` guidance on rate limits
28
- * instead of failing immediately (see cms-worker.ts isPermanentTaskFailure
28
+ * instead of failing immediately (see worker/task-runner.ts's isPermanentTaskFailure
29
29
  * for the safety net this doesn't cover: exhausted plugin retries and
30
30
  * errors the plugin never sees, like non-403 network failures).
31
31
  */
@@ -23,7 +23,7 @@ export declare function sanitizeBranchName(branchName: string): SanitizedBranchN
23
23
  * operating-mode/deployment-name.ts's `resolveDeploymentName`). Exported from
24
24
  * this dependency-free module -- not constructed ad hoc at each call site --
25
25
  * so every consumer that needs to recognize the settings-branch namespace
26
- * agrees on the exact string: worker/cms-worker.ts's `pushSettingsBranches`
26
+ * agrees on the exact string: worker/git-sync.ts's `pushSettingsBranches`
27
27
  * (never push a `canopycms-settings-*` branch this deployment doesn't own),
28
28
  * and api/branch.ts's `createBranchHandler` (reject a user-requested branch
29
29
  * whose SANITIZED name falls in this namespace).