@glw907/cairn-cms 0.59.0 → 0.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/components/CairnAdmin.svelte +3 -0
  3. package/dist/components/CairnTidySettings.svelte +553 -0
  4. package/dist/components/CairnTidySettings.svelte.d.ts +32 -0
  5. package/dist/components/EditPage.svelte +371 -2
  6. package/dist/components/MarkdownEditor.svelte +168 -1
  7. package/dist/components/MarkdownEditor.svelte.d.ts +44 -0
  8. package/dist/components/TidyReview.svelte +463 -0
  9. package/dist/components/TidyReview.svelte.d.ts +47 -0
  10. package/dist/components/cairn-admin.css +764 -0
  11. package/dist/components/editor-tidy.d.ts +31 -0
  12. package/dist/components/editor-tidy.js +199 -0
  13. package/dist/components/index.d.ts +1 -0
  14. package/dist/components/index.js +1 -0
  15. package/dist/components/markdown-directives.d.ts +16 -0
  16. package/dist/components/markdown-directives.js +34 -0
  17. package/dist/components/objective-errors.d.ts +30 -0
  18. package/dist/components/objective-errors.js +113 -0
  19. package/dist/components/spellcheck-assets/dictionary-en-us.txt +104743 -0
  20. package/dist/components/spellcheck-assets/spellchecker-wasm-LICENSE.txt +21 -0
  21. package/dist/components/spellcheck-assets/spellchecker-wasm.wasm +0 -0
  22. package/dist/components/spellcheck-worker.d.ts +80 -0
  23. package/dist/components/spellcheck-worker.js +161 -0
  24. package/dist/components/spellcheck.d.ts +146 -0
  25. package/dist/components/spellcheck.js +541 -0
  26. package/dist/components/tidy-categorize.d.ts +67 -0
  27. package/dist/components/tidy-categorize.js +392 -0
  28. package/dist/components/tidy-diff.d.ts +60 -0
  29. package/dist/components/tidy-diff.js +147 -0
  30. package/dist/components/tidy-validate.d.ts +37 -0
  31. package/dist/components/tidy-validate.js +174 -0
  32. package/dist/content/compose.d.ts +1 -1
  33. package/dist/content/compose.js +11 -0
  34. package/dist/content/site-dictionary.d.ts +31 -0
  35. package/dist/content/site-dictionary.js +82 -0
  36. package/dist/content/types.d.ts +25 -0
  37. package/dist/doctor/checks-local.d.ts +1 -0
  38. package/dist/doctor/checks-local.js +55 -6
  39. package/dist/doctor/index.js +2 -1
  40. package/dist/log/events.d.ts +1 -1
  41. package/dist/nav/site-config.d.ts +98 -0
  42. package/dist/nav/site-config.js +132 -0
  43. package/dist/sveltekit/admin-dispatch.d.ts +2 -0
  44. package/dist/sveltekit/admin-dispatch.js +6 -2
  45. package/dist/sveltekit/cairn-admin.d.ts +13 -1
  46. package/dist/sveltekit/cairn-admin.js +22 -3
  47. package/dist/sveltekit/content-routes.d.ts +135 -1
  48. package/dist/sveltekit/content-routes.js +351 -3
  49. package/dist/sveltekit/tidy-prompt.d.ts +11 -0
  50. package/dist/sveltekit/tidy-prompt.js +118 -0
  51. package/package.json +10 -1
  52. package/src/lib/components/CairnAdmin.svelte +3 -0
  53. package/src/lib/components/CairnTidySettings.svelte +553 -0
  54. package/src/lib/components/EditPage.svelte +371 -2
  55. package/src/lib/components/MarkdownEditor.svelte +168 -1
  56. package/src/lib/components/TidyReview.svelte +463 -0
  57. package/src/lib/components/cairn-admin.css +25 -0
  58. package/src/lib/components/editor-tidy.ts +241 -0
  59. package/src/lib/components/index.ts +1 -0
  60. package/src/lib/components/markdown-directives.ts +35 -0
  61. package/src/lib/components/objective-errors.ts +155 -0
  62. package/src/lib/components/spellcheck-assets/dictionary-en-us.txt +104743 -0
  63. package/src/lib/components/spellcheck-assets/spellchecker-wasm-LICENSE.txt +21 -0
  64. package/src/lib/components/spellcheck-assets/spellchecker-wasm.wasm +0 -0
  65. package/src/lib/components/spellcheck-worker.ts +279 -0
  66. package/src/lib/components/spellcheck.ts +679 -0
  67. package/src/lib/components/tidy-categorize.ts +460 -0
  68. package/src/lib/components/tidy-diff.ts +196 -0
  69. package/src/lib/components/tidy-validate.ts +202 -0
  70. package/src/lib/content/compose.ts +11 -1
  71. package/src/lib/content/site-dictionary.ts +84 -0
  72. package/src/lib/content/types.ts +25 -0
  73. package/src/lib/doctor/checks-local.ts +59 -5
  74. package/src/lib/doctor/index.ts +2 -0
  75. package/src/lib/log/events.ts +7 -1
  76. package/src/lib/nav/site-config.ts +197 -0
  77. package/src/lib/sveltekit/admin-dispatch.ts +7 -3
  78. package/src/lib/sveltekit/cairn-admin.ts +32 -4
  79. package/src/lib/sveltekit/content-routes.ts +504 -4
  80. package/src/lib/sveltekit/tidy-prompt.ts +153 -0
@@ -59,6 +59,114 @@ export function validateNavTree(value, maxDepth) {
59
59
  }
60
60
  return walk(value, 1);
61
61
  }
62
+ /** The default tidy model when a site sets none: Sonnet, the judgment floor for a light copy-edit. */
63
+ export const DEFAULT_TIDY_MODEL = 'claude-sonnet-4-6';
64
+ /** The resting tidy convention set: Fixes on, every style and advanced toggle off. */
65
+ export function defaultTidyConventions() {
66
+ return { fixes: true, enDashRanges: false, smartQuotes: false, brandCaps: false };
67
+ }
68
+ /**
69
+ * Resolve a partial conventions config (from the YAML) into the concrete TidyConventions the prompt
70
+ * builder consumes. An absent field falls to its default: Fixes on, the style and advanced toggles
71
+ * off. A multi-position toggle stays undefined (off) unless the config names a variant.
72
+ */
73
+ export function resolveTidyConventions(partial) {
74
+ const base = defaultTidyConventions();
75
+ if (partial === undefined)
76
+ return base;
77
+ return {
78
+ fixes: partial.fixes ?? base.fixes,
79
+ oxfordComma: partial.oxfordComma,
80
+ numberStyle: partial.numberStyle,
81
+ measurements: partial.measurements,
82
+ percent: partial.percent,
83
+ emDash: partial.emDash,
84
+ enDashRanges: partial.enDashRanges ?? base.enDashRanges,
85
+ ellipsis: partial.ellipsis,
86
+ timeFormat: partial.timeFormat,
87
+ smartQuotes: partial.smartQuotes ?? base.smartQuotes,
88
+ brandCaps: partial.brandCaps ?? base.brandCaps,
89
+ };
90
+ }
91
+ // The allowed values for each multi-position style toggle, the single source the validator checks an
92
+ // untrusted settings payload against. A value outside its list is rejected, so the committed YAML can
93
+ // only ever carry a known variant the prompt builder understands.
94
+ const OXFORD_COMMA_VALUES = ['always', 'complex-only', 'never'];
95
+ const NUMBER_STYLE_VALUES = ['under-ten', 'under-hundred', 'always-numerals'];
96
+ const MEASUREMENTS_VALUES = ['abbreviate', 'spell-out'];
97
+ const PERCENT_VALUES = ['sign', 'word'];
98
+ const EM_DASH_VALUES = ['spaced', 'closed'];
99
+ const ELLIPSIS_VALUES = ['single-char', 'three-dots'];
100
+ const TIME_FORMAT_VALUES = ['5 PM', '5pm', '5 p.m.'];
101
+ export class TidyConventionsError extends Error {
102
+ /** A malformed settings payload maps to the same diagnostic as a malformed config. */
103
+ conditionId = 'config.site-config-invalid';
104
+ constructor(message) {
105
+ super(message);
106
+ this.name = 'TidyConventionsError';
107
+ }
108
+ }
109
+ /**
110
+ * Validate and normalize an untrusted conventions object (from the settings form) into a concrete
111
+ * TidyConventions. This input is committed to the repo, so every field is bounded to its known set:
112
+ * a boolean toggle must be a boolean, and a multi-position toggle must be one of its listed variants
113
+ * or absent (off). An unknown key is dropped rather than carried, so the committed block can never
114
+ * grow a junk key. Throws TidyConventionsError on a value outside its allowed set.
115
+ */
116
+ export function validateTidyConventions(value) {
117
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
118
+ throw new TidyConventionsError('Tidy conventions must be an object');
119
+ }
120
+ const input = value;
121
+ function bool(key, fallback) {
122
+ const v = input[key];
123
+ if (v === undefined)
124
+ return fallback;
125
+ if (typeof v !== 'boolean')
126
+ throw new TidyConventionsError(`${key} must be true or false`);
127
+ return v;
128
+ }
129
+ function variant(key, allowed) {
130
+ const v = input[key];
131
+ if (v === undefined || v === null || v === false)
132
+ return undefined;
133
+ if (typeof v !== 'string' || !allowed.includes(v)) {
134
+ throw new TidyConventionsError(`${key} must be one of ${allowed.join(', ')}`);
135
+ }
136
+ return v;
137
+ }
138
+ return {
139
+ fixes: bool('fixes', true),
140
+ oxfordComma: variant('oxfordComma', OXFORD_COMMA_VALUES),
141
+ numberStyle: variant('numberStyle', NUMBER_STYLE_VALUES),
142
+ measurements: variant('measurements', MEASUREMENTS_VALUES),
143
+ percent: variant('percent', PERCENT_VALUES),
144
+ emDash: variant('emDash', EM_DASH_VALUES),
145
+ enDashRanges: bool('enDashRanges', false),
146
+ ellipsis: variant('ellipsis', ELLIPSIS_VALUES),
147
+ timeFormat: variant('timeFormat', TIME_FORMAT_VALUES),
148
+ smartQuotes: bool('smartQuotes', false),
149
+ brandCaps: bool('brandCaps', false),
150
+ };
151
+ }
152
+ /** The dialect string when a site sets none: US English, the only dictionary that ships today. */
153
+ export const DEFAULT_DIALECT = 'en-US';
154
+ // The dialect-to-dictionary map. Only US English ships now; a new locale adds one entry here and one
155
+ // committed dictionary file under spellcheck-assets, and the rest of the chain (the main-thread URL
156
+ // resolution, the worker fetch) needs no change. An unknown or unset dialect falls back to the default
157
+ // rather than throwing, so a typo or a future-locale config never breaks the editor.
158
+ const DICTIONARY_BY_DIALECT = {
159
+ 'en-US': 'dictionary-en-us.txt',
160
+ };
161
+ /**
162
+ * The dictionary asset file for a site's configured dialect, defaulting to US English. The main thread
163
+ * resolves this filename to a real URL (the spike's out-of-bundle asset) and hands it to the Worker in
164
+ * the `init` message; the Worker never reads config. An unknown dialect falls back to the default file.
165
+ */
166
+ export function dictionaryFileForDialect(dialect) {
167
+ const key = dialect ?? DEFAULT_DIALECT;
168
+ return DICTIONARY_BY_DIALECT[key] ?? DICTIONARY_BY_DIALECT[DEFAULT_DIALECT];
169
+ }
62
170
  export class SiteConfigError extends Error {
63
171
  /** The registered diagnostic condition a malformed site config maps to (mirrors CairnError). */
64
172
  conditionId = 'config.site-config-invalid';
@@ -104,3 +212,27 @@ export function setMenu(raw, name, tree) {
104
212
  doc.setIn(['menus', name], tree);
105
213
  return doc.toString();
106
214
  }
215
+ /**
216
+ * Write the editor-tier tidy conventions into the YAML site-config text and reserialize, preserving
217
+ * every other top-level key and the file's comments and key order (parseDocument round-trips both,
218
+ * the same machinery setMenu uses). Only the `tidy.conventions` block is touched: the developer-tier
219
+ * `tidy.enabled` and `tidy.model` are read-only in the screen, so this leaves them as they are and a
220
+ * save can never silently flip the deploy-time facts. A convention whose value is undefined (a
221
+ * collapsed multi-position toggle, off) is dropped, so the committed block carries only the on
222
+ * toggles, the same shape `resolveTidyConventions` fills the defaults back from on read.
223
+ */
224
+ export function setTidy(raw, conventions) {
225
+ const doc = parseDocument(raw);
226
+ if (doc.get('siteName') === undefined) {
227
+ throw new SiteConfigError('Site config must be a mapping with a siteName');
228
+ }
229
+ // Drop undefined-valued keys (a collapsed multi-position toggle) so the committed YAML carries only
230
+ // the enabled conventions; the resolver fills the rest back from defaults on read.
231
+ const block = {};
232
+ for (const [key, value] of Object.entries(conventions)) {
233
+ if (value !== undefined)
234
+ block[key] = value;
235
+ }
236
+ doc.setIn(['tidy', 'conventions'], block);
237
+ return doc.toString();
238
+ }
@@ -19,6 +19,8 @@ export type AdminView = {
19
19
  view: 'nav';
20
20
  } | {
21
21
  view: 'media';
22
+ } | {
23
+ view: 'settings';
22
24
  };
23
25
  /**
24
26
  * Parse a raw `URL.pathname` (the caller passes `event.url.pathname`, never a SvelteKit rest
@@ -3,8 +3,8 @@ import { isValidId } from '../content/ids.js';
3
3
  /**
4
4
  * Fixed first segments that never resolve as concepts. The engine only allows posts and pages
5
5
  * today, so no collision is possible, but the parser does not depend on that: a reserved
6
- * segment wins before concept lookup. `settings` has no view yet; AdminLayout already links
7
- * the sidebar to /admin/settings, so the URL is spoken for.
6
+ * segment wins before concept lookup. `settings`, `nav`, and `media` are decided as views below,
7
+ * so they are not in this no-view set.
8
8
  */
9
9
  const RESERVED_SEGMENTS = new Set(['login', 'auth', 'editors', 'nav', 'settings']);
10
10
  /**
@@ -47,6 +47,10 @@ export function parseAdminPath(pathname, concepts) {
47
47
  // concept), which is the correct shape.
48
48
  if (head === 'media')
49
49
  return { view: 'media' };
50
+ // settings is its own view, a peer of editors and nav. /admin/settings/<anything> 404s naturally
51
+ // (the two-segment branch never matches settings), which is the correct shape.
52
+ if (head === 'settings')
53
+ return { view: 'settings' };
50
54
  if (RESERVED_SEGMENTS.has(head))
51
55
  return null;
52
56
  const concept = findConcept(concepts, head);
@@ -1,4 +1,4 @@
1
- import { type ContentRoutesDeps, type LayoutData, type ListData, type EditData, type MediaLibraryData } from './content-routes.js';
1
+ import { type ContentRoutesDeps, type LayoutData, type ListData, type EditData, type MediaLibraryData, type SettingsData } from './content-routes.js';
2
2
  import { type NavLoadData } from './nav-routes.js';
3
3
  import type { AuthBranding, SendMagicLink } from '../email.js';
4
4
  import type { AuthEnv, Editor } from '../auth/types.js';
@@ -21,6 +21,11 @@ export interface CairnAdminDeps {
21
21
  branding?: AuthBranding;
22
22
  send?: SendMagicLink;
23
23
  mintToken?: ContentRoutesDeps['mintToken'];
24
+ /** Build the Anthropic client for the tidy action. Forwarded to the content routes; a site that
25
+ * enables tidy injects a stub here to avoid a real network call. Defaults to the real SDK client. */
26
+ anthropic?: ContentRoutesDeps['anthropic'];
27
+ /** The tidy action's own request deadline in milliseconds. Forwarded to the content routes. */
28
+ tidyTimeoutMs?: ContentRoutesDeps['tidyTimeoutMs'];
24
29
  }
25
30
  /**
26
31
  * One admin view's data, discriminated for the admin page component's switch. The public
@@ -65,6 +70,10 @@ export type AdminData = {
65
70
  view: 'media';
66
71
  layout: LayoutData;
67
72
  page: MediaLibraryData;
73
+ } | {
74
+ view: 'settings';
75
+ layout: LayoutData;
76
+ page: SettingsData;
68
77
  };
69
78
  export declare function createCairnAdmin(runtime: CairnRuntime, deps?: CairnAdminDeps): {
70
79
  load: (event: AdminEvent) => Promise<AdminData>;
@@ -74,10 +83,13 @@ export declare function createCairnAdmin(runtime: CairnRuntime, deps?: CairnAdmi
74
83
  logout: (event: AdminEvent) => Promise<never>;
75
84
  create: (event: AdminEvent) => Promise<never>;
76
85
  save: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<unknown>>;
86
+ saveSettings: (event: AdminEvent) => Promise<never>;
77
87
  upload: (event: AdminEvent) => Promise<import("./content-routes.js").UploadResult | import("@sveltejs/kit").ActionFailure<unknown>>;
78
88
  publish: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<unknown>>;
79
89
  discard: (event: AdminEvent) => Promise<never>;
80
90
  rename: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<unknown>>;
91
+ addDictionaryWord: (event: AdminEvent) => Promise<import("./content-routes.js").DictionaryAddResult | import("@sveltejs/kit").ActionFailure<unknown>>;
92
+ tidy: (event: AdminEvent) => Promise<import("./content-routes.js").TidyResult | import("@sveltejs/kit").ActionFailure<unknown>>;
81
93
  delete: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<unknown>>;
82
94
  mediaDelete: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<unknown>>;
83
95
  mediaUpdate: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<unknown>>;
@@ -19,7 +19,11 @@ export function createCairnAdmin(runtime, deps = {}) {
19
19
  replyTo: runtime.sender.replyTo,
20
20
  };
21
21
  const auth = createAuthRoutes({ branding, send: deps.send });
22
- const content = createContentRoutes(runtime, { mintToken: deps.mintToken });
22
+ const content = createContentRoutes(runtime, {
23
+ mintToken: deps.mintToken,
24
+ anthropic: deps.anthropic,
25
+ tidyTimeoutMs: deps.tidyTimeoutMs,
26
+ });
23
27
  const editors = createEditorRoutes();
24
28
  // The nav surface exists only when the site configures a menu; without one its view is a 404.
25
29
  const nav = runtime.navMenu ? createNavRoutes(runtime, { mintToken: deps.mintToken }) : null;
@@ -82,6 +86,11 @@ export function createCairnAdmin(runtime, deps = {}) {
82
86
  const [layout, page] = await Promise.all([content.layoutLoad(delegated), content.mediaLibraryLoad(delegated)]);
83
87
  return { view: 'media', layout, page };
84
88
  }
89
+ case 'settings': {
90
+ const delegated = contentEvent(event, {});
91
+ const [layout, page] = await Promise.all([content.layoutLoad(delegated), content.settingsLoad(delegated)]);
92
+ return { view: 'settings', layout, page };
93
+ }
85
94
  }
86
95
  }
87
96
  /** Wrap a delegate in the parse-and-check every action shares: parse the pathname exactly
@@ -97,9 +106,9 @@ export function createCairnAdmin(runtime, deps = {}) {
97
106
  };
98
107
  }
99
108
  // The topbar posts publishAll from every authed admin page; login and confirm may not.
100
- const authedViews = ['list', 'edit', 'editors', 'nav', 'media'];
109
+ const authedViews = ['list', 'edit', 'editors', 'nav', 'media', 'settings'];
101
110
  // An editor signs out from wherever they are, so logout accepts any parsed view.
102
- const anyView = ['index', 'login', 'confirm', 'list', 'edit', 'editors', 'nav', 'media'];
111
+ const anyView = ['index', 'login', 'confirm', 'list', 'edit', 'editors', 'nav', 'media', 'settings'];
103
112
  /** The full admin action vocabulary, one named async function per action, so a site's
104
113
  * catch-all route exports `admin.actions` directly. Each wrapper stays thin: parse,
105
114
  * validate the view, synthesize the params the wrapped action reads, delegate. The
@@ -116,10 +125,20 @@ export function createCairnAdmin(runtime, deps = {}) {
116
125
  throw error(404, 'Not found');
117
126
  return nav.navSave(contentEvent(event, {}));
118
127
  }),
128
+ // The tidy settings save (spec 2.8, Task 15): the editor commits the per-convention block to the
129
+ // committed YAML. Gated to the settings view, so it 404s elsewhere; the action itself 404s again
130
+ // when tidy is off, the server half of the truthful visibility gate.
131
+ saveSettings: viewAction(['settings'], (event) => content.settingsSave(contentEvent(event, {}))),
119
132
  upload: viewAction(['edit'], (event, view) => content.uploadAction(contentEvent(event, { concept: view.concept.id, id: view.id }))),
120
133
  publish: viewAction(['edit'], (event, view) => content.publishAction(contentEvent(event, { concept: view.concept.id, id: view.id }))),
121
134
  discard: viewAction(['edit'], (event, view) => content.discardAction(contentEvent(event, { concept: view.concept.id, id: view.id }))),
122
135
  rename: viewAction(['edit'], (event, view) => content.renameAction(contentEvent(event, { concept: view.concept.id, id: view.id }))),
136
+ // The personal-dictionary add (spec 1.6): the editor commits its pending add-to-dictionary words at
137
+ // save time. Gated to the edit view, where the spellcheck surface lives, so it 404s elsewhere.
138
+ addDictionaryWord: viewAction(['edit'], (event, view) => content.addDictionaryWord(contentEvent(event, { concept: view.concept.id, id: view.id }))),
139
+ // Tidy (spec 2.1): the editor posts the buffer to `?/tidy` for a light LLM copy-edit. Gated to the
140
+ // edit view, where the review surface lives, so it 404s elsewhere.
141
+ tidy: viewAction(['edit'], (event, view) => content.tidyAction(contentEvent(event, { concept: view.concept.id, id: view.id }))),
123
142
  delete: viewAction(['edit', 'list'], (event, view) => view.view === 'edit'
124
143
  ? content.deleteAction(contentEvent(event, { concept: view.concept.id, id: view.id }))
125
144
  : content.listDeleteAction(contentEvent(event, { concept: view.concept.id }))),
@@ -1,6 +1,7 @@
1
1
  import { fail } from '@sveltejs/kit';
2
2
  import { type GithubKeyEnv } from '../github/credentials.js';
3
3
  import { type LinkTarget, type InboundLink } from '../content/manifest.js';
4
+ import type { TidyConventions } from '../nav/site-config.js';
4
5
  import type { MediaEntry } from '../media/manifest.js';
5
6
  import type { MediaLibrary, MediaLibraryEntry } from '../media/library-entry.js';
6
7
  import type { UsageEntry } from '../media/usage.js';
@@ -116,6 +117,26 @@ export interface EditData {
116
117
  * when one exists, applied over the top-level values); null when the site sets none, which
117
118
  * leaves the frame rendering unstyled markup behind a hint. */
118
119
  preview: ResolvedPreview | null;
120
+ /** The spellcheck dictionary file for the site's configured dialect (default US English), resolved
121
+ * once at compose. The editor resolves it to a real asset URL on the main thread and hands that URL
122
+ * to the spellcheck Worker's `init`, the same way `mediaLibrary` is threaded in. Just the filename,
123
+ * e.g. "dictionary-en-us.txt". */
124
+ spellcheckDictionary: string;
125
+ /** The committed personal-dictionary words for the site (spec 1.6): the durable, shared, reviewable
126
+ * layer the editor seeds the spellcheck Worker's personal set from, the way `mediaLibrary` is handed
127
+ * in. Read from the git-committed `dictionary.txt` at editor load; empty when the file is absent or
128
+ * unreadable (the editor degrades to dialect-only). The dialect dictionary and the session ignore
129
+ * list are the other two layers; only this one is committed. */
130
+ siteDictionary: string[];
131
+ /** The editor-tier tidy facts the review surface needs (spec 2.5): whether tidy is enabled, the model
132
+ * that runs (for the head pill), and the RESOLVED conventions (the only data source for a
133
+ * normalization's because-line and the local category inference). The API key never appears here, it
134
+ * is a Worker secret. `enabled` false hides the Tidy control. */
135
+ tidy: {
136
+ enabled: boolean;
137
+ model: string;
138
+ conventions: TidyConventions;
139
+ };
119
140
  }
120
141
  /** One asset's where-used overlay, kept separate from MediaLibraryEntry so the picker's shared
121
142
  * projection stays decoupled from the Library-only usage facts. */
@@ -145,6 +166,41 @@ export interface MediaLibraryData {
145
166
  * its own slot rather than the degraded-load `error` above, so the two never collide. */
146
167
  flashError: string | null;
147
168
  }
169
+ /** The two-tier tidy settings load (spec 2.8, Task 15). The developer tier is read-only: `enabled`,
170
+ * `keyConfigured`, and `model`/`modelLabel` are deploy-time facts the editor sees but cannot change.
171
+ * The editor tier is the resolved `conventions` block, written back through the save. The visibility
172
+ * gate is truthful: `enabled` is true only when `tidy.enabled` is set AND the API key is present, so
173
+ * the screen renders the convention list only then and the honest gate note otherwise. The key is a
174
+ * Worker secret, so `keyConfigured` is the presence of `ANTHROPIC_API_KEY` in the load's env, never
175
+ * the key itself; nothing here returns or logs the secret. */
176
+ export interface SettingsData {
177
+ /** The truthful gate: tidy is enabled AND the API key is present. The screen renders the editor
178
+ * tier only when this is true, and the honest gate note (a labelled region, no disabled controls)
179
+ * otherwise. */
180
+ enabled: boolean;
181
+ /** Whether `tidy.enabled` is set in the site config, independent of the key. The gate note's
182
+ * checklist reads this to show which deploy-time step is still open. */
183
+ tidyEnabled: boolean;
184
+ /** Whether the API key secret is present in the Worker env. A presence flag, never the key. */
185
+ keyConfigured: boolean;
186
+ /** The model id (a developer-tier fact, read-only on the screen). */
187
+ model: string;
188
+ /** A plain-language label for the model id ("Claude Sonnet"), so the read-only fact is not a bare
189
+ * jargon token. Falls back to the raw id for an unknown model. */
190
+ modelLabel: string;
191
+ /** The resolved editor-tier conventions: every field concrete, the screen's initial control state.
192
+ * Present only when the gate is open; the gate state needs no conventions. */
193
+ conventions: TidyConventions;
194
+ /** The success flash a redirected save carries (`?saved=1`). */
195
+ saved: boolean;
196
+ /** A redirected save's validation or conflict error read from `?error=`. */
197
+ error: string | null;
198
+ }
199
+ /** A refused settings save: a conflict bounce or a malformed conventions payload. Just the one-line
200
+ * summary; the save commits nothing on a refusal. */
201
+ export interface SettingsSaveFailure {
202
+ error: string;
203
+ }
148
204
  /** The structural event the content routes read; a real SvelteKit RequestEvent satisfies it. */
149
205
  export interface ContentEvent extends EventBase<GithubKeyEnv> {
150
206
  params: Record<string, string>;
@@ -153,10 +209,71 @@ export interface ContentEvent extends EventBase<GithubKeyEnv> {
153
209
  cookies?: CookieJar;
154
210
  }
155
211
  /** Injectable dependencies; tests stub the token mint to avoid signing a real key. */
212
+ /** The minimal Anthropic client surface the tidy action uses, typed structurally so the SDK's deep
213
+ * generics never reach a public signature and so the integration test can inject a fake whose
214
+ * `messages.create` it stubs. The real factory builds `new Anthropic({ apiKey })`, which satisfies
215
+ * this shape. The success path reads only the text blocks, the model, the stop reason, and the usage
216
+ * counts. */
217
+ export interface TidyClient {
218
+ messages: {
219
+ create(body: {
220
+ model: string;
221
+ max_tokens: number;
222
+ system: string;
223
+ messages: {
224
+ role: 'user';
225
+ content: string;
226
+ }[];
227
+ }, options?: {
228
+ signal?: AbortSignal;
229
+ }): Promise<{
230
+ content: {
231
+ type: string;
232
+ text?: string;
233
+ }[];
234
+ model: string;
235
+ stop_reason: string | null;
236
+ usage: {
237
+ input_tokens: number;
238
+ output_tokens: number;
239
+ };
240
+ }>;
241
+ };
242
+ }
156
243
  export interface ContentRoutesDeps {
157
244
  /** Mint a GitHub App installation token from the Worker env. Defaults to the real signer.
158
245
  * A bare string works too; the routes await whatever comes back. */
159
246
  mintToken?: (env: GithubKeyEnv) => string | Promise<string>;
247
+ /** Build the Anthropic client for the tidy action from the resolved API key. Defaults to the real
248
+ * SDK client. Injected in tests so `messages.create` is stubbed and no network call (or real key)
249
+ * is ever needed. The factory runs only after the key is read from the env, so a disabled or
250
+ * unconfigured site never constructs a client. */
251
+ anthropic?: (opts: {
252
+ apiKey: string;
253
+ }) => TidyClient;
254
+ /** The tidy action's own request deadline in milliseconds, set shorter than the platform limit so a
255
+ * slow model call becomes a clean retryable fail(502) rather than a platform timeout. Defaults to
256
+ * {@link DEFAULT_TIDY_TIMEOUT_MS}. Overridable in tests to assert the deadline path without waiting. */
257
+ tidyTimeoutMs?: number;
258
+ }
259
+ /** The successful tidy outcome (spec 2.1): the corrected markdown, the model that produced it, and the
260
+ * token usage. The diff is computed on the client (Task 12), so the server returns the plain text and
261
+ * commits nothing. Admin-internal: consumed by the editor's review surface, not on the package's
262
+ * sveltekit subpath, so it carries no reference page. */
263
+ export interface TidyResult {
264
+ corrected: string;
265
+ model: string;
266
+ usage: {
267
+ input_tokens: number;
268
+ output_tokens: number;
269
+ };
270
+ }
271
+ /** A refused tidy: `fail(403)` on a failed CSRF check, `fail(503)` when tidy is disabled or the API
272
+ * key is missing, `fail(413)` for an over-long body, `fail(502)` for a deadline overrun, abort, or
273
+ * model error (all retryable), `fail(422)` for a model refusal, `fail(400)` for a malformed body. Just
274
+ * the one-line summary; the action commits nothing, so a refusal can never corrupt the entry. */
275
+ export interface TidyFailure {
276
+ error: string;
160
277
  }
161
278
  /** A blocked save or publish: `fail(400)` when the body links to a target absent from main. */
162
279
  export interface SaveFailure {
@@ -215,6 +332,19 @@ export interface MediaReplaceFailure {
215
332
  export interface MediaAltPropagateFailure {
216
333
  error: string;
217
334
  }
335
+ /** The personal-dictionary add outcome (spec 1.6): the merged, canonical sorted word list after the
336
+ * add landed. The client reconciles its pending-additions set against this (a word now in the list is
337
+ * committed and dropped from pending). Admin-internal: exported for the editor host's reconcile, not
338
+ * on the package's sveltekit subpath, so it carries no reference page. */
339
+ export interface DictionaryAddResult {
340
+ words: string[];
341
+ }
342
+ /** A refused personal-dictionary add: `fail(403)` on a failed CSRF check, `fail(400)` on a body that
343
+ * carries no valid word. The client keeps its pending additions for the session and re-attempts on
344
+ * the next save, so the word is never silently dropped. Just the one-line summary. */
345
+ export interface DictionaryAddFailure {
346
+ error: string;
347
+ }
218
348
  /** A refused media bulk delete or orphan purge: `fail(503)` for the fail-closed strict-usage refusal
219
349
  * (the whole batch refuses) or media-off / a missing bucket binding. The per-item outcomes ride the
220
350
  * returned summary, not a fail. */
@@ -305,7 +435,7 @@ export interface MediaAltPreviewPlan {
305
435
  * keys identify which guard refused. The media refusals ride here too, so the Media Library's one
306
436
  * `form` prop carries a `?/mediaDelete`, `?/mediaUpdate`, `?/mediaReplace`, or `?/mediaAltPropagate`
307
437
  * refusal without a second type. */
308
- export type ContentFormFailure = Partial<SaveFailure & DeleteRefusal & RenameFailure & MediaDeleteRefusal & MediaUpdateFailure & MediaReplaceFailure & MediaAltPropagateFailure & MediaBulkFailure>;
438
+ export type ContentFormFailure = Partial<SaveFailure & DeleteRefusal & RenameFailure & MediaDeleteRefusal & MediaUpdateFailure & MediaReplaceFailure & MediaAltPropagateFailure & MediaBulkFailure & TidyFailure>;
309
439
  /** The successful upload's response (`uploadAction`). The server-owned `record` rides the editor's
310
440
  * optimistic client state and commits with the entry at Save (the upload itself commits nothing).
311
441
  * `reused` is true when identical bytes were already stored, so the second upload did no second put;
@@ -321,6 +451,8 @@ export declare function createContentRoutes(runtime: CairnRuntime, deps?: Conten
321
451
  indexRedirect: () => never;
322
452
  listLoad: (event: ContentEvent) => Promise<ListData>;
323
453
  mediaLibraryLoad: (event: ContentEvent) => Promise<MediaLibraryData>;
454
+ settingsLoad: (event: ContentEvent) => SettingsData;
455
+ settingsSave: (event: ContentEvent) => Promise<never>;
324
456
  createAction: (event: ContentEvent) => Promise<never>;
325
457
  editLoad: (event: ContentEvent) => Promise<EditData>;
326
458
  saveAction: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;
@@ -340,5 +472,7 @@ export declare function createContentRoutes(runtime: CairnRuntime, deps?: Conten
340
472
  mediaReplaceApply: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;
341
473
  mediaAltPreview: (event: ContentEvent) => Promise<ReturnType<typeof fail> | MediaAltPreviewPlan>;
342
474
  mediaAltApply: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;
475
+ addDictionaryWord: (event: ContentEvent) => Promise<ReturnType<typeof fail> | DictionaryAddResult>;
476
+ tidyAction: (event: ContentEvent) => Promise<ReturnType<typeof fail> | TidyResult>;
343
477
  mintToken: (env: GithubKeyEnv) => string | Promise<string>;
344
478
  };