canopycms 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/CHANGELOG.md +188 -47
  2. package/dist/api/branch-review.d.ts +11 -1
  3. package/dist/api/branch-review.js +96 -18
  4. package/dist/api/branch-status.d.ts +26 -2
  5. package/dist/api/branch-status.js +38 -2
  6. package/dist/api/branch-withdraw.js +4 -7
  7. package/dist/api/branch.d.ts +2 -0
  8. package/dist/api/client.d.ts +3 -3
  9. package/dist/api/client.js +4 -4
  10. package/dist/api/comments.d.ts +2 -2
  11. package/dist/api/comments.js +4 -0
  12. package/dist/api/content.d.ts +2 -2
  13. package/dist/api/github-sync.d.ts +4 -0
  14. package/dist/api/github-sync.js +38 -6
  15. package/dist/api/pr-comment-sync.d.ts +18 -0
  16. package/dist/api/pr-comment-sync.js +77 -0
  17. package/dist/api/request-deadline.d.ts +15 -7
  18. package/dist/api/request-deadline.js +15 -7
  19. package/dist/api/schema.d.ts +16 -16
  20. package/dist/authorization/review-role.d.ts +33 -0
  21. package/dist/authorization/review-role.js +17 -0
  22. package/dist/branch-metadata-file.d.ts +3 -0
  23. package/dist/branch-metadata-file.js +1 -0
  24. package/dist/cli/cli.js +699 -130
  25. package/dist/cli/generate-ai-content.js +524 -59
  26. package/dist/cli/github-app-output.d.ts +34 -0
  27. package/dist/cli/github-app-output.js +85 -0
  28. package/dist/cli/init-github-app.d.ts +18 -0
  29. package/dist/cli/init-github-app.js +66 -58
  30. package/dist/cli/init.js +10 -1
  31. package/dist/comment-store.d.ts +5 -0
  32. package/dist/comment-store.js +24 -0
  33. package/dist/config/helpers.js +1 -3
  34. package/dist/config/schemas/collection.d.ts +2 -2
  35. package/dist/config/schemas/config.d.ts +3 -0
  36. package/dist/config/schemas/config.js +2 -1
  37. package/dist/config/schemas/url.d.ts +6 -0
  38. package/dist/config/schemas/url.js +16 -0
  39. package/dist/config/types.d.ts +12 -1
  40. package/dist/content-id-index.d.ts +9 -0
  41. package/dist/content-id-index.js +31 -0
  42. package/dist/editor/BranchManager.d.ts +3 -0
  43. package/dist/editor/BranchManager.js +75 -44
  44. package/dist/editor/CanopyEditor.js +2 -2
  45. package/dist/editor/CanopyEditorPage.js +3 -4
  46. package/dist/editor/Editor.js +47 -8
  47. package/dist/editor/branch-lock.d.ts +7 -0
  48. package/dist/editor/branch-lock.js +9 -2
  49. package/dist/editor/client-reference-resolver.d.ts +5 -3
  50. package/dist/editor/client-reference-resolver.js +10 -5
  51. package/dist/editor/components/BranchesLoadFailedNotice.d.ts +13 -0
  52. package/dist/editor/components/BranchesLoadFailedNotice.js +12 -0
  53. package/dist/editor/components/EditorHeader.d.ts +10 -4
  54. package/dist/editor/components/EditorHeader.js +108 -28
  55. package/dist/editor/components/EntryHeader.d.ts +5 -1
  56. package/dist/editor/components/EntryHeader.js +3 -1
  57. package/dist/editor/components/ReasonedMenuItem.d.ts +8 -0
  58. package/dist/editor/components/ReasonedMenuItem.js +2 -2
  59. package/dist/editor/components/ReviewDialogs.d.ts +19 -0
  60. package/dist/editor/components/ReviewDialogs.js +41 -0
  61. package/dist/editor/copy.d.ts +4 -0
  62. package/dist/editor/copy.js +4 -0
  63. package/dist/editor/fields/MarkdownField.js +1 -1
  64. package/dist/editor/hooks/create-branch-request.d.ts +6 -2
  65. package/dist/editor/hooks/create-branch-request.js +7 -2
  66. package/dist/editor/hooks/useBranchManager.d.ts +7 -2
  67. package/dist/editor/hooks/useBranchManager.js +252 -37
  68. package/dist/editor-url.d.ts +20 -0
  69. package/dist/editor-url.js +71 -0
  70. package/dist/git-manager.js +10 -6
  71. package/dist/github-service.d.ts +2 -2
  72. package/dist/github-service.js +4 -2
  73. package/dist/pr-comments.d.ts +33 -0
  74. package/dist/pr-comments.js +41 -0
  75. package/dist/schema/schema-store.d.ts +8 -8
  76. package/dist/services.d.ts +2 -17
  77. package/dist/services.js +38 -1
  78. package/dist/settings-workspace.d.ts +21 -7
  79. package/dist/settings-workspace.js +91 -16
  80. package/dist/submission-attribution.d.ts +38 -1
  81. package/dist/submission-attribution.js +197 -18
  82. package/dist/task-queue/cms-task-queue.d.ts +1 -1
  83. package/dist/types.d.ts +6 -0
  84. package/dist/utils/content-serialize.js +7 -6
  85. package/dist/utils/markdown-body-splice.d.ts +15 -10
  86. package/dist/utils/markdown-body-splice.js +71 -28
  87. package/dist/utils/markdown-export-options.d.ts +13 -0
  88. package/dist/utils/markdown-export-options.js +13 -0
  89. package/dist/utils/markdown-restyle.d.ts +58 -0
  90. package/dist/utils/markdown-restyle.js +482 -0
  91. package/dist/version.d.ts +1 -1
  92. package/dist/version.js +1 -1
  93. package/dist/worker/bundle-header.d.ts +13 -9
  94. package/dist/worker/bundle-header.js +17 -11
  95. package/dist/worker/git-process.d.ts +2 -2
  96. package/dist/worker/git-sync.js +5 -0
  97. package/dist/worker/github-gateway-http.js +3 -2
  98. package/dist/worker/github-gateway-wire.d.ts +3 -0
  99. package/dist/worker/github-gateway-wire.js +1 -0
  100. package/dist/worker/github-gateway.d.ts +1 -1
  101. package/dist/worker/github-transfer.js +72 -2
  102. package/dist/worker/task-runner.js +53 -3
  103. package/package.json +2 -2
  104. package/dist/editor/fields/markdown-export-options.d.ts +0 -2
  105. package/dist/editor/fields/markdown-export-options.js +0 -8
@@ -1,11 +1,18 @@
1
1
  import { personName } from './auth/person-name.js';
2
2
  import { getErrorMessage } from './utils/error.js';
3
3
  import { canopyLogWarn } from './utils/logger.js';
4
+ import { editorDeepLink } from './editor-url.js';
4
5
  const MAX_NAME_LENGTH = 80;
5
6
  const MAX_ID_LENGTH = 128;
6
7
  const MAX_EMAIL_LENGTH = 254;
7
8
  const MAX_DESCRIPTION_LENGTH = 10_000;
8
- const MAX_LISTED_ENTRIES = 100;
9
+ /** How many changed paths the PR body lists. */
10
+ export const MAX_LISTED_ENTRIES = 100;
11
+ /**
12
+ * The section's own text, its comment block aside. With the block's MAX_COMMENTS_LENGTH the
13
+ * section stays under 45,200, leaving over 20,000 of GitHub's 65,536 to the text a human adds.
14
+ */
15
+ const MAX_SECTION_LENGTH = 30_000;
9
16
  /**
10
17
  * The PR body region canopycms owns. On update only the text between these
11
18
  * markers is replaced, so anything a human wrote around it survives.
@@ -14,6 +21,14 @@ const MAX_LISTED_ENTRIES = 100;
14
21
  export const PR_SECTION_START = '<!-- canopycms:submission:start -->';
15
22
  /** @internal Exported for tests. */
16
23
  export const PR_SECTION_END = '<!-- canopycms:submission:end -->';
24
+ /**
25
+ * The editor-comment summary, nested in the section so a comment change can replace it alone:
26
+ * the rest of the section is built from the submit (mergeCommentsBlock).
27
+ * @internal Exported for tests.
28
+ */
29
+ export const PR_COMMENTS_START = '<!-- canopycms:comments:start -->';
30
+ /** @internal Exported for tests. */
31
+ export const PR_COMMENTS_END = '<!-- canopycms:comments:end -->';
17
32
  // Cc (C0/C1 controls incl. newlines), Cf (zero-width and bidi overrides), Zl/Zp
18
33
  // (Unicode line/paragraph separators) can forge a trailer line or hide text; Cs
19
34
  // (a lone surrogate) reaches git as U+FFFD, since Node encodes it so.
@@ -189,13 +204,99 @@ function codeSpan(value) {
189
204
  function neutralizeComments(text) {
190
205
  return text.replace(/<!--/g, '&lt;!--').replace(/--!?>/g, '--&gt;');
191
206
  }
207
+ /**
208
+ * A URL safe as a Markdown link destination: the characters that could end the destination,
209
+ * open a title or escape are percent-encoded, which a server reads as the same address.
210
+ * Brackets are legal in a destination and are an IPv6 host's syntax, so they stay.
211
+ */
212
+ function markdownHref(href) {
213
+ return href.replace(/[()<>\\\s'"`]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}`);
214
+ }
215
+ const PR_COMMENTS_NOTICE = 'Comments on this pull request do not reach the CanopyCMS editor, so the author will not see them.';
192
216
  function editorLabel(editor) {
193
217
  return editor.name
194
218
  ? `${codeSpan(editor.name)} (${codeSpan(editor.userId)})`
195
219
  : codeSpan(editor.userId);
196
220
  }
197
- /** The canopycms-owned PR body section, including its start and end markers. */
198
- export function buildPrSection(input) {
221
+ /** A Markdown-safe editor link for `branch` (and an entry), or undefined without a URL or branch. */
222
+ function editorLinker(branch, editorUrl) {
223
+ return (entryPath) => branch && editorUrl ? markdownHref(editorDeepLink(editorUrl, branch, entryPath)) : undefined;
224
+ }
225
+ const MAX_LISTED_THREADS = 50;
226
+ const MAX_EXCERPT_LENGTH = 80;
227
+ const MAX_COMMENTS_LENGTH = 15_000;
228
+ const LINE_BREAK = /\r\n?|\n|\u2028|\u2029/;
229
+ /**
230
+ * An entry path from comments.json, which is read without validation, if it is a plain
231
+ * logical path: the editor's `entry` param matches it exactly, so anything else links nowhere.
232
+ */
233
+ function threadEntryPath(raw) {
234
+ if (typeof raw !== 'string' || cleanPath(raw) !== raw)
235
+ return undefined;
236
+ return raw.split('/').every((s) => s !== '' && s !== '.' && s !== '..') ? raw : undefined;
237
+ }
238
+ /**
239
+ * One line per unresolved thread: where it is, the first line of its first comment and a link.
240
+ * The excerpt is a code span of cleaned text, so a comment cannot mention, reference an issue
241
+ * (or close one), autolink or carry HTML; it is visible to everyone who can read the PR.
242
+ */
243
+ function threadLine(thread, link) {
244
+ const entryPath = threadEntryPath(thread.entryPath);
245
+ const field = thread.type === 'field' && typeof thread.canopyPath === 'string'
246
+ ? cleanText(thread.canopyPath, MAX_NAME_LENGTH)
247
+ : undefined;
248
+ const where = entryPath
249
+ ? `${codeSpan(entryPath)}${field ? ` › ${codeSpan(field)}` : ''}`
250
+ : thread.type === 'branch'
251
+ ? 'Branch'
252
+ : 'An entry';
253
+ const [first] = Array.isArray(thread.comments) ? thread.comments : [];
254
+ const text = typeof first?.text === 'string' ? first.text.split(LINE_BREAK)[0] : undefined;
255
+ const excerpt = cleanText(text, MAX_EXCERPT_LENGTH);
256
+ const href = entryPath ? link(entryPath) : thread.type === 'branch' ? link() : undefined;
257
+ return [
258
+ `- ${where}`,
259
+ excerpt ? ` — ${codeSpan(excerpt)}` : '',
260
+ href ? ` ([open in editor](${href}))` : '',
261
+ ].join('');
262
+ }
263
+ /**
264
+ * The editor-comment summary between its markers: the unresolved threads, oldest first, or
265
+ * that every thread is resolved, or that there are none, or that they could not be read.
266
+ * comments.json is read without validation, so an entry that is not an object is skipped.
267
+ */
268
+ export function buildCommentsBlock(threads, branch, editorUrl) {
269
+ if (threads === null) {
270
+ return `${PR_COMMENTS_START}\nThe editor comments could not be read.\n${PR_COMMENTS_END}`;
271
+ }
272
+ const open = threads
273
+ .filter((t) => typeof t === 'object' && t !== null && !t.resolved)
274
+ .sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
275
+ let body;
276
+ if (open.length > 0) {
277
+ const listed = open.slice(0, MAX_LISTED_THREADS);
278
+ const linker = editorLinker(branch, editorUrl);
279
+ let lines = listed.map((t) => threadLine(t, linker));
280
+ // The PR body's 65,536-character limit is shared with the rest of the section: past the
281
+ // budget the links go, then lines from the end.
282
+ if (lines.join('\n').length > MAX_COMMENTS_LENGTH) {
283
+ lines = listed.map((t) => threadLine(t, () => undefined));
284
+ }
285
+ let shown = lines.length;
286
+ while (shown > 0 && lines.slice(0, shown).join('\n').length > MAX_COMMENTS_LENGTH)
287
+ shown--;
288
+ lines = lines.slice(0, shown);
289
+ if (open.length > shown)
290
+ lines.push(`- …and ${open.length - shown} more`);
291
+ const noun = open.length === 1 ? 'comment thread' : 'comment threads';
292
+ body = `**${open.length} unresolved editor ${noun}**\n\n${lines.join('\n')}`;
293
+ }
294
+ else {
295
+ body = threads.length > 0 ? 'All editor comments resolved.' : 'No editor comments.';
296
+ }
297
+ return `${PR_COMMENTS_START}\n${body}\n${PR_COMMENTS_END}`;
298
+ }
299
+ function renderPrSection(input, layout, commentsBlock) {
199
300
  const parts = [];
200
301
  const [submitter] = input.submitter ? cleanEditors([input.submitter]) : [];
201
302
  parts.push(submitter
@@ -205,23 +306,77 @@ export function buildPrSection(input) {
205
306
  if (others.length > 0) {
206
307
  parts.push(`Also edited by: ${others.map(editorLabel).join(', ')}`);
207
308
  }
208
- const paths = [...new Set(input.changedPaths)]
209
- .map(cleanPath)
210
- .filter((p) => p !== undefined);
309
+ const link = editorLinker(input.branch, input.editorUrl);
310
+ const branchLink = link();
311
+ if (branchLink)
312
+ parts.push(`[Open this branch in the editor](${branchLink})`);
313
+ parts.push(`> [!IMPORTANT]\n> ${PR_COMMENTS_NOTICE} ` +
314
+ (branchLink
315
+ ? `Comment in the [editor](${branchLink}) instead.`
316
+ : 'Comment in the editor instead.'));
317
+ const paths = changedPathsOf(input);
211
318
  if (paths.length > 0) {
212
- const listed = paths.slice(0, MAX_LISTED_ENTRIES).map((p) => `- ${codeSpan(p)}`);
213
- if (paths.length > MAX_LISTED_ENTRIES) {
214
- listed.push(`- …and ${paths.length - MAX_LISTED_ENTRIES} more`);
215
- }
319
+ const listed = paths.slice(0, layout.listed).map(({ raw, clean }) => {
320
+ const entryPath = layout.entryLinks ? input.entryPaths?.get(raw) : undefined;
321
+ const entryLink = entryPath === undefined ? undefined : link(entryPath);
322
+ return entryLink
323
+ ? `- ${codeSpan(clean)} ([open in editor](${entryLink}))`
324
+ : `- ${codeSpan(clean)}`;
325
+ });
326
+ if (paths.length > layout.listed)
327
+ listed.push(`- …and ${paths.length - layout.listed} more`);
216
328
  parts.push(`**Changed entries (${paths.length})**\n\n${listed.join('\n')}`);
217
329
  }
330
+ parts.push(commentsBlock);
218
331
  // Last, so an unclosed fence or `<details>` in free Markdown cannot swallow
219
332
  // the attribution above it.
220
- const description = input.description?.trim();
221
- if (description)
222
- parts.push(neutralizeComments(truncate(description, MAX_DESCRIPTION_LENGTH)));
333
+ if (layout.description)
334
+ parts.push(layout.description);
223
335
  return `${PR_SECTION_START}\n${parts.join('\n\n')}\n${PR_SECTION_END}`;
224
336
  }
337
+ function changedPathsOf(input) {
338
+ return [...new Set(input.changedPaths)]
339
+ .map((raw) => ({ raw, clean: cleanPath(raw) }))
340
+ .filter((p) => p.clean !== undefined);
341
+ }
342
+ /** `text` cut to at most `max` UTF-16 units, never inside a surrogate pair. */
343
+ function truncateUnits(text, max) {
344
+ if (text.length <= max)
345
+ return text;
346
+ if (max < 1)
347
+ return '';
348
+ const cut = text.slice(0, max - 1);
349
+ return `${/[\uD800-\uDBFF]$/.test(cut) ? cut.slice(0, -1) : cut}…`;
350
+ }
351
+ /**
352
+ * The canopycms-owned PR body section, including its start and end markers. GitHub refuses a
353
+ * body over 65,536 characters and the rest of the body is a human's, so a section whose text
354
+ * apart from the comment block is over MAX_SECTION_LENGTH loses its entry links, then the
355
+ * description's end, then entries from the end of the list. The attribution lines themselves
356
+ * are never cut. The comment block, rendered now or re-rendered later, has MAX_COMMENTS_LENGTH.
357
+ */
358
+ export function buildPrSection(input) {
359
+ const raw = input.description?.trim();
360
+ const description = raw ? neutralizeComments(truncate(raw, MAX_DESCRIPTION_LENGTH)) : '';
361
+ const block = buildCommentsBlock(input.threads === undefined ? [] : input.threads, input.branch, input.editorUrl);
362
+ const size = (l) => renderPrSection(input, l, block).length - block.length;
363
+ const layout = { entryLinks: true, listed: MAX_LISTED_ENTRIES, description };
364
+ const fits = (l) => size(l) <= MAX_SECTION_LENGTH;
365
+ if (fits(layout))
366
+ return renderPrSection(input, layout, block);
367
+ layout.entryLinks = false;
368
+ if (fits(layout))
369
+ return renderPrSection(input, layout, block);
370
+ // The description's separator is two newlines.
371
+ const room = MAX_SECTION_LENGTH - size({ ...layout, description: '' }) - 2;
372
+ if (room > 0) {
373
+ return renderPrSection(input, { ...layout, description: truncateUnits(description, room) }, block);
374
+ }
375
+ layout.description = '';
376
+ while (layout.listed > 0 && !fits(layout))
377
+ layout.listed = Math.max(0, layout.listed - 10);
378
+ return renderPrSection(input, layout, block);
379
+ }
225
380
  /**
226
381
  * `existing` with its canopycms section replaced by `section`, keeping every
227
382
  * character outside the markers. The section is the first end marker together
@@ -231,13 +386,37 @@ export function buildPrSection(input) {
231
386
  */
232
387
  export function mergePrSection(existing, section) {
233
388
  const body = existing ?? '';
389
+ const found = findPrSection(body);
390
+ if (found) {
391
+ return body.slice(0, found.start) + section + body.slice(found.end + PR_SECTION_END.length);
392
+ }
393
+ const human = body.split(PR_SECTION_START).join('').split(PR_SECTION_END).join('').trimEnd();
394
+ return human ? `${human}\n\n${section}` : section;
395
+ }
396
+ /** The index pair of the canopycms section in `body` (see mergePrSection), or undefined. */
397
+ function findPrSection(body) {
234
398
  for (let end = body.indexOf(PR_SECTION_END); end >= 0;) {
235
399
  const start = body.lastIndexOf(PR_SECTION_START, end - PR_SECTION_START.length);
236
- if (start >= 0) {
237
- return body.slice(0, start) + section + body.slice(end + PR_SECTION_END.length);
238
- }
400
+ if (start >= 0)
401
+ return { start, end };
239
402
  end = body.indexOf(PR_SECTION_END, end + PR_SECTION_END.length);
240
403
  }
241
- const human = body.split(PR_SECTION_START).join('').split(PR_SECTION_END).join('').trimEnd();
242
- return human ? `${human}\n\n${section}` : section;
404
+ return undefined;
405
+ }
406
+ /**
407
+ * `existing` with the comment block inside its canopycms section replaced by `block`, or
408
+ * undefined when the section or its block is missing (a PR body written before the block
409
+ * existed, or edited by hand): the next submit writes both. Only markers inside the section
410
+ * count, and the description in it cannot spell one (neutralizeComments).
411
+ */
412
+ export function mergeCommentsBlock(existing, block) {
413
+ const body = existing ?? '';
414
+ const section = findPrSection(body);
415
+ if (!section)
416
+ return undefined;
417
+ const start = body.indexOf(PR_COMMENTS_START, section.start);
418
+ const end = start < 0 ? -1 : body.indexOf(PR_COMMENTS_END, start);
419
+ if (start < 0 || end < 0 || end > section.end)
420
+ return undefined;
421
+ return body.slice(0, start) + block + body.slice(end + PR_COMMENTS_END.length);
243
422
  }
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import type { Task, TaskQueueLogger } from './types.js';
7
7
  /** Actions the EC2 worker can execute on behalf of Lambda. */
8
- export type TaskAction = 'push-and-create-pr' | 'push-and-update-pr' | 'push-and-create-or-update-pr' | 'convert-to-draft' | 'close-pr' | 'delete-remote-branch' | 'push-branch';
8
+ export type TaskAction = 'push-and-create-pr' | 'push-and-update-pr' | 'push-and-create-or-update-pr' | 'convert-to-draft' | 'close-pr' | 'delete-remote-branch' | 'push-branch' | 'sync-pr-comments';
9
9
  /** A task with a CMS-specific action. */
10
10
  export type WorkerTask = Task & {
11
11
  action: TaskAction;
package/dist/types.d.ts CHANGED
@@ -45,6 +45,12 @@ export interface BranchMetadata {
45
45
  * leaves no PR number. Absent on branches the CMS never pushed there.
46
46
  */
47
47
  pushedToGitHubAt?: string;
48
+ /**
49
+ * The latest editor URL a submit took from its request (editor-url.ts); a submit that took
50
+ * none, or used config.editorUrl, leaves it as it was. A comment change re-renders the PR's
51
+ * comment summary with config.editorUrl or this, so a commenter never supplies the links.
52
+ */
53
+ editorUrl?: string;
48
54
  /** Sync status for async GitHub operations (used when Lambda has no internet) */
49
55
  syncStatus?: SyncStatus;
50
56
  /** Whether any rebase of this branch kept its version of an entry base also changed */
@@ -519,11 +519,12 @@ function writesFrontmatter(data) {
519
519
  return Object.values(data).some((value) => value !== undefined);
520
520
  }
521
521
  /**
522
- * The body to write: `body`'s content in `existingRaw`'s text wherever they agree
523
- * (`preserveMarkdownSource`), opening with the blank line the frontmatter is followed by on disk.
524
- * gray-matter leaves that blank line in the body, and the editor's body does not carry it, so it
525
- * is restored here even when the splice falls back. A body under frontmatter the file did not
526
- * have, or under frontmatter with no body yet, gets one blank line, Prettier's style.
522
+ * The body to write: `body`'s content in `existingRaw`'s text wherever they agree, and in the
523
+ * export style elsewhere (`preserveMarkdownSource`), opening with the blank line the frontmatter
524
+ * is followed by on disk. gray-matter leaves that blank line in the body, and the editor's body
525
+ * does not carry it, so it is restored here even when the splice falls back. A body under
526
+ * frontmatter the file did not have, or under frontmatter with no body yet, gets one blank line,
527
+ * Prettier's style.
527
528
  *
528
529
  * With no frontmatter written, the body opens the file: it loses its leading blank lines, except
529
530
  * the one newline that stops a body starting with `---` (after any BOM, which gray-matter skips)
@@ -531,7 +532,7 @@ function writesFrontmatter(data) {
531
532
  */
532
533
  function bodyToWrite(body, data, format, existingRaw) {
533
534
  const priorBody = existingRaw === undefined ? undefined : extractBody(existingRaw);
534
- const spliced = priorBody === undefined ? body : preserveMarkdownSource(priorBody, body, format);
535
+ const spliced = preserveMarkdownSource(priorBody, body, format);
535
536
  if (!writesFrontmatter(data)) {
536
537
  const flush = spliced.replace(/^(?:\r?\n)+/, '');
537
538
  return /^\uFEFF?---/.test(flush) ? `\n${flush}` : flush;
@@ -1,10 +1,13 @@
1
1
  /**
2
2
  * Source-preserving save for md/mdx bodies.
3
3
  *
4
- * The editor sends a body re-serialised from its own document model, so every block comes back
5
- * in the serialiser's style (bullet and emphasis markers, rule style, escapes, no blank line
6
- * after the frontmatter) whether or not the user touched it. This writes the on-disk text of
7
- * every block whose MEANING the edit left alone, and the editor's text only where it changed:
4
+ * The rich editor sends a body re-serialised from its own document model, so every block comes
5
+ * back in the serialiser's style (bullet and emphasis markers, rule style, escapes, no blank line
6
+ * after the frontmatter) whether or not the user touched it; the source editor sends whatever was
7
+ * typed. This writes the on-disk text of every block whose MEANING the edit left alone, and the
8
+ * edit's text only where it changed. The edit's changed blocks are first restyled in place to
9
+ * `MARKDOWN_EXPORT_OPTIONS`'s markers (`markdown-restyle.ts`), so a changed block is written in
10
+ * that one style whichever editor mode produced it:
8
11
  *
9
12
  * - Top-level blocks are aligned by a position-free canonical form of their mdast, so a block
10
13
  * matches only when it parses to the same tree. A matched block is written from disk.
@@ -15,10 +18,10 @@
15
18
  * stands for the original it is paired with) is the disk's; elsewhere it is the editor's,
16
19
  * re-indented into the original's nesting and written in its line endings.
17
20
  *
18
- * The result is kept only if it parses to exactly the tree the editor's body parses to. A splice
19
- * that fails that check is retried without descending into lists, and failing that the editor's
20
- * body is written as sent: the worst case is the editor's own serialisation, never different
21
- * content.
21
+ * The result is kept only if it parses to exactly the tree the editor's body parses to, and so is
22
+ * the restyled edit. A splice that fails that check is retried without descending into lists,
23
+ * then both again from the edit as sent; failing all four, the restyled edit is written whole,
24
+ * or the edit as sent when it did not read back: the worst case is never different content.
22
25
  */
23
26
  export type MarkdownBodyFormat = 'md' | 'mdx';
24
27
  /**
@@ -34,6 +37,8 @@ export declare function markdownBlocks(text: string, format: MarkdownBodyFormat)
34
37
  }[] | undefined;
35
38
  /**
36
39
  * The body to write for an edit from `original` (on disk) to `updated` (the editor's body):
37
- * `updated`'s content, in `original`'s text wherever the two agree. See the module header.
40
+ * `updated`'s content, in `original`'s text wherever the two agree. With no `original` to keep
41
+ * text from (a new file, or a body on disk that does not parse), the whole edit is restyled.
42
+ * See the module header.
38
43
  */
39
- export declare function preserveMarkdownSource(original: string, updated: string, format: MarkdownBodyFormat): string;
44
+ export declare function preserveMarkdownSource(original: string | undefined, updated: string, format: MarkdownBodyFormat): string;
@@ -1,10 +1,13 @@
1
1
  /**
2
2
  * Source-preserving save for md/mdx bodies.
3
3
  *
4
- * The editor sends a body re-serialised from its own document model, so every block comes back
5
- * in the serialiser's style (bullet and emphasis markers, rule style, escapes, no blank line
6
- * after the frontmatter) whether or not the user touched it. This writes the on-disk text of
7
- * every block whose MEANING the edit left alone, and the editor's text only where it changed:
4
+ * The rich editor sends a body re-serialised from its own document model, so every block comes
5
+ * back in the serialiser's style (bullet and emphasis markers, rule style, escapes, no blank line
6
+ * after the frontmatter) whether or not the user touched it; the source editor sends whatever was
7
+ * typed. This writes the on-disk text of every block whose MEANING the edit left alone, and the
8
+ * edit's text only where it changed. The edit's changed blocks are first restyled in place to
9
+ * `MARKDOWN_EXPORT_OPTIONS`'s markers (`markdown-restyle.ts`), so a changed block is written in
10
+ * that one style whichever editor mode produced it:
8
11
  *
9
12
  * - Top-level blocks are aligned by a position-free canonical form of their mdast, so a block
10
13
  * matches only when it parses to the same tree. A matched block is written from disk.
@@ -15,10 +18,10 @@
15
18
  * stands for the original it is paired with) is the disk's; elsewhere it is the editor's,
16
19
  * re-indented into the original's nesting and written in its line endings.
17
20
  *
18
- * The result is kept only if it parses to exactly the tree the editor's body parses to. A splice
19
- * that fails that check is retried without descending into lists, and failing that the editor's
20
- * body is written as sent: the worst case is the editor's own serialisation, never different
21
- * content.
21
+ * The result is kept only if it parses to exactly the tree the editor's body parses to, and so is
22
+ * the restyled edit. A splice that fails that check is retried without descending into lists,
23
+ * then both again from the edit as sent; failing all four, the restyled edit is written whole,
24
+ * or the edit as sent when it did not read back: the worst case is never different content.
22
25
  */
23
26
  import { fromMarkdown } from 'mdast-util-from-markdown';
24
27
  import { gfmFromMarkdown } from 'mdast-util-gfm';
@@ -27,6 +30,7 @@ import { gfm } from 'micromark-extension-gfm';
27
30
  import { mdxjs } from 'micromark-extension-mdxjs';
28
31
  import { createDebugLogger } from './debug.js';
29
32
  import { getErrorMessage } from './error.js';
33
+ import { orderedNumber, restyleMarkdown } from './markdown-restyle.js';
30
34
  import { withSourceLineEndings } from './yaml-source-splice.js';
31
35
  const log = createDebugLogger({ prefix: 'MarkdownBodySplice' });
32
36
  /** Above this many LCS cells the middle of a sequence is treated as one changed run. */
@@ -352,10 +356,10 @@ function gapBetween(text, a, b) {
352
356
  }
353
357
  /**
354
358
  * Rewrites an output item's marker to the original list's: its bullet character, or for an
355
- * ordered list its delimiter and numbering scheme (all one number when the original's first two
356
- * items share one, else counting up from its start). Undefined when the original's markers
357
- * cannot be read, or when renumbering would change the width of a multi-line item, whose
358
- * continuation lines are aligned to the old width.
359
+ * ordered list its delimiter, numbered as Prettier numbers the original (`orderedNumber`) and
360
+ * zero-padded as it is. Undefined when the original's markers cannot be read, or when
361
+ * renumbering would change the width of a multi-line item, whose continuation lines are aligned
362
+ * to the old width.
359
363
  */
360
364
  function listMarkers(text, items, ordered) {
361
365
  const firstStart = startOf(items[0]);
@@ -371,17 +375,15 @@ function listMarkers(text, items, ordered) {
371
375
  const first = numberAt(firstStart);
372
376
  if (first === null)
373
377
  return undefined;
374
- const start = Number(first[1]);
375
378
  const delimiter = first[2] ?? '.';
376
379
  // `01.`, `02.`: zero-padded to the first marker's width.
377
380
  const padTo = first[1]?.startsWith('0') ? first[1].length : 0;
378
- const second = items.length > 1 ? numberAt(startOf(items[1])) : null;
379
- const step = second !== null && Number(second[1]) === start ? 0 : 1;
381
+ const typed = items.map((item) => Number(numberAt(startOf(item))?.[1]));
380
382
  return (itemText, index) => {
381
383
  const current = /^\d{1,9}[.)]/.exec(itemText);
382
384
  if (current === null)
383
385
  return undefined;
384
- const marker = `${String(start + step * index).padStart(padTo, '0')}${delimiter}`;
386
+ const marker = `${String(orderedNumber(typed, index)).padStart(padTo, '0')}${delimiter}`;
385
387
  if (marker.length !== current[0].length && itemText.includes('\n'))
386
388
  return undefined;
387
389
  return marker + itemText.slice(current[0].length);
@@ -389,7 +391,9 @@ function listMarkers(text, items, ordered) {
389
391
  }
390
392
  /**
391
393
  * The body to write for an edit from `original` (on disk) to `updated` (the editor's body):
392
- * `updated`'s content, in `original`'s text wherever the two agree. See the module header.
394
+ * `updated`'s content, in `original`'s text wherever the two agree. With no `original` to keep
395
+ * text from (a new file, or a body on disk that does not parse), the whole edit is restyled.
396
+ * See the module header.
393
397
  */
394
398
  export function preserveMarkdownSource(original, updated, format) {
395
399
  if (original === updated)
@@ -403,20 +407,59 @@ export function preserveMarkdownSource(original, updated, format) {
403
407
  return updated;
404
408
  }
405
409
  }
410
+ /**
411
+ * The indexes of the edit's top-level blocks the splice writes from disk: those the alignment
412
+ * matches, and those that moved (an unmatched original of the same meaning, each used once).
413
+ */
414
+ function diskBlocks(oNodes, nNodes) {
415
+ const oKeys = oNodes.map(canonical);
416
+ const nKeys = nNodes.map(canonical);
417
+ const pairs = matchSequences(oKeys, nKeys);
418
+ const fromDisk = new Set(pairs.map(([, ni]) => ni));
419
+ const matched = new Set(pairs.map(([oi]) => oi));
420
+ const unmatched = new Map();
421
+ oKeys.forEach((key, index) => {
422
+ if (!matched.has(index))
423
+ unmatched.set(key, (unmatched.get(key) ?? 0) + 1);
424
+ });
425
+ nKeys.forEach((key, index) => {
426
+ const left = unmatched.get(key) ?? 0;
427
+ if (fromDisk.has(index) || left === 0)
428
+ return;
429
+ unmatched.set(key, left - 1);
430
+ fromDisk.add(index);
431
+ });
432
+ return fromDisk;
433
+ }
406
434
  function splice(original, updated, format) {
407
- const before = parse(original, format);
435
+ const before = original === undefined ? undefined : parse(original, format);
408
436
  const after = parse(updated, format);
409
- if (before === undefined || after === undefined)
437
+ if (after === undefined)
410
438
  return undefined;
411
439
  const target = canonical(after);
412
- for (const deep of [true, false]) {
413
- const candidate = new Splicer(original, updated, deep).body(before, after);
414
- if (candidate === undefined)
415
- continue;
416
- const reparsed = parse(candidate, format);
417
- if (reparsed !== undefined && canonical(reparsed) === target)
418
- return candidate;
419
- log.debug('markdown-body-splice', 'splice does not read back as the edit', { deep });
440
+ const fromDisk = diskBlocks(before?.children ?? [], after.children);
441
+ const restyled = restyleMarkdown(updated, after, (index) => fromDisk.has(index), (text) => {
442
+ const tree = parse(text, format);
443
+ return tree !== undefined && canonical(tree) === target ? tree : undefined;
444
+ });
445
+ if (original === undefined || before === undefined)
446
+ return restyled?.text;
447
+ const asSent = { text: updated, tree: after };
448
+ // The rich editor already writes this style, so its restyled edit is usually the edit as sent.
449
+ const edits = restyled === undefined || restyled.text === updated ? [asSent] : [restyled, asSent];
450
+ for (const edit of edits) {
451
+ for (const deep of [true, false]) {
452
+ const candidate = new Splicer(original, edit.text, deep).body(before, edit.tree);
453
+ if (candidate === undefined)
454
+ continue;
455
+ const reparsed = parse(candidate, format);
456
+ if (reparsed !== undefined && canonical(reparsed) === target)
457
+ return candidate;
458
+ log.debug('markdown-body-splice', 'splice does not read back as the edit', {
459
+ deep,
460
+ restyled: edit !== asSent,
461
+ });
462
+ }
420
463
  }
421
- return undefined;
464
+ return restyled?.text;
422
465
  }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The style every markdown body is written in: Prettier's markers. The rich editor exports with
3
+ * it, and a save restyles edited blocks to it whichever editor mode produced them
4
+ * (`markdown-restyle.ts`). MDXEditor's prop replaces its defaults, so `listItemIndent` is
5
+ * restated.
6
+ */
7
+ export declare const MARKDOWN_EXPORT_OPTIONS: {
8
+ readonly listItemIndent: "one";
9
+ readonly bullet: "-";
10
+ readonly emphasis: "_";
11
+ readonly strong: "*";
12
+ readonly rule: "-";
13
+ };
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The style every markdown body is written in: Prettier's markers. The rich editor exports with
3
+ * it, and a save restyles edited blocks to it whichever editor mode produced them
4
+ * (`markdown-restyle.ts`). MDXEditor's prop replaces its defaults, so `listItemIndent` is
5
+ * restated.
6
+ */
7
+ export const MARKDOWN_EXPORT_OPTIONS = {
8
+ listItemIndent: 'one',
9
+ bullet: '-',
10
+ emphasis: '_',
11
+ strong: '*',
12
+ rule: '-',
13
+ };
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Restyles markdown to `MARKDOWN_EXPORT_OPTIONS`'s markers IN PLACE: each normalisation Prettier
3
+ * makes is an edit to the marker bytes of the text as typed, so every other byte (URLs, titles,
4
+ * entities, escapes, inline code, JSX) is the edit's own by construction. A serialiser cannot
5
+ * promise that: it writes every value from the tree, and a tree-equal rewrite of a URL or an
6
+ * entity is invisible to a tree check.
7
+ *
8
+ * The normalisations, each as Prettier 3 writes them:
9
+ * - emphasis `_`, or `*` where `_` could not delimit it (beside a word character) and inside
10
+ * other emphasis; strong `**`. Emphasis after a literal `_` in its block keeps `*`, because
11
+ * Prettier misreads `a_b _x_` (`underscoreStarts`);
12
+ * - bullets `-`, and ordered delimiters `.`, alternating with `*` / `)` for a list that follows
13
+ * another; ordered numbering by `orderedNumber`, where that keeps each marker's width;
14
+ * - `---` rules (`***` inside a list, see `ruleIn`), `# x` headings for one-line setext and ATX
15
+ * headings, backtick fences for `~~~`;
16
+ * - one blank line between blocks, none between link definitions; between list items and an
17
+ * item's blocks, several blank lines become one, which keeps a list's looseness;
18
+ * - no trailing space before a soft line ending; an escaped `_` between two letters or digits
19
+ * unescaped, except in links and in blocks holding `$` or `[[`;
20
+ * - tables padded to their columns, when every cell is printable ASCII.
21
+ *
22
+ * Each top-level block's edits are kept only if the text they are part of reads back as the
23
+ * edit (`readsBack`): all at once, then halves, within a parse budget, so a block that cannot be
24
+ * restyled leaves the others restyled.
25
+ */
26
+ /** The parts of an mdast node the restyle and the splice read. */
27
+ export interface MdNode {
28
+ readonly type: string;
29
+ readonly children?: readonly MdNode[];
30
+ readonly position?: {
31
+ readonly start: {
32
+ readonly offset?: number;
33
+ };
34
+ readonly end: {
35
+ readonly offset?: number;
36
+ };
37
+ };
38
+ readonly ordered?: boolean | null;
39
+ readonly checked?: boolean | null;
40
+ readonly depth?: number;
41
+ readonly value?: string;
42
+ readonly align?: readonly (string | null)[] | null;
43
+ }
44
+ /**
45
+ * The number Prettier writes for item `index` of an ordered list typed with `numbers`: the first
46
+ * keeps its number; the rest count up from it, or are all 1 when the list is typed that way
47
+ * (its second number is 1, and so is its third when it starts at 0).
48
+ */
49
+ export declare function orderedNumber(numbers: readonly number[], index: number): number;
50
+ /**
51
+ * `source` (whose parse is `tree`) restyled in place, every top-level block for which `fixed`
52
+ * is false; undefined when no restyle reads back. `readsBack` returns the parse of a candidate
53
+ * when it means what `source` means.
54
+ */
55
+ export declare function restyleMarkdown<T extends MdNode>(source: string, tree: MdNode, fixed: (index: number) => boolean, readsBack: (text: string) => T | undefined): {
56
+ text: string;
57
+ tree: T;
58
+ } | undefined;