@glw907/cairn-cms 0.60.0 → 0.62.1
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 +82 -0
- package/dist/components/AdminLayout.svelte +152 -229
- package/dist/components/CairnAdmin.svelte +13 -42
- package/dist/components/CairnLogo.svelte +1 -6
- package/dist/components/CairnMediaLibrary.svelte +821 -1210
- package/dist/components/CairnTidySettings.svelte +194 -261
- package/dist/components/CairnTidySettings.svelte.d.ts +1 -1
- package/dist/components/ComponentForm.svelte +110 -185
- package/dist/components/ComponentInsertDialog.svelte +163 -283
- package/dist/components/ConceptList.svelte +111 -191
- package/dist/components/ConfirmPage.svelte +5 -12
- package/dist/components/CsrfField.svelte +5 -11
- package/dist/components/DeleteDialog.svelte +15 -42
- package/dist/components/EditPage.svelte +781 -1205
- package/dist/components/EditorToolbar.svelte +108 -170
- package/dist/components/HelpHome.svelte +824 -0
- package/dist/components/HelpHome.svelte.d.ts +22 -0
- package/dist/components/IconPicker.svelte +23 -53
- package/dist/components/LinkPicker.svelte +34 -58
- package/dist/components/LoginPage.svelte +14 -27
- package/dist/components/ManageEditors.svelte +3 -15
- package/dist/components/MarkdownEditor.svelte +689 -957
- package/dist/components/MarkdownHelpDialog.svelte +12 -27
- package/dist/components/MediaCaptureCard.svelte +18 -57
- package/dist/components/MediaFigureControl.svelte +32 -71
- package/dist/components/MediaHeroField.svelte +210 -329
- package/dist/components/MediaInsertPopover.svelte +156 -283
- package/dist/components/MediaPicker.svelte +67 -131
- package/dist/components/NavTree.svelte +46 -78
- package/dist/components/RenameDialog.svelte +16 -43
- package/dist/components/ShortcutsDialog.svelte +9 -13
- package/dist/components/ShortcutsGrid.svelte +1 -2
- package/dist/components/TidyReview.svelte +140 -248
- package/dist/components/WebLinkDialog.svelte +19 -40
- package/dist/components/cairn-admin.css +4 -0
- 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 +57 -29
- package/dist/components/spellcheck.js +50 -20
- 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 +51 -0
- package/dist/content/advisories.js +79 -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/CairnHead.svelte +8 -11
- 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 +295 -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 +8 -2
- 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 +92 -40
- 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 +141 -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 +492 -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, 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,32 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
441
502
|
}));
|
|
442
503
|
inbound = inboundLinks(manifest, concept.id, id);
|
|
443
504
|
}
|
|
505
|
+
// The cross-branch address-collision advisory: warn-and-allow, never a gate. Build it from the
|
|
506
|
+
// same manifest read above (no second read) and degrade to no notice on any read failure, so a
|
|
507
|
+
// transient GitHub error never blocks the editor. Skip the build with no manifest to index.
|
|
508
|
+
let advisories = [];
|
|
509
|
+
if (manifest !== null) {
|
|
510
|
+
try {
|
|
511
|
+
const identity = entryIdentity(concept, path, parsed.frontmatter);
|
|
512
|
+
const addressIndex = await buildAddressIndex(runtime.backend, token, runtime.concepts, manifest);
|
|
513
|
+
const other = addressCollision(addressIndex, { concept: concept.id, id }, identity.permalink);
|
|
514
|
+
if (other) {
|
|
515
|
+
const otherConcept = findConcept(runtime.concepts, other.concept);
|
|
516
|
+
const label = otherConcept ? otherConcept.label : other.concept;
|
|
517
|
+
advisories = [
|
|
518
|
+
{
|
|
519
|
+
kind: 'address-collision',
|
|
520
|
+
severity: 'warn',
|
|
521
|
+
message: `Another ${label} already uses the address ${identity.permalink}. Publish this one and it replaces the other at that address.`,
|
|
522
|
+
actions: [{ label: `Open ${other.title}`, href: `/admin/${other.concept}/${other.id}` }],
|
|
523
|
+
},
|
|
524
|
+
];
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
catch (err) {
|
|
528
|
+
log.warn('github.unreachable', { scope: 'edit-advisories', error: String(err) });
|
|
529
|
+
}
|
|
530
|
+
}
|
|
444
531
|
// Project the one committed media manifest read two ways: the minimal resolver triple the preview
|
|
445
532
|
// needs (`mediaTargets`) and the picker's full human layer (`mediaLibrary`), both keyed by hash.
|
|
446
533
|
// A corrupt committed file degrades both to empty, not a throw.
|
|
@@ -487,16 +574,21 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
487
574
|
model: runtime.tidy?.model || DEFAULT_TIDY_MODEL,
|
|
488
575
|
conventions: resolveTidyConventions(runtime.tidy?.conventions),
|
|
489
576
|
},
|
|
577
|
+
advisories,
|
|
490
578
|
};
|
|
491
579
|
}
|
|
492
|
-
/**
|
|
493
|
-
*
|
|
580
|
+
/**
|
|
581
|
+
* The repo-relative personal-dictionary path, defaulting a hand-built runtime that omits it to the
|
|
582
|
+
* same `.cairn/` content root the manifests use. composeRuntime always fills `dictionaryPath`.
|
|
583
|
+
*/
|
|
494
584
|
function dictionaryFilePath() {
|
|
495
585
|
return runtime.dictionaryPath ?? 'src/content/.cairn/dictionary.txt';
|
|
496
586
|
}
|
|
497
|
-
/**
|
|
587
|
+
/**
|
|
588
|
+
* Log a failed commit: a conflict is the expected last-writer-wins outcome, so it warns with a
|
|
498
589
|
* 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.
|
|
590
|
+
* failures carry the same shape under their own event name.
|
|
591
|
+
*/
|
|
500
592
|
function logCommitFailed(fields, err, event = 'commit.failed') {
|
|
501
593
|
if (isConflict(err)) {
|
|
502
594
|
log.warn(event, { ...fields, reason: 'conflict' });
|
|
@@ -505,9 +597,11 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
505
597
|
log.error(event, { ...fields, error: String(err) });
|
|
506
598
|
}
|
|
507
599
|
}
|
|
508
|
-
/**
|
|
600
|
+
/**
|
|
601
|
+
* The shared commit catch for the entry actions: log the failure, bounce a conflict back to
|
|
509
602
|
* `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`).
|
|
603
|
+
* params the bounce must carry (saveAction's `&new=1`).
|
|
604
|
+
*/
|
|
511
605
|
function commitFailure(fields, err, page, message, opts = {}) {
|
|
512
606
|
logCommitFailed(fields, err, opts.event);
|
|
513
607
|
if (isConflict(err)) {
|
|
@@ -515,11 +609,13 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
515
609
|
}
|
|
516
610
|
throw err;
|
|
517
611
|
}
|
|
518
|
-
/**
|
|
612
|
+
/**
|
|
613
|
+
* The shared core of save and publish: parse the posted form, validate the frontmatter,
|
|
519
614
|
* guard the body's cairn links, ensure the pending branch, and commit the entry file there
|
|
520
615
|
* with the session editor as author. Returns the broken-link fail for the page to render,
|
|
521
616
|
* or the held state; throws the redirect bounces save has always thrown (invalid
|
|
522
|
-
* frontmatter, a branch-commit conflict). Main stays untouched.
|
|
617
|
+
* frontmatter, a branch-commit conflict). Main stays untouched.
|
|
618
|
+
*/
|
|
523
619
|
async function saveToBranch(event, editor, concept, id) {
|
|
524
620
|
const path = `${concept.dir}/${filenameFromId(id)}`;
|
|
525
621
|
const form = await event.request.formData();
|
|
@@ -604,8 +700,10 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
604
700
|
}
|
|
605
701
|
return { path, markdown, branch, branchSha, manifest: upserted, draftLinks, token, mediaChange };
|
|
606
702
|
}
|
|
607
|
-
/**
|
|
608
|
-
*
|
|
703
|
+
/**
|
|
704
|
+
* Save an edit: validate, then commit to the entry's pending branch with the session editor
|
|
705
|
+
* as author. Main and its manifest stay untouched until publish. Fails safe on 409.
|
|
706
|
+
*/
|
|
609
707
|
async function saveAction(event) {
|
|
610
708
|
const editor = requireSession(event);
|
|
611
709
|
const concept = conceptOf(runtime, event.params);
|
|
@@ -622,12 +720,14 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
622
720
|
: 'saved=1';
|
|
623
721
|
throw redirect(303, `/admin/${concept.id}/${id}?${savedQuery}`);
|
|
624
722
|
}
|
|
625
|
-
/**
|
|
723
|
+
/**
|
|
724
|
+
* Publish an entry: validate and hold the posted form exactly like save (the branch gets the
|
|
626
725
|
* same commit), then copy that markdown to main with the manifest row upserted in one atomic
|
|
627
726
|
* commit. Publish-what-you-see: the posted form is the published content, so text typed
|
|
628
727
|
* after the last save goes live too, and publish works regardless of prior branch state.
|
|
629
728
|
* 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.
|
|
729
|
+
* concurrent save moved it, so the entry stays pending and the next publish picks it up.
|
|
730
|
+
*/
|
|
631
731
|
async function publishAction(event) {
|
|
632
732
|
const editor = requireSession(event);
|
|
633
733
|
const concept = conceptOf(runtime, event.params);
|
|
@@ -647,10 +747,37 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
647
747
|
];
|
|
648
748
|
if (mediaChange)
|
|
649
749
|
changes.push(mediaChange);
|
|
750
|
+
// The cross-branch address-collision re-check: warn-and-allow, last-write-wins, never a gate.
|
|
751
|
+
// Resolve this entry's own address the way editLoad does and look it up in the index built from
|
|
752
|
+
// the same manifest the publish carries. The read fails open: a thrown index build degrades to
|
|
753
|
+
// no event and the publish proceeds, so a transient GitHub error never blocks a publish.
|
|
754
|
+
let address = '';
|
|
755
|
+
let collision = null;
|
|
756
|
+
try {
|
|
757
|
+
const { frontmatter } = parseMarkdown(markdown);
|
|
758
|
+
address = entryIdentity(concept, path, frontmatter).permalink;
|
|
759
|
+
const addressIndex = await buildAddressIndex(runtime.backend, token, runtime.concepts, manifest);
|
|
760
|
+
collision = addressCollision(addressIndex, { concept: concept.id, id }, address);
|
|
761
|
+
}
|
|
762
|
+
catch (err) {
|
|
763
|
+
// Fail open, the same as editLoad: a thrown index build degrades to no event and the publish
|
|
764
|
+
// proceeds. Log it so a persistently failing advisory build is diagnosable, not invisible.
|
|
765
|
+
collision = null;
|
|
766
|
+
log.warn('github.unreachable', { scope: 'publish-advisories', error: String(err) });
|
|
767
|
+
}
|
|
650
768
|
const commitFields = { concept: concept.id, id, editor: editor.email };
|
|
651
769
|
try {
|
|
652
770
|
await commitFiles(runtime.backend, changes, { message: `Publish ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } }, token);
|
|
653
771
|
log.info('entry.published', { ...commitFields, batch: false });
|
|
772
|
+
// Only after the publish lands: a diagnostic that a live address now has a new owner.
|
|
773
|
+
if (collision) {
|
|
774
|
+
log.warn('publish.address_collision', {
|
|
775
|
+
editor: editor.email,
|
|
776
|
+
address,
|
|
777
|
+
displacedConcept: collision.concept,
|
|
778
|
+
displacedId: collision.id,
|
|
779
|
+
});
|
|
780
|
+
}
|
|
654
781
|
}
|
|
655
782
|
catch (err) {
|
|
656
783
|
// The branch already holds the just-committed edits, so a conflict here loses nothing.
|
|
@@ -664,10 +791,12 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
664
791
|
}
|
|
665
792
|
throw redirect(303, `/admin/${concept.id}/${id}?published=1`);
|
|
666
793
|
}
|
|
667
|
-
/**
|
|
794
|
+
/**
|
|
795
|
+
* Publish every pending entry site-wide: one atomic commit on main carrying each branch's
|
|
668
796
|
* entry file plus the manifest with every row upserted, then delete the consumed branches.
|
|
669
797
|
* 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.
|
|
798
|
+
* concept param is ignored and the redirect lands on the first configured concept.
|
|
799
|
+
*/
|
|
671
800
|
async function publishAllAction(event) {
|
|
672
801
|
const editor = requireSession(event);
|
|
673
802
|
const first = runtime.concepts[0];
|
|
@@ -745,8 +874,10 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
745
874
|
}
|
|
746
875
|
throw redirect(303, `${listPage}?publishedAll=${published.length}`);
|
|
747
876
|
}
|
|
748
|
-
/**
|
|
749
|
-
*
|
|
877
|
+
/**
|
|
878
|
+
* Discard an entry's pending edits: delete the branch (tolerant of already-gone) and return to
|
|
879
|
+
* the edit page when the entry lives on main, else to the list (the entry is gone entirely).
|
|
880
|
+
*/
|
|
750
881
|
async function discardAction(event) {
|
|
751
882
|
const editor = requireSession(event);
|
|
752
883
|
const concept = conceptOf(runtime, event.params);
|
|
@@ -761,11 +892,13 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
761
892
|
throw redirect(303, `/admin/${concept.id}/${id}?discarded=1`);
|
|
762
893
|
throw redirect(303, `/admin/${concept.id}`);
|
|
763
894
|
}
|
|
764
|
-
/**
|
|
895
|
+
/**
|
|
896
|
+
* The shared delete core. Block-until-clean: refuse while inbound links exist (naming them), else
|
|
765
897
|
* commit the file removal and the manifest patch in one commit. The inbound recheck here is the
|
|
766
898
|
* authoritative gate, closing the load-to-delete race. Both the editor delete (id from params) and
|
|
767
899
|
* the list delete (id from the form body) call this with an already-validated id, so the guard is
|
|
768
|
-
* enforced once.
|
|
900
|
+
* enforced once.
|
|
901
|
+
*/
|
|
769
902
|
async function deleteEntry(event, concept, id, editor) {
|
|
770
903
|
const path = `${concept.dir}/${filenameFromId(id)}`;
|
|
771
904
|
const token = await mintToken(event.platform?.env ?? {});
|
|
@@ -832,10 +965,12 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
832
965
|
throw error(400, 'Invalid entry id');
|
|
833
966
|
return deleteEntry(event, concept, id, editor);
|
|
834
967
|
}
|
|
835
|
-
/**
|
|
968
|
+
/**
|
|
969
|
+
* Rename an entry: change its slug, move the file, and rewrite every inbound cairn token in one
|
|
836
970
|
* atomic commit, so no internal link breaks. The collision check and the inbound recompute here
|
|
837
971
|
* 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.
|
|
972
|
+
* caught by the build's fail-closed backstop.
|
|
973
|
+
*/
|
|
839
974
|
async function renameAction(event) {
|
|
840
975
|
const editor = requireSession(event);
|
|
841
976
|
const concept = conceptOf(runtime, event.params);
|
|
@@ -1042,7 +1177,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1042
1177
|
const MEDIA_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
1043
1178
|
/** A 16-hex content-hash prefix, the immutable asset key. */
|
|
1044
1179
|
const MEDIA_HASH_RE = /^[0-9a-f]{16}$/;
|
|
1045
|
-
/**
|
|
1180
|
+
/**
|
|
1181
|
+
* Safe-delete a committed media asset. The gate rechecks usage server-side against a FRESH index
|
|
1046
1182
|
* read at delete time (never a client-passed count), mirroring deleteEntry's authoritative inbound
|
|
1047
1183
|
* recheck. An in-use asset refuses unless the form carries the typed-slug override (the in-use
|
|
1048
1184
|
* alertdialog's type-to-confirm). When confirmed, the order is load-bearing: commit the manifest
|
|
@@ -1059,7 +1195,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1059
1195
|
* a referenced asset for an orphan. There is an inherent stale-read window between the recheck and
|
|
1060
1196
|
* the commit (no sha-guard ties them); it is bounded because the resolver and the route key on the
|
|
1061
1197
|
* 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.
|
|
1198
|
+
* delete-races-an-edit window every safe delete carries.
|
|
1199
|
+
*/
|
|
1063
1200
|
async function mediaDeleteAction(event) {
|
|
1064
1201
|
const editor = requireSession(event);
|
|
1065
1202
|
const token = await mintToken(event.platform?.env ?? {});
|
|
@@ -1144,7 +1281,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1144
1281
|
log.info('media.deleted', { editor: editor.email, hash });
|
|
1145
1282
|
throw redirect(303, '/admin/media?deleted=1');
|
|
1146
1283
|
}
|
|
1147
|
-
/**
|
|
1284
|
+
/**
|
|
1285
|
+
* Bulk safe-delete a multi-select of committed media assets. This is mediaDeleteAction extended to
|
|
1148
1286
|
* many items, with the same safety primitives and one rule that defines the batch: the gate is ONE
|
|
1149
1287
|
* shared strict cross-branch usage index built per batch, never N per-item reads (N strict reads
|
|
1150
1288
|
* would blow the workerd connection budget at many open branches). The fail-closed posture is for
|
|
@@ -1162,7 +1300,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1162
1300
|
* leaves bytes with no row (a benign orphan) rather than a row pointing at deleted bytes. Each R2
|
|
1163
1301
|
* delete is best-effort and batch-resilient: a per-object error is reported in `failed` and never
|
|
1164
1302
|
* 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.
|
|
1303
|
+
* (deleted / skipped with reasons / failed); there is no success redirect.
|
|
1304
|
+
*/
|
|
1166
1305
|
async function mediaBulkDelete(event) {
|
|
1167
1306
|
const editor = requireSession(event);
|
|
1168
1307
|
const token = await mintToken(event.platform?.env ?? {});
|
|
@@ -1245,7 +1384,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1245
1384
|
log.info('media.bulk_deleted', { editor: editor.email, deleted: deleted.length, skipped: plan.skipped.length });
|
|
1246
1385
|
return { deleted, skipped: plan.skipped, failed };
|
|
1247
1386
|
}
|
|
1248
|
-
/**
|
|
1387
|
+
/**
|
|
1388
|
+
* The on-demand orphan scan: a read-only reconcile of stored R2 bytes against the manifest, joined
|
|
1249
1389
|
* with one strict cross-branch usage index for the broken-reference where-used. It runs only when
|
|
1250
1390
|
* requested, never on the loaded index, because it is heavier than the load path: a full R2 list
|
|
1251
1391
|
* plus a reconcile pass on top of the strict usage build.
|
|
@@ -1259,7 +1399,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1259
1399
|
*
|
|
1260
1400
|
* The result is the OrphanScan projection: orphanedBytes (stored keys with no manifest row, the
|
|
1261
1401
|
* 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).
|
|
1402
|
+
* where-used so an operator can re-ingest rather than purge a still-referenced record).
|
|
1403
|
+
*/
|
|
1263
1404
|
async function mediaOrphanScan(event) {
|
|
1264
1405
|
requireSession(event);
|
|
1265
1406
|
const token = await mintToken(event.platform?.env ?? {});
|
|
@@ -1291,7 +1432,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1291
1432
|
}
|
|
1292
1433
|
return buildOrphanScan(reconcile, manifest, index);
|
|
1293
1434
|
}
|
|
1294
|
-
/**
|
|
1435
|
+
/**
|
|
1436
|
+
* Purge orphaned R2 bytes: the one IRREVERSIBLE media action. Raw object bytes live only in R2, not
|
|
1295
1437
|
* in git, so a purged orphan cannot be recovered the way a deleted manifest row can be reverted in
|
|
1296
1438
|
* history. The whole action is built around that fact.
|
|
1297
1439
|
*
|
|
@@ -1315,7 +1457,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1315
1457
|
*
|
|
1316
1458
|
* There is no commit. An orphan by definition has no manifest row to remove, so the purge deletes
|
|
1317
1459
|
* 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).
|
|
1460
|
+
* reported in `failed` and the loop continues; an absent object is a no-op (the R2 contract).
|
|
1461
|
+
*/
|
|
1319
1462
|
async function mediaPurgeOrphans(event) {
|
|
1320
1463
|
const editor = requireSession(event);
|
|
1321
1464
|
const token = await mintToken(event.platform?.env ?? {});
|
|
@@ -1383,10 +1526,12 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1383
1526
|
log.info('media.orphans_purged', { editor: editor.email, purged: purged.length });
|
|
1384
1527
|
return { purged, skippedClaimed, failed };
|
|
1385
1528
|
}
|
|
1386
|
-
/**
|
|
1529
|
+
/**
|
|
1530
|
+
* Edit a committed asset's metadata: its display name, slug, and default alt. A single media.json
|
|
1387
1531
|
* row commit, with NO reference rewrite: the resolver and the delivery route key on the hash, so a
|
|
1388
1532
|
* 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.
|
|
1533
|
+
* next placement, never a propagating edit of the alt already committed in existing placements.
|
|
1534
|
+
*/
|
|
1390
1535
|
async function mediaUpdateAction(event) {
|
|
1391
1536
|
const editor = requireSession(event);
|
|
1392
1537
|
const token = await mintToken(event.platform?.env ?? {});
|
|
@@ -1416,13 +1561,16 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1416
1561
|
}
|
|
1417
1562
|
throw redirect(303, '/admin/media?updated=1');
|
|
1418
1563
|
}
|
|
1419
|
-
/**
|
|
1564
|
+
/**
|
|
1565
|
+
* Build the canonical `media:` token for a replacement, treating a slug that fails the grammar (or
|
|
1420
1566
|
* 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.
|
|
1567
|
+
* the hash, so a missing slug still resolves. Shared by the preview and apply token construction.
|
|
1568
|
+
*/
|
|
1422
1569
|
function replacementToken(slug, hash) {
|
|
1423
1570
|
return mediaToken({ slug: MEDIA_SLUG_RE.test(slug) ? slug : null, hash });
|
|
1424
1571
|
}
|
|
1425
|
-
/**
|
|
1572
|
+
/**
|
|
1573
|
+
* Preview a replace-in-place: the display-only fetch action (the 2a transport). It plans the rewrite
|
|
1426
1574
|
* of every published main entry that references `oldHash` to the new asset's `media:` token, enriches
|
|
1427
1575
|
* each with its title and permalink, and returns the plan plus the report-only cross-branch delta.
|
|
1428
1576
|
* It commits nothing. The plan runs strict (fail-closed): an unverifiable usage read returns a 503
|
|
@@ -1432,7 +1580,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1432
1580
|
* the `X-Cairn-CSRF` header (the raw-body transport, no form-CSRF), and a `MediaReplacePreviewPlan`
|
|
1433
1581
|
* returned as the 200 ActionResult the client reads. A refusal rides a `fail(status, ...)` envelope
|
|
1434
1582
|
* 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.
|
|
1583
|
+
* `type`/`status` from the body, never the HTTP status.
|
|
1584
|
+
*/
|
|
1436
1585
|
async function mediaReplacePreview(event) {
|
|
1437
1586
|
// CSRF first: this is a raw-body (JSON) POST, so the header witness is the authority, like the
|
|
1438
1587
|
// upload action. A failed check refuses before the session read or any GitHub call.
|
|
@@ -1498,7 +1647,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1498
1647
|
});
|
|
1499
1648
|
return { affectedCount: plan.affectedCount, entries, branchDelta: plan.branchDelta };
|
|
1500
1649
|
}
|
|
1501
|
-
/**
|
|
1650
|
+
/**
|
|
1651
|
+
* Apply a replace-in-place: rewrite every published main entry that references the old asset to the
|
|
1502
1652
|
* new asset's `media:` token, and add the new media.json row, in ONE atomic commit. The plan is
|
|
1503
1653
|
* re-derived here from a FRESH read (never a client-passed plan), so a concurrent edit between the
|
|
1504
1654
|
* preview and the apply is rewritten too. EVERY replace is gated behind the typed-slug confirm
|
|
@@ -1509,7 +1659,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1509
1659
|
*
|
|
1510
1660
|
* No R2 operation: the new bytes were already stored put-first by the upload action, and the old
|
|
1511
1661
|
* 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.
|
|
1662
|
+
* resolves the bucket binding. It guards `resolvedAssets.enabled` for the media-off case only.
|
|
1663
|
+
*/
|
|
1513
1664
|
async function mediaReplaceApply(event) {
|
|
1514
1665
|
const editor = requireSession(event);
|
|
1515
1666
|
const token = await mintToken(event.platform?.env ?? {});
|
|
@@ -1598,7 +1749,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1598
1749
|
}
|
|
1599
1750
|
throw redirect(303, '/admin/media?replaced=1');
|
|
1600
1751
|
}
|
|
1601
|
-
/**
|
|
1752
|
+
/**
|
|
1753
|
+
* Preview an alt-propagation: the display-only fetch action (the 2a transport). It plans filling the
|
|
1602
1754
|
* asset's default alt across every published main entry that references it, bucketing each placement
|
|
1603
1755
|
* (a will-fill empty alt, a customized alt left as-is, a decorative hero skipped), and returns the
|
|
1604
1756
|
* enriched entries, the report-only cross-branch delta, and the bucket counts. It commits nothing.
|
|
@@ -1608,7 +1760,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1608
1760
|
* Wire contract: a fetch POST with the JSON body `{ hash }`, the CSRF token in the `X-Cairn-CSRF`
|
|
1609
1761
|
* header (the raw-body transport, no form-CSRF), and a `MediaAltPreviewPlan` returned as the 200
|
|
1610
1762
|
* 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.
|
|
1763
|
+
* MediaAltPropagateFailure shape, so the client reads `type`/`status` from the body.
|
|
1764
|
+
*/
|
|
1612
1765
|
async function mediaAltPreview(event) {
|
|
1613
1766
|
// CSRF first: a raw-body (JSON) POST, so the header witness is the authority, like the upload and
|
|
1614
1767
|
// replace-preview actions. A failed check refuses before the session read or any GitHub call.
|
|
@@ -1676,14 +1829,16 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1676
1829
|
});
|
|
1677
1830
|
return { entries, branchDelta: plan.branchDelta, counts };
|
|
1678
1831
|
}
|
|
1679
|
-
/**
|
|
1832
|
+
/**
|
|
1833
|
+
* Apply an alt-propagation: fill the asset's default alt into every empty placement across the
|
|
1680
1834
|
* published corpus (and, on the `overwrite` opt-in, customized placements too), in ONE atomic
|
|
1681
1835
|
* commit. The plan is re-derived from a FRESH read (never a client plan). Three deliberate
|
|
1682
1836
|
* differences from replace: there is NO typed-slug gate (alt fill is reversible and frequent), there
|
|
1683
1837
|
* is NO media.json change (the default alt is READ from the row, never rewritten there), and a
|
|
1684
1838
|
* decorative hero is never written regardless of `overwrite` (enforced inside fillAltForHash). A run
|
|
1685
1839
|
* 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).
|
|
1840
|
+
* closed on an unverifiable usage read, and writes only entry files in git (no R2 op).
|
|
1841
|
+
*/
|
|
1687
1842
|
async function mediaAltApply(event) {
|
|
1688
1843
|
const editor = requireSession(event);
|
|
1689
1844
|
const token = await mintToken(event.platform?.env ?? {});
|
|
@@ -1735,19 +1890,25 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1735
1890
|
}
|
|
1736
1891
|
throw redirect(303, '/admin/media?altPropagated=1');
|
|
1737
1892
|
}
|
|
1738
|
-
/**
|
|
1893
|
+
/**
|
|
1894
|
+
* The cap on a personal-dictionary word, matched by isValidDictionaryWord. A word is one line, so
|
|
1739
1895
|
* 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.
|
|
1896
|
+
* whitespace and control bytes so a body can never inject an extra line into the committed file.
|
|
1897
|
+
*/
|
|
1741
1898
|
const MAX_DICTIONARY_WORD = 64;
|
|
1742
|
-
/**
|
|
1743
|
-
*
|
|
1899
|
+
/**
|
|
1900
|
+
* The cap on the words a single add request carries: an editor adds a handful at save time, never
|
|
1901
|
+
* a flood. Past this the body is treated as abusive and the surplus is dropped.
|
|
1902
|
+
*/
|
|
1744
1903
|
const MAX_DICTIONARY_BATCH = 100;
|
|
1745
|
-
/**
|
|
1904
|
+
/**
|
|
1905
|
+
* Read the committed personal dictionary, merge the validated additions in sorted order, and commit
|
|
1746
1906
|
* the canonical file back. Shared by the first attempt and the post-conflict retry, so both re-read
|
|
1747
1907
|
* the head and re-merge the same additions; the merge is order-independent, so a concurrent editor's
|
|
1748
1908
|
* word that already landed is preserved and the result is the same sorted set regardless of order.
|
|
1749
1909
|
* Returns the merged word list. Throws CommitConflictError (via commitFiles) when the branch moves
|
|
1750
|
-
* under the commit, which the caller catches to retry once.
|
|
1910
|
+
* under the commit, which the caller catches to retry once.
|
|
1911
|
+
*/
|
|
1751
1912
|
async function mergeAndCommitDictionary(token, additions, editor) {
|
|
1752
1913
|
const path = dictionaryFilePath();
|
|
1753
1914
|
// The existing file as its canonical sorted set, so a no-op add is detected against the same
|
|
@@ -1762,25 +1923,31 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1762
1923
|
await commitFiles(runtime.backend, [{ path, content: serializeDictionary(merged) }], { message: `Add to dictionary: ${additions.join(', ')}`, author: { name: editor.displayName, email: editor.email } }, token);
|
|
1763
1924
|
return merged;
|
|
1764
1925
|
}
|
|
1765
|
-
/**
|
|
1926
|
+
/**
|
|
1927
|
+
* The repo-relative site-config path the settings save reads and commits. It is the same committed
|
|
1766
1928
|
* 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.
|
|
1929
|
+
* scaffold default when no menu is configured.
|
|
1930
|
+
*/
|
|
1768
1931
|
function siteConfigPath() {
|
|
1769
1932
|
return runtime.navMenu?.configPath ?? DEFAULT_SITE_CONFIG_PATH;
|
|
1770
1933
|
}
|
|
1771
|
-
/**
|
|
1934
|
+
/**
|
|
1935
|
+
* Read whether the Anthropic API key secret is present in the load's env. A presence flag for the
|
|
1772
1936
|
* 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.
|
|
1937
|
+
* that a non-empty `ANTHROPIC_API_KEY` exists and the value never leaves the server.
|
|
1938
|
+
*/
|
|
1774
1939
|
function keyConfigured(event) {
|
|
1775
1940
|
const env = (event.platform?.env ?? {});
|
|
1776
1941
|
return typeof env.ANTHROPIC_API_KEY === 'string' && env.ANTHROPIC_API_KEY.length > 0;
|
|
1777
1942
|
}
|
|
1778
|
-
/**
|
|
1943
|
+
/**
|
|
1944
|
+
* Load the two-tier tidy settings (spec 2.8, Task 15). The developer tier (enabled, key, model) is
|
|
1779
1945
|
* read-only; the editor tier is the resolved conventions block. The visibility gate is truthful: the
|
|
1780
1946
|
* `enabled` flag is true only when `tidy.enabled` is set AND the key is present, so the screen renders
|
|
1781
1947
|
* the convention list only then and the honest gate note otherwise. No secret is returned: only a
|
|
1782
1948
|
* 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.
|
|
1949
|
+
* the tidy action's prompt reads), so the screen and the prompt can never diverge.
|
|
1950
|
+
*/
|
|
1784
1951
|
function settingsLoad(event) {
|
|
1785
1952
|
requireSession(event);
|
|
1786
1953
|
const tidy = runtime.tidy;
|
|
@@ -1798,13 +1965,15 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1798
1965
|
error: event.url.searchParams.get('error'),
|
|
1799
1966
|
};
|
|
1800
1967
|
}
|
|
1801
|
-
/**
|
|
1968
|
+
/**
|
|
1969
|
+
* Save the editor-tier tidy conventions: validate the posted block, then read-modify-commit it into
|
|
1802
1970
|
* the same committed YAML the nav editor writes, with the session editor as author. The transport is
|
|
1803
1971
|
* the nav save's exactly: a form POST carrying the conventions JSON, the read-modify-commit through
|
|
1804
1972
|
* `commitFile`, and a stale-SHA `isConflict` bounced back as a reload prompt. Only the conventions
|
|
1805
1973
|
* block is written (setTidy leaves `tidy.enabled` and `tidy.model` untouched), so an editor's save can
|
|
1806
1974
|
* 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.
|
|
1975
|
+
* enabled, so the gate state's absent editor tier can never be saved past.
|
|
1976
|
+
*/
|
|
1808
1977
|
async function settingsSave(event) {
|
|
1809
1978
|
const editor = requireSession(event);
|
|
1810
1979
|
// The editor tier does not exist when tidy is off, so a save in that state is a 404 (no editable
|
|
@@ -1843,7 +2012,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1843
2012
|
}
|
|
1844
2013
|
throw redirect(303, '/admin/settings?saved=1');
|
|
1845
2014
|
}
|
|
1846
|
-
/**
|
|
2015
|
+
/**
|
|
2016
|
+
* Add a word (or batch) to the git-committed personal dictionary (spec 1.6). The transport mirrors
|
|
1847
2017
|
* the media raw-body actions exactly: a `text/plain` POST, the CSRF token in `X-Cairn-CSRF` validated
|
|
1848
2018
|
* by validateCsrfHeader (CSRF first, then the session), and a small JSON body `{ word }` or
|
|
1849
2019
|
* `{ words }`. It reads the current file from the default branch, inserts the validated words in
|
|
@@ -1858,7 +2028,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1858
2028
|
* Input validation is load-bearing here: this commits to the repo from request input, so every word
|
|
1859
2029
|
* is length-bounded and rejected if it carries whitespace or control characters (a word is one
|
|
1860
2030
|
* 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.
|
|
2031
|
+
* nothing, so the committed file can never gain an injected or empty line.
|
|
2032
|
+
*/
|
|
1862
2033
|
async function addDictionaryWord(event) {
|
|
1863
2034
|
// CSRF first: a raw-body (JSON) POST, so the header witness is the authority, like the upload and
|
|
1864
2035
|
// media actions. A failed check refuses before the session read or any GitHub call.
|
|
@@ -1911,7 +2082,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1911
2082
|
}
|
|
1912
2083
|
}
|
|
1913
2084
|
}
|
|
1914
|
-
/**
|
|
2085
|
+
/**
|
|
2086
|
+
* Tidy: a light LLM copy-edit of the author's markdown (spec 2.1). The first remote model call in
|
|
1915
2087
|
* the library, so this is the highest-blast-radius server action: untrusted content and the Anthropic
|
|
1916
2088
|
* API key. The transport mirrors the media raw-body actions (a `text/plain` POST carrying JSON
|
|
1917
2089
|
* `{ text, scope }`, the CSRF token in `X-Cairn-CSRF`, the response deserialized by the client), with
|
|
@@ -1929,7 +2101,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1929
2101
|
* prompt's injection framing (Task 10) treats it as data. The API key never leaves the action: it is
|
|
1930
2102
|
* not returned and not logged, and the log line carries no content. The action commits NOTHING, so a
|
|
1931
2103
|
* 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.
|
|
2104
|
+
* (Task 12), so the server stays a thin model-call boundary.
|
|
2105
|
+
*/
|
|
1933
2106
|
async function tidyAction(event) {
|
|
1934
2107
|
// CSRF first: a raw-body (JSON) POST, so the header witness is the authority. A failed check refuses
|
|
1935
2108
|
// before the session read and before any model call.
|
|
@@ -2019,10 +2192,12 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
2019
2192
|
log.info('tidy.done', { editor: editor.email, model: message.model, usage: message.usage });
|
|
2020
2193
|
return { corrected, model: message.model, usage: message.usage };
|
|
2021
2194
|
}
|
|
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 };
|
|
2195
|
+
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
2196
|
}
|
|
2024
|
-
/**
|
|
2025
|
-
*
|
|
2197
|
+
/**
|
|
2198
|
+
* The cap, in characters, on the stored alt text. The human fields are display copy, not content,
|
|
2199
|
+
* so a generous cap rejects only abuse-scale input.
|
|
2200
|
+
*/
|
|
2026
2201
|
const MAX_ALT = 160;
|
|
2027
2202
|
/** The cap, in characters, on the stored display name. */
|
|
2028
2203
|
const MAX_DISPLAY_NAME = 120;
|
|
@@ -2030,8 +2205,10 @@ const MAX_DISPLAY_NAME = 120;
|
|
|
2030
2205
|
const MAX_ORIGINAL_FILENAME = 120;
|
|
2031
2206
|
/** The largest pixel dimension kept; anything larger is treated as bogus and clamped to null. */
|
|
2032
2207
|
const MAX_DIMENSION = 60000;
|
|
2033
|
-
/**
|
|
2034
|
-
*
|
|
2208
|
+
/**
|
|
2209
|
+
* Decode a percent-encoded header value, yielding `''` on a malformed sequence or an absent header,
|
|
2210
|
+
* so a hostile `X-Cairn-*` value cannot throw past the gate.
|
|
2211
|
+
*/
|
|
2035
2212
|
function safeDecode(value) {
|
|
2036
2213
|
if (value === null)
|
|
2037
2214
|
return '';
|
|
@@ -2042,35 +2219,46 @@ function safeDecode(value) {
|
|
|
2042
2219
|
return '';
|
|
2043
2220
|
}
|
|
2044
2221
|
}
|
|
2045
|
-
/**
|
|
2046
|
-
*
|
|
2222
|
+
/**
|
|
2223
|
+
* The basename of a decoded filename: the final path segment after any `/` or `\`. A client value
|
|
2224
|
+
* of `../../evil.png` yields `evil.png`, so no path component reaches the stored record.
|
|
2225
|
+
*/
|
|
2047
2226
|
function basename(name) {
|
|
2048
2227
|
const parts = name.split(/[/\\]/);
|
|
2049
2228
|
return parts[parts.length - 1];
|
|
2050
2229
|
}
|
|
2051
|
-
/**
|
|
2052
|
-
*
|
|
2230
|
+
/**
|
|
2231
|
+
* Sort key for a where-used row's origin: published rows rank before branch rows, so the in-use
|
|
2232
|
+
* refusal lists "Published on the site" first, then the edit-branch references.
|
|
2233
|
+
*/
|
|
2053
2234
|
function originRank(entry) {
|
|
2054
2235
|
return entry.origin.kind === 'published' ? 0 : 1;
|
|
2055
2236
|
}
|
|
2056
|
-
/**
|
|
2057
|
-
*
|
|
2237
|
+
/**
|
|
2238
|
+
* A where-used row's branch name for the secondary sort (the empty string for a published row,
|
|
2239
|
+
* which sorts ahead of any branch by `originRank` already).
|
|
2240
|
+
*/
|
|
2058
2241
|
function branchKey(entry) {
|
|
2059
2242
|
return entry.origin.kind === 'branch' ? entry.origin.branch : '';
|
|
2060
2243
|
}
|
|
2061
|
-
/**
|
|
2062
|
-
*
|
|
2244
|
+
/**
|
|
2245
|
+
* The distinct-entry count behind a where-used set: a published use and an edit-branch edit of the
|
|
2246
|
+
* same entry are two rows but one distinct entry, so count by concept/id.
|
|
2247
|
+
*/
|
|
2063
2248
|
function distinctEntryCount(rows) {
|
|
2064
2249
|
return new Set(rows.map((e) => `${e.concept}/${e.id}`)).size;
|
|
2065
2250
|
}
|
|
2066
|
-
/**
|
|
2067
|
-
*
|
|
2251
|
+
/**
|
|
2252
|
+
* Strip control characters from a human field and cap it at `max` characters. Control characters
|
|
2253
|
+
* (C0 and DEL) never belong in display copy and could corrupt a log line or a committed JSON.
|
|
2254
|
+
*/
|
|
2068
2255
|
function sanitizeField(value, max) {
|
|
2069
|
-
// eslint-disable-next-line no-control-regex
|
|
2070
2256
|
return value.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, max);
|
|
2071
2257
|
}
|
|
2072
|
-
/**
|
|
2073
|
-
*
|
|
2258
|
+
/**
|
|
2259
|
+
* Parse an advisory pixel dimension header. A valid integer in `[1, MAX_DIMENSION]` is kept; an
|
|
2260
|
+
* absent, non-numeric, or out-of-range value becomes null (MediaEntry dimensions are `number | null`).
|
|
2261
|
+
*/
|
|
2074
2262
|
function clampDimension(value) {
|
|
2075
2263
|
if (value === null)
|
|
2076
2264
|
return null;
|