@glw907/cairn-cms 0.60.1 → 0.62.2
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/CHANGELOG.md +78 -0
- package/dist/components/AdminLayout.svelte +22 -0
- package/dist/components/CairnAdmin.svelte +3 -0
- package/dist/components/CairnTidySettings.svelte +2 -2
- package/dist/components/CairnTidySettings.svelte.d.ts +1 -1
- package/dist/components/EditPage.svelte +116 -39
- package/dist/components/HelpHome.svelte +824 -0
- package/dist/components/HelpHome.svelte.d.ts +22 -0
- package/dist/components/MarkdownHelpDialog.svelte +4 -15
- package/dist/components/client-ingest.d.ts +16 -8
- package/dist/components/client-ingest.js +12 -6
- package/dist/components/editor-media.js +16 -8
- package/dist/components/editor-placeholder.d.ts +4 -2
- package/dist/components/editor-tidy.d.ts +24 -12
- package/dist/components/editor-tidy.js +8 -4
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/components/link-completion.d.ts +12 -6
- package/dist/components/link-completion.js +12 -6
- package/dist/components/markdown-directives.d.ts +9 -6
- package/dist/components/markdown-directives.js +9 -6
- package/dist/components/markdown-format.d.ts +7 -2
- package/dist/components/markdown-format.js +59 -28
- package/dist/components/markdown-reference.d.ts +8 -0
- package/dist/components/markdown-reference.js +22 -0
- package/dist/components/media-upload-outcome.d.ts +12 -6
- package/dist/components/objective-errors.d.ts +8 -4
- package/dist/components/objective-errors.js +8 -4
- package/dist/components/preview-doc.d.ts +4 -2
- package/dist/components/preview-doc.js +4 -2
- package/dist/components/spellcheck.d.ts +55 -29
- package/dist/components/spellcheck.js +39 -21
- package/dist/components/tidy-categorize.d.ts +20 -10
- package/dist/components/tidy-categorize.js +16 -8
- package/dist/components/tidy-validate.d.ts +12 -6
- package/dist/components/tidy-validate.js +20 -10
- package/dist/components/topbar-context.d.ts +4 -2
- package/dist/content/advisories.d.ts +56 -0
- package/dist/content/advisories.js +87 -0
- package/dist/content/compose.d.ts +4 -2
- package/dist/content/compose.js +1 -0
- package/dist/content/excerpt.js +4 -2
- package/dist/content/getting-started.d.ts +18 -0
- package/dist/content/getting-started.js +12 -0
- package/dist/content/links.d.ts +16 -8
- package/dist/content/links.js +12 -6
- package/dist/content/manifest.d.ts +36 -18
- package/dist/content/manifest.js +32 -16
- package/dist/content/media-refs.d.ts +4 -2
- package/dist/content/media-refs.js +4 -2
- package/dist/content/media-rewrite.d.ts +8 -4
- package/dist/content/media-rewrite.js +76 -38
- package/dist/content/schema.d.ts +20 -10
- package/dist/content/site-dictionary.d.ts +4 -2
- package/dist/content/site-dictionary.js +8 -4
- package/dist/content/types.d.ts +97 -42
- package/dist/delivery/content-index.d.ts +16 -8
- package/dist/delivery/feeds.js +4 -2
- package/dist/delivery/json-ld.d.ts +3 -0
- package/dist/delivery/json-ld.js +3 -0
- package/dist/delivery/manifest.d.ts +4 -2
- package/dist/delivery/manifest.js +4 -2
- package/dist/delivery/public-routes.d.ts +12 -6
- package/dist/delivery/public-routes.js +4 -2
- package/dist/delivery/seo-fields.d.ts +12 -6
- package/dist/delivery/seo-fields.js +8 -4
- package/dist/delivery/site-indexes.d.ts +4 -2
- package/dist/delivery/site-resolver.d.ts +4 -2
- package/dist/delivery/site-resolver.js +4 -2
- package/dist/doctor/cloudflare-api.d.ts +6 -0
- package/dist/doctor/cloudflare-api.js +6 -0
- package/dist/doctor/index.d.ts +12 -6
- package/dist/doctor/report.d.ts +3 -0
- package/dist/doctor/report.js +3 -0
- package/dist/doctor/run.d.ts +3 -0
- package/dist/doctor/run.js +3 -0
- package/dist/doctor/types.d.ts +10 -2
- package/dist/doctor/types.js +6 -0
- package/dist/doctor/wrangler-config.d.ts +7 -2
- package/dist/doctor/wrangler-config.js +3 -0
- package/dist/email.d.ts +4 -2
- package/dist/env.d.ts +0 -3
- package/dist/env.js +0 -3
- package/dist/github/branches.d.ts +4 -2
- package/dist/github/branches.js +4 -2
- package/dist/github/signing.d.ts +1 -1
- package/dist/github/signing.js +2 -2
- package/dist/log/events.d.ts +1 -1
- package/dist/media/bulk-delete-plan.d.ts +8 -4
- package/dist/media/config.d.ts +12 -6
- package/dist/media/config.js +16 -8
- package/dist/media/delivery-bucket.d.ts +4 -2
- package/dist/media/library-entry.d.ts +4 -2
- package/dist/media/library-entry.js +4 -2
- package/dist/media/manifest.d.ts +29 -15
- package/dist/media/manifest.js +29 -16
- package/dist/media/naming.d.ts +12 -6
- package/dist/media/naming.js +24 -12
- package/dist/media/orphan-scan.d.ts +4 -2
- package/dist/media/reconcile.d.ts +21 -11
- package/dist/media/reconcile.js +12 -6
- package/dist/media/reference.d.ts +8 -4
- package/dist/media/reference.js +12 -6
- package/dist/media/rewrite-plan.d.ts +12 -6
- package/dist/media/sniff.d.ts +4 -2
- package/dist/media/sniff.js +28 -14
- package/dist/media/store.d.ts +16 -8
- package/dist/media/store.js +4 -2
- package/dist/media/transform-url.d.ts +12 -6
- package/dist/media/transform-url.js +8 -4
- package/dist/media/usage.d.ts +8 -4
- package/dist/nav/site-config.d.ts +16 -8
- package/dist/render/component-grammar.d.ts +23 -10
- package/dist/render/component-grammar.js +19 -8
- package/dist/render/component-insert.d.ts +8 -4
- package/dist/render/component-insert.js +4 -2
- package/dist/render/component-reference.d.ts +4 -2
- package/dist/render/component-reference.js +4 -2
- package/dist/render/component-validate.d.ts +3 -0
- package/dist/render/component-validate.js +3 -0
- package/dist/render/glyph.d.ts +4 -2
- package/dist/render/glyph.js +4 -2
- package/dist/render/pipeline.d.ts +20 -10
- package/dist/render/pipeline.js +4 -2
- package/dist/render/registry.d.ts +40 -20
- package/dist/render/registry.js +16 -8
- package/dist/render/rehype-dispatch.d.ts +22 -8
- package/dist/render/rehype-dispatch.js +22 -8
- package/dist/render/remark-directives.d.ts +3 -0
- package/dist/render/remark-directives.js +3 -0
- package/dist/render/remark-figure.d.ts +4 -2
- package/dist/render/remark-figure.js +4 -2
- package/dist/render/resolve-links.d.ts +4 -2
- package/dist/render/resolve-links.js +4 -2
- package/dist/render/resolve-media.d.ts +16 -8
- package/dist/render/resolve-media.js +12 -6
- package/dist/sveltekit/admin-dispatch.d.ts +2 -0
- package/dist/sveltekit/admin-dispatch.js +9 -3
- package/dist/sveltekit/auth-routes.d.ts +3 -0
- package/dist/sveltekit/auth-routes.js +3 -0
- package/dist/sveltekit/cairn-admin.d.ts +16 -5
- package/dist/sveltekit/cairn-admin.js +26 -10
- package/dist/sveltekit/content-routes.d.ts +191 -86
- package/dist/sveltekit/content-routes.js +297 -107
- package/dist/sveltekit/editors-routes.d.ts +3 -0
- package/dist/sveltekit/editors-routes.js +3 -0
- package/dist/sveltekit/guard.d.ts +4 -2
- package/dist/sveltekit/guard.js +4 -2
- package/dist/sveltekit/https-required-page.d.ts +1 -1
- package/dist/sveltekit/https-required-page.js +1 -1
- package/dist/sveltekit/index.d.ts +1 -1
- package/dist/sveltekit/media-route.d.ts +1 -2
- package/dist/sveltekit/media-route.js +13 -8
- package/dist/sveltekit/nav-routes.d.ts +7 -2
- package/dist/sveltekit/nav-routes.js +3 -0
- package/dist/sveltekit/types.d.ts +4 -2
- package/dist/vite/index.d.ts +32 -16
- package/dist/vite/index.js +52 -26
- package/dist/vite/resolve-root.d.ts +8 -4
- package/dist/vite/resolve-root.js +4 -2
- package/package.json +7 -1
- package/src/lib/components/AdminLayout.svelte +22 -0
- package/src/lib/components/CairnAdmin.svelte +3 -0
- package/src/lib/components/CairnTidySettings.svelte +2 -2
- package/src/lib/components/ComponentForm.svelte +0 -1
- package/src/lib/components/EditPage.svelte +133 -41
- package/src/lib/components/HelpHome.svelte +850 -0
- package/src/lib/components/MarkdownHelpDialog.svelte +4 -15
- package/src/lib/components/client-ingest.ts +20 -10
- package/src/lib/components/editor-media.ts +20 -10
- package/src/lib/components/editor-placeholder.ts +12 -6
- package/src/lib/components/editor-tidy.ts +28 -14
- package/src/lib/components/index.ts +1 -0
- package/src/lib/components/link-completion.ts +12 -6
- package/src/lib/components/markdown-directives.ts +13 -8
- package/src/lib/components/markdown-format.ts +63 -30
- package/src/lib/components/markdown-reference.ts +30 -0
- package/src/lib/components/media-upload-outcome.ts +12 -6
- package/src/lib/components/objective-errors.ts +16 -8
- package/src/lib/components/preview-doc.ts +4 -2
- package/src/lib/components/spellcheck.ts +79 -41
- package/src/lib/components/tidy-categorize.ts +28 -14
- package/src/lib/components/tidy-validate.ts +28 -14
- package/src/lib/components/topbar-context.ts +4 -2
- package/src/lib/content/advisories.ts +150 -0
- package/src/lib/content/compose.ts +5 -2
- package/src/lib/content/excerpt.ts +4 -2
- package/src/lib/content/getting-started.ts +31 -0
- package/src/lib/content/links.ts +16 -8
- package/src/lib/content/manifest.ts +36 -18
- package/src/lib/content/media-refs.ts +4 -2
- package/src/lib/content/media-rewrite.ts +100 -50
- package/src/lib/content/schema.ts +20 -10
- package/src/lib/content/site-dictionary.ts +8 -4
- package/src/lib/content/types.ts +97 -42
- package/src/lib/delivery/content-index.ts +16 -8
- package/src/lib/delivery/feeds.ts +4 -2
- package/src/lib/delivery/json-ld.ts +3 -0
- package/src/lib/delivery/manifest.ts +4 -2
- package/src/lib/delivery/public-routes.ts +16 -8
- package/src/lib/delivery/seo-fields.ts +12 -6
- package/src/lib/delivery/site-indexes.ts +4 -2
- package/src/lib/delivery/site-resolver.ts +4 -2
- package/src/lib/doctor/cloudflare-api.ts +6 -0
- package/src/lib/doctor/index.ts +12 -6
- package/src/lib/doctor/report.ts +3 -0
- package/src/lib/doctor/run.ts +3 -0
- package/src/lib/doctor/types.ts +10 -2
- package/src/lib/doctor/wrangler-config.ts +7 -2
- package/src/lib/email.ts +4 -2
- package/src/lib/env.ts +0 -3
- package/src/lib/github/branches.ts +4 -2
- package/src/lib/github/signing.ts +2 -2
- package/src/lib/log/events.ts +1 -0
- package/src/lib/media/bulk-delete-plan.ts +8 -4
- package/src/lib/media/config.ts +24 -12
- package/src/lib/media/delivery-bucket.ts +4 -2
- package/src/lib/media/library-entry.ts +4 -2
- package/src/lib/media/manifest.ts +33 -18
- package/src/lib/media/naming.ts +24 -12
- package/src/lib/media/orphan-scan.ts +4 -2
- package/src/lib/media/reconcile.ts +21 -11
- package/src/lib/media/reference.ts +12 -6
- package/src/lib/media/rewrite-plan.ts +12 -6
- package/src/lib/media/sniff.ts +28 -14
- package/src/lib/media/store.ts +16 -8
- package/src/lib/media/transform-url.ts +12 -6
- package/src/lib/media/usage.ts +8 -4
- package/src/lib/nav/site-config.ts +16 -8
- package/src/lib/render/component-grammar.ts +23 -10
- package/src/lib/render/component-insert.ts +8 -4
- package/src/lib/render/component-reference.ts +4 -2
- package/src/lib/render/component-validate.ts +3 -0
- package/src/lib/render/glyph.ts +4 -2
- package/src/lib/render/pipeline.ts +20 -10
- package/src/lib/render/registry.ts +44 -22
- package/src/lib/render/rehype-dispatch.ts +22 -8
- package/src/lib/render/remark-directives.ts +3 -0
- package/src/lib/render/remark-figure.ts +4 -2
- package/src/lib/render/resolve-links.ts +4 -2
- package/src/lib/render/resolve-media.ts +16 -8
- package/src/lib/sveltekit/admin-dispatch.ts +10 -4
- package/src/lib/sveltekit/auth-routes.ts +3 -0
- package/src/lib/sveltekit/cairn-admin.ts +37 -15
- package/src/lib/sveltekit/content-routes.ts +494 -197
- package/src/lib/sveltekit/editors-routes.ts +3 -0
- package/src/lib/sveltekit/guard.ts +4 -2
- package/src/lib/sveltekit/https-required-page.ts +1 -1
- package/src/lib/sveltekit/index.ts +3 -0
- package/src/lib/sveltekit/media-route.ts +13 -8
- package/src/lib/sveltekit/nav-routes.ts +7 -2
- package/src/lib/sveltekit/types.ts +4 -2
- package/src/lib/vite/index.ts +60 -30
- package/src/lib/vite/resolve-root.ts +8 -4
|
@@ -7,7 +7,8 @@ import { findConcept } from '../content/concepts.js';
|
|
|
7
7
|
import { extractCairnLinks, formatCairnToken, rewriteCairnLink } from '../content/links.js';
|
|
8
8
|
import { frontmatterFromForm, parseMarkdown, dateInputValue, serializeMarkdown } from '../content/frontmatter.js';
|
|
9
9
|
import { deriveExcerpt } from '../content/excerpt.js';
|
|
10
|
-
import { asString } from '../content/identity.js';
|
|
10
|
+
import { asString, entryIdentity } from '../content/identity.js';
|
|
11
|
+
import { buildAddressIndex, mainAddressIndex, addressCollision } from '../content/advisories.js';
|
|
11
12
|
import { isValidId, slugify, filenameFromId, composeDatedId, slugFromId, renameId } from '../content/ids.js';
|
|
12
13
|
import { appCredentials } from '../github/credentials.js';
|
|
13
14
|
import { listMarkdown, readRaw, commitFile, commitFiles } from '../github/repo.js';
|
|
@@ -15,6 +16,8 @@ import { branchHeadSha, createBranch, deleteBranch, listBranches } from '../gith
|
|
|
15
16
|
import { PENDING_PREFIX, pendingBranch, parsePendingBranch } from '../content/pending.js';
|
|
16
17
|
import { cachedInstallationToken } from '../github/signing.js';
|
|
17
18
|
import { emptyManifest, manifestEntryFromFile, parseManifest, serializeManifest, upsertEntry, removeEntry, inboundLinks } from '../content/manifest.js';
|
|
19
|
+
import { deriveGettingStarted } from '../content/getting-started.js';
|
|
20
|
+
import { markdownReference } from '../components/markdown-reference.js';
|
|
18
21
|
import { isConflict } from '../github/types.js';
|
|
19
22
|
import { log } from '../log/index.js';
|
|
20
23
|
import { dictionaryFileForDialect, DEFAULT_TIDY_MODEL, resolveTidyConventions, parseSiteConfig, setTidy, validateTidyConventions, TidyConventionsError } from '../nav/site-config.js';
|
|
@@ -40,19 +43,25 @@ import { buildOrphanScan } from '../media/orphan-scan.js';
|
|
|
40
43
|
import { repointMediaRef, fillAltForHash } from '../content/media-rewrite.js';
|
|
41
44
|
import { planMediaRewrite } from '../media/rewrite-plan.js';
|
|
42
45
|
import { planBulkDelete } from '../media/bulk-delete-plan.js';
|
|
43
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* The Worker-side request deadline for the tidy model call: 30 seconds. A tidy call to Sonnet on a
|
|
44
48
|
* full entry can run many seconds, so the action bounds it with an AbortSignal and maps the overrun to
|
|
45
49
|
* a retryable fail(502). This sits well under Cloudflare's per-request wall-clock ceiling (a Worker
|
|
46
50
|
* invocation can run far longer, but a single subrequest left open near that ceiling would surface as a
|
|
47
51
|
* platform timeout the action could not shape into a clean retry). 30s comfortably covers a proofread
|
|
48
|
-
* of the bounded input (see MAX_TIDY_CHARS) while leaving headroom under the platform limit.
|
|
52
|
+
* of the bounded input (see MAX_TIDY_CHARS) while leaving headroom under the platform limit.
|
|
53
|
+
*/
|
|
49
54
|
const DEFAULT_TIDY_TIMEOUT_MS = 30_000;
|
|
50
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* The fallback site-config path when no nav menu names one: the convention every scaffolded site
|
|
51
57
|
* uses. The settings save edits the same committed YAML the nav editor does, so it resolves the path
|
|
52
|
-
* from the configured nav menu first and falls back to this default.
|
|
58
|
+
* from the configured nav menu first and falls back to this default.
|
|
59
|
+
*/
|
|
53
60
|
const DEFAULT_SITE_CONFIG_PATH = 'src/lib/site.config.yaml';
|
|
54
|
-
/**
|
|
55
|
-
*
|
|
61
|
+
/**
|
|
62
|
+
* Plain-language labels for the known tidy models, so the read-only model fact reads as a name rather
|
|
63
|
+
* than a bare id. An unknown id falls back to itself.
|
|
64
|
+
*/
|
|
56
65
|
const TIDY_MODEL_LABELS = {
|
|
57
66
|
'claude-sonnet-4-6': 'Claude Sonnet',
|
|
58
67
|
'claude-haiku-4-5': 'Claude Haiku',
|
|
@@ -61,14 +70,18 @@ const TIDY_MODEL_LABELS = {
|
|
|
61
70
|
function tidyModelLabel(model) {
|
|
62
71
|
return TIDY_MODEL_LABELS[model] ?? model;
|
|
63
72
|
}
|
|
64
|
-
/**
|
|
73
|
+
/**
|
|
74
|
+
* The input cap for a single tidy request: 24000 characters (~6k input tokens). A proofread runs at
|
|
65
75
|
* roughly input length, so this stays comfortably inside the 30s deadline; a longer entry refuses with
|
|
66
76
|
* fail(413) and the author tidies a selection instead. The cap is enforced BEFORE the model call, so an
|
|
67
|
-
* over-long body never spends a token or risks the deadline.
|
|
77
|
+
* over-long body never spends a token or risks the deadline.
|
|
78
|
+
*/
|
|
68
79
|
const MAX_TIDY_CHARS = 24_000;
|
|
69
|
-
/**
|
|
80
|
+
/**
|
|
81
|
+
* Resolve the effective preview for one concept: its `byConcept` override wins per key, with
|
|
70
82
|
* nullish coalescing so an override key that is present but undefined keeps the top-level value.
|
|
71
|
-
* Stylesheets are always shared, and the `byConcept` map never reaches the client.
|
|
83
|
+
* Stylesheets are always shared, and the `byConcept` map never reaches the client.
|
|
84
|
+
*/
|
|
72
85
|
function resolvePreview(preview, conceptId) {
|
|
73
86
|
if (!preview)
|
|
74
87
|
return null;
|
|
@@ -86,6 +99,9 @@ function conceptOf(runtime, params) {
|
|
|
86
99
|
throw error(404, `Unknown content type: ${params.concept ?? ''}`);
|
|
87
100
|
return concept;
|
|
88
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
*
|
|
104
|
+
*/
|
|
89
105
|
export function createContentRoutes(runtime, deps = {}) {
|
|
90
106
|
const mintToken = deps.mintToken ?? ((env) => cachedInstallationToken(appCredentials(runtime.backend, env)));
|
|
91
107
|
// The default Anthropic factory builds the real SDK client from the resolved key. Tests inject a fake
|
|
@@ -93,15 +109,19 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
93
109
|
// SDK client satisfies TidyClient structurally; the cast names that to the compiler.
|
|
94
110
|
const anthropicClient = deps.anthropic ?? ((opts) => new Anthropic({ apiKey: opts.apiKey }));
|
|
95
111
|
const tidyTimeoutMs = deps.tidyTimeoutMs ?? DEFAULT_TIDY_TIMEOUT_MS;
|
|
96
|
-
/**
|
|
97
|
-
*
|
|
112
|
+
/**
|
|
113
|
+
* Main's manifest, parsed. A missing file starts empty (a fresh repo before the first commit).
|
|
114
|
+
* Always read from main: pending branches carry no manifest copy.
|
|
115
|
+
*/
|
|
98
116
|
async function readManifest(token) {
|
|
99
117
|
const raw = await readRaw(runtime.backend, runtime.manifestPath, token);
|
|
100
118
|
return raw === null ? emptyManifest() : parseManifest(raw);
|
|
101
119
|
}
|
|
102
|
-
/**
|
|
120
|
+
/**
|
|
121
|
+
* Parse a committed media.json body to a plain value for parseMediaManifest, degrading a missing
|
|
103
122
|
* or corrupt file to null (an empty manifest). The committed file is always our own serialization,
|
|
104
|
-
* so the catch only guards a hand-edited or truncated file rather than a normal path.
|
|
123
|
+
* so the catch only guards a hand-edited or truncated file rather than a normal path.
|
|
124
|
+
*/
|
|
105
125
|
function parseMediaJson(raw) {
|
|
106
126
|
if (raw === null)
|
|
107
127
|
return null;
|
|
@@ -112,11 +132,13 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
112
132
|
return null;
|
|
113
133
|
}
|
|
114
134
|
}
|
|
115
|
-
/**
|
|
135
|
+
/**
|
|
136
|
+
* The pending entry a `cairn/` ref names, or null for a ref the engine must ignore: a
|
|
116
137
|
* malformed name, an id that fails the slug rule (entry paths are built from it, so this is
|
|
117
138
|
* the path confinement), or a concept this site does not configure. Every ref consumer
|
|
118
139
|
* (the layout count, the list view, publish-all) applies this one predicate, so a stray
|
|
119
|
-
* hand-pushed ref cannot inflate a count it can never clear or reach a contents read.
|
|
140
|
+
* hand-pushed ref cannot inflate a count it can never clear or reach a contents read.
|
|
141
|
+
*/
|
|
120
142
|
function pendingEntryOf(name) {
|
|
121
143
|
const ref = parsePendingBranch(name);
|
|
122
144
|
if (!ref || !isValidId(ref.id))
|
|
@@ -124,8 +146,10 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
124
146
|
const concept = findConcept(runtime.concepts, ref.concept);
|
|
125
147
|
return concept ? { concept, id: ref.id } : null;
|
|
126
148
|
}
|
|
127
|
-
/**
|
|
128
|
-
*
|
|
149
|
+
/**
|
|
150
|
+
* Layout load for every admin page: the nav, the user, the active path, the resolved theme,
|
|
151
|
+
* and the pending entries behind the topbar's publish-all action.
|
|
152
|
+
*/
|
|
129
153
|
async function layoutLoad(event) {
|
|
130
154
|
const editor = requireSession(event);
|
|
131
155
|
const cookieTheme = event.cookies?.get('cairn-admin-theme');
|
|
@@ -162,6 +186,33 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
162
186
|
pendingEntries,
|
|
163
187
|
};
|
|
164
188
|
}
|
|
189
|
+
/**
|
|
190
|
+
* Load the Help home: the getting-started progress derived from the committed manifest and the open
|
|
191
|
+
* pending branches, the markdown reference, and the runtime's support contact. A GitHub failure
|
|
192
|
+
* degrades to an empty corpus (0 of 3) rather than failing the screen, the same fail-safe layoutLoad uses.
|
|
193
|
+
*/
|
|
194
|
+
async function helpLoad(event) {
|
|
195
|
+
requireSession(event);
|
|
196
|
+
let manifest = emptyManifest();
|
|
197
|
+
let pending = [];
|
|
198
|
+
try {
|
|
199
|
+
const token = await mintToken(event.platform?.env ?? {});
|
|
200
|
+
manifest = await readManifest(token);
|
|
201
|
+
const names = await listBranches(runtime.backend, PENDING_PREFIX, token);
|
|
202
|
+
pending = names.flatMap((name) => {
|
|
203
|
+
const entry = pendingEntryOf(name);
|
|
204
|
+
return entry ? [{ concept: entry.concept.id, id: entry.id }] : [];
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
log.warn('github.unreachable', { scope: 'help', error: String(err) });
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
gettingStarted: deriveGettingStarted(manifest, pending),
|
|
212
|
+
reference: markdownReference,
|
|
213
|
+
supportContact: runtime.supportContact,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
165
216
|
/** Redirect /admin to the first concept's list (spec §7.6: land on the first concept). */
|
|
166
217
|
function indexRedirect() {
|
|
167
218
|
const first = runtime.concepts[0];
|
|
@@ -169,8 +220,10 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
169
220
|
throw error(404, 'No content types configured');
|
|
170
221
|
throw redirect(307, `/admin/${first.id}`);
|
|
171
222
|
}
|
|
172
|
-
/**
|
|
173
|
-
*
|
|
223
|
+
/**
|
|
224
|
+
* Read a file's frontmatter for its list row, degrading to the id on any read failure. The
|
|
225
|
+
* repo defaults to main; a pending entry (edited or branch-only) passes its pending branch.
|
|
226
|
+
*/
|
|
174
227
|
async function summarize(file, token, status, repo = runtime.backend) {
|
|
175
228
|
try {
|
|
176
229
|
const raw = await readRaw(repo, file.path, token);
|
|
@@ -188,17 +241,21 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
188
241
|
return { id: file.id, title: file.id, date: null, draft: false, status, summary: null };
|
|
189
242
|
}
|
|
190
243
|
}
|
|
191
|
-
/**
|
|
244
|
+
/**
|
|
245
|
+
* Read an entry's list row from its pending branch, so a pending title or draft change shows
|
|
192
246
|
* in the list instead of reading as a lost save. summarize degrades a failed or empty read to
|
|
193
|
-
* an id-only row, so a ghost ref still lists.
|
|
247
|
+
* an id-only row, so a ghost ref still lists.
|
|
248
|
+
*/
|
|
194
249
|
function pendingRow(concept, id, status, token) {
|
|
195
250
|
return summarize({ id, path: `${concept.dir}/${filenameFromId(id)}` }, token, status, {
|
|
196
251
|
...runtime.backend,
|
|
197
252
|
branch: pendingBranch(concept.id, id),
|
|
198
253
|
});
|
|
199
254
|
}
|
|
200
|
-
/**
|
|
201
|
-
*
|
|
255
|
+
/**
|
|
256
|
+
* The per-file crawl, kept only for a repo with no committed manifest yet: list main's files
|
|
257
|
+
* and read each one for its row, with edited and new rows reading branch-first.
|
|
258
|
+
*/
|
|
202
259
|
async function crawlEntries(concept, pendingIds, token) {
|
|
203
260
|
const files = await listMarkdown(runtime.backend, concept.dir, token);
|
|
204
261
|
const entries = await Promise.all(files.map((f) => (pendingIds.has(f.id) ? pendingRow(concept, f.id, 'edited', token) : summarize(f, token, 'published'))));
|
|
@@ -207,12 +264,14 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
207
264
|
const newRows = await Promise.all([...pendingIds].filter((id) => !listed.has(id)).map((id) => pendingRow(concept, id, 'new', token)));
|
|
208
265
|
return [...entries, ...newRows];
|
|
209
266
|
}
|
|
210
|
-
/**
|
|
267
|
+
/**
|
|
268
|
+
* List a concept's entries with their publish status. Published rows project straight from
|
|
211
269
|
* main's manifest, which publish, delete, and rename keep atomically in sync with main, so
|
|
212
270
|
* the listing costs one manifest read plus one branch read per pending entry rather than one
|
|
213
271
|
* read per file. A manifest row with a pending ref is `edited` and reads branch-first; a ref
|
|
214
272
|
* with no manifest row appends a `new` row read from its branch. A listing failure degrades
|
|
215
|
-
* to an inline error, not a thrown 500.
|
|
273
|
+
* to an inline error, not a thrown 500.
|
|
274
|
+
*/
|
|
216
275
|
async function listLoad(event) {
|
|
217
276
|
requireSession(event);
|
|
218
277
|
const concept = conceptOf(runtime, event.params);
|
|
@@ -256,12 +315,14 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
256
315
|
return { ...base, entries: [], error: 'Could not load this content type from GitHub.' };
|
|
257
316
|
}
|
|
258
317
|
}
|
|
259
|
-
/**
|
|
318
|
+
/**
|
|
319
|
+
* The admin Media Library load: union the media manifest across main and every open cairn/*
|
|
260
320
|
* branch (so a not-yet-published asset shows), project each row through the shared
|
|
261
321
|
* mediaLibraryEntry helper, and attach the cross-branch where-used overlay keyed by content
|
|
262
322
|
* hash. The assets union and the usage overlay degrade independently: a usage-build failure
|
|
263
323
|
* still lists the assets with an empty overlay, and a wholesale read failure degrades to the
|
|
264
|
-
* assets gathered so far rather than a thrown 500, mirroring listLoad's posture.
|
|
324
|
+
* assets gathered so far rather than a thrown 500, mirroring listLoad's posture.
|
|
325
|
+
*/
|
|
265
326
|
async function mediaLibraryLoad(event) {
|
|
266
327
|
requireSession(event);
|
|
267
328
|
// Read the flash flags a redirected action carried back, mirroring listLoad's `?error`/
|
|
@@ -427,10 +488,10 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
427
488
|
const published = mainRaw !== null;
|
|
428
489
|
const parsed = raw === null ? { frontmatter: {}, body: '' } : parseMarkdown(raw);
|
|
429
490
|
const title = typeof parsed.frontmatter.title === 'string' && parsed.frontmatter.title.trim() ? parsed.frontmatter.title : id;
|
|
491
|
+
const manifest = manifestRaw !== null ? parseManifest(manifestRaw) : null;
|
|
430
492
|
let linkTargets = [];
|
|
431
493
|
let inbound = [];
|
|
432
|
-
if (
|
|
433
|
-
const manifest = parseManifest(manifestRaw);
|
|
494
|
+
if (manifest !== null) {
|
|
434
495
|
linkTargets = manifest.entries.map((e) => ({
|
|
435
496
|
concept: e.concept,
|
|
436
497
|
id: e.id,
|
|
@@ -441,6 +502,34 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
441
502
|
}));
|
|
442
503
|
inbound = inboundLinks(manifest, concept.id, id);
|
|
443
504
|
}
|
|
505
|
+
// The address-collision advisory: warn-and-allow, never a gate. At edit-load it checks the
|
|
506
|
+
// published corpus only, built synchronously from the same manifest read above (no extra GitHub
|
|
507
|
+
// read per editor open); publishAction re-checks the full cross-branch index before it lands. The
|
|
508
|
+
// try/catch degrades to no notice if entryIdentity throws on a malformed-date entry. Skip the build
|
|
509
|
+
// with no manifest to index.
|
|
510
|
+
let advisories = [];
|
|
511
|
+
if (manifest !== null) {
|
|
512
|
+
try {
|
|
513
|
+
const identity = entryIdentity(concept, path, parsed.frontmatter);
|
|
514
|
+
const addressIndex = mainAddressIndex(manifest);
|
|
515
|
+
const other = addressCollision(addressIndex, { concept: concept.id, id }, identity.permalink);
|
|
516
|
+
if (other) {
|
|
517
|
+
const otherConcept = findConcept(runtime.concepts, other.concept);
|
|
518
|
+
const label = otherConcept ? otherConcept.label : other.concept;
|
|
519
|
+
advisories = [
|
|
520
|
+
{
|
|
521
|
+
kind: 'address-collision',
|
|
522
|
+
severity: 'warn',
|
|
523
|
+
message: `Another ${label} already uses the address ${identity.permalink}. Publish this one and it replaces the other at that address.`,
|
|
524
|
+
actions: [{ label: `Open ${other.title}`, href: `/admin/${other.concept}/${other.id}` }],
|
|
525
|
+
},
|
|
526
|
+
];
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
catch {
|
|
530
|
+
// A malformed-date entry that cannot resolve its permalink degrades to no advisory, fail open.
|
|
531
|
+
}
|
|
532
|
+
}
|
|
444
533
|
// Project the one committed media manifest read two ways: the minimal resolver triple the preview
|
|
445
534
|
// needs (`mediaTargets`) and the picker's full human layer (`mediaLibrary`), both keyed by hash.
|
|
446
535
|
// A corrupt committed file degrades both to empty, not a throw.
|
|
@@ -487,16 +576,21 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
487
576
|
model: runtime.tidy?.model || DEFAULT_TIDY_MODEL,
|
|
488
577
|
conventions: resolveTidyConventions(runtime.tidy?.conventions),
|
|
489
578
|
},
|
|
579
|
+
advisories,
|
|
490
580
|
};
|
|
491
581
|
}
|
|
492
|
-
/**
|
|
493
|
-
*
|
|
582
|
+
/**
|
|
583
|
+
* The repo-relative personal-dictionary path, defaulting a hand-built runtime that omits it to the
|
|
584
|
+
* same `.cairn/` content root the manifests use. composeRuntime always fills `dictionaryPath`.
|
|
585
|
+
*/
|
|
494
586
|
function dictionaryFilePath() {
|
|
495
587
|
return runtime.dictionaryPath ?? 'src/content/.cairn/dictionary.txt';
|
|
496
588
|
}
|
|
497
|
-
/**
|
|
589
|
+
/**
|
|
590
|
+
* Log a failed commit: a conflict is the expected last-writer-wins outcome, so it warns with a
|
|
498
591
|
* reason; any other error is unexpected and logs at error with the stringified cause. Publish
|
|
499
|
-
* failures carry the same shape under their own event name.
|
|
592
|
+
* failures carry the same shape under their own event name.
|
|
593
|
+
*/
|
|
500
594
|
function logCommitFailed(fields, err, event = 'commit.failed') {
|
|
501
595
|
if (isConflict(err)) {
|
|
502
596
|
log.warn(event, { ...fields, reason: 'conflict' });
|
|
@@ -505,9 +599,11 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
505
599
|
log.error(event, { ...fields, error: String(err) });
|
|
506
600
|
}
|
|
507
601
|
}
|
|
508
|
-
/**
|
|
602
|
+
/**
|
|
603
|
+
* The shared commit catch for the entry actions: log the failure, bounce a conflict back to
|
|
509
604
|
* `page` with `message` as the inline error, and rethrow anything else. `query` keeps any extra
|
|
510
|
-
* params the bounce must carry (saveAction's `&new=1`).
|
|
605
|
+
* params the bounce must carry (saveAction's `&new=1`).
|
|
606
|
+
*/
|
|
511
607
|
function commitFailure(fields, err, page, message, opts = {}) {
|
|
512
608
|
logCommitFailed(fields, err, opts.event);
|
|
513
609
|
if (isConflict(err)) {
|
|
@@ -515,11 +611,13 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
515
611
|
}
|
|
516
612
|
throw err;
|
|
517
613
|
}
|
|
518
|
-
/**
|
|
614
|
+
/**
|
|
615
|
+
* The shared core of save and publish: parse the posted form, validate the frontmatter,
|
|
519
616
|
* guard the body's cairn links, ensure the pending branch, and commit the entry file there
|
|
520
617
|
* with the session editor as author. Returns the broken-link fail for the page to render,
|
|
521
618
|
* or the held state; throws the redirect bounces save has always thrown (invalid
|
|
522
|
-
* frontmatter, a branch-commit conflict). Main stays untouched.
|
|
619
|
+
* frontmatter, a branch-commit conflict). Main stays untouched.
|
|
620
|
+
*/
|
|
523
621
|
async function saveToBranch(event, editor, concept, id) {
|
|
524
622
|
const path = `${concept.dir}/${filenameFromId(id)}`;
|
|
525
623
|
const form = await event.request.formData();
|
|
@@ -604,8 +702,10 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
604
702
|
}
|
|
605
703
|
return { path, markdown, branch, branchSha, manifest: upserted, draftLinks, token, mediaChange };
|
|
606
704
|
}
|
|
607
|
-
/**
|
|
608
|
-
*
|
|
705
|
+
/**
|
|
706
|
+
* Save an edit: validate, then commit to the entry's pending branch with the session editor
|
|
707
|
+
* as author. Main and its manifest stay untouched until publish. Fails safe on 409.
|
|
708
|
+
*/
|
|
609
709
|
async function saveAction(event) {
|
|
610
710
|
const editor = requireSession(event);
|
|
611
711
|
const concept = conceptOf(runtime, event.params);
|
|
@@ -622,12 +722,14 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
622
722
|
: 'saved=1';
|
|
623
723
|
throw redirect(303, `/admin/${concept.id}/${id}?${savedQuery}`);
|
|
624
724
|
}
|
|
625
|
-
/**
|
|
725
|
+
/**
|
|
726
|
+
* Publish an entry: validate and hold the posted form exactly like save (the branch gets the
|
|
626
727
|
* same commit), then copy that markdown to main with the manifest row upserted in one atomic
|
|
627
728
|
* commit. Publish-what-you-see: the posted form is the published content, so text typed
|
|
628
729
|
* after the last save goes live too, and publish works regardless of prior branch state.
|
|
629
730
|
* The branch is deleted only when its head still matches the commit this action made; a
|
|
630
|
-
* concurrent save moved it, so the entry stays pending and the next publish picks it up.
|
|
731
|
+
* concurrent save moved it, so the entry stays pending and the next publish picks it up.
|
|
732
|
+
*/
|
|
631
733
|
async function publishAction(event) {
|
|
632
734
|
const editor = requireSession(event);
|
|
633
735
|
const concept = conceptOf(runtime, event.params);
|
|
@@ -647,10 +749,37 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
647
749
|
];
|
|
648
750
|
if (mediaChange)
|
|
649
751
|
changes.push(mediaChange);
|
|
752
|
+
// The cross-branch address-collision re-check: warn-and-allow, last-write-wins, never a gate.
|
|
753
|
+
// Resolve this entry's own address the way editLoad does and look it up in the index built from
|
|
754
|
+
// the same manifest the publish carries. The read fails open: a thrown index build degrades to
|
|
755
|
+
// no event and the publish proceeds, so a transient GitHub error never blocks a publish.
|
|
756
|
+
let address = '';
|
|
757
|
+
let collision = null;
|
|
758
|
+
try {
|
|
759
|
+
const { frontmatter } = parseMarkdown(markdown);
|
|
760
|
+
address = entryIdentity(concept, path, frontmatter).permalink;
|
|
761
|
+
const addressIndex = await buildAddressIndex(runtime.backend, token, runtime.concepts, manifest);
|
|
762
|
+
collision = addressCollision(addressIndex, { concept: concept.id, id }, address);
|
|
763
|
+
}
|
|
764
|
+
catch (err) {
|
|
765
|
+
// Fail open, the same as editLoad: a thrown index build degrades to no event and the publish
|
|
766
|
+
// proceeds. Log it so a persistently failing advisory build is diagnosable, not invisible.
|
|
767
|
+
collision = null;
|
|
768
|
+
log.warn('github.unreachable', { scope: 'publish-advisories', error: String(err) });
|
|
769
|
+
}
|
|
650
770
|
const commitFields = { concept: concept.id, id, editor: editor.email };
|
|
651
771
|
try {
|
|
652
772
|
await commitFiles(runtime.backend, changes, { message: `Publish ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } }, token);
|
|
653
773
|
log.info('entry.published', { ...commitFields, batch: false });
|
|
774
|
+
// Only after the publish lands: a diagnostic that a live address now has a new owner.
|
|
775
|
+
if (collision) {
|
|
776
|
+
log.warn('publish.address_collision', {
|
|
777
|
+
editor: editor.email,
|
|
778
|
+
address,
|
|
779
|
+
displacedConcept: collision.concept,
|
|
780
|
+
displacedId: collision.id,
|
|
781
|
+
});
|
|
782
|
+
}
|
|
654
783
|
}
|
|
655
784
|
catch (err) {
|
|
656
785
|
// The branch already holds the just-committed edits, so a conflict here loses nothing.
|
|
@@ -664,10 +793,12 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
664
793
|
}
|
|
665
794
|
throw redirect(303, `/admin/${concept.id}/${id}?published=1`);
|
|
666
795
|
}
|
|
667
|
-
/**
|
|
796
|
+
/**
|
|
797
|
+
* Publish every pending entry site-wide: one atomic commit on main carrying each branch's
|
|
668
798
|
* entry file plus the manifest with every row upserted, then delete the consumed branches.
|
|
669
799
|
* Mounted on the concept list shim, but the topbar posts here from anywhere, so the route's
|
|
670
|
-
* concept param is ignored and the redirect lands on the first configured concept.
|
|
800
|
+
* concept param is ignored and the redirect lands on the first configured concept.
|
|
801
|
+
*/
|
|
671
802
|
async function publishAllAction(event) {
|
|
672
803
|
const editor = requireSession(event);
|
|
673
804
|
const first = runtime.concepts[0];
|
|
@@ -745,8 +876,10 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
745
876
|
}
|
|
746
877
|
throw redirect(303, `${listPage}?publishedAll=${published.length}`);
|
|
747
878
|
}
|
|
748
|
-
/**
|
|
749
|
-
*
|
|
879
|
+
/**
|
|
880
|
+
* Discard an entry's pending edits: delete the branch (tolerant of already-gone) and return to
|
|
881
|
+
* the edit page when the entry lives on main, else to the list (the entry is gone entirely).
|
|
882
|
+
*/
|
|
750
883
|
async function discardAction(event) {
|
|
751
884
|
const editor = requireSession(event);
|
|
752
885
|
const concept = conceptOf(runtime, event.params);
|
|
@@ -761,11 +894,13 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
761
894
|
throw redirect(303, `/admin/${concept.id}/${id}?discarded=1`);
|
|
762
895
|
throw redirect(303, `/admin/${concept.id}`);
|
|
763
896
|
}
|
|
764
|
-
/**
|
|
897
|
+
/**
|
|
898
|
+
* The shared delete core. Block-until-clean: refuse while inbound links exist (naming them), else
|
|
765
899
|
* commit the file removal and the manifest patch in one commit. The inbound recheck here is the
|
|
766
900
|
* authoritative gate, closing the load-to-delete race. Both the editor delete (id from params) and
|
|
767
901
|
* the list delete (id from the form body) call this with an already-validated id, so the guard is
|
|
768
|
-
* enforced once.
|
|
902
|
+
* enforced once.
|
|
903
|
+
*/
|
|
769
904
|
async function deleteEntry(event, concept, id, editor) {
|
|
770
905
|
const path = `${concept.dir}/${filenameFromId(id)}`;
|
|
771
906
|
const token = await mintToken(event.platform?.env ?? {});
|
|
@@ -832,10 +967,12 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
832
967
|
throw error(400, 'Invalid entry id');
|
|
833
968
|
return deleteEntry(event, concept, id, editor);
|
|
834
969
|
}
|
|
835
|
-
/**
|
|
970
|
+
/**
|
|
971
|
+
* Rename an entry: change its slug, move the file, and rewrite every inbound cairn token in one
|
|
836
972
|
* atomic commit, so no internal link breaks. The collision check and the inbound recompute here
|
|
837
973
|
* are the authoritative gate. The same last-writer-wins manifest race as save and delete applies,
|
|
838
|
-
* caught by the build's fail-closed backstop.
|
|
974
|
+
* caught by the build's fail-closed backstop.
|
|
975
|
+
*/
|
|
839
976
|
async function renameAction(event) {
|
|
840
977
|
const editor = requireSession(event);
|
|
841
978
|
const concept = conceptOf(runtime, event.params);
|
|
@@ -1042,7 +1179,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1042
1179
|
const MEDIA_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
1043
1180
|
/** A 16-hex content-hash prefix, the immutable asset key. */
|
|
1044
1181
|
const MEDIA_HASH_RE = /^[0-9a-f]{16}$/;
|
|
1045
|
-
/**
|
|
1182
|
+
/**
|
|
1183
|
+
* Safe-delete a committed media asset. The gate rechecks usage server-side against a FRESH index
|
|
1046
1184
|
* read at delete time (never a client-passed count), mirroring deleteEntry's authoritative inbound
|
|
1047
1185
|
* recheck. An in-use asset refuses unless the form carries the typed-slug override (the in-use
|
|
1048
1186
|
* alertdialog's type-to-confirm). When confirmed, the order is load-bearing: commit the manifest
|
|
@@ -1059,7 +1197,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1059
1197
|
* a referenced asset for an orphan. There is an inherent stale-read window between the recheck and
|
|
1060
1198
|
* the commit (no sha-guard ties them); it is bounded because the resolver and the route key on the
|
|
1061
1199
|
* hash, so a reference added in that window still resolves to bytes that may be gone, the same
|
|
1062
|
-
* delete-races-an-edit window every safe delete carries.
|
|
1200
|
+
* delete-races-an-edit window every safe delete carries.
|
|
1201
|
+
*/
|
|
1063
1202
|
async function mediaDeleteAction(event) {
|
|
1064
1203
|
const editor = requireSession(event);
|
|
1065
1204
|
const token = await mintToken(event.platform?.env ?? {});
|
|
@@ -1144,7 +1283,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1144
1283
|
log.info('media.deleted', { editor: editor.email, hash });
|
|
1145
1284
|
throw redirect(303, '/admin/media?deleted=1');
|
|
1146
1285
|
}
|
|
1147
|
-
/**
|
|
1286
|
+
/**
|
|
1287
|
+
* Bulk safe-delete a multi-select of committed media assets. This is mediaDeleteAction extended to
|
|
1148
1288
|
* many items, with the same safety primitives and one rule that defines the batch: the gate is ONE
|
|
1149
1289
|
* shared strict cross-branch usage index built per batch, never N per-item reads (N strict reads
|
|
1150
1290
|
* would blow the workerd connection budget at many open branches). The fail-closed posture is for
|
|
@@ -1162,7 +1302,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1162
1302
|
* leaves bytes with no row (a benign orphan) rather than a row pointing at deleted bytes. Each R2
|
|
1163
1303
|
* delete is best-effort and batch-resilient: a per-object error is reported in `failed` and never
|
|
1164
1304
|
* aborts the rest of the batch. The result is an itemized 207-style summary the component renders
|
|
1165
|
-
* (deleted / skipped with reasons / failed); there is no success redirect.
|
|
1305
|
+
* (deleted / skipped with reasons / failed); there is no success redirect.
|
|
1306
|
+
*/
|
|
1166
1307
|
async function mediaBulkDelete(event) {
|
|
1167
1308
|
const editor = requireSession(event);
|
|
1168
1309
|
const token = await mintToken(event.platform?.env ?? {});
|
|
@@ -1245,7 +1386,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1245
1386
|
log.info('media.bulk_deleted', { editor: editor.email, deleted: deleted.length, skipped: plan.skipped.length });
|
|
1246
1387
|
return { deleted, skipped: plan.skipped, failed };
|
|
1247
1388
|
}
|
|
1248
|
-
/**
|
|
1389
|
+
/**
|
|
1390
|
+
* The on-demand orphan scan: a read-only reconcile of stored R2 bytes against the manifest, joined
|
|
1249
1391
|
* with one strict cross-branch usage index for the broken-reference where-used. It runs only when
|
|
1250
1392
|
* requested, never on the loaded index, because it is heavier than the load path: a full R2 list
|
|
1251
1393
|
* plus a reconcile pass on top of the strict usage build.
|
|
@@ -1259,7 +1401,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1259
1401
|
*
|
|
1260
1402
|
* The result is the OrphanScan projection: orphanedBytes (stored keys with no manifest row, the
|
|
1261
1403
|
* purge surface) and brokenRefs (manifest rows whose bytes are gone, read-only, shown with their
|
|
1262
|
-
* where-used so an operator can re-ingest rather than purge a still-referenced record).
|
|
1404
|
+
* where-used so an operator can re-ingest rather than purge a still-referenced record).
|
|
1405
|
+
*/
|
|
1263
1406
|
async function mediaOrphanScan(event) {
|
|
1264
1407
|
requireSession(event);
|
|
1265
1408
|
const token = await mintToken(event.platform?.env ?? {});
|
|
@@ -1291,7 +1434,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1291
1434
|
}
|
|
1292
1435
|
return buildOrphanScan(reconcile, manifest, index);
|
|
1293
1436
|
}
|
|
1294
|
-
/**
|
|
1437
|
+
/**
|
|
1438
|
+
* Purge orphaned R2 bytes: the one IRREVERSIBLE media action. Raw object bytes live only in R2, not
|
|
1295
1439
|
* in git, so a purged orphan cannot be recovered the way a deleted manifest row can be reverted in
|
|
1296
1440
|
* history. The whole action is built around that fact.
|
|
1297
1441
|
*
|
|
@@ -1315,7 +1459,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1315
1459
|
*
|
|
1316
1460
|
* There is no commit. An orphan by definition has no manifest row to remove, so the purge deletes
|
|
1317
1461
|
* the R2 object directly. Each delete is best-effort and batch-resilient: a per-object error is
|
|
1318
|
-
* reported in `failed` and the loop continues; an absent object is a no-op (the R2 contract).
|
|
1462
|
+
* reported in `failed` and the loop continues; an absent object is a no-op (the R2 contract).
|
|
1463
|
+
*/
|
|
1319
1464
|
async function mediaPurgeOrphans(event) {
|
|
1320
1465
|
const editor = requireSession(event);
|
|
1321
1466
|
const token = await mintToken(event.platform?.env ?? {});
|
|
@@ -1383,10 +1528,12 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1383
1528
|
log.info('media.orphans_purged', { editor: editor.email, purged: purged.length });
|
|
1384
1529
|
return { purged, skippedClaimed, failed };
|
|
1385
1530
|
}
|
|
1386
|
-
/**
|
|
1531
|
+
/**
|
|
1532
|
+
* Edit a committed asset's metadata: its display name, slug, and default alt. A single media.json
|
|
1387
1533
|
* row commit, with NO reference rewrite: the resolver and the delivery route key on the hash, so a
|
|
1388
1534
|
* rename never breaks an existing `media:` reference. The default alt is the asset's value for the
|
|
1389
|
-
* next placement, never a propagating edit of the alt already committed in existing placements.
|
|
1535
|
+
* next placement, never a propagating edit of the alt already committed in existing placements.
|
|
1536
|
+
*/
|
|
1390
1537
|
async function mediaUpdateAction(event) {
|
|
1391
1538
|
const editor = requireSession(event);
|
|
1392
1539
|
const token = await mintToken(event.platform?.env ?? {});
|
|
@@ -1416,13 +1563,16 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1416
1563
|
}
|
|
1417
1564
|
throw redirect(303, '/admin/media?updated=1');
|
|
1418
1565
|
}
|
|
1419
|
-
/**
|
|
1566
|
+
/**
|
|
1567
|
+
* Build the canonical `media:` token for a replacement, treating a slug that fails the grammar (or
|
|
1420
1568
|
* an empty one) as absent so the bare-hash form is used. The slug is cosmetic: the resolver keys on
|
|
1421
|
-
* the hash, so a missing slug still resolves. Shared by the preview and apply token construction.
|
|
1569
|
+
* the hash, so a missing slug still resolves. Shared by the preview and apply token construction.
|
|
1570
|
+
*/
|
|
1422
1571
|
function replacementToken(slug, hash) {
|
|
1423
1572
|
return mediaToken({ slug: MEDIA_SLUG_RE.test(slug) ? slug : null, hash });
|
|
1424
1573
|
}
|
|
1425
|
-
/**
|
|
1574
|
+
/**
|
|
1575
|
+
* Preview a replace-in-place: the display-only fetch action (the 2a transport). It plans the rewrite
|
|
1426
1576
|
* of every published main entry that references `oldHash` to the new asset's `media:` token, enriches
|
|
1427
1577
|
* each with its title and permalink, and returns the plan plus the report-only cross-branch delta.
|
|
1428
1578
|
* It commits nothing. The plan runs strict (fail-closed): an unverifiable usage read returns a 503
|
|
@@ -1432,7 +1582,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1432
1582
|
* the `X-Cairn-CSRF` header (the raw-body transport, no form-CSRF), and a `MediaReplacePreviewPlan`
|
|
1433
1583
|
* returned as the 200 ActionResult the client reads. A refusal rides a `fail(status, ...)` envelope
|
|
1434
1584
|
* with the MediaReplaceFailure shape (the same fail shape the apply uses), so the client reads
|
|
1435
|
-
* `type`/`status` from the body, never the HTTP status.
|
|
1585
|
+
* `type`/`status` from the body, never the HTTP status.
|
|
1586
|
+
*/
|
|
1436
1587
|
async function mediaReplacePreview(event) {
|
|
1437
1588
|
// CSRF first: this is a raw-body (JSON) POST, so the header witness is the authority, like the
|
|
1438
1589
|
// upload action. A failed check refuses before the session read or any GitHub call.
|
|
@@ -1498,7 +1649,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1498
1649
|
});
|
|
1499
1650
|
return { affectedCount: plan.affectedCount, entries, branchDelta: plan.branchDelta };
|
|
1500
1651
|
}
|
|
1501
|
-
/**
|
|
1652
|
+
/**
|
|
1653
|
+
* Apply a replace-in-place: rewrite every published main entry that references the old asset to the
|
|
1502
1654
|
* new asset's `media:` token, and add the new media.json row, in ONE atomic commit. The plan is
|
|
1503
1655
|
* re-derived here from a FRESH read (never a client-passed plan), so a concurrent edit between the
|
|
1504
1656
|
* preview and the apply is rewritten too. EVERY replace is gated behind the typed-slug confirm
|
|
@@ -1509,7 +1661,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1509
1661
|
*
|
|
1510
1662
|
* No R2 operation: the new bytes were already stored put-first by the upload action, and the old
|
|
1511
1663
|
* bytes are KEPT (the old row stays in media.json), so this action writes only to git and never
|
|
1512
|
-
* resolves the bucket binding. It guards `resolvedAssets.enabled` for the media-off case only.
|
|
1664
|
+
* resolves the bucket binding. It guards `resolvedAssets.enabled` for the media-off case only.
|
|
1665
|
+
*/
|
|
1513
1666
|
async function mediaReplaceApply(event) {
|
|
1514
1667
|
const editor = requireSession(event);
|
|
1515
1668
|
const token = await mintToken(event.platform?.env ?? {});
|
|
@@ -1598,7 +1751,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1598
1751
|
}
|
|
1599
1752
|
throw redirect(303, '/admin/media?replaced=1');
|
|
1600
1753
|
}
|
|
1601
|
-
/**
|
|
1754
|
+
/**
|
|
1755
|
+
* Preview an alt-propagation: the display-only fetch action (the 2a transport). It plans filling the
|
|
1602
1756
|
* asset's default alt across every published main entry that references it, bucketing each placement
|
|
1603
1757
|
* (a will-fill empty alt, a customized alt left as-is, a decorative hero skipped), and returns the
|
|
1604
1758
|
* enriched entries, the report-only cross-branch delta, and the bucket counts. It commits nothing.
|
|
@@ -1608,7 +1762,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1608
1762
|
* Wire contract: a fetch POST with the JSON body `{ hash }`, the CSRF token in the `X-Cairn-CSRF`
|
|
1609
1763
|
* header (the raw-body transport, no form-CSRF), and a `MediaAltPreviewPlan` returned as the 200
|
|
1610
1764
|
* ActionResult the client reads. A refusal rides a `fail(status, ...)` envelope with the
|
|
1611
|
-
* MediaAltPropagateFailure shape, so the client reads `type`/`status` from the body.
|
|
1765
|
+
* MediaAltPropagateFailure shape, so the client reads `type`/`status` from the body.
|
|
1766
|
+
*/
|
|
1612
1767
|
async function mediaAltPreview(event) {
|
|
1613
1768
|
// CSRF first: a raw-body (JSON) POST, so the header witness is the authority, like the upload and
|
|
1614
1769
|
// replace-preview actions. A failed check refuses before the session read or any GitHub call.
|
|
@@ -1676,14 +1831,16 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1676
1831
|
});
|
|
1677
1832
|
return { entries, branchDelta: plan.branchDelta, counts };
|
|
1678
1833
|
}
|
|
1679
|
-
/**
|
|
1834
|
+
/**
|
|
1835
|
+
* Apply an alt-propagation: fill the asset's default alt into every empty placement across the
|
|
1680
1836
|
* published corpus (and, on the `overwrite` opt-in, customized placements too), in ONE atomic
|
|
1681
1837
|
* commit. The plan is re-derived from a FRESH read (never a client plan). Three deliberate
|
|
1682
1838
|
* differences from replace: there is NO typed-slug gate (alt fill is reversible and frequent), there
|
|
1683
1839
|
* is NO media.json change (the default alt is READ from the row, never rewritten there), and a
|
|
1684
1840
|
* decorative hero is never written regardless of `overwrite` (enforced inside fillAltForHash). A run
|
|
1685
1841
|
* that changes nothing commits nothing and still redirects (a no-op success). It fails the operation
|
|
1686
|
-
* closed on an unverifiable usage read, and writes only entry files in git (no R2 op).
|
|
1842
|
+
* closed on an unverifiable usage read, and writes only entry files in git (no R2 op).
|
|
1843
|
+
*/
|
|
1687
1844
|
async function mediaAltApply(event) {
|
|
1688
1845
|
const editor = requireSession(event);
|
|
1689
1846
|
const token = await mintToken(event.platform?.env ?? {});
|
|
@@ -1735,19 +1892,25 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1735
1892
|
}
|
|
1736
1893
|
throw redirect(303, '/admin/media?altPropagated=1');
|
|
1737
1894
|
}
|
|
1738
|
-
/**
|
|
1895
|
+
/**
|
|
1896
|
+
* The cap on a personal-dictionary word, matched by isValidDictionaryWord. A word is one line, so
|
|
1739
1897
|
* this bounds an abusive input; the real authority is the per-character validation, which rejects
|
|
1740
|
-
* whitespace and control bytes so a body can never inject an extra line into the committed file.
|
|
1898
|
+
* whitespace and control bytes so a body can never inject an extra line into the committed file.
|
|
1899
|
+
*/
|
|
1741
1900
|
const MAX_DICTIONARY_WORD = 64;
|
|
1742
|
-
/**
|
|
1743
|
-
*
|
|
1901
|
+
/**
|
|
1902
|
+
* The cap on the words a single add request carries: an editor adds a handful at save time, never
|
|
1903
|
+
* a flood. Past this the body is treated as abusive and the surplus is dropped.
|
|
1904
|
+
*/
|
|
1744
1905
|
const MAX_DICTIONARY_BATCH = 100;
|
|
1745
|
-
/**
|
|
1906
|
+
/**
|
|
1907
|
+
* Read the committed personal dictionary, merge the validated additions in sorted order, and commit
|
|
1746
1908
|
* the canonical file back. Shared by the first attempt and the post-conflict retry, so both re-read
|
|
1747
1909
|
* the head and re-merge the same additions; the merge is order-independent, so a concurrent editor's
|
|
1748
1910
|
* word that already landed is preserved and the result is the same sorted set regardless of order.
|
|
1749
1911
|
* Returns the merged word list. Throws CommitConflictError (via commitFiles) when the branch moves
|
|
1750
|
-
* under the commit, which the caller catches to retry once.
|
|
1912
|
+
* under the commit, which the caller catches to retry once.
|
|
1913
|
+
*/
|
|
1751
1914
|
async function mergeAndCommitDictionary(token, additions, editor) {
|
|
1752
1915
|
const path = dictionaryFilePath();
|
|
1753
1916
|
// The existing file as its canonical sorted set, so a no-op add is detected against the same
|
|
@@ -1762,25 +1925,31 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1762
1925
|
await commitFiles(runtime.backend, [{ path, content: serializeDictionary(merged) }], { message: `Add to dictionary: ${additions.join(', ')}`, author: { name: editor.displayName, email: editor.email } }, token);
|
|
1763
1926
|
return merged;
|
|
1764
1927
|
}
|
|
1765
|
-
/**
|
|
1928
|
+
/**
|
|
1929
|
+
* The repo-relative site-config path the settings save reads and commits. It is the same committed
|
|
1766
1930
|
* YAML the nav editor edits, so it comes from the configured nav menu first and falls back to the
|
|
1767
|
-
* scaffold default when no menu is configured.
|
|
1931
|
+
* scaffold default when no menu is configured.
|
|
1932
|
+
*/
|
|
1768
1933
|
function siteConfigPath() {
|
|
1769
1934
|
return runtime.navMenu?.configPath ?? DEFAULT_SITE_CONFIG_PATH;
|
|
1770
1935
|
}
|
|
1771
|
-
/**
|
|
1936
|
+
/**
|
|
1937
|
+
* Read whether the Anthropic API key secret is present in the load's env. A presence flag for the
|
|
1772
1938
|
* truthful visibility gate, never the key itself: the key is a Worker secret, so this only reports
|
|
1773
|
-
* that a non-empty `ANTHROPIC_API_KEY` exists and the value never leaves the server.
|
|
1939
|
+
* that a non-empty `ANTHROPIC_API_KEY` exists and the value never leaves the server.
|
|
1940
|
+
*/
|
|
1774
1941
|
function keyConfigured(event) {
|
|
1775
1942
|
const env = (event.platform?.env ?? {});
|
|
1776
1943
|
return typeof env.ANTHROPIC_API_KEY === 'string' && env.ANTHROPIC_API_KEY.length > 0;
|
|
1777
1944
|
}
|
|
1778
|
-
/**
|
|
1945
|
+
/**
|
|
1946
|
+
* Load the two-tier tidy settings (spec 2.8, Task 15). The developer tier (enabled, key, model) is
|
|
1779
1947
|
* read-only; the editor tier is the resolved conventions block. The visibility gate is truthful: the
|
|
1780
1948
|
* `enabled` flag is true only when `tidy.enabled` is set AND the key is present, so the screen renders
|
|
1781
1949
|
* the convention list only then and the honest gate note otherwise. No secret is returned: only a
|
|
1782
1950
|
* presence flag for the key. The conventions come straight from the runtime config (the same source
|
|
1783
|
-
* the tidy action's prompt reads), so the screen and the prompt can never diverge.
|
|
1951
|
+
* the tidy action's prompt reads), so the screen and the prompt can never diverge.
|
|
1952
|
+
*/
|
|
1784
1953
|
function settingsLoad(event) {
|
|
1785
1954
|
requireSession(event);
|
|
1786
1955
|
const tidy = runtime.tidy;
|
|
@@ -1798,13 +1967,15 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1798
1967
|
error: event.url.searchParams.get('error'),
|
|
1799
1968
|
};
|
|
1800
1969
|
}
|
|
1801
|
-
/**
|
|
1970
|
+
/**
|
|
1971
|
+
* Save the editor-tier tidy conventions: validate the posted block, then read-modify-commit it into
|
|
1802
1972
|
* the same committed YAML the nav editor writes, with the session editor as author. The transport is
|
|
1803
1973
|
* the nav save's exactly: a form POST carrying the conventions JSON, the read-modify-commit through
|
|
1804
1974
|
* `commitFile`, and a stale-SHA `isConflict` bounced back as a reload prompt. Only the conventions
|
|
1805
1975
|
* block is written (setTidy leaves `tidy.enabled` and `tidy.model` untouched), so an editor's save can
|
|
1806
1976
|
* never flip the developer-tier deploy facts. The save refuses before any commit when tidy is not
|
|
1807
|
-
* enabled, so the gate state's absent editor tier can never be saved past.
|
|
1977
|
+
* enabled, so the gate state's absent editor tier can never be saved past.
|
|
1978
|
+
*/
|
|
1808
1979
|
async function settingsSave(event) {
|
|
1809
1980
|
const editor = requireSession(event);
|
|
1810
1981
|
// The editor tier does not exist when tidy is off, so a save in that state is a 404 (no editable
|
|
@@ -1843,7 +2014,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1843
2014
|
}
|
|
1844
2015
|
throw redirect(303, '/admin/settings?saved=1');
|
|
1845
2016
|
}
|
|
1846
|
-
/**
|
|
2017
|
+
/**
|
|
2018
|
+
* Add a word (or batch) to the git-committed personal dictionary (spec 1.6). The transport mirrors
|
|
1847
2019
|
* the media raw-body actions exactly: a `text/plain` POST, the CSRF token in `X-Cairn-CSRF` validated
|
|
1848
2020
|
* by validateCsrfHeader (CSRF first, then the session), and a small JSON body `{ word }` or
|
|
1849
2021
|
* `{ words }`. It reads the current file from the default branch, inserts the validated words in
|
|
@@ -1858,7 +2030,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1858
2030
|
* Input validation is load-bearing here: this commits to the repo from request input, so every word
|
|
1859
2031
|
* is length-bounded and rejected if it carries whitespace or control characters (a word is one
|
|
1860
2032
|
* line), and the batch is capped. A body that yields no valid word refuses with a 400 and commits
|
|
1861
|
-
* nothing, so the committed file can never gain an injected or empty line.
|
|
2033
|
+
* nothing, so the committed file can never gain an injected or empty line.
|
|
2034
|
+
*/
|
|
1862
2035
|
async function addDictionaryWord(event) {
|
|
1863
2036
|
// CSRF first: a raw-body (JSON) POST, so the header witness is the authority, like the upload and
|
|
1864
2037
|
// media actions. A failed check refuses before the session read or any GitHub call.
|
|
@@ -1911,7 +2084,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1911
2084
|
}
|
|
1912
2085
|
}
|
|
1913
2086
|
}
|
|
1914
|
-
/**
|
|
2087
|
+
/**
|
|
2088
|
+
* Tidy: a light LLM copy-edit of the author's markdown (spec 2.1). The first remote model call in
|
|
1915
2089
|
* the library, so this is the highest-blast-radius server action: untrusted content and the Anthropic
|
|
1916
2090
|
* API key. The transport mirrors the media raw-body actions (a `text/plain` POST carrying JSON
|
|
1917
2091
|
* `{ text, scope }`, the CSRF token in `X-Cairn-CSRF`, the response deserialized by the client), with
|
|
@@ -1929,7 +2103,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1929
2103
|
* prompt's injection framing (Task 10) treats it as data. The API key never leaves the action: it is
|
|
1930
2104
|
* not returned and not logged, and the log line carries no content. The action commits NOTHING, so a
|
|
1931
2105
|
* failed, aborted, or refused tidy can never corrupt the entry; the diff is computed on the client
|
|
1932
|
-
* (Task 12), so the server stays a thin model-call boundary.
|
|
2106
|
+
* (Task 12), so the server stays a thin model-call boundary.
|
|
2107
|
+
*/
|
|
1933
2108
|
async function tidyAction(event) {
|
|
1934
2109
|
// CSRF first: a raw-body (JSON) POST, so the header witness is the authority. A failed check refuses
|
|
1935
2110
|
// before the session read and before any model call.
|
|
@@ -2019,10 +2194,12 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
2019
2194
|
log.info('tidy.done', { editor: editor.email, model: message.model, usage: message.usage });
|
|
2020
2195
|
return { corrected, model: message.model, usage: message.usage };
|
|
2021
2196
|
}
|
|
2022
|
-
return { layoutLoad, indexRedirect, listLoad, mediaLibraryLoad, settingsLoad, settingsSave, createAction, editLoad, saveAction, publishAction, publishAllAction, discardAction, deleteAction, listDeleteAction, renameAction, uploadAction, mediaDeleteAction, mediaBulkDelete, mediaOrphanScan, mediaPurgeOrphans, mediaUpdateAction, mediaReplacePreview, mediaReplaceApply, mediaAltPreview, mediaAltApply, addDictionaryWord, tidyAction, mintToken };
|
|
2197
|
+
return { layoutLoad, helpLoad, indexRedirect, listLoad, mediaLibraryLoad, settingsLoad, settingsSave, createAction, editLoad, saveAction, publishAction, publishAllAction, discardAction, deleteAction, listDeleteAction, renameAction, uploadAction, mediaDeleteAction, mediaBulkDelete, mediaOrphanScan, mediaPurgeOrphans, mediaUpdateAction, mediaReplacePreview, mediaReplaceApply, mediaAltPreview, mediaAltApply, addDictionaryWord, tidyAction, mintToken };
|
|
2023
2198
|
}
|
|
2024
|
-
/**
|
|
2025
|
-
*
|
|
2199
|
+
/**
|
|
2200
|
+
* The cap, in characters, on the stored alt text. The human fields are display copy, not content,
|
|
2201
|
+
* so a generous cap rejects only abuse-scale input.
|
|
2202
|
+
*/
|
|
2026
2203
|
const MAX_ALT = 160;
|
|
2027
2204
|
/** The cap, in characters, on the stored display name. */
|
|
2028
2205
|
const MAX_DISPLAY_NAME = 120;
|
|
@@ -2030,8 +2207,10 @@ const MAX_DISPLAY_NAME = 120;
|
|
|
2030
2207
|
const MAX_ORIGINAL_FILENAME = 120;
|
|
2031
2208
|
/** The largest pixel dimension kept; anything larger is treated as bogus and clamped to null. */
|
|
2032
2209
|
const MAX_DIMENSION = 60000;
|
|
2033
|
-
/**
|
|
2034
|
-
*
|
|
2210
|
+
/**
|
|
2211
|
+
* Decode a percent-encoded header value, yielding `''` on a malformed sequence or an absent header,
|
|
2212
|
+
* so a hostile `X-Cairn-*` value cannot throw past the gate.
|
|
2213
|
+
*/
|
|
2035
2214
|
function safeDecode(value) {
|
|
2036
2215
|
if (value === null)
|
|
2037
2216
|
return '';
|
|
@@ -2042,35 +2221,46 @@ function safeDecode(value) {
|
|
|
2042
2221
|
return '';
|
|
2043
2222
|
}
|
|
2044
2223
|
}
|
|
2045
|
-
/**
|
|
2046
|
-
*
|
|
2224
|
+
/**
|
|
2225
|
+
* The basename of a decoded filename: the final path segment after any `/` or `\`. A client value
|
|
2226
|
+
* of `../../evil.png` yields `evil.png`, so no path component reaches the stored record.
|
|
2227
|
+
*/
|
|
2047
2228
|
function basename(name) {
|
|
2048
2229
|
const parts = name.split(/[/\\]/);
|
|
2049
2230
|
return parts[parts.length - 1];
|
|
2050
2231
|
}
|
|
2051
|
-
/**
|
|
2052
|
-
*
|
|
2232
|
+
/**
|
|
2233
|
+
* Sort key for a where-used row's origin: published rows rank before branch rows, so the in-use
|
|
2234
|
+
* refusal lists "Published on the site" first, then the edit-branch references.
|
|
2235
|
+
*/
|
|
2053
2236
|
function originRank(entry) {
|
|
2054
2237
|
return entry.origin.kind === 'published' ? 0 : 1;
|
|
2055
2238
|
}
|
|
2056
|
-
/**
|
|
2057
|
-
*
|
|
2239
|
+
/**
|
|
2240
|
+
* A where-used row's branch name for the secondary sort (the empty string for a published row,
|
|
2241
|
+
* which sorts ahead of any branch by `originRank` already).
|
|
2242
|
+
*/
|
|
2058
2243
|
function branchKey(entry) {
|
|
2059
2244
|
return entry.origin.kind === 'branch' ? entry.origin.branch : '';
|
|
2060
2245
|
}
|
|
2061
|
-
/**
|
|
2062
|
-
*
|
|
2246
|
+
/**
|
|
2247
|
+
* The distinct-entry count behind a where-used set: a published use and an edit-branch edit of the
|
|
2248
|
+
* same entry are two rows but one distinct entry, so count by concept/id.
|
|
2249
|
+
*/
|
|
2063
2250
|
function distinctEntryCount(rows) {
|
|
2064
2251
|
return new Set(rows.map((e) => `${e.concept}/${e.id}`)).size;
|
|
2065
2252
|
}
|
|
2066
|
-
/**
|
|
2067
|
-
*
|
|
2253
|
+
/**
|
|
2254
|
+
* Strip control characters from a human field and cap it at `max` characters. Control characters
|
|
2255
|
+
* (C0 and DEL) never belong in display copy and could corrupt a log line or a committed JSON.
|
|
2256
|
+
*/
|
|
2068
2257
|
function sanitizeField(value, max) {
|
|
2069
|
-
// eslint-disable-next-line no-control-regex
|
|
2070
2258
|
return value.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, max);
|
|
2071
2259
|
}
|
|
2072
|
-
/**
|
|
2073
|
-
*
|
|
2260
|
+
/**
|
|
2261
|
+
* Parse an advisory pixel dimension header. A valid integer in `[1, MAX_DIMENSION]` is kept; an
|
|
2262
|
+
* absent, non-numeric, or out-of-range value becomes null (MediaEntry dimensions are `number | null`).
|
|
2263
|
+
*/
|
|
2074
2264
|
function clampDimension(value) {
|
|
2075
2265
|
if (value === null)
|
|
2076
2266
|
return null;
|