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.
- package/README.md +1 -3
- package/dist/ai/generate.d.ts +13 -0
- package/dist/ai/generate.js +9 -3
- package/dist/ai/to-plain-text.js +4 -0
- package/dist/ai/types.d.ts +20 -1
- package/dist/api/admin-branch-health.js +13 -12
- package/dist/api/branch.js +2 -2
- package/dist/branch-health.js +1 -1
- package/dist/branch-metadata-file.d.ts +58 -0
- package/dist/branch-metadata-file.js +65 -0
- package/dist/branch-metadata.d.ts +2 -26
- package/dist/branch-metadata.js +6 -33
- package/dist/branch-registry.js +4 -2
- package/dist/build/generate-ai-content.js +103 -0
- package/dist/cli/cli.js +432 -317
- package/dist/cli/generate-ai-content.js +264 -160
- package/dist/content-id-index.js +22 -0
- package/dist/content-listing.js +3 -0
- package/dist/content-reader.d.ts +25 -1
- package/dist/content-reader.js +15 -0
- package/dist/content-store.d.ts +50 -1
- package/dist/content-store.js +102 -4
- package/dist/context.d.ts +36 -4
- package/dist/context.js +15 -4
- package/dist/editor/admin/SystemHealthPanel.js +2 -2
- package/dist/entry-schema.d.ts +22 -0
- package/dist/git-manager.d.ts +9 -8
- package/dist/git-manager.js +35 -9
- package/dist/github-service.d.ts +1 -1
- package/dist/github-service.js +1 -1
- package/dist/paths/branch-name.d.ts +1 -1
- package/dist/paths/branch-name.js +1 -1
- package/dist/schema/index.d.ts +1 -1
- package/dist/schema/index.js +1 -1
- package/dist/schema/meta-loader.js +1 -1
- package/dist/services.d.ts +22 -0
- package/dist/types.d.ts +1 -1
- 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/dist/utils/content-write-lock.d.ts +2 -1
- package/dist/utils/content-write-lock.js +2 -1
- package/dist/utils/error.d.ts +17 -0
- package/dist/utils/error.js +17 -0
- package/dist/utils/git.d.ts +1 -1
- package/dist/utils/git.js +1 -1
- package/dist/utils/occ-json-write.js +1 -1
- package/dist/worker/cms-worker.d.ts +22 -288
- package/dist/worker/cms-worker.js +118 -1995
- package/dist/worker/git-sync.d.ts +166 -0
- package/dist/worker/git-sync.js +554 -0
- package/dist/worker/history-rewrite.d.ts +129 -0
- package/dist/worker/history-rewrite.js +216 -0
- package/dist/worker/rebase.d.ts +171 -0
- package/dist/worker/rebase.js +859 -0
- package/dist/worker/task-runner.d.ts +93 -0
- package/dist/worker/task-runner.js +529 -0
- package/dist/worker/worker-context.d.ts +133 -0
- package/dist/worker/worker-context.js +1 -0
- package/package.json +1 -1
package/dist/content-id-index.js
CHANGED
|
@@ -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';
|
package/dist/content-listing.js
CHANGED
|
@@ -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
|
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
|
@@ -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.
|
|
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
|
package/dist/content-store.js
CHANGED
|
@@ -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
|
-
//
|
|
503
|
-
//
|
|
504
|
-
//
|
|
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
|
-
|
|
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
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
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
|
|
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
|
|
@@ -37,7 +37,7 @@ function formatAgeMs(ms) {
|
|
|
37
37
|
return `${totalDays}d ${totalHours % 24}h`;
|
|
38
38
|
}
|
|
39
39
|
/**
|
|
40
|
-
*
|
|
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
|
-
//
|
|
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
|
package/dist/entry-schema.d.ts
CHANGED
|
@@ -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 = {
|
package/dist/git-manager.d.ts
CHANGED
|
@@ -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
|
|
6
|
-
* (`pushBranchToGitHub
|
|
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/
|
|
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/
|
|
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
|
|
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/
|
|
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/
|
|
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
|
package/dist/git-manager.js
CHANGED
|
@@ -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
|
|
48
|
-
* (`pushBranchToGitHub
|
|
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/
|
|
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/
|
|
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
|
|
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/
|
|
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/
|
|
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
|
|
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 {
|
package/dist/github-service.d.ts
CHANGED
|
@@ -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
|
|
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
|
*/
|
package/dist/github-service.js
CHANGED
|
@@ -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
|
|
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/
|
|
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).
|