@glw907/cairn-cms 0.60.1 → 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 +69 -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 +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/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 +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 +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, type AdvisoryNotice, type AddressEntry } from '../content/advisories.js';
|
|
11
12
|
import { isValidId, slugify, filenameFromId, composeDatedId, slugFromId, renameId } from '../content/ids.js';
|
|
12
13
|
import { appCredentials, type GithubKeyEnv } from '../github/credentials.js';
|
|
13
14
|
import { listMarkdown, readRaw, commitFile, commitFiles, type FileChange } 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, type Manifest, type LinkTarget, type InboundLink } from '../content/manifest.js';
|
|
19
|
+
import { deriveGettingStarted, type GettingStarted } from '../content/getting-started.js';
|
|
20
|
+
import { markdownReference, type MarkdownReferenceRow } 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';
|
|
@@ -54,6 +57,10 @@ import type { Editor, Role } from '../auth/types.js';
|
|
|
54
57
|
// import that never appears in an exported signature, so it does not reach the public `.d.ts`.
|
|
55
58
|
import type { R2Bucket } from '@cloudflare/workers-types';
|
|
56
59
|
|
|
60
|
+
// The advisory notice types are defined alongside the cross-branch address index in the content
|
|
61
|
+
// layer; re-export them here so EditData's advisories and the /sveltekit subpath carry one shape.
|
|
62
|
+
export type { AdvisoryNotice, AdvisoryAction } from '../content/advisories.js';
|
|
63
|
+
|
|
57
64
|
/** A sidebar concept entry: just enough to render the nav without shipping validators to the client. */
|
|
58
65
|
export interface NavConcept {
|
|
59
66
|
id: string;
|
|
@@ -71,13 +78,17 @@ export interface LayoutData {
|
|
|
71
78
|
navLabel: string | null;
|
|
72
79
|
/** The admin theme resolved for SSR: the persisted cookie choice, or the light default. */
|
|
73
80
|
theme: 'cairn-admin' | 'cairn-admin-dark';
|
|
74
|
-
/**
|
|
75
|
-
*
|
|
81
|
+
/**
|
|
82
|
+
* The nav group labels the user has collapsed, from the persisted cookie. Read at SSR so a
|
|
83
|
+
* collapsed group renders collapsed with no flash. Empty when none are collapsed.
|
|
84
|
+
*/
|
|
76
85
|
collapsedNav: string[];
|
|
77
86
|
/** The session's CSRF double-submit token, rendered as a hidden field in every admin form. */
|
|
78
87
|
csrf: string;
|
|
79
|
-
/**
|
|
80
|
-
*
|
|
88
|
+
/**
|
|
89
|
+
* Every entry with unpublished edits (a `cairn/` ref), for the topbar's publish-all action.
|
|
90
|
+
* Null when GitHub is unreachable, so the topbar hides the action rather than lying.
|
|
91
|
+
*/
|
|
81
92
|
pendingEntries: { concept: string; id: string }[] | null;
|
|
82
93
|
}
|
|
83
94
|
|
|
@@ -89,8 +100,10 @@ export interface EntrySummary {
|
|
|
89
100
|
draft: boolean;
|
|
90
101
|
/** Publish state derived from the ref set: live as-is, live with pending edits, or branch-only. */
|
|
91
102
|
status: 'published' | 'edited' | 'new';
|
|
92
|
-
/**
|
|
93
|
-
*
|
|
103
|
+
/**
|
|
104
|
+
* The row's one-line summary: the manifest's indexed excerpt for a published row, the branch
|
|
105
|
+
* frontmatter/body excerpt for a pending one, and null when neither yields text.
|
|
106
|
+
*/
|
|
94
107
|
summary: string | null;
|
|
95
108
|
}
|
|
96
109
|
|
|
@@ -98,8 +111,10 @@ export interface EntrySummary {
|
|
|
98
111
|
export interface ListData {
|
|
99
112
|
conceptId: string;
|
|
100
113
|
label: string;
|
|
101
|
-
/**
|
|
102
|
-
*
|
|
114
|
+
/**
|
|
115
|
+
* The singular noun for the create affordances ("New post"); from the descriptor, which defaults
|
|
116
|
+
* it to `label`.
|
|
117
|
+
*/
|
|
103
118
|
singular: string;
|
|
104
119
|
/** Posts carry a date in the new-entry form; pages do not (concept routing, spec §7.2). */
|
|
105
120
|
dated: boolean;
|
|
@@ -130,13 +145,17 @@ export interface EditData {
|
|
|
130
145
|
slug: string;
|
|
131
146
|
/** The site's link targets, for the preview resolver and the link picker; from the committed manifest. */
|
|
132
147
|
linkTargets: LinkTarget[];
|
|
133
|
-
/**
|
|
134
|
-
*
|
|
148
|
+
/**
|
|
149
|
+
* The minimal media-resolver input the edit page builds its preview `resolveMedia` from, keyed by
|
|
150
|
+
* the 16-hex content hash and parallel to `linkTargets`. Empty when media is off or the read fails.
|
|
151
|
+
*/
|
|
135
152
|
mediaTargets: Record<string, { slug: string; ext: string; contentType: string }>;
|
|
136
|
-
/**
|
|
153
|
+
/**
|
|
154
|
+
* The picker's human layer for each stored asset, keyed by the 16-hex content hash and projected
|
|
137
155
|
* from the same committed media manifest read that populates `mediaTargets`. The `hash` field
|
|
138
156
|
* duplicates the key, so the picker can iterate `Object.values`. Empty when media is off or the
|
|
139
|
-
* read fails (the same degradation path as `mediaTargets`).
|
|
157
|
+
* read fails (the same degradation path as `mediaTargets`).
|
|
158
|
+
*/
|
|
140
159
|
mediaLibrary: MediaLibrary;
|
|
141
160
|
/** The entries that link to this one, for the delete guard. Empty when nothing links here. */
|
|
142
161
|
inboundLinks: InboundLink[];
|
|
@@ -148,30 +167,42 @@ export interface EditData {
|
|
|
148
167
|
publishedFlash: boolean;
|
|
149
168
|
/** True after a discard redirect (`?discarded=1`), for the confirmation strip. */
|
|
150
169
|
discardedFlash: boolean;
|
|
151
|
-
/**
|
|
170
|
+
/**
|
|
171
|
+
* The adapter's preview knob resolved for this entry's concept (its `byConcept` override,
|
|
152
172
|
* when one exists, applied over the top-level values); null when the site sets none, which
|
|
153
|
-
* leaves the frame rendering unstyled markup behind a hint.
|
|
173
|
+
* leaves the frame rendering unstyled markup behind a hint.
|
|
174
|
+
*/
|
|
154
175
|
preview: ResolvedPreview | null;
|
|
155
|
-
/**
|
|
176
|
+
/**
|
|
177
|
+
* The spellcheck dictionary file for the site's configured dialect (default US English), resolved
|
|
156
178
|
* once at compose. The editor resolves it to a real asset URL on the main thread and hands that URL
|
|
157
179
|
* to the spellcheck Worker's `init`, the same way `mediaLibrary` is threaded in. Just the filename,
|
|
158
|
-
* e.g. "dictionary-en-us.txt".
|
|
180
|
+
* e.g. "dictionary-en-us.txt".
|
|
181
|
+
*/
|
|
159
182
|
spellcheckDictionary: string;
|
|
160
|
-
/**
|
|
183
|
+
/**
|
|
184
|
+
* The committed personal-dictionary words for the site (spec 1.6): the durable, shared, reviewable
|
|
161
185
|
* layer the editor seeds the spellcheck Worker's personal set from, the way `mediaLibrary` is handed
|
|
162
186
|
* in. Read from the git-committed `dictionary.txt` at editor load; empty when the file is absent or
|
|
163
187
|
* unreadable (the editor degrades to dialect-only). The dialect dictionary and the session ignore
|
|
164
|
-
* list are the other two layers; only this one is committed.
|
|
188
|
+
* list are the other two layers; only this one is committed.
|
|
189
|
+
*/
|
|
165
190
|
siteDictionary: string[];
|
|
166
|
-
/**
|
|
191
|
+
/**
|
|
192
|
+
* The editor-tier tidy facts the review surface needs (spec 2.5): whether tidy is enabled, the model
|
|
167
193
|
* that runs (for the head pill), and the RESOLVED conventions (the only data source for a
|
|
168
194
|
* normalization's because-line and the local category inference). The API key never appears here, it
|
|
169
|
-
* is a Worker secret. `enabled` false hides the Tidy control.
|
|
195
|
+
* is a Worker secret. `enabled` false hides the Tidy control.
|
|
196
|
+
*/
|
|
170
197
|
tidy: { enabled: boolean; model: string; conventions: TidyConventions };
|
|
198
|
+
/** Non-blocking editor advisories built server-side; today the cross-branch address collision. */
|
|
199
|
+
advisories: AdvisoryNotice[];
|
|
171
200
|
}
|
|
172
201
|
|
|
173
|
-
/**
|
|
174
|
-
*
|
|
202
|
+
/**
|
|
203
|
+
* One asset's where-used overlay, kept separate from MediaLibraryEntry so the picker's shared
|
|
204
|
+
* projection stays decoupled from the Library-only usage facts.
|
|
205
|
+
*/
|
|
175
206
|
export interface MediaUsageInfo {
|
|
176
207
|
/** Distinct content entries that reference the asset (count by distinct concept+id). */
|
|
177
208
|
count: number;
|
|
@@ -179,51 +210,69 @@ export interface MediaUsageInfo {
|
|
|
179
210
|
entries: UsageEntry[];
|
|
180
211
|
}
|
|
181
212
|
|
|
182
|
-
/**
|
|
213
|
+
/**
|
|
214
|
+
* The Media Library screen's data: the unioned assets, the per-hash usage overlay, and the
|
|
183
215
|
* degraded-load error. The usage overlay is keyed by content hash; an asset with no references
|
|
184
|
-
* simply has no key, which the screen renders as "no references found".
|
|
216
|
+
* simply has no key, which the screen renders as "no references found".
|
|
217
|
+
*/
|
|
185
218
|
export interface MediaLibraryData {
|
|
186
219
|
assets: MediaLibraryEntry[];
|
|
187
220
|
/** Per-hash usage overlay, kept separate from MediaLibraryEntry so the popover stays decoupled. */
|
|
188
221
|
usage: Record<string, MediaUsageInfo>;
|
|
189
|
-
/**
|
|
222
|
+
/**
|
|
223
|
+
* The degraded-load error: a failed token mint or media read. This slot is the failure of THIS
|
|
190
224
|
* load, distinct from a prior action's conflict error (see `flashError`), so a read failure and a
|
|
191
|
-
* redirected commit conflict never overwrite each other.
|
|
225
|
+
* redirected commit conflict never overwrite each other.
|
|
226
|
+
*/
|
|
192
227
|
error: string | null;
|
|
193
|
-
/**
|
|
228
|
+
/**
|
|
229
|
+
* The success flash a redirected action carries: `deleted` from `?deleted=1`, `updated` from
|
|
194
230
|
* `?updated=1`, `replaced` from `?replaced=1`, `altPropagated` from `?altPropagated=1`,
|
|
195
231
|
* `bulkDeleted` from `?bulkDeleted=1`, `orphansPurged` from `?orphansPurged=1`, null otherwise.
|
|
196
|
-
* The component renders a polite success strip for each.
|
|
232
|
+
* The component renders a polite success strip for each.
|
|
233
|
+
*/
|
|
197
234
|
flash: 'deleted' | 'updated' | 'replaced' | 'altPropagated' | 'bulkDeleted' | 'orphansPurged' | null;
|
|
198
|
-
/**
|
|
199
|
-
*
|
|
235
|
+
/**
|
|
236
|
+
* A redirected action's conflict error read from `?error=` (a commit-conflict bounce). Kept in
|
|
237
|
+
* its own slot rather than the degraded-load `error` above, so the two never collide.
|
|
238
|
+
*/
|
|
200
239
|
flashError: string | null;
|
|
201
240
|
}
|
|
202
241
|
|
|
203
|
-
/**
|
|
242
|
+
/**
|
|
243
|
+
* The two-tier tidy settings load (spec 2.8, Task 15). The developer tier is read-only: `enabled`,
|
|
204
244
|
* `keyConfigured`, and `model`/`modelLabel` are deploy-time facts the editor sees but cannot change.
|
|
205
245
|
* The editor tier is the resolved `conventions` block, written back through the save. The visibility
|
|
206
246
|
* gate is truthful: `enabled` is true only when `tidy.enabled` is set AND the API key is present, so
|
|
207
247
|
* the screen renders the convention list only then and the honest gate note otherwise. The key is a
|
|
208
248
|
* Worker secret, so `keyConfigured` is the presence of `ANTHROPIC_API_KEY` in the load's env, never
|
|
209
|
-
* the key itself; nothing here returns or logs the secret.
|
|
249
|
+
* the key itself; nothing here returns or logs the secret.
|
|
250
|
+
*/
|
|
210
251
|
export interface SettingsData {
|
|
211
|
-
/**
|
|
252
|
+
/**
|
|
253
|
+
* The truthful gate: tidy is enabled AND the API key is present. The screen renders the editor
|
|
212
254
|
* tier only when this is true, and the honest gate note (a labelled region, no disabled controls)
|
|
213
|
-
* otherwise.
|
|
255
|
+
* otherwise.
|
|
256
|
+
*/
|
|
214
257
|
enabled: boolean;
|
|
215
|
-
/**
|
|
216
|
-
*
|
|
258
|
+
/**
|
|
259
|
+
* Whether `tidy.enabled` is set in the site config, independent of the key. The gate note's
|
|
260
|
+
* checklist reads this to show which deploy-time step is still open.
|
|
261
|
+
*/
|
|
217
262
|
tidyEnabled: boolean;
|
|
218
263
|
/** Whether the API key secret is present in the Worker env. A presence flag, never the key. */
|
|
219
264
|
keyConfigured: boolean;
|
|
220
265
|
/** The model id (a developer-tier fact, read-only on the screen). */
|
|
221
266
|
model: string;
|
|
222
|
-
/**
|
|
223
|
-
*
|
|
267
|
+
/**
|
|
268
|
+
* A plain-language label for the model id ("Claude Sonnet"), so the read-only fact is not a bare
|
|
269
|
+
* jargon token. Falls back to the raw id for an unknown model.
|
|
270
|
+
*/
|
|
224
271
|
modelLabel: string;
|
|
225
|
-
/**
|
|
226
|
-
*
|
|
272
|
+
/**
|
|
273
|
+
* The resolved editor-tier conventions: every field concrete, the screen's initial control state.
|
|
274
|
+
* Present only when the gate is open; the gate state needs no conventions.
|
|
275
|
+
*/
|
|
227
276
|
conventions: TidyConventions;
|
|
228
277
|
/** The success flash a redirected save carries (`?saved=1`). */
|
|
229
278
|
saved: boolean;
|
|
@@ -231,26 +280,42 @@ export interface SettingsData {
|
|
|
231
280
|
error: string | null;
|
|
232
281
|
}
|
|
233
282
|
|
|
234
|
-
/**
|
|
235
|
-
*
|
|
283
|
+
/**
|
|
284
|
+
* A refused settings save: a conflict bounce or a malformed conventions payload. Just the one-line
|
|
285
|
+
* summary; the save commits nothing on a refusal.
|
|
286
|
+
*/
|
|
236
287
|
export interface SettingsSaveFailure {
|
|
237
288
|
error: string;
|
|
238
289
|
}
|
|
239
290
|
|
|
291
|
+
/**
|
|
292
|
+
* The Help home's data: the derived getting-started progress, the full markdown reference (the
|
|
293
|
+
* component curates by group), and the optional support hand-off (rendered only when set).
|
|
294
|
+
*/
|
|
295
|
+
export interface HelpData {
|
|
296
|
+
gettingStarted: GettingStarted;
|
|
297
|
+
reference: MarkdownReferenceRow[];
|
|
298
|
+
supportContact?: string;
|
|
299
|
+
}
|
|
300
|
+
|
|
240
301
|
/** The structural event the content routes read; a real SvelteKit RequestEvent satisfies it. */
|
|
241
302
|
export interface ContentEvent extends EventBase<GithubKeyEnv> {
|
|
242
303
|
params: Record<string, string>;
|
|
243
|
-
/**
|
|
244
|
-
*
|
|
304
|
+
/**
|
|
305
|
+
* SvelteKit's cookie jar. The layout load reads the persisted admin theme and issues the CSRF
|
|
306
|
+
* token. Optional for non-route callers.
|
|
307
|
+
*/
|
|
245
308
|
cookies?: CookieJar;
|
|
246
309
|
}
|
|
247
310
|
|
|
248
311
|
/** Injectable dependencies; tests stub the token mint to avoid signing a real key. */
|
|
249
|
-
/**
|
|
312
|
+
/**
|
|
313
|
+
* The minimal Anthropic client surface the tidy action uses, typed structurally so the SDK's deep
|
|
250
314
|
* generics never reach a public signature and so the integration test can inject a fake whose
|
|
251
315
|
* `messages.create` it stubs. The real factory builds `new Anthropic({ apiKey })`, which satisfies
|
|
252
316
|
* this shape. The success path reads only the text blocks, the model, the stop reason, and the usage
|
|
253
|
-
* counts.
|
|
317
|
+
* counts.
|
|
318
|
+
*/
|
|
254
319
|
export interface TidyClient {
|
|
255
320
|
messages: {
|
|
256
321
|
create(
|
|
@@ -273,53 +338,69 @@ export interface TidyClient {
|
|
|
273
338
|
}
|
|
274
339
|
|
|
275
340
|
export interface ContentRoutesDeps {
|
|
276
|
-
/**
|
|
277
|
-
*
|
|
341
|
+
/**
|
|
342
|
+
* Mint a GitHub App installation token from the Worker env. Defaults to the real signer.
|
|
343
|
+
* A bare string works too; the routes await whatever comes back.
|
|
344
|
+
*/
|
|
278
345
|
mintToken?: (env: GithubKeyEnv) => string | Promise<string>;
|
|
279
|
-
/**
|
|
346
|
+
/**
|
|
347
|
+
* Build the Anthropic client for the tidy action from the resolved API key. Defaults to the real
|
|
280
348
|
* SDK client. Injected in tests so `messages.create` is stubbed and no network call (or real key)
|
|
281
349
|
* is ever needed. The factory runs only after the key is read from the env, so a disabled or
|
|
282
|
-
* unconfigured site never constructs a client.
|
|
350
|
+
* unconfigured site never constructs a client.
|
|
351
|
+
*/
|
|
283
352
|
anthropic?: (opts: { apiKey: string }) => TidyClient;
|
|
284
|
-
/**
|
|
353
|
+
/**
|
|
354
|
+
* The tidy action's own request deadline in milliseconds, set shorter than the platform limit so a
|
|
285
355
|
* slow model call becomes a clean retryable fail(502) rather than a platform timeout. Defaults to
|
|
286
|
-
* {@link DEFAULT_TIDY_TIMEOUT_MS}. Overridable in tests to assert the deadline path without waiting.
|
|
356
|
+
* {@link DEFAULT_TIDY_TIMEOUT_MS}. Overridable in tests to assert the deadline path without waiting.
|
|
357
|
+
*/
|
|
287
358
|
tidyTimeoutMs?: number;
|
|
288
359
|
}
|
|
289
360
|
|
|
290
|
-
/**
|
|
361
|
+
/**
|
|
362
|
+
* The successful tidy outcome (spec 2.1): the corrected markdown, the model that produced it, and the
|
|
291
363
|
* token usage. The diff is computed on the client (Task 12), so the server returns the plain text and
|
|
292
364
|
* commits nothing. Admin-internal: consumed by the editor's review surface, not on the package's
|
|
293
|
-
* sveltekit subpath, so it carries no reference page.
|
|
365
|
+
* sveltekit subpath, so it carries no reference page.
|
|
366
|
+
*/
|
|
294
367
|
export interface TidyResult {
|
|
295
368
|
corrected: string;
|
|
296
369
|
model: string;
|
|
297
370
|
usage: { input_tokens: number; output_tokens: number };
|
|
298
371
|
}
|
|
299
372
|
|
|
300
|
-
/**
|
|
373
|
+
/**
|
|
374
|
+
* A refused tidy: `fail(403)` on a failed CSRF check, `fail(503)` when tidy is disabled or the API
|
|
301
375
|
* key is missing, `fail(413)` for an over-long body, `fail(502)` for a deadline overrun, abort, or
|
|
302
376
|
* model error (all retryable), `fail(422)` for a model refusal, `fail(400)` for a malformed body. Just
|
|
303
|
-
* the one-line summary; the action commits nothing, so a refusal can never corrupt the entry.
|
|
377
|
+
* the one-line summary; the action commits nothing, so a refusal can never corrupt the entry.
|
|
378
|
+
*/
|
|
304
379
|
export interface TidyFailure {
|
|
305
380
|
error: string;
|
|
306
381
|
}
|
|
307
382
|
|
|
308
|
-
/**
|
|
383
|
+
/**
|
|
384
|
+
* The Worker-side request deadline for the tidy model call: 30 seconds. A tidy call to Sonnet on a
|
|
309
385
|
* full entry can run many seconds, so the action bounds it with an AbortSignal and maps the overrun to
|
|
310
386
|
* a retryable fail(502). This sits well under Cloudflare's per-request wall-clock ceiling (a Worker
|
|
311
387
|
* invocation can run far longer, but a single subrequest left open near that ceiling would surface as a
|
|
312
388
|
* platform timeout the action could not shape into a clean retry). 30s comfortably covers a proofread
|
|
313
|
-
* of the bounded input (see MAX_TIDY_CHARS) while leaving headroom under the platform limit.
|
|
389
|
+
* of the bounded input (see MAX_TIDY_CHARS) while leaving headroom under the platform limit.
|
|
390
|
+
*/
|
|
314
391
|
const DEFAULT_TIDY_TIMEOUT_MS = 30_000;
|
|
315
392
|
|
|
316
|
-
/**
|
|
393
|
+
/**
|
|
394
|
+
* The fallback site-config path when no nav menu names one: the convention every scaffolded site
|
|
317
395
|
* uses. The settings save edits the same committed YAML the nav editor does, so it resolves the path
|
|
318
|
-
* from the configured nav menu first and falls back to this default.
|
|
396
|
+
* from the configured nav menu first and falls back to this default.
|
|
397
|
+
*/
|
|
319
398
|
const DEFAULT_SITE_CONFIG_PATH = 'src/lib/site.config.yaml';
|
|
320
399
|
|
|
321
|
-
/**
|
|
322
|
-
*
|
|
400
|
+
/**
|
|
401
|
+
* Plain-language labels for the known tidy models, so the read-only model fact reads as a name rather
|
|
402
|
+
* than a bare id. An unknown id falls back to itself.
|
|
403
|
+
*/
|
|
323
404
|
const TIDY_MODEL_LABELS: Record<string, string> = {
|
|
324
405
|
'claude-sonnet-4-6': 'Claude Sonnet',
|
|
325
406
|
'claude-haiku-4-5': 'Claude Haiku',
|
|
@@ -330,10 +411,12 @@ function tidyModelLabel(model: string): string {
|
|
|
330
411
|
return TIDY_MODEL_LABELS[model] ?? model;
|
|
331
412
|
}
|
|
332
413
|
|
|
333
|
-
/**
|
|
414
|
+
/**
|
|
415
|
+
* The input cap for a single tidy request: 24000 characters (~6k input tokens). A proofread runs at
|
|
334
416
|
* roughly input length, so this stays comfortably inside the 30s deadline; a longer entry refuses with
|
|
335
417
|
* fail(413) and the author tidies a selection instead. The cap is enforced BEFORE the model call, so an
|
|
336
|
-
* over-long body never spends a token or risks the deadline.
|
|
418
|
+
* over-long body never spends a token or risks the deadline.
|
|
419
|
+
*/
|
|
337
420
|
const MAX_TIDY_CHARS = 24_000;
|
|
338
421
|
|
|
339
422
|
/** A blocked save or publish: `fail(400)` when the body links to a target absent from main. */
|
|
@@ -362,9 +445,11 @@ export interface RenameFailure {
|
|
|
362
445
|
error: string;
|
|
363
446
|
}
|
|
364
447
|
|
|
365
|
-
/**
|
|
448
|
+
/**
|
|
449
|
+
* A refused media delete: `fail(404)` for an asset not committed on the default branch, or
|
|
366
450
|
* `fail(409)` when a fresh usage read finds the asset still in use and the typed-slug override
|
|
367
|
-
* was not given. `fail(503)` covers media-off or a missing bucket binding.
|
|
451
|
+
* was not given. `fail(503)` covers media-off or a missing bucket binding.
|
|
452
|
+
*/
|
|
368
453
|
export interface MediaDeleteRefusal {
|
|
369
454
|
/** The one-line human summary every action failure carries. */
|
|
370
455
|
error: string;
|
|
@@ -376,16 +461,20 @@ export interface MediaDeleteRefusal {
|
|
|
376
461
|
foundIn: number;
|
|
377
462
|
}
|
|
378
463
|
|
|
379
|
-
/**
|
|
380
|
-
*
|
|
464
|
+
/**
|
|
465
|
+
* A refused media metadata edit: `fail(404)` for an asset not committed on the default branch, or
|
|
466
|
+
* `fail(400)` for an invalid slug.
|
|
467
|
+
*/
|
|
381
468
|
export interface MediaUpdateFailure {
|
|
382
469
|
/** The one-line human summary every action failure carries. */
|
|
383
470
|
error: string;
|
|
384
471
|
}
|
|
385
472
|
|
|
386
|
-
/**
|
|
473
|
+
/**
|
|
474
|
+
* A refused media replace: `fail(409)` when a fresh usage read finds the asset still in use and the
|
|
387
475
|
* typed-slug override was not given, or `fail(503)` when usage cannot be verified (fail closed) or the
|
|
388
|
-
* bucket is unbound. Mirrors MediaDeleteRefusal: the asset hash, the where-used rows, and the count.
|
|
476
|
+
* bucket is unbound. Mirrors MediaDeleteRefusal: the asset hash, the where-used rows, and the count.
|
|
477
|
+
*/
|
|
389
478
|
export interface MediaReplaceFailure {
|
|
390
479
|
error: string;
|
|
391
480
|
hash: string;
|
|
@@ -393,57 +482,71 @@ export interface MediaReplaceFailure {
|
|
|
393
482
|
foundIn: number;
|
|
394
483
|
}
|
|
395
484
|
|
|
396
|
-
/**
|
|
485
|
+
/**
|
|
486
|
+
* A refused media alt-propagation: `fail(503)` when usage cannot be verified across main and every
|
|
397
487
|
* open branch (fail closed), or the bucket is unbound. Just the one-line summary; alt fill has no
|
|
398
|
-
* typed-slug gate.
|
|
488
|
+
* typed-slug gate.
|
|
489
|
+
*/
|
|
399
490
|
export interface MediaAltPropagateFailure {
|
|
400
491
|
error: string;
|
|
401
492
|
}
|
|
402
493
|
|
|
403
|
-
/**
|
|
494
|
+
/**
|
|
495
|
+
* The personal-dictionary add outcome (spec 1.6): the merged, canonical sorted word list after the
|
|
404
496
|
* add landed. The client reconciles its pending-additions set against this (a word now in the list is
|
|
405
497
|
* committed and dropped from pending). Admin-internal: exported for the editor host's reconcile, not
|
|
406
|
-
* on the package's sveltekit subpath, so it carries no reference page.
|
|
498
|
+
* on the package's sveltekit subpath, so it carries no reference page.
|
|
499
|
+
*/
|
|
407
500
|
export interface DictionaryAddResult {
|
|
408
501
|
words: string[];
|
|
409
502
|
}
|
|
410
503
|
|
|
411
|
-
/**
|
|
504
|
+
/**
|
|
505
|
+
* A refused personal-dictionary add: `fail(403)` on a failed CSRF check, `fail(400)` on a body that
|
|
412
506
|
* carries no valid word. The client keeps its pending additions for the session and re-attempts on
|
|
413
|
-
* the next save, so the word is never silently dropped. Just the one-line summary.
|
|
507
|
+
* the next save, so the word is never silently dropped. Just the one-line summary.
|
|
508
|
+
*/
|
|
414
509
|
export interface DictionaryAddFailure {
|
|
415
510
|
error: string;
|
|
416
511
|
}
|
|
417
512
|
|
|
418
|
-
/**
|
|
513
|
+
/**
|
|
514
|
+
* A refused media bulk delete or orphan purge: `fail(503)` for the fail-closed strict-usage refusal
|
|
419
515
|
* (the whole batch refuses) or media-off / a missing bucket binding. The per-item outcomes ride the
|
|
420
|
-
* returned summary, not a fail.
|
|
516
|
+
* returned summary, not a fail.
|
|
517
|
+
*/
|
|
421
518
|
export interface MediaBulkFailure {
|
|
422
519
|
error: string;
|
|
423
520
|
}
|
|
424
521
|
|
|
425
|
-
/**
|
|
522
|
+
/**
|
|
523
|
+
* The bulk-delete outcome the component renders: the deleted hashes, the skipped rows from the
|
|
426
524
|
* partition (with their reason and where-used), and any per-object R2 delete failure. Admin-internal,
|
|
427
|
-
* not on the package subpath, so no reference page.
|
|
525
|
+
* not on the package subpath, so no reference page.
|
|
526
|
+
*/
|
|
428
527
|
export interface MediaBulkDeleteResult {
|
|
429
528
|
deleted: string[];
|
|
430
529
|
skipped: BulkDeleteSkip[];
|
|
431
530
|
failed: { hash: string; error: string }[];
|
|
432
531
|
}
|
|
433
532
|
|
|
434
|
-
/**
|
|
435
|
-
*
|
|
533
|
+
/**
|
|
534
|
+
* The orphan-purge outcome: the purged R2 keys, the keys skipped because their hash was claimed by a
|
|
535
|
+
* manifest row since the scan, and any per-object delete failure. Admin-internal, no reference page.
|
|
536
|
+
*/
|
|
436
537
|
export interface MediaOrphanPurgeResult {
|
|
437
538
|
purged: string[];
|
|
438
539
|
skippedClaimed: string[];
|
|
439
540
|
failed: { key: string; error: string }[];
|
|
440
541
|
}
|
|
441
542
|
|
|
442
|
-
/**
|
|
543
|
+
/**
|
|
544
|
+
* One entry the replace preview will rewrite, enriched with its display title and permalink from the
|
|
443
545
|
* content manifest (the planner's PlannedEntry carries neither). The screen lists these as the
|
|
444
546
|
* confirm dialog's where-touched preview, and the apply re-derives its own plan rather than trusting
|
|
445
547
|
* this. Admin-internal: exported from content-routes for the bundled Media Library component, not
|
|
446
|
-
* added to the package's sveltekit subpath, so it carries no reference page.
|
|
548
|
+
* added to the package's sveltekit subpath, so it carries no reference page.
|
|
549
|
+
*/
|
|
447
550
|
export interface MediaReplacePreviewEntry {
|
|
448
551
|
/** The concept id, e.g. "posts". */
|
|
449
552
|
concept: string;
|
|
@@ -457,21 +560,25 @@ export interface MediaReplacePreviewEntry {
|
|
|
457
560
|
placements: RepointPlacement[];
|
|
458
561
|
}
|
|
459
562
|
|
|
460
|
-
/**
|
|
563
|
+
/**
|
|
564
|
+
* The replace preview plan: the affected main entries (enriched), the distinct affected count, and
|
|
461
565
|
* the report-only cross-branch delta (open cairn/* branches that reference the same bytes; an apply
|
|
462
|
-
* rewrites main only). Display-only: the apply re-derives a fresh plan and never trusts this.
|
|
566
|
+
* rewrites main only). Display-only: the apply re-derives a fresh plan and never trusts this.
|
|
567
|
+
*/
|
|
463
568
|
export interface MediaReplacePreviewPlan {
|
|
464
569
|
affectedCount: number;
|
|
465
570
|
entries: MediaReplacePreviewEntry[];
|
|
466
571
|
branchDelta: BranchRef[];
|
|
467
572
|
}
|
|
468
573
|
|
|
469
|
-
/**
|
|
574
|
+
/**
|
|
575
|
+
* One entry the alt-propagation preview reports, enriched with its display title and permalink from
|
|
470
576
|
* the content manifest. Its placements carry every reference of the asset on this entry, each tagged
|
|
471
577
|
* with the bucket it falls in (a will-fill, a customized alt left as-is, or a decorative hero), so
|
|
472
578
|
* the screen can show what would change. Admin-internal: exported from content-routes for the bundled
|
|
473
579
|
* Media Library component, not added to the package's sveltekit subpath, so it carries no reference
|
|
474
|
-
* page.
|
|
580
|
+
* page.
|
|
581
|
+
*/
|
|
475
582
|
export interface MediaAltPreviewEntry {
|
|
476
583
|
/** The concept id, e.g. "posts". */
|
|
477
584
|
concept: string;
|
|
@@ -485,11 +592,13 @@ export interface MediaAltPreviewEntry {
|
|
|
485
592
|
placements: AltPlacement[];
|
|
486
593
|
}
|
|
487
594
|
|
|
488
|
-
/**
|
|
595
|
+
/**
|
|
596
|
+
* The alt-propagation preview plan: every entry that references the asset (enriched), the report-only
|
|
489
597
|
* cross-branch delta, and the bucket counts aggregated across every placement. Display-only: the
|
|
490
598
|
* apply re-derives a fresh plan and never trusts this. The preview reports an entry even when its
|
|
491
599
|
* only placements are reported-but-unchanged (a kept custom alt, a decorative hero), so the screen
|
|
492
|
-
* can show every bucket; the apply commits only the entries it actually changes.
|
|
600
|
+
* can show every bucket; the apply commits only the entries it actually changes.
|
|
601
|
+
*/
|
|
493
602
|
export interface MediaAltPreviewPlan {
|
|
494
603
|
entries: MediaAltPreviewEntry[];
|
|
495
604
|
branchDelta: BranchRef[];
|
|
@@ -497,19 +606,23 @@ export interface MediaAltPreviewPlan {
|
|
|
497
606
|
counts: { willFill: number; customized: number; decorativeSkipped: number };
|
|
498
607
|
}
|
|
499
608
|
|
|
500
|
-
/**
|
|
609
|
+
/**
|
|
610
|
+
* What a route's single `form` export presents to a view component: whichever content action
|
|
501
611
|
* last failed, merged with every field optional. `error` is always set on a failure; the richer
|
|
502
612
|
* keys identify which guard refused. The media refusals ride here too, so the Media Library's one
|
|
503
613
|
* `form` prop carries a `?/mediaDelete`, `?/mediaUpdate`, `?/mediaReplace`, or `?/mediaAltPropagate`
|
|
504
|
-
* refusal without a second type.
|
|
614
|
+
* refusal without a second type.
|
|
615
|
+
*/
|
|
505
616
|
export type ContentFormFailure = Partial<
|
|
506
617
|
SaveFailure & DeleteRefusal & RenameFailure & MediaDeleteRefusal & MediaUpdateFailure & MediaReplaceFailure & MediaAltPropagateFailure & MediaBulkFailure & TidyFailure
|
|
507
618
|
>;
|
|
508
619
|
|
|
509
|
-
/**
|
|
620
|
+
/**
|
|
621
|
+
* The successful upload's response (`uploadAction`). The server-owned `record` rides the editor's
|
|
510
622
|
* optimistic client state and commits with the entry at Save (the upload itself commits nothing).
|
|
511
623
|
* `reused` is true when identical bytes were already stored, so the second upload did no second put;
|
|
512
|
-
* `mismatch` flags an existing object whose stored content type differs from this sniff.
|
|
624
|
+
* `mismatch` flags an existing object whose stored content type differs from this sniff.
|
|
625
|
+
*/
|
|
513
626
|
export interface UploadResult {
|
|
514
627
|
reference: string;
|
|
515
628
|
record: MediaEntry;
|
|
@@ -517,9 +630,11 @@ export interface UploadResult {
|
|
|
517
630
|
mismatch: boolean;
|
|
518
631
|
}
|
|
519
632
|
|
|
520
|
-
/**
|
|
633
|
+
/**
|
|
634
|
+
* Resolve the effective preview for one concept: its `byConcept` override wins per key, with
|
|
521
635
|
* nullish coalescing so an override key that is present but undefined keeps the top-level value.
|
|
522
|
-
* Stylesheets are always shared, and the `byConcept` map never reaches the client.
|
|
636
|
+
* Stylesheets are always shared, and the `byConcept` map never reaches the client.
|
|
637
|
+
*/
|
|
523
638
|
function resolvePreview(preview: PreviewConfig | undefined, conceptId: string): ResolvedPreview | null {
|
|
524
639
|
if (!preview) return null;
|
|
525
640
|
const override = preview.byConcept?.[conceptId];
|
|
@@ -537,6 +652,9 @@ function conceptOf(runtime: CairnRuntime, params: Record<string, string>): Conce
|
|
|
537
652
|
return concept;
|
|
538
653
|
}
|
|
539
654
|
|
|
655
|
+
/**
|
|
656
|
+
*
|
|
657
|
+
*/
|
|
540
658
|
export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDeps = {}) {
|
|
541
659
|
const mintToken =
|
|
542
660
|
deps.mintToken ?? ((env: GithubKeyEnv) => cachedInstallationToken(appCredentials(runtime.backend, env)));
|
|
@@ -548,16 +666,20 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
548
666
|
deps.anthropic ?? ((opts: { apiKey: string }) => new Anthropic({ apiKey: opts.apiKey }) as unknown as TidyClient);
|
|
549
667
|
const tidyTimeoutMs = deps.tidyTimeoutMs ?? DEFAULT_TIDY_TIMEOUT_MS;
|
|
550
668
|
|
|
551
|
-
/**
|
|
552
|
-
*
|
|
669
|
+
/**
|
|
670
|
+
* Main's manifest, parsed. A missing file starts empty (a fresh repo before the first commit).
|
|
671
|
+
* Always read from main: pending branches carry no manifest copy.
|
|
672
|
+
*/
|
|
553
673
|
async function readManifest(token: string): Promise<Manifest> {
|
|
554
674
|
const raw = await readRaw(runtime.backend, runtime.manifestPath, token);
|
|
555
675
|
return raw === null ? emptyManifest() : parseManifest(raw);
|
|
556
676
|
}
|
|
557
677
|
|
|
558
|
-
/**
|
|
678
|
+
/**
|
|
679
|
+
* Parse a committed media.json body to a plain value for parseMediaManifest, degrading a missing
|
|
559
680
|
* or corrupt file to null (an empty manifest). The committed file is always our own serialization,
|
|
560
|
-
* so the catch only guards a hand-edited or truncated file rather than a normal path.
|
|
681
|
+
* so the catch only guards a hand-edited or truncated file rather than a normal path.
|
|
682
|
+
*/
|
|
561
683
|
function parseMediaJson(raw: string | null): unknown {
|
|
562
684
|
if (raw === null) return null;
|
|
563
685
|
try {
|
|
@@ -567,11 +689,13 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
567
689
|
}
|
|
568
690
|
}
|
|
569
691
|
|
|
570
|
-
/**
|
|
692
|
+
/**
|
|
693
|
+
* The pending entry a `cairn/` ref names, or null for a ref the engine must ignore: a
|
|
571
694
|
* malformed name, an id that fails the slug rule (entry paths are built from it, so this is
|
|
572
695
|
* the path confinement), or a concept this site does not configure. Every ref consumer
|
|
573
696
|
* (the layout count, the list view, publish-all) applies this one predicate, so a stray
|
|
574
|
-
* hand-pushed ref cannot inflate a count it can never clear or reach a contents read.
|
|
697
|
+
* hand-pushed ref cannot inflate a count it can never clear or reach a contents read.
|
|
698
|
+
*/
|
|
575
699
|
function pendingEntryOf(name: string): { concept: ConceptDescriptor; id: string } | null {
|
|
576
700
|
const ref = parsePendingBranch(name);
|
|
577
701
|
if (!ref || !isValidId(ref.id)) return null;
|
|
@@ -579,8 +703,10 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
579
703
|
return concept ? { concept, id: ref.id } : null;
|
|
580
704
|
}
|
|
581
705
|
|
|
582
|
-
/**
|
|
583
|
-
*
|
|
706
|
+
/**
|
|
707
|
+
* Layout load for every admin page: the nav, the user, the active path, the resolved theme,
|
|
708
|
+
* and the pending entries behind the topbar's publish-all action.
|
|
709
|
+
*/
|
|
584
710
|
async function layoutLoad(event: ContentEvent): Promise<LayoutData> {
|
|
585
711
|
const editor = requireSession(event);
|
|
586
712
|
const cookieTheme = event.cookies?.get('cairn-admin-theme');
|
|
@@ -617,6 +743,33 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
617
743
|
};
|
|
618
744
|
}
|
|
619
745
|
|
|
746
|
+
/**
|
|
747
|
+
* Load the Help home: the getting-started progress derived from the committed manifest and the open
|
|
748
|
+
* pending branches, the markdown reference, and the runtime's support contact. A GitHub failure
|
|
749
|
+
* degrades to an empty corpus (0 of 3) rather than failing the screen, the same fail-safe layoutLoad uses.
|
|
750
|
+
*/
|
|
751
|
+
async function helpLoad(event: ContentEvent): Promise<HelpData> {
|
|
752
|
+
requireSession(event);
|
|
753
|
+
let manifest = emptyManifest();
|
|
754
|
+
let pending: { concept: string; id: string }[] = [];
|
|
755
|
+
try {
|
|
756
|
+
const token = await mintToken(event.platform?.env ?? {});
|
|
757
|
+
manifest = await readManifest(token);
|
|
758
|
+
const names = await listBranches(runtime.backend, PENDING_PREFIX, token);
|
|
759
|
+
pending = names.flatMap((name) => {
|
|
760
|
+
const entry = pendingEntryOf(name);
|
|
761
|
+
return entry ? [{ concept: entry.concept.id, id: entry.id }] : [];
|
|
762
|
+
});
|
|
763
|
+
} catch (err) {
|
|
764
|
+
log.warn('github.unreachable', { scope: 'help', error: String(err) });
|
|
765
|
+
}
|
|
766
|
+
return {
|
|
767
|
+
gettingStarted: deriveGettingStarted(manifest, pending),
|
|
768
|
+
reference: markdownReference,
|
|
769
|
+
supportContact: runtime.supportContact,
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
|
|
620
773
|
/** Redirect /admin to the first concept's list (spec §7.6: land on the first concept). */
|
|
621
774
|
function indexRedirect(): never {
|
|
622
775
|
const first = runtime.concepts[0];
|
|
@@ -624,8 +777,10 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
624
777
|
throw redirect(307, `/admin/${first.id}`);
|
|
625
778
|
}
|
|
626
779
|
|
|
627
|
-
/**
|
|
628
|
-
*
|
|
780
|
+
/**
|
|
781
|
+
* Read a file's frontmatter for its list row, degrading to the id on any read failure. The
|
|
782
|
+
* repo defaults to main; a pending entry (edited or branch-only) passes its pending branch.
|
|
783
|
+
*/
|
|
629
784
|
async function summarize(
|
|
630
785
|
file: { id: string; path: string },
|
|
631
786
|
token: string,
|
|
@@ -647,9 +802,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
647
802
|
}
|
|
648
803
|
}
|
|
649
804
|
|
|
650
|
-
/**
|
|
805
|
+
/**
|
|
806
|
+
* Read an entry's list row from its pending branch, so a pending title or draft change shows
|
|
651
807
|
* in the list instead of reading as a lost save. summarize degrades a failed or empty read to
|
|
652
|
-
* an id-only row, so a ghost ref still lists.
|
|
808
|
+
* an id-only row, so a ghost ref still lists.
|
|
809
|
+
*/
|
|
653
810
|
function pendingRow(concept: ConceptDescriptor, id: string, status: EntrySummary['status'], token: string): Promise<EntrySummary> {
|
|
654
811
|
return summarize({ id, path: `${concept.dir}/${filenameFromId(id)}` }, token, status, {
|
|
655
812
|
...runtime.backend,
|
|
@@ -657,8 +814,10 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
657
814
|
});
|
|
658
815
|
}
|
|
659
816
|
|
|
660
|
-
/**
|
|
661
|
-
*
|
|
817
|
+
/**
|
|
818
|
+
* The per-file crawl, kept only for a repo with no committed manifest yet: list main's files
|
|
819
|
+
* and read each one for its row, with edited and new rows reading branch-first.
|
|
820
|
+
*/
|
|
662
821
|
async function crawlEntries(concept: ConceptDescriptor, pendingIds: Set<string>, token: string): Promise<EntrySummary[]> {
|
|
663
822
|
const files = await listMarkdown(runtime.backend, concept.dir, token);
|
|
664
823
|
const entries = await Promise.all(
|
|
@@ -672,12 +831,14 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
672
831
|
return [...entries, ...newRows];
|
|
673
832
|
}
|
|
674
833
|
|
|
675
|
-
/**
|
|
834
|
+
/**
|
|
835
|
+
* List a concept's entries with their publish status. Published rows project straight from
|
|
676
836
|
* main's manifest, which publish, delete, and rename keep atomically in sync with main, so
|
|
677
837
|
* the listing costs one manifest read plus one branch read per pending entry rather than one
|
|
678
838
|
* read per file. A manifest row with a pending ref is `edited` and reads branch-first; a ref
|
|
679
839
|
* with no manifest row appends a `new` row read from its branch. A listing failure degrades
|
|
680
|
-
* to an inline error, not a thrown 500.
|
|
840
|
+
* to an inline error, not a thrown 500.
|
|
841
|
+
*/
|
|
681
842
|
async function listLoad(event: ContentEvent): Promise<ListData> {
|
|
682
843
|
requireSession(event);
|
|
683
844
|
const concept = conceptOf(runtime, event.params);
|
|
@@ -728,12 +889,14 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
728
889
|
}
|
|
729
890
|
}
|
|
730
891
|
|
|
731
|
-
/**
|
|
892
|
+
/**
|
|
893
|
+
* The admin Media Library load: union the media manifest across main and every open cairn/*
|
|
732
894
|
* branch (so a not-yet-published asset shows), project each row through the shared
|
|
733
895
|
* mediaLibraryEntry helper, and attach the cross-branch where-used overlay keyed by content
|
|
734
896
|
* hash. The assets union and the usage overlay degrade independently: a usage-build failure
|
|
735
897
|
* still lists the assets with an empty overlay, and a wholesale read failure degrades to the
|
|
736
|
-
* assets gathered so far rather than a thrown 500, mirroring listLoad's posture.
|
|
898
|
+
* assets gathered so far rather than a thrown 500, mirroring listLoad's posture.
|
|
899
|
+
*/
|
|
737
900
|
async function mediaLibraryLoad(event: ContentEvent): Promise<MediaLibraryData> {
|
|
738
901
|
requireSession(event);
|
|
739
902
|
// Read the flash flags a redirected action carried back, mirroring listLoad's `?error`/
|
|
@@ -894,10 +1057,10 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
894
1057
|
const parsed = raw === null ? { frontmatter: {}, body: '' } : parseMarkdown(raw);
|
|
895
1058
|
const title = typeof parsed.frontmatter.title === 'string' && parsed.frontmatter.title.trim() ? parsed.frontmatter.title : id;
|
|
896
1059
|
|
|
1060
|
+
const manifest = manifestRaw !== null ? parseManifest(manifestRaw) : null;
|
|
897
1061
|
let linkTargets: LinkTarget[] = [];
|
|
898
1062
|
let inbound: InboundLink[] = [];
|
|
899
|
-
if (
|
|
900
|
-
const manifest = parseManifest(manifestRaw);
|
|
1063
|
+
if (manifest !== null) {
|
|
901
1064
|
linkTargets = manifest.entries.map((e) => ({
|
|
902
1065
|
concept: e.concept,
|
|
903
1066
|
id: e.id,
|
|
@@ -909,6 +1072,32 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
909
1072
|
inbound = inboundLinks(manifest, concept.id, id);
|
|
910
1073
|
}
|
|
911
1074
|
|
|
1075
|
+
// The cross-branch address-collision advisory: warn-and-allow, never a gate. Build it from the
|
|
1076
|
+
// same manifest read above (no second read) and degrade to no notice on any read failure, so a
|
|
1077
|
+
// transient GitHub error never blocks the editor. Skip the build with no manifest to index.
|
|
1078
|
+
let advisories: AdvisoryNotice[] = [];
|
|
1079
|
+
if (manifest !== null) {
|
|
1080
|
+
try {
|
|
1081
|
+
const identity = entryIdentity(concept, path, parsed.frontmatter);
|
|
1082
|
+
const addressIndex = await buildAddressIndex(runtime.backend, token, runtime.concepts, manifest);
|
|
1083
|
+
const other = addressCollision(addressIndex, { concept: concept.id, id }, identity.permalink);
|
|
1084
|
+
if (other) {
|
|
1085
|
+
const otherConcept = findConcept(runtime.concepts, other.concept);
|
|
1086
|
+
const label = otherConcept ? otherConcept.label : other.concept;
|
|
1087
|
+
advisories = [
|
|
1088
|
+
{
|
|
1089
|
+
kind: 'address-collision',
|
|
1090
|
+
severity: 'warn',
|
|
1091
|
+
message: `Another ${label} already uses the address ${identity.permalink}. Publish this one and it replaces the other at that address.`,
|
|
1092
|
+
actions: [{ label: `Open ${other.title}`, href: `/admin/${other.concept}/${other.id}` }],
|
|
1093
|
+
},
|
|
1094
|
+
];
|
|
1095
|
+
}
|
|
1096
|
+
} catch (err) {
|
|
1097
|
+
log.warn('github.unreachable', { scope: 'edit-advisories', error: String(err) });
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
912
1101
|
// Project the one committed media manifest read two ways: the minimal resolver triple the preview
|
|
913
1102
|
// needs (`mediaTargets`) and the picker's full human layer (`mediaLibrary`), both keyed by hash.
|
|
914
1103
|
// A corrupt committed file degrades both to empty, not a throw.
|
|
@@ -956,18 +1145,23 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
956
1145
|
model: runtime.tidy?.model || DEFAULT_TIDY_MODEL,
|
|
957
1146
|
conventions: resolveTidyConventions(runtime.tidy?.conventions),
|
|
958
1147
|
},
|
|
1148
|
+
advisories,
|
|
959
1149
|
};
|
|
960
1150
|
}
|
|
961
1151
|
|
|
962
|
-
/**
|
|
963
|
-
*
|
|
1152
|
+
/**
|
|
1153
|
+
* The repo-relative personal-dictionary path, defaulting a hand-built runtime that omits it to the
|
|
1154
|
+
* same `.cairn/` content root the manifests use. composeRuntime always fills `dictionaryPath`.
|
|
1155
|
+
*/
|
|
964
1156
|
function dictionaryFilePath(): string {
|
|
965
1157
|
return runtime.dictionaryPath ?? 'src/content/.cairn/dictionary.txt';
|
|
966
1158
|
}
|
|
967
1159
|
|
|
968
|
-
/**
|
|
1160
|
+
/**
|
|
1161
|
+
* Log a failed commit: a conflict is the expected last-writer-wins outcome, so it warns with a
|
|
969
1162
|
* reason; any other error is unexpected and logs at error with the stringified cause. Publish
|
|
970
|
-
* failures carry the same shape under their own event name.
|
|
1163
|
+
* failures carry the same shape under their own event name.
|
|
1164
|
+
*/
|
|
971
1165
|
function logCommitFailed(
|
|
972
1166
|
fields: { concept: string; id: string; editor: string },
|
|
973
1167
|
err: unknown,
|
|
@@ -980,9 +1174,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
980
1174
|
}
|
|
981
1175
|
}
|
|
982
1176
|
|
|
983
|
-
/**
|
|
1177
|
+
/**
|
|
1178
|
+
* The shared commit catch for the entry actions: log the failure, bounce a conflict back to
|
|
984
1179
|
* `page` with `message` as the inline error, and rethrow anything else. `query` keeps any extra
|
|
985
|
-
* params the bounce must carry (saveAction's `&new=1`).
|
|
1180
|
+
* params the bounce must carry (saveAction's `&new=1`).
|
|
1181
|
+
*/
|
|
986
1182
|
function commitFailure(
|
|
987
1183
|
fields: { concept: string; id: string; editor: string },
|
|
988
1184
|
err: unknown,
|
|
@@ -997,11 +1193,13 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
997
1193
|
throw err;
|
|
998
1194
|
}
|
|
999
1195
|
|
|
1000
|
-
/**
|
|
1196
|
+
/**
|
|
1197
|
+
* The held outcome of a validated save: everything publish needs to copy the same markdown
|
|
1001
1198
|
* to main without re-reading the branch. `branchSha` is the branch commit saveToBranch just
|
|
1002
1199
|
* made, the guard for the post-publish branch delete; `manifest` is main's manifest with
|
|
1003
1200
|
* this entry's row upserted from the new markdown (the same last-writer-wins manifest race
|
|
1004
|
-
* as delete and rename applies, caught by the build's fail-closed backstop).
|
|
1201
|
+
* as delete and rename applies, caught by the build's fail-closed backstop).
|
|
1202
|
+
*/
|
|
1005
1203
|
interface SaveHold {
|
|
1006
1204
|
path: string;
|
|
1007
1205
|
markdown: string;
|
|
@@ -1011,18 +1209,22 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1011
1209
|
/** The draft-target tokens the body links to, for save's warning query. */
|
|
1012
1210
|
draftLinks: string[];
|
|
1013
1211
|
token: string;
|
|
1014
|
-
/**
|
|
1212
|
+
/**
|
|
1213
|
+
* The merged media.json change this save committed to the branch, when media is on and the
|
|
1015
1214
|
* post carried records. Publish reuses it verbatim so the main commit promotes the exact same
|
|
1016
1215
|
* merged content (decision 1: the default-branch base is read once, here, not re-merged at
|
|
1017
|
-
* publish). Absent when media is off or no records were posted.
|
|
1216
|
+
* publish). Absent when media is off or no records were posted.
|
|
1217
|
+
*/
|
|
1018
1218
|
mediaChange?: FileChange;
|
|
1019
1219
|
}
|
|
1020
1220
|
|
|
1021
|
-
/**
|
|
1221
|
+
/**
|
|
1222
|
+
* The shared core of save and publish: parse the posted form, validate the frontmatter,
|
|
1022
1223
|
* guard the body's cairn links, ensure the pending branch, and commit the entry file there
|
|
1023
1224
|
* with the session editor as author. Returns the broken-link fail for the page to render,
|
|
1024
1225
|
* or the held state; throws the redirect bounces save has always thrown (invalid
|
|
1025
|
-
* frontmatter, a branch-commit conflict). Main stays untouched.
|
|
1226
|
+
* frontmatter, a branch-commit conflict). Main stays untouched.
|
|
1227
|
+
*/
|
|
1026
1228
|
async function saveToBranch(
|
|
1027
1229
|
event: ContentEvent,
|
|
1028
1230
|
editor: Editor,
|
|
@@ -1121,8 +1323,10 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1121
1323
|
return { path, markdown, branch, branchSha, manifest: upserted, draftLinks, token, mediaChange };
|
|
1122
1324
|
}
|
|
1123
1325
|
|
|
1124
|
-
/**
|
|
1125
|
-
*
|
|
1326
|
+
/**
|
|
1327
|
+
* Save an edit: validate, then commit to the entry's pending branch with the session editor
|
|
1328
|
+
* as author. Main and its manifest stay untouched until publish. Fails safe on 409.
|
|
1329
|
+
*/
|
|
1126
1330
|
async function saveAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
|
|
1127
1331
|
const editor = requireSession(event);
|
|
1128
1332
|
const concept = conceptOf(runtime, event.params);
|
|
@@ -1138,12 +1342,14 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1138
1342
|
throw redirect(303, `/admin/${concept.id}/${id}?${savedQuery}`);
|
|
1139
1343
|
}
|
|
1140
1344
|
|
|
1141
|
-
/**
|
|
1345
|
+
/**
|
|
1346
|
+
* Publish an entry: validate and hold the posted form exactly like save (the branch gets the
|
|
1142
1347
|
* same commit), then copy that markdown to main with the manifest row upserted in one atomic
|
|
1143
1348
|
* commit. Publish-what-you-see: the posted form is the published content, so text typed
|
|
1144
1349
|
* after the last save goes live too, and publish works regardless of prior branch state.
|
|
1145
1350
|
* The branch is deleted only when its head still matches the commit this action made; a
|
|
1146
|
-
* concurrent save moved it, so the entry stays pending and the next publish picks it up.
|
|
1351
|
+
* concurrent save moved it, so the entry stays pending and the next publish picks it up.
|
|
1352
|
+
*/
|
|
1147
1353
|
async function publishAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
|
|
1148
1354
|
const editor = requireSession(event);
|
|
1149
1355
|
const concept = conceptOf(runtime, event.params);
|
|
@@ -1162,6 +1368,24 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1162
1368
|
];
|
|
1163
1369
|
if (mediaChange) changes.push(mediaChange);
|
|
1164
1370
|
|
|
1371
|
+
// The cross-branch address-collision re-check: warn-and-allow, last-write-wins, never a gate.
|
|
1372
|
+
// Resolve this entry's own address the way editLoad does and look it up in the index built from
|
|
1373
|
+
// the same manifest the publish carries. The read fails open: a thrown index build degrades to
|
|
1374
|
+
// no event and the publish proceeds, so a transient GitHub error never blocks a publish.
|
|
1375
|
+
let address = '';
|
|
1376
|
+
let collision: AddressEntry | null = null;
|
|
1377
|
+
try {
|
|
1378
|
+
const { frontmatter } = parseMarkdown(markdown);
|
|
1379
|
+
address = entryIdentity(concept, path, frontmatter).permalink;
|
|
1380
|
+
const addressIndex = await buildAddressIndex(runtime.backend, token, runtime.concepts, manifest);
|
|
1381
|
+
collision = addressCollision(addressIndex, { concept: concept.id, id }, address);
|
|
1382
|
+
} catch (err) {
|
|
1383
|
+
// Fail open, the same as editLoad: a thrown index build degrades to no event and the publish
|
|
1384
|
+
// proceeds. Log it so a persistently failing advisory build is diagnosable, not invisible.
|
|
1385
|
+
collision = null;
|
|
1386
|
+
log.warn('github.unreachable', { scope: 'publish-advisories', error: String(err) });
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1165
1389
|
const commitFields = { concept: concept.id, id, editor: editor.email };
|
|
1166
1390
|
try {
|
|
1167
1391
|
await commitFiles(
|
|
@@ -1171,6 +1395,15 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1171
1395
|
token,
|
|
1172
1396
|
);
|
|
1173
1397
|
log.info('entry.published', { ...commitFields, batch: false });
|
|
1398
|
+
// Only after the publish lands: a diagnostic that a live address now has a new owner.
|
|
1399
|
+
if (collision) {
|
|
1400
|
+
log.warn('publish.address_collision', {
|
|
1401
|
+
editor: editor.email,
|
|
1402
|
+
address,
|
|
1403
|
+
displacedConcept: collision.concept,
|
|
1404
|
+
displacedId: collision.id,
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1174
1407
|
} catch (err) {
|
|
1175
1408
|
// The branch already holds the just-committed edits, so a conflict here loses nothing.
|
|
1176
1409
|
commitFailure(commitFields, err, `/admin/${concept.id}/${id}`,
|
|
@@ -1185,10 +1418,12 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1185
1418
|
throw redirect(303, `/admin/${concept.id}/${id}?published=1`);
|
|
1186
1419
|
}
|
|
1187
1420
|
|
|
1188
|
-
/**
|
|
1421
|
+
/**
|
|
1422
|
+
* Publish every pending entry site-wide: one atomic commit on main carrying each branch's
|
|
1189
1423
|
* entry file plus the manifest with every row upserted, then delete the consumed branches.
|
|
1190
1424
|
* Mounted on the concept list shim, but the topbar posts here from anywhere, so the route's
|
|
1191
|
-
* concept param is ignored and the redirect lands on the first configured concept.
|
|
1425
|
+
* concept param is ignored and the redirect lands on the first configured concept.
|
|
1426
|
+
*/
|
|
1192
1427
|
async function publishAllAction(event: ContentEvent): Promise<never> {
|
|
1193
1428
|
const editor = requireSession(event);
|
|
1194
1429
|
const first = runtime.concepts[0];
|
|
@@ -1274,8 +1509,10 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1274
1509
|
throw redirect(303, `${listPage}?publishedAll=${published.length}`);
|
|
1275
1510
|
}
|
|
1276
1511
|
|
|
1277
|
-
/**
|
|
1278
|
-
*
|
|
1512
|
+
/**
|
|
1513
|
+
* Discard an entry's pending edits: delete the branch (tolerant of already-gone) and return to
|
|
1514
|
+
* the edit page when the entry lives on main, else to the list (the entry is gone entirely).
|
|
1515
|
+
*/
|
|
1279
1516
|
async function discardAction(event: ContentEvent): Promise<never> {
|
|
1280
1517
|
const editor = requireSession(event);
|
|
1281
1518
|
const concept = conceptOf(runtime, event.params);
|
|
@@ -1291,11 +1528,13 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1291
1528
|
throw redirect(303, `/admin/${concept.id}`);
|
|
1292
1529
|
}
|
|
1293
1530
|
|
|
1294
|
-
/**
|
|
1531
|
+
/**
|
|
1532
|
+
* The shared delete core. Block-until-clean: refuse while inbound links exist (naming them), else
|
|
1295
1533
|
* commit the file removal and the manifest patch in one commit. The inbound recheck here is the
|
|
1296
1534
|
* authoritative gate, closing the load-to-delete race. Both the editor delete (id from params) and
|
|
1297
1535
|
* the list delete (id from the form body) call this with an already-validated id, so the guard is
|
|
1298
|
-
* enforced once.
|
|
1536
|
+
* enforced once.
|
|
1537
|
+
*/
|
|
1299
1538
|
async function deleteEntry(
|
|
1300
1539
|
event: ContentEvent,
|
|
1301
1540
|
concept: ConceptDescriptor,
|
|
@@ -1375,10 +1614,12 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1375
1614
|
return deleteEntry(event, concept, id, editor);
|
|
1376
1615
|
}
|
|
1377
1616
|
|
|
1378
|
-
/**
|
|
1617
|
+
/**
|
|
1618
|
+
* Rename an entry: change its slug, move the file, and rewrite every inbound cairn token in one
|
|
1379
1619
|
* atomic commit, so no internal link breaks. The collision check and the inbound recompute here
|
|
1380
1620
|
* are the authoritative gate. The same last-writer-wins manifest race as save and delete applies,
|
|
1381
|
-
* caught by the build's fail-closed backstop.
|
|
1621
|
+
* caught by the build's fail-closed backstop.
|
|
1622
|
+
*/
|
|
1382
1623
|
async function renameAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
|
|
1383
1624
|
const editor = requireSession(event);
|
|
1384
1625
|
const concept = conceptOf(runtime, event.params);
|
|
@@ -1606,7 +1847,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1606
1847
|
/** A 16-hex content-hash prefix, the immutable asset key. */
|
|
1607
1848
|
const MEDIA_HASH_RE = /^[0-9a-f]{16}$/;
|
|
1608
1849
|
|
|
1609
|
-
/**
|
|
1850
|
+
/**
|
|
1851
|
+
* Safe-delete a committed media asset. The gate rechecks usage server-side against a FRESH index
|
|
1610
1852
|
* read at delete time (never a client-passed count), mirroring deleteEntry's authoritative inbound
|
|
1611
1853
|
* recheck. An in-use asset refuses unless the form carries the typed-slug override (the in-use
|
|
1612
1854
|
* alertdialog's type-to-confirm). When confirmed, the order is load-bearing: commit the manifest
|
|
@@ -1623,7 +1865,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1623
1865
|
* a referenced asset for an orphan. There is an inherent stale-read window between the recheck and
|
|
1624
1866
|
* the commit (no sha-guard ties them); it is bounded because the resolver and the route key on the
|
|
1625
1867
|
* hash, so a reference added in that window still resolves to bytes that may be gone, the same
|
|
1626
|
-
* delete-races-an-edit window every safe delete carries.
|
|
1868
|
+
* delete-races-an-edit window every safe delete carries.
|
|
1869
|
+
*/
|
|
1627
1870
|
async function mediaDeleteAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
|
|
1628
1871
|
const editor = requireSession(event);
|
|
1629
1872
|
const token = await mintToken(event.platform?.env ?? {});
|
|
@@ -1718,7 +1961,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1718
1961
|
throw redirect(303, '/admin/media?deleted=1');
|
|
1719
1962
|
}
|
|
1720
1963
|
|
|
1721
|
-
/**
|
|
1964
|
+
/**
|
|
1965
|
+
* Bulk safe-delete a multi-select of committed media assets. This is mediaDeleteAction extended to
|
|
1722
1966
|
* many items, with the same safety primitives and one rule that defines the batch: the gate is ONE
|
|
1723
1967
|
* shared strict cross-branch usage index built per batch, never N per-item reads (N strict reads
|
|
1724
1968
|
* would blow the workerd connection budget at many open branches). The fail-closed posture is for
|
|
@@ -1736,7 +1980,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1736
1980
|
* leaves bytes with no row (a benign orphan) rather than a row pointing at deleted bytes. Each R2
|
|
1737
1981
|
* delete is best-effort and batch-resilient: a per-object error is reported in `failed` and never
|
|
1738
1982
|
* aborts the rest of the batch. The result is an itemized 207-style summary the component renders
|
|
1739
|
-
* (deleted / skipped with reasons / failed); there is no success redirect.
|
|
1983
|
+
* (deleted / skipped with reasons / failed); there is no success redirect.
|
|
1984
|
+
*/
|
|
1740
1985
|
async function mediaBulkDelete(event: ContentEvent): Promise<ReturnType<typeof fail> | MediaBulkDeleteResult> {
|
|
1741
1986
|
const editor = requireSession(event);
|
|
1742
1987
|
const token = await mintToken(event.platform?.env ?? {});
|
|
@@ -1828,7 +2073,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1828
2073
|
return { deleted, skipped: plan.skipped, failed } satisfies MediaBulkDeleteResult;
|
|
1829
2074
|
}
|
|
1830
2075
|
|
|
1831
|
-
/**
|
|
2076
|
+
/**
|
|
2077
|
+
* The on-demand orphan scan: a read-only reconcile of stored R2 bytes against the manifest, joined
|
|
1832
2078
|
* with one strict cross-branch usage index for the broken-reference where-used. It runs only when
|
|
1833
2079
|
* requested, never on the loaded index, because it is heavier than the load path: a full R2 list
|
|
1834
2080
|
* plus a reconcile pass on top of the strict usage build.
|
|
@@ -1842,7 +2088,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1842
2088
|
*
|
|
1843
2089
|
* The result is the OrphanScan projection: orphanedBytes (stored keys with no manifest row, the
|
|
1844
2090
|
* purge surface) and brokenRefs (manifest rows whose bytes are gone, read-only, shown with their
|
|
1845
|
-
* where-used so an operator can re-ingest rather than purge a still-referenced record).
|
|
2091
|
+
* where-used so an operator can re-ingest rather than purge a still-referenced record).
|
|
2092
|
+
*/
|
|
1846
2093
|
async function mediaOrphanScan(event: ContentEvent): Promise<ReturnType<typeof fail> | OrphanScan> {
|
|
1847
2094
|
requireSession(event);
|
|
1848
2095
|
const token = await mintToken(event.platform?.env ?? {});
|
|
@@ -1878,7 +2125,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1878
2125
|
return buildOrphanScan(reconcile, manifest, index);
|
|
1879
2126
|
}
|
|
1880
2127
|
|
|
1881
|
-
/**
|
|
2128
|
+
/**
|
|
2129
|
+
* Purge orphaned R2 bytes: the one IRREVERSIBLE media action. Raw object bytes live only in R2, not
|
|
1882
2130
|
* in git, so a purged orphan cannot be recovered the way a deleted manifest row can be reverted in
|
|
1883
2131
|
* history. The whole action is built around that fact.
|
|
1884
2132
|
*
|
|
@@ -1902,7 +2150,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1902
2150
|
*
|
|
1903
2151
|
* There is no commit. An orphan by definition has no manifest row to remove, so the purge deletes
|
|
1904
2152
|
* the R2 object directly. Each delete is best-effort and batch-resilient: a per-object error is
|
|
1905
|
-
* reported in `failed` and the loop continues; an absent object is a no-op (the R2 contract).
|
|
2153
|
+
* reported in `failed` and the loop continues; an absent object is a no-op (the R2 contract).
|
|
2154
|
+
*/
|
|
1906
2155
|
async function mediaPurgeOrphans(event: ContentEvent): Promise<ReturnType<typeof fail> | MediaOrphanPurgeResult> {
|
|
1907
2156
|
const editor = requireSession(event);
|
|
1908
2157
|
const token = await mintToken(event.platform?.env ?? {});
|
|
@@ -1975,10 +2224,12 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1975
2224
|
return { purged, skippedClaimed, failed } satisfies MediaOrphanPurgeResult;
|
|
1976
2225
|
}
|
|
1977
2226
|
|
|
1978
|
-
/**
|
|
2227
|
+
/**
|
|
2228
|
+
* Edit a committed asset's metadata: its display name, slug, and default alt. A single media.json
|
|
1979
2229
|
* row commit, with NO reference rewrite: the resolver and the delivery route key on the hash, so a
|
|
1980
2230
|
* rename never breaks an existing `media:` reference. The default alt is the asset's value for the
|
|
1981
|
-
* next placement, never a propagating edit of the alt already committed in existing placements.
|
|
2231
|
+
* next placement, never a propagating edit of the alt already committed in existing placements.
|
|
2232
|
+
*/
|
|
1982
2233
|
async function mediaUpdateAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
|
|
1983
2234
|
const editor = requireSession(event);
|
|
1984
2235
|
const token = await mintToken(event.platform?.env ?? {});
|
|
@@ -2017,14 +2268,17 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2017
2268
|
throw redirect(303, '/admin/media?updated=1');
|
|
2018
2269
|
}
|
|
2019
2270
|
|
|
2020
|
-
/**
|
|
2271
|
+
/**
|
|
2272
|
+
* Build the canonical `media:` token for a replacement, treating a slug that fails the grammar (or
|
|
2021
2273
|
* an empty one) as absent so the bare-hash form is used. The slug is cosmetic: the resolver keys on
|
|
2022
|
-
* the hash, so a missing slug still resolves. Shared by the preview and apply token construction.
|
|
2274
|
+
* the hash, so a missing slug still resolves. Shared by the preview and apply token construction.
|
|
2275
|
+
*/
|
|
2023
2276
|
function replacementToken(slug: string, hash: string): string {
|
|
2024
2277
|
return mediaToken({ slug: MEDIA_SLUG_RE.test(slug) ? slug : null, hash });
|
|
2025
2278
|
}
|
|
2026
2279
|
|
|
2027
|
-
/**
|
|
2280
|
+
/**
|
|
2281
|
+
* Preview a replace-in-place: the display-only fetch action (the 2a transport). It plans the rewrite
|
|
2028
2282
|
* of every published main entry that references `oldHash` to the new asset's `media:` token, enriches
|
|
2029
2283
|
* each with its title and permalink, and returns the plan plus the report-only cross-branch delta.
|
|
2030
2284
|
* It commits nothing. The plan runs strict (fail-closed): an unverifiable usage read returns a 503
|
|
@@ -2034,7 +2288,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2034
2288
|
* the `X-Cairn-CSRF` header (the raw-body transport, no form-CSRF), and a `MediaReplacePreviewPlan`
|
|
2035
2289
|
* returned as the 200 ActionResult the client reads. A refusal rides a `fail(status, ...)` envelope
|
|
2036
2290
|
* with the MediaReplaceFailure shape (the same fail shape the apply uses), so the client reads
|
|
2037
|
-
* `type`/`status` from the body, never the HTTP status.
|
|
2291
|
+
* `type`/`status` from the body, never the HTTP status.
|
|
2292
|
+
*/
|
|
2038
2293
|
async function mediaReplacePreview(event: ContentEvent): Promise<ReturnType<typeof fail> | MediaReplacePreviewPlan> {
|
|
2039
2294
|
// CSRF first: this is a raw-body (JSON) POST, so the header witness is the authority, like the
|
|
2040
2295
|
// upload action. A failed check refuses before the session read or any GitHub call.
|
|
@@ -2104,7 +2359,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2104
2359
|
return { affectedCount: plan.affectedCount, entries, branchDelta: plan.branchDelta };
|
|
2105
2360
|
}
|
|
2106
2361
|
|
|
2107
|
-
/**
|
|
2362
|
+
/**
|
|
2363
|
+
* Apply a replace-in-place: rewrite every published main entry that references the old asset to the
|
|
2108
2364
|
* new asset's `media:` token, and add the new media.json row, in ONE atomic commit. The plan is
|
|
2109
2365
|
* re-derived here from a FRESH read (never a client-passed plan), so a concurrent edit between the
|
|
2110
2366
|
* preview and the apply is rewritten too. EVERY replace is gated behind the typed-slug confirm
|
|
@@ -2115,7 +2371,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2115
2371
|
*
|
|
2116
2372
|
* No R2 operation: the new bytes were already stored put-first by the upload action, and the old
|
|
2117
2373
|
* bytes are KEPT (the old row stays in media.json), so this action writes only to git and never
|
|
2118
|
-
* resolves the bucket binding. It guards `resolvedAssets.enabled` for the media-off case only.
|
|
2374
|
+
* resolves the bucket binding. It guards `resolvedAssets.enabled` for the media-off case only.
|
|
2375
|
+
*/
|
|
2119
2376
|
async function mediaReplaceApply(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
|
|
2120
2377
|
const editor = requireSession(event);
|
|
2121
2378
|
const token = await mintToken(event.platform?.env ?? {});
|
|
@@ -2216,7 +2473,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2216
2473
|
throw redirect(303, '/admin/media?replaced=1');
|
|
2217
2474
|
}
|
|
2218
2475
|
|
|
2219
|
-
/**
|
|
2476
|
+
/**
|
|
2477
|
+
* Preview an alt-propagation: the display-only fetch action (the 2a transport). It plans filling the
|
|
2220
2478
|
* asset's default alt across every published main entry that references it, bucketing each placement
|
|
2221
2479
|
* (a will-fill empty alt, a customized alt left as-is, a decorative hero skipped), and returns the
|
|
2222
2480
|
* enriched entries, the report-only cross-branch delta, and the bucket counts. It commits nothing.
|
|
@@ -2226,7 +2484,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2226
2484
|
* Wire contract: a fetch POST with the JSON body `{ hash }`, the CSRF token in the `X-Cairn-CSRF`
|
|
2227
2485
|
* header (the raw-body transport, no form-CSRF), and a `MediaAltPreviewPlan` returned as the 200
|
|
2228
2486
|
* ActionResult the client reads. A refusal rides a `fail(status, ...)` envelope with the
|
|
2229
|
-
* MediaAltPropagateFailure shape, so the client reads `type`/`status` from the body.
|
|
2487
|
+
* MediaAltPropagateFailure shape, so the client reads `type`/`status` from the body.
|
|
2488
|
+
*/
|
|
2230
2489
|
async function mediaAltPreview(event: ContentEvent): Promise<ReturnType<typeof fail> | MediaAltPreviewPlan> {
|
|
2231
2490
|
// CSRF first: a raw-body (JSON) POST, so the header witness is the authority, like the upload and
|
|
2232
2491
|
// replace-preview actions. A failed check refuses before the session read or any GitHub call.
|
|
@@ -2295,14 +2554,16 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2295
2554
|
return { entries, branchDelta: plan.branchDelta, counts };
|
|
2296
2555
|
}
|
|
2297
2556
|
|
|
2298
|
-
/**
|
|
2557
|
+
/**
|
|
2558
|
+
* Apply an alt-propagation: fill the asset's default alt into every empty placement across the
|
|
2299
2559
|
* published corpus (and, on the `overwrite` opt-in, customized placements too), in ONE atomic
|
|
2300
2560
|
* commit. The plan is re-derived from a FRESH read (never a client plan). Three deliberate
|
|
2301
2561
|
* differences from replace: there is NO typed-slug gate (alt fill is reversible and frequent), there
|
|
2302
2562
|
* is NO media.json change (the default alt is READ from the row, never rewritten there), and a
|
|
2303
2563
|
* decorative hero is never written regardless of `overwrite` (enforced inside fillAltForHash). A run
|
|
2304
2564
|
* that changes nothing commits nothing and still redirects (a no-op success). It fails the operation
|
|
2305
|
-
* closed on an unverifiable usage read, and writes only entry files in git (no R2 op).
|
|
2565
|
+
* closed on an unverifiable usage read, and writes only entry files in git (no R2 op).
|
|
2566
|
+
*/
|
|
2306
2567
|
async function mediaAltApply(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
|
|
2307
2568
|
const editor = requireSession(event);
|
|
2308
2569
|
const token = await mintToken(event.platform?.env ?? {});
|
|
@@ -2363,20 +2624,26 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2363
2624
|
throw redirect(303, '/admin/media?altPropagated=1');
|
|
2364
2625
|
}
|
|
2365
2626
|
|
|
2366
|
-
/**
|
|
2627
|
+
/**
|
|
2628
|
+
* The cap on a personal-dictionary word, matched by isValidDictionaryWord. A word is one line, so
|
|
2367
2629
|
* this bounds an abusive input; the real authority is the per-character validation, which rejects
|
|
2368
|
-
* whitespace and control bytes so a body can never inject an extra line into the committed file.
|
|
2630
|
+
* whitespace and control bytes so a body can never inject an extra line into the committed file.
|
|
2631
|
+
*/
|
|
2369
2632
|
const MAX_DICTIONARY_WORD = 64;
|
|
2370
|
-
/**
|
|
2371
|
-
*
|
|
2633
|
+
/**
|
|
2634
|
+
* The cap on the words a single add request carries: an editor adds a handful at save time, never
|
|
2635
|
+
* a flood. Past this the body is treated as abusive and the surplus is dropped.
|
|
2636
|
+
*/
|
|
2372
2637
|
const MAX_DICTIONARY_BATCH = 100;
|
|
2373
2638
|
|
|
2374
|
-
/**
|
|
2639
|
+
/**
|
|
2640
|
+
* Read the committed personal dictionary, merge the validated additions in sorted order, and commit
|
|
2375
2641
|
* the canonical file back. Shared by the first attempt and the post-conflict retry, so both re-read
|
|
2376
2642
|
* the head and re-merge the same additions; the merge is order-independent, so a concurrent editor's
|
|
2377
2643
|
* word that already landed is preserved and the result is the same sorted set regardless of order.
|
|
2378
2644
|
* Returns the merged word list. Throws CommitConflictError (via commitFiles) when the branch moves
|
|
2379
|
-
* under the commit, which the caller catches to retry once.
|
|
2645
|
+
* under the commit, which the caller catches to retry once.
|
|
2646
|
+
*/
|
|
2380
2647
|
async function mergeAndCommitDictionary(token: string, additions: string[], editor: Editor): Promise<string[]> {
|
|
2381
2648
|
const path = dictionaryFilePath();
|
|
2382
2649
|
// The existing file as its canonical sorted set, so a no-op add is detected against the same
|
|
@@ -2396,27 +2663,33 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2396
2663
|
return merged;
|
|
2397
2664
|
}
|
|
2398
2665
|
|
|
2399
|
-
/**
|
|
2666
|
+
/**
|
|
2667
|
+
* The repo-relative site-config path the settings save reads and commits. It is the same committed
|
|
2400
2668
|
* YAML the nav editor edits, so it comes from the configured nav menu first and falls back to the
|
|
2401
|
-
* scaffold default when no menu is configured.
|
|
2669
|
+
* scaffold default when no menu is configured.
|
|
2670
|
+
*/
|
|
2402
2671
|
function siteConfigPath(): string {
|
|
2403
2672
|
return runtime.navMenu?.configPath ?? DEFAULT_SITE_CONFIG_PATH;
|
|
2404
2673
|
}
|
|
2405
2674
|
|
|
2406
|
-
/**
|
|
2675
|
+
/**
|
|
2676
|
+
* Read whether the Anthropic API key secret is present in the load's env. A presence flag for the
|
|
2407
2677
|
* truthful visibility gate, never the key itself: the key is a Worker secret, so this only reports
|
|
2408
|
-
* that a non-empty `ANTHROPIC_API_KEY` exists and the value never leaves the server.
|
|
2678
|
+
* that a non-empty `ANTHROPIC_API_KEY` exists and the value never leaves the server.
|
|
2679
|
+
*/
|
|
2409
2680
|
function keyConfigured(event: ContentEvent): boolean {
|
|
2410
2681
|
const env = (event.platform?.env ?? {}) as Record<string, unknown>;
|
|
2411
2682
|
return typeof env.ANTHROPIC_API_KEY === 'string' && env.ANTHROPIC_API_KEY.length > 0;
|
|
2412
2683
|
}
|
|
2413
2684
|
|
|
2414
|
-
/**
|
|
2685
|
+
/**
|
|
2686
|
+
* Load the two-tier tidy settings (spec 2.8, Task 15). The developer tier (enabled, key, model) is
|
|
2415
2687
|
* read-only; the editor tier is the resolved conventions block. The visibility gate is truthful: the
|
|
2416
2688
|
* `enabled` flag is true only when `tidy.enabled` is set AND the key is present, so the screen renders
|
|
2417
2689
|
* the convention list only then and the honest gate note otherwise. No secret is returned: only a
|
|
2418
2690
|
* presence flag for the key. The conventions come straight from the runtime config (the same source
|
|
2419
|
-
* the tidy action's prompt reads), so the screen and the prompt can never diverge.
|
|
2691
|
+
* the tidy action's prompt reads), so the screen and the prompt can never diverge.
|
|
2692
|
+
*/
|
|
2420
2693
|
function settingsLoad(event: ContentEvent): SettingsData {
|
|
2421
2694
|
requireSession(event);
|
|
2422
2695
|
const tidy = runtime.tidy;
|
|
@@ -2435,13 +2708,15 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2435
2708
|
};
|
|
2436
2709
|
}
|
|
2437
2710
|
|
|
2438
|
-
/**
|
|
2711
|
+
/**
|
|
2712
|
+
* Save the editor-tier tidy conventions: validate the posted block, then read-modify-commit it into
|
|
2439
2713
|
* the same committed YAML the nav editor writes, with the session editor as author. The transport is
|
|
2440
2714
|
* the nav save's exactly: a form POST carrying the conventions JSON, the read-modify-commit through
|
|
2441
2715
|
* `commitFile`, and a stale-SHA `isConflict` bounced back as a reload prompt. Only the conventions
|
|
2442
2716
|
* block is written (setTidy leaves `tidy.enabled` and `tidy.model` untouched), so an editor's save can
|
|
2443
2717
|
* never flip the developer-tier deploy facts. The save refuses before any commit when tidy is not
|
|
2444
|
-
* enabled, so the gate state's absent editor tier can never be saved past.
|
|
2718
|
+
* enabled, so the gate state's absent editor tier can never be saved past.
|
|
2719
|
+
*/
|
|
2445
2720
|
async function settingsSave(event: ContentEvent): Promise<never> {
|
|
2446
2721
|
const editor = requireSession(event);
|
|
2447
2722
|
// The editor tier does not exist when tidy is off, so a save in that state is a 404 (no editable
|
|
@@ -2487,7 +2762,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2487
2762
|
throw redirect(303, '/admin/settings?saved=1');
|
|
2488
2763
|
}
|
|
2489
2764
|
|
|
2490
|
-
/**
|
|
2765
|
+
/**
|
|
2766
|
+
* Add a word (or batch) to the git-committed personal dictionary (spec 1.6). The transport mirrors
|
|
2491
2767
|
* the media raw-body actions exactly: a `text/plain` POST, the CSRF token in `X-Cairn-CSRF` validated
|
|
2492
2768
|
* by validateCsrfHeader (CSRF first, then the session), and a small JSON body `{ word }` or
|
|
2493
2769
|
* `{ words }`. It reads the current file from the default branch, inserts the validated words in
|
|
@@ -2502,7 +2778,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2502
2778
|
* Input validation is load-bearing here: this commits to the repo from request input, so every word
|
|
2503
2779
|
* is length-bounded and rejected if it carries whitespace or control characters (a word is one
|
|
2504
2780
|
* line), and the batch is capped. A body that yields no valid word refuses with a 400 and commits
|
|
2505
|
-
* nothing, so the committed file can never gain an injected or empty line.
|
|
2781
|
+
* nothing, so the committed file can never gain an injected or empty line.
|
|
2782
|
+
*/
|
|
2506
2783
|
async function addDictionaryWord(event: ContentEvent): Promise<ReturnType<typeof fail> | DictionaryAddResult> {
|
|
2507
2784
|
// CSRF first: a raw-body (JSON) POST, so the header witness is the authority, like the upload and
|
|
2508
2785
|
// media actions. A failed check refuses before the session read or any GitHub call.
|
|
@@ -2554,7 +2831,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2554
2831
|
}
|
|
2555
2832
|
}
|
|
2556
2833
|
|
|
2557
|
-
/**
|
|
2834
|
+
/**
|
|
2835
|
+
* Tidy: a light LLM copy-edit of the author's markdown (spec 2.1). The first remote model call in
|
|
2558
2836
|
* the library, so this is the highest-blast-radius server action: untrusted content and the Anthropic
|
|
2559
2837
|
* API key. The transport mirrors the media raw-body actions (a `text/plain` POST carrying JSON
|
|
2560
2838
|
* `{ text, scope }`, the CSRF token in `X-Cairn-CSRF`, the response deserialized by the client), with
|
|
@@ -2572,7 +2850,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2572
2850
|
* prompt's injection framing (Task 10) treats it as data. The API key never leaves the action: it is
|
|
2573
2851
|
* not returned and not logged, and the log line carries no content. The action commits NOTHING, so a
|
|
2574
2852
|
* failed, aborted, or refused tidy can never corrupt the entry; the diff is computed on the client
|
|
2575
|
-
* (Task 12), so the server stays a thin model-call boundary.
|
|
2853
|
+
* (Task 12), so the server stays a thin model-call boundary.
|
|
2854
|
+
*/
|
|
2576
2855
|
async function tidyAction(event: ContentEvent): Promise<ReturnType<typeof fail> | TidyResult> {
|
|
2577
2856
|
// CSRF first: a raw-body (JSON) POST, so the header witness is the authority. A failed check refuses
|
|
2578
2857
|
// before the session read and before any model call.
|
|
@@ -2669,11 +2948,13 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2669
2948
|
return { corrected, model: message.model, usage: message.usage };
|
|
2670
2949
|
}
|
|
2671
2950
|
|
|
2672
|
-
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 };
|
|
2951
|
+
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 };
|
|
2673
2952
|
}
|
|
2674
2953
|
|
|
2675
|
-
/**
|
|
2676
|
-
*
|
|
2954
|
+
/**
|
|
2955
|
+
* The cap, in characters, on the stored alt text. The human fields are display copy, not content,
|
|
2956
|
+
* so a generous cap rejects only abuse-scale input.
|
|
2957
|
+
*/
|
|
2677
2958
|
const MAX_ALT = 160;
|
|
2678
2959
|
/** The cap, in characters, on the stored display name. */
|
|
2679
2960
|
const MAX_DISPLAY_NAME = 120;
|
|
@@ -2682,8 +2963,10 @@ const MAX_ORIGINAL_FILENAME = 120;
|
|
|
2682
2963
|
/** The largest pixel dimension kept; anything larger is treated as bogus and clamped to null. */
|
|
2683
2964
|
const MAX_DIMENSION = 60000;
|
|
2684
2965
|
|
|
2685
|
-
/**
|
|
2686
|
-
*
|
|
2966
|
+
/**
|
|
2967
|
+
* Decode a percent-encoded header value, yielding `''` on a malformed sequence or an absent header,
|
|
2968
|
+
* so a hostile `X-Cairn-*` value cannot throw past the gate.
|
|
2969
|
+
*/
|
|
2687
2970
|
function safeDecode(value: string | null): string {
|
|
2688
2971
|
if (value === null) return '';
|
|
2689
2972
|
try {
|
|
@@ -2693,40 +2976,52 @@ function safeDecode(value: string | null): string {
|
|
|
2693
2976
|
}
|
|
2694
2977
|
}
|
|
2695
2978
|
|
|
2696
|
-
/**
|
|
2697
|
-
*
|
|
2979
|
+
/**
|
|
2980
|
+
* The basename of a decoded filename: the final path segment after any `/` or `\`. A client value
|
|
2981
|
+
* of `../../evil.png` yields `evil.png`, so no path component reaches the stored record.
|
|
2982
|
+
*/
|
|
2698
2983
|
function basename(name: string): string {
|
|
2699
2984
|
const parts = name.split(/[/\\]/);
|
|
2700
2985
|
return parts[parts.length - 1];
|
|
2701
2986
|
}
|
|
2702
2987
|
|
|
2703
|
-
/**
|
|
2704
|
-
*
|
|
2988
|
+
/**
|
|
2989
|
+
* Sort key for a where-used row's origin: published rows rank before branch rows, so the in-use
|
|
2990
|
+
* refusal lists "Published on the site" first, then the edit-branch references.
|
|
2991
|
+
*/
|
|
2705
2992
|
function originRank(entry: UsageEntry): number {
|
|
2706
2993
|
return entry.origin.kind === 'published' ? 0 : 1;
|
|
2707
2994
|
}
|
|
2708
2995
|
|
|
2709
|
-
/**
|
|
2710
|
-
*
|
|
2996
|
+
/**
|
|
2997
|
+
* A where-used row's branch name for the secondary sort (the empty string for a published row,
|
|
2998
|
+
* which sorts ahead of any branch by `originRank` already).
|
|
2999
|
+
*/
|
|
2711
3000
|
function branchKey(entry: UsageEntry): string {
|
|
2712
3001
|
return entry.origin.kind === 'branch' ? entry.origin.branch : '';
|
|
2713
3002
|
}
|
|
2714
3003
|
|
|
2715
|
-
/**
|
|
2716
|
-
*
|
|
3004
|
+
/**
|
|
3005
|
+
* The distinct-entry count behind a where-used set: a published use and an edit-branch edit of the
|
|
3006
|
+
* same entry are two rows but one distinct entry, so count by concept/id.
|
|
3007
|
+
*/
|
|
2717
3008
|
function distinctEntryCount(rows: UsageEntry[]): number {
|
|
2718
3009
|
return new Set(rows.map((e) => `${e.concept}/${e.id}`)).size;
|
|
2719
3010
|
}
|
|
2720
3011
|
|
|
2721
|
-
/**
|
|
2722
|
-
*
|
|
3012
|
+
/**
|
|
3013
|
+
* Strip control characters from a human field and cap it at `max` characters. Control characters
|
|
3014
|
+
* (C0 and DEL) never belong in display copy and could corrupt a log line or a committed JSON.
|
|
3015
|
+
*/
|
|
2723
3016
|
function sanitizeField(value: string, max: number): string {
|
|
2724
|
-
|
|
3017
|
+
|
|
2725
3018
|
return value.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, max);
|
|
2726
3019
|
}
|
|
2727
3020
|
|
|
2728
|
-
/**
|
|
2729
|
-
*
|
|
3021
|
+
/**
|
|
3022
|
+
* Parse an advisory pixel dimension header. A valid integer in `[1, MAX_DIMENSION]` is kept; an
|
|
3023
|
+
* absent, non-numeric, or out-of-range value becomes null (MediaEntry dimensions are `number | null`).
|
|
3024
|
+
*/
|
|
2730
3025
|
function clampDimension(value: string | null): number | null {
|
|
2731
3026
|
if (value === null) return null;
|
|
2732
3027
|
const n = Number(value);
|