@rigel-cms/rigel 1.0.0 → 1.1.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 (156) hide show
  1. package/README.md +4 -0
  2. package/docs/extensions.md +194 -0
  3. package/package.json +15 -3
  4. package/src/article-body.ts +49 -37
  5. package/src/article-render.ts +25 -2
  6. package/src/article.ts +44 -0
  7. package/src/config.ts +12 -2
  8. package/src/editor/App.svelte +98 -97
  9. package/src/editor/blocks/extension.ts +29 -0
  10. package/src/editor/blocks/index.ts +17 -15
  11. package/src/editor/blocks/video.ts +2 -1
  12. package/src/editor/i18n/README.md +23 -0
  13. package/src/editor/i18n/de/connection.json +136 -0
  14. package/src/editor/i18n/de/defaults.json +6 -0
  15. package/src/editor/i18n/de/fields.json +130 -0
  16. package/src/editor/i18n/de/local.json +49 -0
  17. package/src/editor/i18n/de/media.json +166 -0
  18. package/src/editor/i18n/de/richtext.json +116 -0
  19. package/src/editor/i18n/de/shell.json +181 -0
  20. package/src/editor/i18n/de/system.json +14 -0
  21. package/src/editor/i18n/de/validation.json +82 -0
  22. package/src/editor/i18n/en/connection.json +136 -0
  23. package/src/editor/i18n/en/defaults.json +6 -0
  24. package/src/editor/i18n/en/fields.json +130 -0
  25. package/src/editor/i18n/en/local.json +49 -0
  26. package/src/editor/i18n/en/media.json +166 -0
  27. package/src/editor/i18n/en/richtext.json +116 -0
  28. package/src/editor/i18n/en/shell.json +181 -0
  29. package/src/editor/i18n/en/system.json +14 -0
  30. package/src/editor/i18n/en/validation.json +82 -0
  31. package/src/editor/i18n/es/connection.json +136 -0
  32. package/src/editor/i18n/es/defaults.json +6 -0
  33. package/src/editor/i18n/es/fields.json +130 -0
  34. package/src/editor/i18n/es/local.json +49 -0
  35. package/src/editor/i18n/es/media.json +166 -0
  36. package/src/editor/i18n/es/richtext.json +116 -0
  37. package/src/editor/i18n/es/shell.json +181 -0
  38. package/src/editor/i18n/es/system.json +14 -0
  39. package/src/editor/i18n/es/validation.json +82 -0
  40. package/src/editor/i18n/format.ts +26 -0
  41. package/src/editor/i18n/fr/connection.json +136 -0
  42. package/src/editor/i18n/fr/defaults.json +6 -0
  43. package/src/editor/i18n/fr/fields.json +130 -0
  44. package/src/editor/i18n/fr/local.json +49 -0
  45. package/src/editor/i18n/fr/media.json +166 -0
  46. package/src/editor/i18n/fr/richtext.json +116 -0
  47. package/src/editor/i18n/fr/shell.json +181 -0
  48. package/src/editor/i18n/fr/system.json +14 -0
  49. package/src/editor/i18n/fr/validation.json +82 -0
  50. package/src/editor/i18n/index.ts +18 -0
  51. package/src/editor/i18n/it/connection.json +136 -0
  52. package/src/editor/i18n/it/defaults.json +6 -0
  53. package/src/editor/i18n/it/fields.json +130 -0
  54. package/src/editor/i18n/it/local.json +49 -0
  55. package/src/editor/i18n/it/media.json +166 -0
  56. package/src/editor/i18n/it/richtext.json +116 -0
  57. package/src/editor/i18n/it/shell.json +181 -0
  58. package/src/editor/i18n/it/system.json +14 -0
  59. package/src/editor/i18n/it/validation.json +82 -0
  60. package/src/editor/i18n/ja/connection.json +136 -0
  61. package/src/editor/i18n/ja/defaults.json +6 -0
  62. package/src/editor/i18n/ja/fields.json +130 -0
  63. package/src/editor/i18n/ja/local.json +49 -0
  64. package/src/editor/i18n/ja/media.json +166 -0
  65. package/src/editor/i18n/ja/richtext.json +116 -0
  66. package/src/editor/i18n/ja/shell.json +181 -0
  67. package/src/editor/i18n/ja/system.json +14 -0
  68. package/src/editor/i18n/ja/validation.json +82 -0
  69. package/src/editor/i18n/locales.ts +13 -0
  70. package/src/editor/i18n/nl/connection.json +136 -0
  71. package/src/editor/i18n/nl/defaults.json +6 -0
  72. package/src/editor/i18n/nl/fields.json +130 -0
  73. package/src/editor/i18n/nl/local.json +49 -0
  74. package/src/editor/i18n/nl/media.json +166 -0
  75. package/src/editor/i18n/nl/richtext.json +116 -0
  76. package/src/editor/i18n/nl/shell.json +181 -0
  77. package/src/editor/i18n/nl/system.json +14 -0
  78. package/src/editor/i18n/nl/validation.json +82 -0
  79. package/src/editor/i18n/ru/connection.json +138 -0
  80. package/src/editor/i18n/ru/defaults.json +6 -0
  81. package/src/editor/i18n/ru/fields.json +132 -0
  82. package/src/editor/i18n/ru/local.json +49 -0
  83. package/src/editor/i18n/ru/media.json +166 -0
  84. package/src/editor/i18n/ru/richtext.json +116 -0
  85. package/src/editor/i18n/ru/shell.json +181 -0
  86. package/src/editor/i18n/ru/system.json +14 -0
  87. package/src/editor/i18n/ru/validation.json +82 -0
  88. package/src/editor/i18n/server.ts +13 -0
  89. package/src/editor/i18n/zh/connection.json +136 -0
  90. package/src/editor/i18n/zh/defaults.json +6 -0
  91. package/src/editor/i18n/zh/fields.json +130 -0
  92. package/src/editor/i18n/zh/local.json +49 -0
  93. package/src/editor/i18n/zh/media.json +166 -0
  94. package/src/editor/i18n/zh/richtext.json +116 -0
  95. package/src/editor/i18n/zh/shell.json +181 -0
  96. package/src/editor/i18n/zh/system.json +14 -0
  97. package/src/editor/i18n/zh/validation.json +82 -0
  98. package/src/editor/lib/ActionDialog.svelte +5 -4
  99. package/src/editor/lib/BlockEditor.svelte +305 -88
  100. package/src/editor/lib/CollectionPanel.svelte +17 -15
  101. package/src/editor/lib/DatePicker.svelte +15 -12
  102. package/src/editor/lib/ExtensionMount.svelte +12 -0
  103. package/src/editor/lib/Field.svelte +3 -2
  104. package/src/editor/lib/ImagePicker.svelte +82 -81
  105. package/src/editor/lib/MarkdownEditor.svelte +25 -24
  106. package/src/editor/lib/MediaPicker.svelte +6 -5
  107. package/src/editor/lib/MenuEditor.svelte +31 -30
  108. package/src/editor/lib/PreviewFrame.svelte +11 -6
  109. package/src/editor/lib/PropertiesTabs.svelte +3 -2
  110. package/src/editor/lib/PublishDialog.svelte +15 -14
  111. package/src/editor/lib/ReferenceSelect.svelte +3 -2
  112. package/src/editor/lib/SchemaField.svelte +15 -13
  113. package/src/editor/lib/SchemaPanel.svelte +20 -19
  114. package/src/editor/lib/Select.svelte +3 -2
  115. package/src/editor/lib/SeoGuidance.svelte +6 -5
  116. package/src/editor/lib/Settings.svelte +54 -54
  117. package/src/editor/lib/Shortcuts.svelte +7 -6
  118. package/src/editor/lib/SitePreview.svelte +10 -9
  119. package/src/editor/lib/TopBar.svelte +18 -17
  120. package/src/editor/lib/UnsavedChanges.svelte +7 -6
  121. package/src/editor/lib/VisualEditor.svelte +14 -13
  122. package/src/editor/lib/calendar.ts +10 -4
  123. package/src/editor/lib/embed-preview.ts +23 -5
  124. package/src/editor/lib/extension-preview.ts +121 -0
  125. package/src/editor/lib/github-auth.ts +23 -22
  126. package/src/editor/lib/github-backend.ts +96 -91
  127. package/src/editor/lib/images.ts +33 -31
  128. package/src/editor/lib/menu-tree.ts +5 -4
  129. package/src/editor/lib/preview-frame.ts +4 -3
  130. package/src/editor/lib/schema-edit.ts +3 -2
  131. package/src/editor/lib/settings.ts +5 -3
  132. package/src/editor/lib/slash-command.ts +1 -0
  133. package/src/editor/lib/tooltip.ts +1 -1
  134. package/src/editor/main.ts +9 -0
  135. package/src/editor/styles.css +6 -3
  136. package/src/env.d.ts +11 -1
  137. package/src/extensions.ts +160 -0
  138. package/src/manifest/collections.ts +6 -5
  139. package/src/manifest/menu.ts +12 -10
  140. package/src/manifest/navigation.ts +9 -8
  141. package/src/manifest/references.ts +8 -7
  142. package/src/manifest/seo.ts +4 -3
  143. package/src/manifest/types.ts +1 -0
  144. package/src/preview.ts +22 -2
  145. package/src/routes/local-api.ts +5 -3
  146. package/src/routes/local-documents.ts +20 -16
  147. package/src/routes/local-media.ts +21 -16
  148. package/src/routes/local-menu.ts +9 -7
  149. package/src/routes/local-post.ts +18 -12
  150. package/src/routes/media-files.ts +10 -8
  151. package/src/routes/rigel-page.astro +2 -1
  152. package/src/schema/store.ts +22 -20
  153. package/src/schema/types.ts +1 -0
  154. package/src/schema/validate.ts +73 -69
  155. package/src/slugs.ts +11 -10
  156. package/src/vite/manifest-plugin.ts +33 -1
@@ -1,3 +1,6 @@
1
+ import { translate } from "../i18n";
2
+ import extensions from "virtual:rigel-extensions";
3
+ import { resolveExtensions, validateExtensionBlocks } from "../../extensions";
1
4
  import type { Manifest } from "../../manifest/types";
2
5
  import { serializeContentJson } from "../../json-output";
3
6
  import type { Selection } from "../../manifest/navigation";
@@ -23,25 +26,27 @@ class GitError extends Error {
23
26
  constructor(message: string, public status: number) { super(message); }
24
27
  }
25
28
 
29
+ export class PublishReviewChangedError extends Error {}
30
+
26
31
  function safePath(path: string, empty = false): string {
27
- if (typeof path !== "string" || (!path && !empty) || (path && !path.split("/").every(part => /^[A-Za-z0-9_-][A-Za-z0-9_. -]*$/.test(part)))) throw new Error("Unsafe GitHub content path");
32
+ if (typeof path !== "string" || (!path && !empty) || (path && !path.split("/").every(part => /^[A-Za-z0-9_-][A-Za-z0-9_. -]*$/.test(part)))) throw new Error(translate("connection.unsafePath"));
28
33
  return path;
29
34
  }
30
35
 
31
36
  function branch(name: string): string {
32
- if (typeof name !== "string" || !/^[A-Za-z0-9_-][A-Za-z0-9_./-]*$/.test(name) || name.includes("..") || name.includes("//") || name.endsWith("/") || name.endsWith(".") || name.split("/").some(part => part.startsWith(".") || part.endsWith(".lock"))) throw new Error("Invalid GitHub branch");
37
+ if (typeof name !== "string" || !/^[A-Za-z0-9_-][A-Za-z0-9_./-]*$/.test(name) || name.includes("..") || name.includes("//") || name.endsWith("/") || name.endsWith(".") || name.split("/").some(part => part.startsWith(".") || part.endsWith(".lock"))) throw new Error(translate("connection.invalidBranch"));
33
38
  return name;
34
39
  }
35
40
 
36
41
  function config(ctx: GitContext, manifest: Manifest) {
37
42
  const cfg = manifest.github;
38
- if (!cfg || !/^[A-Za-z0-9_-]+\/[A-Za-z0-9_.-]+$/.test(ctx.repo) || ctx.repo !== cfg.repo || !ctx.token.trim()) throw new Error("Connect the configured GitHub repository first");
43
+ if (!cfg || !/^[A-Za-z0-9_-]+\/[A-Za-z0-9_.-]+$/.test(ctx.repo) || ctx.repo !== cfg.repo || !ctx.token.trim()) throw new Error(translate("connection.connectFirst"));
39
44
  branch(ctx.branch); branch(cfg.publishBranch);
40
- if (ctx.branch !== (cfg.branch || "drafts") || ctx.branch === cfg.publishBranch || ctx.branch === "main" || ctx.branch === "master") throw new Error("GitHub content changes require a dedicated drafts branch, never the publishing branch");
45
+ if (ctx.branch !== (cfg.branch || "drafts") || ctx.branch === cfg.publishBranch || ctx.branch === "main" || ctx.branch === "master") throw new Error(translate("connection.dedicatedDraftBranch"));
41
46
  safePath(cfg.repoRoot, true); safePath(cfg.copyDir);
42
47
  const folders = [cfg.copyDir, ...cfg.collections.map(c => c.folder), ...manifest.schema?.documents.map(d => d.folder) ?? [], ...(cfg.menus ? [cfg.menus.folder] : [])];
43
48
  folders.forEach(folder => safePath(folder));
44
- if (folders.some((folder, index) => folders.some((other, j) => j !== index && (folder === other || folder.startsWith(other + "/"))))) throw new Error("GitHub content directories must not overlap");
49
+ if (folders.some((folder, index) => folders.some((other, j) => j !== index && (folder === other || folder.startsWith(other + "/"))))) throw new Error(translate("connection.overlappingDirectories"));
45
50
  if (manifest.schema) validateSchema(manifest.schema);
46
51
  return cfg;
47
52
  }
@@ -49,7 +54,7 @@ function config(ctx: GitContext, manifest: Manifest) {
49
54
  function target(ctx: GitContext, manifest: Manifest) { return JSON.stringify([ctx.repo, ctx.branch, manifest.github, manifest.schema]); }
50
55
  function filePath(ctx: GitContext, manifest: Manifest, folder: string, slug: string, extension = "json", pattern = SLUG) {
51
56
  const cfg = config(ctx, manifest);
52
- if (!pattern.test(slug)) throw new Error("Invalid content filename");
57
+ if (!pattern.test(slug)) throw new Error(translate("connection.invalidFilename"));
53
58
  return [cfg.repoRoot, safePath(folder), `${slug}.${extension}`].filter(Boolean).join("/");
54
59
  }
55
60
 
@@ -59,17 +64,17 @@ async function gh(ctx: GitContext, path: string, init: RequestInit = {}): Promis
59
64
  response = await fetch(`https://api.github.com/repos/${ctx.repo}${path}`, {
60
65
  ...init, headers: {Authorization: `Bearer ${ctx.token}`, Accept: "application/vnd.github+json", "X-GitHub-Api-Version":"2022-11-28", "Content-Type":"application/json"}, redirect:"error", cache:"no-store", credentials:"omit", signal:AbortSignal.timeout(15000),
61
66
  });
62
- } catch { throw new Error("GitHub request failed; check your connection and reconnect"); }
67
+ } catch { throw new Error(translate("connection.requestFailed")); }
63
68
  if (!response.ok) {
64
69
  const rateLimited = response.status === 429 || response.headers.get("x-ratelimit-remaining") === "0" || response.headers.has("retry-after");
65
- const action = rateLimited ? "GitHub rate limit reached; wait before retrying" : ({401:"GitHub session expired; reconnect",403:"GitHub access denied; check repository installation permissions",404:"GitHub repository, branch or file unavailable; check access and reload",409:"GitHub content conflict; reload before saving",422:"GitHub rejected this change; the path or branch may already exist; reload"} as Record<number,string>)[response.status] ?? "GitHub request failed; retry after checking repository access";
66
- throw new GitError(`${action} (${response.status})`, response.status);
70
+ const key = rateLimited ? "connection.requestRateLimited" : ({401:"connection.requestSessionExpired",403:"connection.requestAccessDenied",404:"connection.requestUnavailable",409:"connection.requestConflict",422:"connection.requestRejected"} as Record<number,string>)[response.status] ?? "connection.requestError";
71
+ throw new GitError(translate(key, { status: response.status }), response.status);
67
72
  }
68
- try { return await response.json(); } catch { throw new Error("Malformed GitHub API response"); }
73
+ try { return await response.json(); } catch { throw new Error(translate("connection.malformedResponse")); }
69
74
  }
70
75
 
71
76
  function sha(value: unknown): string {
72
- if (typeof value !== "string" || !SHA.test(value)) throw new Error("Malformed GitHub SHA");
77
+ if (typeof value !== "string" || !SHA.test(value)) throw new Error(translate("connection.malformedSha"));
73
78
  return value;
74
79
  }
75
80
 
@@ -85,39 +90,39 @@ async function draftHead(ctx: GitContext, manifest: Manifest, create = true): Pr
85
90
  }
86
91
 
87
92
  function decode(payload: any): string {
88
- if (!payload || payload.encoding !== "base64" || typeof payload.content !== "string") throw new Error("Malformed GitHub content payload");
93
+ if (!payload || payload.encoding !== "base64" || typeof payload.content !== "string") throw new Error(translate("connection.malformedPayload"));
89
94
  const base64 = payload.content.replace(/[\r\n]/g, "");
90
- if (base64.length > Math.ceil(MAX_BYTES / 3) * 4 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(base64)) throw new Error("Invalid or oversized GitHub content payload");
95
+ if (base64.length > Math.ceil(MAX_BYTES / 3) * 4 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(base64)) throw new Error(translate("connection.invalidPayload"));
91
96
  try {
92
97
  const bytes = Uint8Array.from(atob(base64), char => char.charCodeAt(0));
93
98
  if (bytes.length > MAX_BYTES) throw new Error();
94
99
  return new TextDecoder("utf-8", {fatal:true}).decode(bytes);
95
- } catch { throw new Error("Invalid UTF-8 GitHub content payload"); }
100
+ } catch { throw new Error(translate("connection.invalidUtf8")); }
96
101
  }
97
102
 
98
103
  function encode(text: string): string {
99
104
  const bytes = new TextEncoder().encode(text);
100
- if (bytes.length > MAX_BYTES) throw new Error("Content exceeds the 1 MiB limit");
105
+ if (bytes.length > MAX_BYTES) throw new Error(translate("connection.contentTooLarge"));
101
106
  let binary = "";
102
107
  for (const byte of bytes) binary += String.fromCharCode(byte);
103
108
  return btoa(binary);
104
109
  }
105
110
 
106
111
  function parseJson(text: string): unknown {
107
- try { return JSON.parse(text); } catch { throw new Error("Invalid remote content JSON"); }
112
+ try { return JSON.parse(text); } catch { throw new Error(translate("connection.invalidRemoteJson")); }
108
113
  }
109
114
 
110
115
  export async function loadManifestRemote(ctx: GitContext, blueprint: Manifest, createDraft = true): Promise<Manifest> {
111
116
  const cfg = config(ctx, blueprint);
112
117
  const head = await draftHead(ctx, blueprint, createDraft);
113
118
  const tree = await gh(ctx, `/git/trees/${head}?recursive=1`);
114
- if (!tree || tree.truncated !== false || !Array.isArray(tree.tree)) throw new Error("Incomplete GitHub tree; content cannot be loaded safely");
119
+ if (!tree || tree.truncated !== false || !Array.isArray(tree.tree)) throw new Error(translate("connection.incompleteTree"));
115
120
  const manifest = structuredClone(blueprint);
116
121
  const snapshot: Snapshot = {target:target(ctx,manifest),head,writes:Promise.resolve(),files:new Map(),paths:new Set(),pending:new Map()};
117
122
  const entries = new Map<string, any>();
118
123
  for (const entry of tree.tree) {
119
- if (!entry || typeof entry.path !== "string" || entry.path.split("/").some((part: string) => !part || part === "." || part === "..") || /[\\\u0000-\u001f]/.test(entry.path) || !["blob","tree","commit"].includes(entry.type) || !SHA.test(entry.sha) || !["100644","100755","120000","040000","160000"].includes(entry.mode)) throw new Error("Malformed GitHub tree entry");
120
- if (entries.has(entry.path)) throw new Error("Duplicate GitHub tree path");
124
+ if (!entry || typeof entry.path !== "string" || entry.path.split("/").some((part: string) => !part || part === "." || part === "..") || /[\\\u0000-\u001f]/.test(entry.path) || !["blob","tree","commit"].includes(entry.type) || !SHA.test(entry.sha) || !["100644","100755","120000","040000","160000"].includes(entry.mode)) throw new Error(translate("connection.malformedTreeEntry"));
125
+ if (entries.has(entry.path)) throw new Error(translate("connection.duplicateTreePath"));
121
126
  entries.set(entry.path, entry); snapshot.paths.add(entry.path);
122
127
  }
123
128
  const folders = [cfg.copyDir, ...cfg.collections.map(c => c.folder), ...(manifest.schema?.documents.map(d => d.folder) ?? []), ...(cfg.menus ? [cfg.menus.folder] : [])];
@@ -125,57 +130,57 @@ export async function loadManifestRemote(ctx: GitContext, blueprint: Manifest, c
125
130
  const parts = [cfg.repoRoot,folder].filter(Boolean).join("/").split("/");
126
131
  for (let count = 1; count <= parts.length; count++) {
127
132
  const entry = entries.get(parts.slice(0,count).join("/"));
128
- if (entry && (entry.type !== "tree" || entry.mode !== "040000")) throw new Error("Content directory must be a real GitHub directory");
133
+ if (entry && (entry.type !== "tree" || entry.mode !== "040000")) throw new Error(translate("connection.invalidDirectory"));
129
134
  }
130
135
  }
131
136
  const read = async (path: string): Promise<string> => {
132
137
  const entry = entries.get(path);
133
- if (!entry || entry.type !== "blob" || !["100644","100755"].includes(entry.mode)) throw new Error("Content must be an existing regular GitHub file");
138
+ if (!entry || entry.type !== "blob" || !["100644","100755"].includes(entry.mode)) throw new Error(translate("connection.invalidFile"));
134
139
  const expected = sha(entry.sha);
135
140
  const blob = await gh(ctx, `/git/blobs/${expected}`);
136
- if (sha(blob?.sha) !== expected) throw new Error("GitHub blob SHA mismatch");
141
+ if (sha(blob?.sha) !== expected) throw new Error(translate("connection.blobShaMismatch"));
137
142
  const text = decode(blob); snapshot.files.set(path,{sha:expected,text,mode:entry.mode}); return text;
138
143
  };
139
144
  const list = (folder: string, pattern = SLUG) => {
140
145
  const prefix = [cfg.repoRoot,folder].filter(Boolean).join("/") + "/";
141
146
  return [...entries.keys()].filter(path => path.startsWith(prefix) && !path.slice(prefix.length).includes("/") && path.endsWith(".json")).sort().map(path => {
142
147
  const slug = path.slice(prefix.length,-5);
143
- if (!pattern.test(slug)) throw new Error("Invalid remote content filename");
148
+ if (!pattern.test(slug)) throw new Error(translate("connection.invalidRemoteFilename"));
144
149
  return {path,slug};
145
150
  });
146
151
  };
147
152
  for (const page of manifest.pages) {
148
153
  const path = filePath(ctx,manifest,cfg.copyDir,page.slug,"ts");
149
154
  const values = readValues(await read(path));
150
- if (!Object.values(values).every(isPlainObject)) throw new Error("Copy groups must contain value objects");
155
+ if (!Object.values(values).every(isPlainObject)) throw new Error(translate("connection.invalidCopyGroups"));
151
156
  page.values = values as typeof page.values;
152
157
  }
153
158
  manifest.documents = [];
154
159
  for (const type of manifest.schema?.documents ?? []) for (const file of list(type.folder)) {
155
160
  const data = parseJson(await read(file.path));
156
- if (!isPlainObject(data)) throw new Error("Remote document must be an object");
161
+ if (!isPlainObject(data)) throw new Error(translate("connection.invalidDocument"));
157
162
  manifest.documents.push({id:`${type.name}:${file.slug}`,type:type.name,slug:file.slug,data});
158
163
  }
159
- documentIndex(manifest.documents);
160
- for (const doc of manifest.documents) validateDocument(manifest.schema!,doc.type,doc.slug,doc.data,manifest.documents);
161
- if (manifest.schema) validateDocumentSlugs(manifest.schema, manifest.documents, manifest.preview);
164
+ documentIndex(manifest.documents, translate);
165
+ for (const doc of manifest.documents) validateDocument(manifest.schema!,doc.type,doc.slug,doc.data,manifest.documents,translate);
166
+ if (manifest.schema) validateDocumentSlugs(manifest.schema, manifest.documents, manifest.preview, translate);
162
167
  for (const collection of manifest.collections ?? []) {
163
168
  const folder = cfg.collections.find(c => c.name === collection.name)?.folder;
164
- if (!folder) throw new Error("Missing configured GitHub collection");
169
+ if (!folder) throw new Error(translate("connection.missingCollection"));
165
170
  collection.posts = [];
166
171
  for (const file of list(folder,POST_SLUG)) {
167
172
  const post = parseJson(await read(file.path));
168
- if (!isPlainObject(post) || !isPlainObject(post.frontmatter)) throw new Error("Invalid remote article");
169
- validateBody(post.body); validateCollectionValues(collection.fields,post.frontmatter,manifest.documents);
173
+ if (!isPlainObject(post) || !isPlainObject(post.frontmatter)) throw new Error(translate("connection.invalidArticle"));
174
+ validateBody(post.body,translate); validateCollectionValues(collection.fields,post.frontmatter,manifest.documents,translate);
170
175
  collection.posts.push({slug:file.slug,frontmatter:post.frontmatter,body:post.body});
171
176
  }
172
- assertUniqueUrlSlugs(collection.posts.map(post => ({ slug: post.slug, values: post.frontmatter })));
177
+ assertUniqueUrlSlugs(collection.posts.map(post => ({ slug: post.slug, values: post.frontmatter })), translate);
173
178
  }
174
179
  if (cfg.menus) {
175
180
  manifest.menus = {maxDepth:cfg.menus.maxDepth,entries:[]};
176
181
  for (const file of list(cfg.menus.folder)) {
177
- const menu = validateMenu(parseJson(await read(file.path)),cfg.menus.maxDepth);
178
- if (menu.slug !== file.slug) throw new Error("Remote menu slug does not match filename");
182
+ const menu = validateMenu(parseJson(await read(file.path)),cfg.menus.maxDepth,translate);
183
+ if (menu.slug !== file.slug) throw new Error(translate("connection.menuSlugMismatch"));
179
184
  manifest.menus.entries.push(menu);
180
185
  }
181
186
  }
@@ -186,7 +191,7 @@ export async function loadManifestRemote(ctx: GitContext, blueprint: Manifest, c
186
191
  function loaded(ctx: GitContext, manifest: Manifest): Snapshot {
187
192
  config(ctx,manifest);
188
193
  const snapshot = (manifest as Manifest & { [REMOTE_SNAPSHOT]?: Snapshot })[REMOTE_SNAPSHOT];
189
- if (!snapshot || snapshot.target !== target(ctx,manifest)) throw new Error("Reload draft content before saving to this repository");
194
+ if (!snapshot || snapshot.target !== target(ctx,manifest)) throw new Error(translate("connection.reloadDraft"));
190
195
  return snapshot;
191
196
  }
192
197
 
@@ -194,14 +199,14 @@ async function mutate(ctx: GitContext, manifest: Manifest, path: string, text: s
194
199
  const snapshot = loaded(ctx,manifest);
195
200
  const head = sha((await gh(ctx, `/git/ref/heads/${encodeURIComponent(ctx.branch)}`))?.object?.sha);
196
201
  const uncertain = snapshot.pending.get(path);
197
- if (head !== snapshot.head && head !== uncertain?.head) throw new Error("GitHub draft branch conflict; reload before saving. Your unsaved edits are still here.");
202
+ if (head !== snapshot.head && head !== uncertain?.head) throw new Error(translate("connection.draftConflictUnsaved"));
198
203
  const endpoint = `/contents/${path.split("/").map(encodeURIComponent).join("/")}`;
199
204
  const verify = async (pending: PendingWrite) => {
200
205
  const current = sha((await gh(ctx, `/git/ref/heads/${encodeURIComponent(ctx.branch)}`))?.object?.sha);
201
206
  if (current !== pending.head) {
202
- if (current !== snapshot.head) throw new Error("GitHub draft branch conflict; reload before saving.");
207
+ if (current !== snapshot.head) throw new Error(translate("connection.draftConflict"));
203
208
  const result = await gh(ctx, `/git/refs/heads/${encodeURIComponent(ctx.branch)}`, { method: "PATCH", body: JSON.stringify({ sha: pending.head, force: false }) });
204
- if (sha(result?.object?.sha) !== pending.head) throw new Error("GitHub branch update could not be verified; retry or reload.");
209
+ if (sha(result?.object?.sha) !== pending.head) throw new Error(translate("connection.branchUpdateUnverified"));
205
210
  }
206
211
  let verified;
207
212
  try { verified = await gh(ctx,`${endpoint}?ref=${encodeURIComponent(ctx.branch)}`); }
@@ -209,10 +214,10 @@ async function mutate(ctx: GitContext, manifest: Manifest, path: string, text: s
209
214
  if (pending.text !== null || !(error instanceof GitError) || error.status !== 404) throw error;
210
215
  }
211
216
  if (pending.text === null) {
212
- if (verified !== undefined) throw new Error("GitHub deletion could not be verified; reload");
217
+ if (verified !== undefined) throw new Error(translate("connection.deletionUnverified"));
213
218
  snapshot.files.delete(path); snapshot.paths.delete(path);
214
219
  } else {
215
- if (verified?.type !== "file" || sha(verified.sha) !== pending.sha || decode(verified) !== pending.text) throw new Error("GitHub save changed before verification; reload");
220
+ if (verified?.type !== "file" || sha(verified.sha) !== pending.sha || decode(verified) !== pending.text) throw new Error(translate("connection.saveChanged"));
216
221
  snapshot.files.set(path,{sha:pending.sha,text:pending.text,mode:pending.mode}); snapshot.paths.add(path);
217
222
  }
218
223
  snapshot.head = pending.head;
@@ -224,7 +229,7 @@ async function mutate(ctx: GitContext, manifest: Manifest, path: string, text: s
224
229
  if (creating && uncertain.creating) creating = false;
225
230
  }
226
231
  const previous = snapshot.files.get(path);
227
- if (creating ? snapshot.paths.has(path) : !previous) throw new Error(creating ? "Content already exists; choose another slug" : "Reload this content before changing it");
232
+ if (creating ? snapshot.paths.has(path) : !previous) throw new Error(creating ? translate("connection.contentExists") : translate("connection.reloadContent"));
228
233
  const blob = text === null ? null : sha((await gh(ctx, "/git/blobs", { method: "POST", body: JSON.stringify({ content: encode(text), encoding: "base64" }) }))?.sha);
229
234
  const mode = previous?.mode ?? "100644";
230
235
  const base = sha((await gh(ctx, `/git/commits/${snapshot.head}`))?.tree?.sha);
@@ -237,7 +242,7 @@ async function mutate(ctx: GitContext, manifest: Manifest, path: string, text: s
237
242
 
238
243
  export async function saveRemoteSelection(...args: Parameters<typeof saveSelection>): Promise<void> {
239
244
  const key = writeKey(args[0]);
240
- if (publishing.has(key)) throw new Error("Wait for publishing to finish before saving.");
245
+ if (publishing.has(key)) throw new Error(translate("connection.waitBeforeSaving"));
241
246
  const snapshot = loaded(args[0], args[1]);
242
247
  saving.set(key, (saving.get(key) ?? 0) + 1);
243
248
  const result = snapshot.writes.then(() => saveSelection(...args));
@@ -248,48 +253,48 @@ export async function saveRemoteSelection(...args: Parameters<typeof saveSelecti
248
253
  async function saveSelection(ctx: GitContext, manifest: Manifest, selection: Selection, values: Record<string, unknown>, body: ArticleBody | "", creating: boolean): Promise<void> {
249
254
  const cfg = config(ctx,manifest);
250
255
  const snapshot = loaded(ctx,manifest);
251
- if (!isPlainObject(values)) throw new Error("Content values must be an object");
256
+ if (!isPlainObject(values)) throw new Error(translate("connection.invalidValues"));
252
257
  let path: string;
253
258
  let data: unknown;
254
259
  if (selection.kind === "page") {
255
- if (creating || !manifest.pages.some(page => page.slug === selection.slug)) throw new Error("Unknown copy page");
256
- if (!Object.values(values).every(isPlainObject)) throw new Error("Copy groups must contain value objects");
260
+ if (creating || !manifest.pages.some(page => page.slug === selection.slug)) throw new Error(translate("connection.unknownCopyPage"));
261
+ if (!Object.values(values).every(isPlainObject)) throw new Error(translate("connection.invalidCopyGroups"));
257
262
  path = filePath(ctx,manifest,cfg.copyDir,selection.slug,"ts");
258
263
  const source = snapshot.files.get(path)?.text;
259
- if (!source) throw new Error("Reload the copy page before saving");
264
+ if (!source) throw new Error(translate("connection.reloadCopyPage"));
260
265
  await mutate(ctx,manifest,path,applyValues(source,values),false); return;
261
266
  } else if (selection.kind === "post") {
262
267
  const folder = cfg.collections.find(c => c.name === selection.collection)?.folder;
263
268
  const collection = manifest.collections?.find(c => c.name === selection.collection);
264
- if (!folder || !collection) throw new Error("Unknown collection");
265
- validateBody(body); validateCollectionValues(collection.fields,values,manifest.documents ?? []);
269
+ if (!folder || !collection) throw new Error(translate("connection.unknownCollection"));
270
+ validateBody(body,translate); validateExtensionBlocks(body, resolveExtensions(extensions).blocks); validateCollectionValues(collection.fields,values,manifest.documents ?? [],translate);
266
271
  path = filePath(ctx,manifest,folder,selection.slug,"json",POST_SLUG);
267
272
  const frontmatter = { ...values };
268
- if (Object.hasOwn(frontmatter, "slug")) frontmatter.slug = normalizeUrlSlug(frontmatter.slug);
273
+ if (Object.hasOwn(frontmatter, "slug")) frontmatter.slug = normalizeUrlSlug(frontmatter.slug, translate);
269
274
  const prefix = filePath(ctx,manifest,folder,"placeholder","json",POST_SLUG).replace(/placeholder\.json$/, "");
270
275
  const others = [...snapshot.files].filter(([file]) => file.startsWith(prefix) && file !== path && file.endsWith(".json")).map(([file, value]) => ({ slug: file.slice(prefix.length, -5), values: (parseJson(value.text) as { frontmatter: Record<string, unknown> }).frontmatter }));
271
- assertUniqueUrlSlugs([...others, { slug: selection.slug, values: frontmatter }]);
276
+ assertUniqueUrlSlugs([...others, { slug: selection.slug, values: frontmatter }], translate);
272
277
  data = {frontmatter,body};
273
278
  } else if (selection.kind === "document") {
274
- if (!manifest.schema) throw new Error("No document schema configured");
279
+ if (!manifest.schema) throw new Error(translate("connection.missingSchema"));
275
280
  const document = validateDocumentId(manifest.schema,selection.id);
276
- if (!document || document.type.name !== selection.type) throw new Error("Unknown document type or slug");
277
- if (creating && (document.type.creatable === false || document.type.singleton)) throw new Error("Creating entries is disabled for this document type");
281
+ if (!document || document.type.name !== selection.type) throw new Error(translate("connection.unknownDocument"));
282
+ if (creating && (document.type.creatable === false || document.type.singleton)) throw new Error(translate("connection.creationDisabled"));
278
283
  const next = {...values,_id:selection.id,_type:selection.type} as Record<string, unknown>;
279
- if (!document.type.shared && Object.hasOwn(next, "slug")) next.slug = normalizeUrlSlug(next.slug);
284
+ if (!document.type.shared && Object.hasOwn(next, "slug")) next.slug = normalizeUrlSlug(next.slug, translate);
280
285
  data = next;
281
- validateDocument(manifest.schema,selection.type,document.slug,data as Record<string,unknown>,manifest.documents ?? []);
286
+ validateDocument(manifest.schema,selection.type,document.slug,data as Record<string,unknown>,manifest.documents ?? [],translate);
282
287
  path = filePath(ctx,manifest,document.type.folder,document.slug);
283
288
  const documents = manifest.schema.documents.flatMap(type => {
284
289
  const prefix = filePath(ctx,manifest,type.folder,"placeholder").replace(/placeholder\.json$/, "");
285
290
  return [...snapshot.files].filter(([file]) => file.startsWith(prefix) && file !== path && file.endsWith(".json")).map(([file, value]) => ({ id: `${type.name}:${file.slice(prefix.length,-5)}`, type: type.name, slug: file.slice(prefix.length,-5), data: parseJson(value.text) as Record<string, unknown> }));
286
291
  });
287
- validateDocumentSlugs(manifest.schema, [...documents, { id: selection.id, type: selection.type, slug: document.slug, data: next }], manifest.preview);
292
+ validateDocumentSlugs(manifest.schema, [...documents, { id: selection.id, type: selection.type, slug: document.slug, data: next }], manifest.preview, translate);
288
293
  } else if (selection.kind === "menu") {
289
- if (!cfg.menus || creating) throw new Error("Menus must be configured by the site developer");
290
- data = validateMenu({...values,slug:selection.slug},cfg.menus.maxDepth);
294
+ if (!cfg.menus || creating) throw new Error(translate("connection.menusNotConfigured"));
295
+ data = validateMenu({...values,slug:selection.slug},cfg.menus.maxDepth,translate);
291
296
  path = filePath(ctx,manifest,cfg.menus.folder,selection.slug);
292
- } else throw new Error("Select editable content before saving");
297
+ } else throw new Error(translate("connection.selectContent"));
293
298
  await mutate(ctx,manifest,path,serializeContentJson(data,cfg.jsonOutput),creating);
294
299
  }
295
300
 
@@ -307,12 +312,12 @@ async function publishHeads(ctx: GitContext, publishBranch: string, request: typ
307
312
 
308
313
  async function publishTree(ctx: GitContext, head: string, request: typeof gh) {
309
314
  const data = await request(ctx, `/git/trees/${head}?recursive=1`);
310
- if (data?.truncated !== false || !Array.isArray(data.tree)) throw new Error("Incomplete GitHub tree. Publishing is unavailable; review this change on GitHub.");
315
+ if (data?.truncated !== false || !Array.isArray(data.tree)) throw new Error(translate("connection.incompletePublishTree"));
311
316
  const files = new Map<string, string>();
312
317
  const paths = new Set<string>();
313
318
  for (const entry of data.tree) {
314
- if (!entry || typeof entry.path !== "string" || !entry.path || /[\u0000-\u001f\u007f\\\\]/.test(entry.path) || entry.path.split("/").some((p: string) => !p || p === "." || p === "..") || paths.has(entry.path) || !["blob", "tree", "commit"].includes(entry.type) || !["100644", "100755", "120000", "040000", "160000"].includes(entry.mode)) throw new Error("Malformed GitHub publish tree");
315
- if (!({ blob: ["100644", "100755", "120000"], tree: ["040000"], commit: ["160000"] } as Record<string, string[]>)[entry.type]?.includes(entry.mode)) throw new Error("Malformed GitHub publish tree mode");
319
+ if (!entry || typeof entry.path !== "string" || !entry.path || /[\u0000-\u001f\u007f\\\\]/.test(entry.path) || entry.path.split("/").some((p: string) => !p || p === "." || p === "..") || paths.has(entry.path) || !["blob", "tree", "commit"].includes(entry.type) || !["100644", "100755", "120000", "040000", "160000"].includes(entry.mode)) throw new Error(translate("connection.malformedPublishTree"));
320
+ if (!({ blob: ["100644", "100755", "120000"], tree: ["040000"], commit: ["160000"] } as Record<string, string[]>)[entry.type]?.includes(entry.mode)) throw new Error(translate("connection.malformedPublishTreeMode"));
316
321
  paths.add(entry.path);
317
322
  const id = sha(entry.sha);
318
323
  if (entry.type !== "tree") files.set(entry.path, `${entry.mode}:${entry.type}:${id}`);
@@ -324,37 +329,37 @@ export async function reviewRemotePublish(ctx: GitContext, manifest: Manifest, a
324
329
  const cfg = config(ctx, manifest);
325
330
  const request: typeof gh = (context, path, init) => { assertActive(); return gh(context, path, init); };
326
331
  const repository = await request(ctx, "");
327
- if (repository?.permissions?.push !== true) throw new Error("Publishing requires repository write access.");
328
- if (repository.allow_squash_merge !== true) throw new Error("Squash merging is disabled. Ask the repository owner to enable it before publishing.");
329
- if (repository.delete_branch_on_merge !== false) throw new Error("Ask the repository owner to disable automatic head-branch deletion before publishing, so the drafts branch is preserved.");
332
+ if (repository?.permissions?.push !== true) throw new Error(translate("connection.publishWriteRequired"));
333
+ if (repository.allow_squash_merge !== true) throw new Error(translate("connection.squashDisabled"));
334
+ if (repository.delete_branch_on_merge !== false) throw new Error(translate("connection.automaticDeletionEnabled"));
330
335
  const { head, base } = await publishHeads(ctx, cfg.publishBranch, request);
331
336
  const [source, destination] = await Promise.all([publishTree(ctx, head, request), publishTree(ctx, base, request)]);
332
337
  const changed = new Set([...source.keys(), ...destination.keys()].filter(path => source.get(path) !== destination.get(path)));
333
338
  const review: PublishReview = { repo: ctx.repo, branch: ctx.branch, publishBranch: cfg.publishBranch, head, base, files: [], needsSync: false };
334
339
  if (changed.size) {
335
340
  const comparison = await request(ctx, `/compare/${base}...${head}?per_page=1&page=1`);
336
- if (sha(comparison?.base_commit?.sha) !== base) throw new Error("GitHub comparison does not match the reviewed target.");
341
+ if (sha(comparison?.base_commit?.sha) !== base) throw new Error(translate("connection.comparisonTargetMismatch"));
337
342
  review.needsSync = sha(comparison?.merge_base_commit?.sha) !== base;
338
343
  if (review.needsSync) {
339
344
  review.files = [...changed].sort().map(filename => ({ filename, status: !source.has(filename) ? "removed" : !destination.has(filename) ? "added" : "modified" }));
340
345
  } else {
341
- if (!Array.isArray(comparison.files) || comparison.files.length >= 300) throw new Error("GitHub comparisons of 300 or more files are not supported. Publish this change on GitHub; no partial list is shown.");
346
+ if (!Array.isArray(comparison.files) || comparison.files.length >= 300) throw new Error(translate("connection.comparisonLimit"));
342
347
  const covered = new Set<string>();
343
348
  for (const file of comparison.files) {
344
- if (!file || typeof file.filename !== "string" || covered.has(file.filename) || !changed.has(file.filename) || !["added", "removed", "modified", "renamed"].includes(file.status)) throw new Error("Incomplete or unsupported GitHub file comparison. Publish this change on GitHub.");
349
+ if (!file || typeof file.filename !== "string" || covered.has(file.filename) || !changed.has(file.filename) || !["added", "removed", "modified", "renamed"].includes(file.status)) throw new Error(translate("connection.unsupportedComparison"));
345
350
  covered.add(file.filename);
346
351
  if (file.status === "renamed") {
347
- if (typeof file.previous_filename !== "string" || !changed.has(file.previous_filename) || covered.has(file.previous_filename)) throw new Error("Incomplete GitHub rename comparison");
352
+ if (typeof file.previous_filename !== "string" || !changed.has(file.previous_filename) || covered.has(file.previous_filename)) throw new Error(translate("connection.incompleteRename"));
348
353
  covered.add(file.previous_filename);
349
354
  }
350
355
  review.files.push({ filename: file.filename, status: file.status, ...(file.status === "renamed" ? { previous_filename: file.previous_filename } : {}) });
351
356
  }
352
- if (covered.size !== changed.size) throw new Error("Incomplete GitHub file comparison. Publishing is unavailable; no partial list is shown.");
357
+ if (covered.size !== changed.size) throw new Error(translate("connection.incompleteComparison"));
353
358
  }
354
- if (review.files.length >= 300) throw new Error("Publishing 300 or more files is not supported. Review and publish this change on GitHub.");
359
+ if (review.files.length >= 300) throw new Error(translate("connection.publishFileLimit"));
355
360
  }
356
361
  const current = await publishHeads(ctx, cfg.publishBranch, request);
357
- if (current.head !== head || current.base !== base) throw new Error("Branches changed during review. Review changes again before publishing.");
362
+ if (current.head !== head || current.base !== base) throw new PublishReviewChangedError(translate("connection.branchesChangedDuringReview"));
358
363
  assertActive();
359
364
  return review;
360
365
  }
@@ -365,18 +370,18 @@ const writeKey = (ctx: GitContext) => `${ctx.repo}:${ctx.branch}`;
365
370
 
366
371
  export async function confirmRemotePublish(ctx: GitContext, manifest: Manifest, review: PublishReview, assertActive: () => void = () => {}): Promise<"published" | "synced"> {
367
372
  const cfg = config(ctx, manifest), key = writeKey(ctx);
368
- if (publishing.has(key) || saving.get(key)) throw new Error("Wait for the current save or publish to finish.");
369
- if (review.repo !== ctx.repo || review.branch !== ctx.branch || review.publishBranch !== cfg.publishBranch || !review.files.length) throw new Error("Review saved changes before publishing.");
373
+ if (publishing.has(key) || saving.get(key)) throw new Error(translate("connection.waitForWrite"));
374
+ if (review.repo !== ctx.repo || review.branch !== ctx.branch || review.publishBranch !== cfg.publishBranch || !review.files.length) throw new Error(translate("connection.reviewFirst"));
370
375
  sha(review.head); sha(review.base);
371
376
  publishing.add(key);
372
377
  const request: typeof gh = (context, path, init) => { assertActive(); return gh(context, path, init); };
373
378
  const unchanged = async () => {
374
379
  const current = await publishHeads(ctx, cfg.publishBranch, request);
375
- if (current.head !== review.head || current.base !== review.base) throw new Error("Branches changed. Review changes again before publishing.");
380
+ if (current.head !== review.head || current.base !== review.base) throw new PublishReviewChangedError(translate("connection.branchesChanged"));
376
381
  };
377
382
  const matches = (pr: any) => pr?.head?.repo?.full_name?.toLowerCase() === ctx.repo.toLowerCase() && pr?.base?.repo?.full_name?.toLowerCase() === ctx.repo.toLowerCase() && pr.head.ref === ctx.branch && pr.base.ref === cfg.publishBranch;
378
383
  const validate = (pr: any) => {
379
- if (!matches(pr) || !Number.isSafeInteger(pr.number) || pr.number <= 0 || pr.head.sha !== review.head) throw new Error("Pull request no longer matches this review. Review changes again.");
384
+ if (!matches(pr) || !Number.isSafeInteger(pr.number) || pr.number <= 0 || pr.head.sha !== review.head) throw new PublishReviewChangedError(translate("connection.pullRequestMismatch"));
380
385
  return pr;
381
386
  };
382
387
  const merged = (pr: any) => {
@@ -389,12 +394,12 @@ export async function confirmRemotePublish(ctx: GitContext, manifest: Manifest,
389
394
  for (let page = 1; page <= 100; page++) {
390
395
  const query = new URLSearchParams({ state: "open", head: `${ctx.repo.split("/")[0]}:${ctx.branch}`, base: cfg.publishBranch, per_page: "100", page: String(page) });
391
396
  const list = await request(ctx, `/pulls?${query}`);
392
- if (!Array.isArray(list)) throw new Error("Incomplete GitHub pull request listing");
397
+ if (!Array.isArray(list)) throw new Error(translate("connection.incompletePullRequests"));
393
398
  found.push(...list.filter(matches));
394
- if (found.length > 1) throw new Error("Multiple matching pull requests. Resolve them on GitHub before publishing.");
399
+ if (found.length > 1) throw new Error(translate("connection.multiplePullRequests"));
395
400
  if (list.length < 100) return found[0];
396
401
  }
397
- throw new Error("Incomplete GitHub pull request listing; publishing stopped.");
402
+ throw new Error(translate("connection.pullRequestListingStopped"));
398
403
  };
399
404
  try {
400
405
  if (review.prNumber) {
@@ -403,10 +408,10 @@ export async function confirmRemotePublish(ctx: GitContext, manifest: Manifest,
403
408
  }
404
409
  await unchanged();
405
410
  const repository = await request(ctx, "");
406
- if (repository?.permissions?.push !== true || repository.allow_squash_merge !== true || repository.delete_branch_on_merge !== false) throw new Error("Publishing settings or access changed. Enable squash merges, disable automatic branch deletion and verify write access, then review again.");
411
+ if (repository?.permissions?.push !== true || repository.allow_squash_merge !== true || repository.delete_branch_on_merge !== false) throw new PublishReviewChangedError(translate("connection.publishSettingsChanged"));
407
412
  let pr = review.prNumber ? await request(ctx, `/pulls/${review.prNumber}`) : await find();
408
413
  if (!pr) {
409
- if (review.creationUncertain) throw new Error("The previous pull request creation is unconfirmed. Check GitHub before retrying; no duplicate was created.");
414
+ if (review.creationUncertain) throw new Error(translate("connection.pullRequestCreationUnconfirmed"));
410
415
  await unchanged();
411
416
  review.creationUncertain = true;
412
417
  try {
@@ -420,43 +425,43 @@ export async function confirmRemotePublish(ctx: GitContext, manifest: Manifest,
420
425
  review.prNumber = pr.number;
421
426
  pr = validate(await request(ctx, `/pulls/${pr.number}`));
422
427
  if (merged(pr)) return "published";
423
- if (pr.state !== "open" || pr.draft || pr.base.sha !== review.base) throw new Error("Pull request or target changed. Review changes again before publishing.");
428
+ if (pr.state !== "open" || pr.draft || pr.base.sha !== review.base) throw new PublishReviewChangedError(translate("connection.pullRequestTargetChanged"));
424
429
  if (review.needsSync) {
425
430
  await unchanged();
426
431
  await request(ctx, `/pulls/${pr.number}/update-branch`, { method: "PUT", body: JSON.stringify({ expected_head_sha: review.head }) });
427
432
  const updated = await request(ctx, `/pulls/${pr.number}`);
428
- if (!matches(updated) || updated.state !== "open" || sha(updated.head.sha) === review.head) throw new Error("GitHub is still syncing drafts, or the sync could not be verified. Wait, reload draft content and review again. Nothing was published.");
433
+ if (!matches(updated) || updated.state !== "open" || sha(updated.head.sha) === review.head) throw new Error(translate("connection.syncUnverified"));
429
434
  return "synced";
430
435
  }
431
- if (pr.mergeable !== true || pr.mergeable_state !== "clean") throw new Error("GitHub has not allowed this merge. Resolve conflicts, required checks, reviews or branch protections on GitHub, then retry. No protection is bypassed.");
436
+ if (pr.mergeable !== true || pr.mergeable_state !== "clean") throw new Error(translate("connection.mergeBlocked"));
432
437
  await unchanged();
433
438
  let failure: unknown;
434
439
  try {
435
440
  const result = await request(ctx, `/pulls/${pr.number}/merge`, { method: "PUT", body: JSON.stringify({ merge_method: "squash", sha: review.head }) });
436
- if (result?.merged !== true) failure = new Error("GitHub did not merge the pull request. Check required checks, reviews and branch protections, then retry.");
441
+ if (result?.merged !== true) failure = new Error(translate("connection.mergeFailed"));
437
442
  } catch (error) { failure = error; }
438
443
  let verified;
439
444
  try { verified = await request(ctx, `/pulls/${pr.number}`); }
440
445
  catch (error) {
441
446
  if (error instanceof GitError && error.status === 401) throw error;
442
- throw new Error(`Publish outcome is unconfirmed. Check pull request #${pr.number} on GitHub, or retry to verify it before starting another publish.`);
447
+ throw new Error(translate("connection.publishOutcomeUnconfirmed", { number: pr.number }));
443
448
  }
444
449
  if (merged(verified)) return "published";
445
450
  if (failure) throw failure;
446
- throw new Error("The publish result could not be verified. Check this pull request on GitHub before retrying.");
451
+ throw new Error(translate("connection.publishUnverified"));
447
452
  } catch (error) {
448
- if (error instanceof GitError && [403, 404].includes(error.status)) throw new GitError("Publishing requires GitHub App Contents and Pull requests read/write permissions, installation-owner approval and repository write access. Reconnect after approval. Branch protections still apply.", error.status);
449
- if (error instanceof GitError && [405, 409, 422].includes(error.status)) throw new GitError("GitHub rejected publishing. Check conflicts, required checks/reviews, branch protections and squash settings. Review changes again if either branch moved.", error.status);
453
+ if (error instanceof GitError && [403, 404].includes(error.status)) throw new GitError(translate("connection.publishPermissions"), error.status);
454
+ if (error instanceof GitError && [405, 409, 422].includes(error.status)) throw new GitError(translate("connection.publishRejected"), error.status);
450
455
  throw error;
451
456
  } finally { publishing.delete(key); }
452
457
  }
453
458
 
454
459
  export async function deleteRemotePost(ctx: GitContext, manifest: Manifest, collection: string, slug: string): Promise<void> {
455
460
  const key = writeKey(ctx);
456
- if (publishing.has(key)) throw new Error("Wait for publishing to finish before deleting.");
461
+ if (publishing.has(key)) throw new Error(translate("connection.waitBeforeDeleting"));
457
462
  const cfg = config(ctx,manifest);
458
463
  const folder = cfg.collections.find(c => c.name === collection)?.folder;
459
- if (!folder || !manifest.collections?.some(c => c.name === collection)) throw new Error("Unknown collection");
464
+ if (!folder || !manifest.collections?.some(c => c.name === collection)) throw new Error(translate("connection.unknownCollection"));
460
465
  saving.set(key, (saving.get(key) ?? 0) + 1);
461
466
  try { await mutate(ctx,manifest,filePath(ctx,manifest,folder,slug,"json",POST_SLUG),null,false); }
462
467
  finally { saving.set(key, (saving.get(key) ?? 1) - 1); }