@glw907/cairn-cms 0.38.0 → 0.41.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 (97) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +7 -6
  3. package/dist/components/AdminLayout.svelte +53 -0
  4. package/dist/components/ComponentInsertDialog.svelte +27 -13
  5. package/dist/components/ComponentInsertDialog.svelte.d.ts +13 -2
  6. package/dist/components/ConceptList.svelte +22 -3
  7. package/dist/components/DeleteDialog.svelte +18 -7
  8. package/dist/components/DeleteDialog.svelte.d.ts +11 -1
  9. package/dist/components/EditPage.svelte +604 -75
  10. package/dist/components/EditPage.svelte.d.ts +8 -1
  11. package/dist/components/EditorToolbar.svelte +206 -29
  12. package/dist/components/EditorToolbar.svelte.d.ts +12 -4
  13. package/dist/components/LinkPicker.svelte +14 -6
  14. package/dist/components/LinkPicker.svelte.d.ts +9 -2
  15. package/dist/components/MarkdownEditor.svelte +80 -34
  16. package/dist/components/MarkdownEditor.svelte.d.ts +9 -3
  17. package/dist/components/MarkdownHelpDialog.svelte +58 -0
  18. package/dist/components/MarkdownHelpDialog.svelte.d.ts +11 -0
  19. package/dist/components/RenameDialog.svelte +13 -4
  20. package/dist/components/RenameDialog.svelte.d.ts +9 -1
  21. package/dist/components/WebLinkDialog.svelte +89 -0
  22. package/dist/components/WebLinkDialog.svelte.d.ts +23 -0
  23. package/dist/components/cairn-admin.css +353 -4
  24. package/dist/components/editor-highlight.d.ts +9 -0
  25. package/dist/components/editor-highlight.js +62 -0
  26. package/dist/components/link-completion.js +10 -3
  27. package/dist/components/markdown-directives.d.ts +7 -0
  28. package/dist/components/markdown-directives.js +22 -0
  29. package/dist/components/markdown-format.d.ts +1 -1
  30. package/dist/components/markdown-format.js +91 -12
  31. package/dist/content/pending.d.ts +9 -0
  32. package/dist/content/pending.js +24 -0
  33. package/dist/diagnostics/conditions.d.ts +8 -1
  34. package/dist/diagnostics/conditions.js +68 -1
  35. package/dist/doctor/bin.d.ts +2 -0
  36. package/dist/doctor/bin.js +44 -0
  37. package/dist/doctor/check-send.d.ts +3 -0
  38. package/dist/doctor/check-send.js +43 -0
  39. package/dist/doctor/checks-cloudflare.d.ts +5 -0
  40. package/dist/doctor/checks-cloudflare.js +200 -0
  41. package/dist/doctor/checks-github.d.ts +2 -0
  42. package/dist/doctor/checks-github.js +57 -0
  43. package/dist/doctor/checks-local.d.ts +5 -0
  44. package/dist/doctor/checks-local.js +112 -0
  45. package/dist/doctor/cloudflare-api.d.ts +7 -0
  46. package/dist/doctor/cloudflare-api.js +24 -0
  47. package/dist/doctor/index.d.ts +23 -0
  48. package/dist/doctor/index.js +68 -0
  49. package/dist/doctor/report.d.ts +5 -0
  50. package/dist/doctor/report.js +21 -0
  51. package/dist/doctor/run.d.ts +8 -0
  52. package/dist/doctor/run.js +20 -0
  53. package/dist/doctor/types.d.ts +41 -0
  54. package/dist/doctor/types.js +10 -0
  55. package/dist/doctor/wrangler-config.d.ts +12 -0
  56. package/dist/doctor/wrangler-config.js +125 -0
  57. package/dist/github/branches.d.ts +11 -0
  58. package/dist/github/branches.js +75 -0
  59. package/dist/github/signing.d.ts +3 -1
  60. package/dist/github/signing.js +13 -5
  61. package/dist/log/events.d.ts +1 -1
  62. package/dist/sveltekit/content-routes.d.ts +22 -1
  63. package/dist/sveltekit/content-routes.js +320 -72
  64. package/package.json +8 -5
  65. package/src/lib/components/AdminLayout.svelte +53 -0
  66. package/src/lib/components/ComponentInsertDialog.svelte +27 -13
  67. package/src/lib/components/ConceptList.svelte +22 -3
  68. package/src/lib/components/DeleteDialog.svelte +18 -7
  69. package/src/lib/components/EditPage.svelte +604 -75
  70. package/src/lib/components/EditorToolbar.svelte +206 -29
  71. package/src/lib/components/LinkPicker.svelte +14 -6
  72. package/src/lib/components/MarkdownEditor.svelte +80 -34
  73. package/src/lib/components/MarkdownHelpDialog.svelte +58 -0
  74. package/src/lib/components/RenameDialog.svelte +13 -4
  75. package/src/lib/components/WebLinkDialog.svelte +89 -0
  76. package/src/lib/components/cairn-admin.css +26 -4
  77. package/src/lib/components/editor-highlight.ts +67 -0
  78. package/src/lib/components/link-completion.ts +10 -3
  79. package/src/lib/components/markdown-directives.ts +23 -0
  80. package/src/lib/components/markdown-format.ts +118 -13
  81. package/src/lib/content/pending.ts +24 -0
  82. package/src/lib/diagnostics/conditions.ts +75 -2
  83. package/src/lib/doctor/bin.ts +45 -0
  84. package/src/lib/doctor/check-send.ts +43 -0
  85. package/src/lib/doctor/checks-cloudflare.ts +222 -0
  86. package/src/lib/doctor/checks-github.ts +63 -0
  87. package/src/lib/doctor/checks-local.ts +119 -0
  88. package/src/lib/doctor/cloudflare-api.ts +33 -0
  89. package/src/lib/doctor/index.ts +93 -0
  90. package/src/lib/doctor/report.ts +30 -0
  91. package/src/lib/doctor/run.ts +23 -0
  92. package/src/lib/doctor/types.ts +52 -0
  93. package/src/lib/doctor/wrangler-config.ts +142 -0
  94. package/src/lib/github/branches.ts +83 -0
  95. package/src/lib/github/signing.ts +13 -6
  96. package/src/lib/log/events.ts +4 -0
  97. package/src/lib/sveltekit/content-routes.ts +400 -73
@@ -0,0 +1,125 @@
1
+ export async function readWranglerConfig(readFile) {
2
+ const jsonc = await readFile('wrangler.jsonc');
3
+ if (jsonc !== null)
4
+ return factsFromJsonc(jsonc);
5
+ const toml = await readFile('wrangler.toml');
6
+ if (toml !== null)
7
+ return factsFromToml(toml);
8
+ return null;
9
+ }
10
+ // Strip // and /* */ comments outside string literals, character by character, so a URL
11
+ // inside a string survives. Trailing commas go by regex afterward; a string containing
12
+ // ",}" would be mangled, an accepted gap in a tolerant reader.
13
+ function stripJsonc(text) {
14
+ let out = '';
15
+ let inString = false;
16
+ let i = 0;
17
+ while (i < text.length) {
18
+ const ch = text[i];
19
+ if (inString) {
20
+ out += ch;
21
+ if (ch === '\\') {
22
+ out += text[i + 1] ?? '';
23
+ i += 2;
24
+ continue;
25
+ }
26
+ if (ch === '"')
27
+ inString = false;
28
+ i += 1;
29
+ continue;
30
+ }
31
+ if (ch === '"') {
32
+ inString = true;
33
+ out += ch;
34
+ i += 1;
35
+ continue;
36
+ }
37
+ if (ch === '/' && text[i + 1] === '/') {
38
+ const end = text.indexOf('\n', i);
39
+ i = end === -1 ? text.length : end;
40
+ continue;
41
+ }
42
+ if (ch === '/' && text[i + 1] === '*') {
43
+ const end = text.indexOf('*/', i + 2);
44
+ i = end === -1 ? text.length : end + 2;
45
+ continue;
46
+ }
47
+ out += ch;
48
+ i += 1;
49
+ }
50
+ return out.replace(/,(\s*[}\]])/g, '$1');
51
+ }
52
+ function factsFromJsonc(text) {
53
+ let config;
54
+ try {
55
+ config = JSON.parse(stripJsonc(text));
56
+ }
57
+ catch {
58
+ // V8's SyntaxError embeds a source snippet, which would land verbatim in the report;
59
+ // a file that exists but does not parse is a fail with a clean message instead.
60
+ throw new Error('wrangler.jsonc did not parse');
61
+ }
62
+ const sendEmail = Array.isArray(config.send_email) ? config.send_email : [];
63
+ const hasEmailBinding = sendEmail.some((entry) => typeof entry === 'object' && entry !== null && entry.name === 'EMAIL');
64
+ const databases = Array.isArray(config.d1_databases) ? config.d1_databases : [];
65
+ const authDb = databases.find((entry) => typeof entry === 'object' && entry !== null && entry.binding === 'AUTH_DB');
66
+ const observability = config.observability;
67
+ const facts = {
68
+ hasEmailBinding,
69
+ hasAuthDb: authDb !== undefined,
70
+ observabilityEnabled: observability?.enabled === true,
71
+ };
72
+ if (typeof authDb?.database_id === 'string')
73
+ facts.authDbId = authDb.database_id;
74
+ return facts;
75
+ }
76
+ // The toml read is deliberately shallow: line-anchored matching for the three facts, not a
77
+ // TOML parser. The remediation tells the operator exactly what to add, so full fidelity
78
+ // buys nothing here. A table header opens a section; the relevant key lines are matched
79
+ // within it and the d1 table flushes on the next header.
80
+ function factsFromToml(text) {
81
+ const facts = {
82
+ hasEmailBinding: false,
83
+ hasAuthDb: false,
84
+ observabilityEnabled: false,
85
+ };
86
+ let section = '';
87
+ let d1Binding;
88
+ let d1Id;
89
+ const flushD1 = () => {
90
+ if (d1Binding === 'AUTH_DB') {
91
+ facts.hasAuthDb = true;
92
+ if (d1Id !== undefined)
93
+ facts.authDbId = d1Id;
94
+ }
95
+ d1Binding = undefined;
96
+ d1Id = undefined;
97
+ };
98
+ for (const line of text.split('\n')) {
99
+ const header = line.match(/^\s*(\[\[?[\w.]+\]?\])\s*(?:#.*)?$/);
100
+ if (header) {
101
+ flushD1();
102
+ section = header[1];
103
+ continue;
104
+ }
105
+ const kv = line.match(/^\s*(\w+)\s*=\s*(.+?)\s*$/);
106
+ if (!kv)
107
+ continue;
108
+ const [, key, value] = kv;
109
+ const str = value.match(/^["'](.*)["']/)?.[1];
110
+ if (section === '[[send_email]]' && key === 'name' && str === 'EMAIL') {
111
+ facts.hasEmailBinding = true;
112
+ }
113
+ else if (section === '[[d1_databases]]') {
114
+ if (key === 'binding')
115
+ d1Binding = str;
116
+ if (key === 'database_id')
117
+ d1Id = str;
118
+ }
119
+ else if (section === '[observability]' && key === 'enabled' && value.startsWith('true')) {
120
+ facts.observabilityEnabled = true;
121
+ }
122
+ }
123
+ flushD1();
124
+ return facts;
125
+ }
@@ -0,0 +1,11 @@
1
+ import type { RepoRef } from './types.js';
2
+ /** The head commit sha of a branch, or null when the branch does not exist. */
3
+ export declare function branchHeadSha(repo: RepoRef, branch: string, token: string): Promise<string | null>;
4
+ /** Create `branch` pointing at `fromSha`. Throws on any failure including an existing ref. */
5
+ export declare function createBranch(repo: RepoRef, branch: string, fromSha: string, token: string): Promise<void>;
6
+ /** Delete `branch`. A 404 (already gone) is success: the desired state holds. */
7
+ export declare function deleteBranch(repo: RepoRef, branch: string, token: string): Promise<void>;
8
+ /** Branch names under `prefix`, sorted. The matching-refs API paginates at 30 by default, so a
9
+ * site with 31+ pending entries would silently truncate; request the 100-per-page maximum and
10
+ * follow the Link rel="next" chain until exhausted. */
11
+ export declare function listBranches(repo: RepoRef, prefix: string, token: string): Promise<string[]>;
@@ -0,0 +1,75 @@
1
+ const API = 'https://api.github.com';
2
+ function headers(token) {
3
+ return {
4
+ Accept: 'application/vnd.github+json',
5
+ 'User-Agent': 'cairn-cms',
6
+ 'X-GitHub-Api-Version': '2022-11-28',
7
+ Authorization: `Bearer ${token}`,
8
+ 'Content-Type': 'application/json',
9
+ };
10
+ }
11
+ function gitUrl(repo, suffix) {
12
+ return `${API}/repos/${repo.owner}/${repo.repo}/git/${suffix}`;
13
+ }
14
+ /** The head commit sha of a branch, or null when the branch does not exist. */
15
+ export async function branchHeadSha(repo, branch, token) {
16
+ const res = await fetch(gitUrl(repo, `ref/heads/${encodeURIComponent(branch)}`), { headers: headers(token) });
17
+ // The 404 probe is a hot path (every editLoad); drain the body so the connection frees
18
+ // immediately instead of pinning one of workerd's six until GC.
19
+ if (res.status === 404) {
20
+ await res.body?.cancel();
21
+ return null;
22
+ }
23
+ if (!res.ok)
24
+ throw new Error(`GitHub ref ${branch} failed: ${res.status} ${await res.text()}`);
25
+ return (await res.json()).object.sha;
26
+ }
27
+ /** Create `branch` pointing at `fromSha`. Throws on any failure including an existing ref. */
28
+ export async function createBranch(repo, branch, fromSha, token) {
29
+ const res = await fetch(gitUrl(repo, 'refs'), {
30
+ method: 'POST',
31
+ headers: headers(token),
32
+ body: JSON.stringify({ ref: `refs/heads/${branch}`, sha: fromSha }),
33
+ });
34
+ if (!res.ok)
35
+ throw new Error(`GitHub branch create ${branch} failed: ${res.status} ${await res.text()}`);
36
+ await res.body?.cancel();
37
+ }
38
+ /** Delete `branch`. A 404 (already gone) is success: the desired state holds. */
39
+ export async function deleteBranch(repo, branch, token) {
40
+ const res = await fetch(gitUrl(repo, `refs/heads/${encodeURIComponent(branch)}`), {
41
+ method: 'DELETE',
42
+ headers: headers(token),
43
+ });
44
+ if (!res.ok && res.status !== 404) {
45
+ throw new Error(`GitHub branch delete ${branch} failed: ${res.status} ${await res.text()}`);
46
+ }
47
+ await res.body?.cancel();
48
+ }
49
+ /** The rel="next" URL from a GitHub Link header, or null on the last page. */
50
+ function nextPageUrl(link) {
51
+ if (!link)
52
+ return null;
53
+ for (const part of link.split(',')) {
54
+ const match = part.match(/<([^>]+)>\s*;\s*rel="next"/);
55
+ if (match)
56
+ return match[1];
57
+ }
58
+ return null;
59
+ }
60
+ /** Branch names under `prefix`, sorted. The matching-refs API paginates at 30 by default, so a
61
+ * site with 31+ pending entries would silently truncate; request the 100-per-page maximum and
62
+ * follow the Link rel="next" chain until exhausted. */
63
+ export async function listBranches(repo, prefix, token) {
64
+ const names = [];
65
+ let url = `${gitUrl(repo, `matching-refs/heads/${prefix}`)}?per_page=100`;
66
+ while (url) {
67
+ const res = await fetch(url, { headers: headers(token) });
68
+ if (!res.ok)
69
+ throw new Error(`GitHub matching-refs ${prefix} failed: ${res.status} ${await res.text()}`);
70
+ const refs = (await res.json());
71
+ names.push(...refs.map((r) => r.ref.replace(/^refs\/heads\//, '')));
72
+ url = nextPageUrl(res.headers.get('Link'));
73
+ }
74
+ return names;
75
+ }
@@ -9,7 +9,9 @@ export declare function installationToken(creds: AppCredentials): Promise<string
9
9
  * instead of re-signing and re-calling GitHub on every list and commit. A cold isolate re-mints,
10
10
  * which is always safe. This mirrors the default of @octokit/auth-app, which caches installation
11
11
  * tokens in memory and returns them until expiry. The TTL stays under GitHub's documented one-hour
12
- * lifetime, so a fixed margin avoids parsing the API expiry. `mint` and `now` are injected so the
12
+ * lifetime, so a fixed margin avoids parsing the API expiry. The cache holds the in-flight
13
+ * promise, not the resolved token, so a cold isolate's parallel loads coalesce into one mint;
14
+ * a rejected mint evicts itself so the next call retries. `mint` and `now` are injected so the
13
15
  * cache is testable with no network call and no real clock.
14
16
  */
15
17
  export declare function createInstallationTokenCache(mint?: (creds: AppCredentials) => Promise<string>, now?: () => number, ttlMs?: number): (creds: AppCredentials) => Promise<string>;
@@ -65,18 +65,26 @@ export async function installationToken(creds) {
65
65
  * instead of re-signing and re-calling GitHub on every list and commit. A cold isolate re-mints,
66
66
  * which is always safe. This mirrors the default of @octokit/auth-app, which caches installation
67
67
  * tokens in memory and returns them until expiry. The TTL stays under GitHub's documented one-hour
68
- * lifetime, so a fixed margin avoids parsing the API expiry. `mint` and `now` are injected so the
68
+ * lifetime, so a fixed margin avoids parsing the API expiry. The cache holds the in-flight
69
+ * promise, not the resolved token, so a cold isolate's parallel loads coalesce into one mint;
70
+ * a rejected mint evicts itself so the next call retries. `mint` and `now` are injected so the
69
71
  * cache is testable with no network call and no real clock.
70
72
  */
71
73
  export function createInstallationTokenCache(mint = installationToken, now = () => Date.now(), ttlMs = 55 * 60 * 1000) {
72
74
  const cache = new Map();
73
- return async function get(creds) {
75
+ return function get(creds) {
74
76
  const hit = cache.get(creds.installationId);
75
77
  if (hit && hit.expiresAt > now())
76
78
  return hit.token;
77
- const token = await mint(creds);
78
- cache.set(creds.installationId, { token, expiresAt: now() + ttlMs });
79
- return token;
79
+ const entry = { token: mint(creds), expiresAt: now() + ttlMs };
80
+ cache.set(creds.installationId, entry);
81
+ // Evict only this entry on rejection: a newer entry that replaced it must survive. The
82
+ // caller's await surfaces the rejection itself, so this side handler swallows nothing.
83
+ entry.token.catch(() => {
84
+ if (cache.get(creds.installationId) === entry)
85
+ cache.delete(creds.installationId);
86
+ });
87
+ return entry.token;
80
88
  };
81
89
  }
82
90
  /** The shared installation-token cache, one instance per Worker isolate. */
@@ -1 +1 @@
1
- export type CairnLogEvent = 'auth.link.requested' | 'auth.link.send_failed' | 'auth.token.minted' | 'auth.token.confirmed' | 'auth.session.created' | 'auth.session.destroyed' | 'commit.succeeded' | 'commit.failed' | 'guard.rejected';
1
+ export type CairnLogEvent = 'auth.link.requested' | 'auth.link.send_failed' | 'auth.token.minted' | 'auth.token.confirmed' | 'auth.session.created' | 'auth.session.destroyed' | 'commit.succeeded' | 'commit.failed' | 'entry.published' | 'entry.discarded' | 'publish.failed' | 'github.unreachable' | 'guard.rejected';
@@ -29,6 +29,12 @@ export interface LayoutData {
29
29
  collapsedNav: string[];
30
30
  /** The session's CSRF double-submit token, rendered as a hidden field in every admin form. */
31
31
  csrf: string;
32
+ /** Every entry with unpublished edits (a `cairn/` ref), for the topbar's publish-all action.
33
+ * Null when GitHub is unreachable, so the topbar hides the action rather than lying. */
34
+ pendingEntries: {
35
+ concept: string;
36
+ id: string;
37
+ }[] | null;
32
38
  }
33
39
  /** One row in a concept's list view. */
34
40
  export interface EntrySummary {
@@ -36,6 +42,8 @@ export interface EntrySummary {
36
42
  title: string;
37
43
  date: string | null;
38
44
  draft: boolean;
45
+ /** Publish state derived from the ref set: live as-is, live with pending edits, or branch-only. */
46
+ status: 'published' | 'edited' | 'new';
39
47
  }
40
48
  /** The concept list view's data. */
41
49
  export interface ListData {
@@ -48,6 +56,8 @@ export interface ListData {
48
56
  error: string | null;
49
57
  /** A create-form bounce error read from `?error`. */
50
58
  formError: string | null;
59
+ /** The entry count from a publish-all redirect (`?publishedAll=`), for the list page's flash. */
60
+ publishedAll: number | null;
51
61
  }
52
62
  /** The editor's data. `frontmatter` holds form-ready values (dates already `YYYY-MM-DD`). */
53
63
  export interface EditData {
@@ -69,6 +79,14 @@ export interface EditData {
69
79
  linkTargets: LinkTarget[];
70
80
  /** The entries that link to this one, for the delete guard. Empty when nothing links here. */
71
81
  inboundLinks: InboundLink[];
82
+ /** True when the entry has a pending branch, so the body above came from that branch. */
83
+ pending: boolean;
84
+ /** True when the entry file exists on the default branch (the live site shows it). */
85
+ published: boolean;
86
+ /** True after a publish redirect (`?published=1`), for the confirmation strip. */
87
+ publishedFlash: boolean;
88
+ /** True after a discard redirect (`?discarded=1`), for the confirmation strip. */
89
+ discardedFlash: boolean;
72
90
  }
73
91
  /** The structural event the content routes read; a real SvelteKit RequestEvent satisfies it. */
74
92
  export interface ContentEvent {
@@ -91,12 +109,15 @@ export interface ContentRoutesDeps {
91
109
  mintToken?: (env: GithubKeyEnv) => Promise<string>;
92
110
  }
93
111
  export declare function createContentRoutes(runtime: CairnRuntime, deps?: ContentRoutesDeps): {
94
- layoutLoad: (event: ContentEvent) => LayoutData;
112
+ layoutLoad: (event: ContentEvent) => Promise<LayoutData>;
95
113
  indexRedirect: () => never;
96
114
  listLoad: (event: ContentEvent) => Promise<ListData>;
97
115
  createAction: (event: ContentEvent) => Promise<never>;
98
116
  editLoad: (event: ContentEvent) => Promise<EditData>;
99
117
  saveAction: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;
118
+ publishAction: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;
119
+ publishAllAction: (event: ContentEvent) => Promise<never>;
120
+ discardAction: (event: ContentEvent) => Promise<never>;
100
121
  deleteAction: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;
101
122
  listDeleteAction: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;
102
123
  renameAction: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;