canopycms 0.6.0 → 0.7.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.
- package/CHANGELOG.md +174 -47
- package/dist/api/branch-review.d.ts +11 -1
- package/dist/api/branch-review.js +96 -18
- package/dist/api/branch-status.d.ts +26 -2
- package/dist/api/branch-status.js +38 -2
- package/dist/api/branch-withdraw.js +4 -7
- package/dist/api/branch.d.ts +2 -0
- package/dist/api/client.d.ts +3 -3
- package/dist/api/client.js +4 -4
- package/dist/api/comments.d.ts +2 -2
- package/dist/api/comments.js +4 -0
- package/dist/api/content.d.ts +2 -2
- package/dist/api/github-sync.d.ts +4 -0
- package/dist/api/github-sync.js +38 -6
- package/dist/api/pr-comment-sync.d.ts +18 -0
- package/dist/api/pr-comment-sync.js +77 -0
- package/dist/api/request-deadline.d.ts +15 -7
- package/dist/api/request-deadline.js +15 -7
- package/dist/api/schema.d.ts +16 -16
- package/dist/authorization/review-role.d.ts +33 -0
- package/dist/authorization/review-role.js +17 -0
- package/dist/branch-metadata-file.d.ts +3 -0
- package/dist/branch-metadata-file.js +1 -0
- package/dist/cli/cli.js +699 -130
- package/dist/cli/generate-ai-content.js +524 -59
- package/dist/cli/github-app-output.d.ts +34 -0
- package/dist/cli/github-app-output.js +85 -0
- package/dist/cli/init-github-app.d.ts +18 -0
- package/dist/cli/init-github-app.js +66 -58
- package/dist/cli/init.js +10 -1
- package/dist/comment-store.d.ts +5 -0
- package/dist/comment-store.js +24 -0
- package/dist/config/helpers.js +1 -3
- package/dist/config/schemas/collection.d.ts +2 -2
- package/dist/config/schemas/config.d.ts +3 -0
- package/dist/config/schemas/config.js +2 -1
- package/dist/config/schemas/url.d.ts +6 -0
- package/dist/config/schemas/url.js +16 -0
- package/dist/config/types.d.ts +12 -1
- package/dist/content-id-index.d.ts +9 -0
- package/dist/content-id-index.js +31 -0
- package/dist/editor/BranchManager.d.ts +3 -0
- package/dist/editor/BranchManager.js +75 -44
- package/dist/editor/CanopyEditor.js +2 -2
- package/dist/editor/CanopyEditorPage.js +3 -4
- package/dist/editor/Editor.js +47 -8
- package/dist/editor/branch-lock.d.ts +7 -0
- package/dist/editor/branch-lock.js +9 -2
- package/dist/editor/client-reference-resolver.d.ts +5 -3
- package/dist/editor/client-reference-resolver.js +10 -5
- package/dist/editor/components/BranchesLoadFailedNotice.d.ts +13 -0
- package/dist/editor/components/BranchesLoadFailedNotice.js +12 -0
- package/dist/editor/components/EditorHeader.d.ts +10 -4
- package/dist/editor/components/EditorHeader.js +108 -28
- package/dist/editor/components/EntryHeader.d.ts +5 -1
- package/dist/editor/components/EntryHeader.js +3 -1
- package/dist/editor/components/ReasonedMenuItem.d.ts +8 -0
- package/dist/editor/components/ReasonedMenuItem.js +2 -2
- package/dist/editor/components/ReviewDialogs.d.ts +19 -0
- package/dist/editor/components/ReviewDialogs.js +41 -0
- package/dist/editor/copy.d.ts +4 -0
- package/dist/editor/copy.js +4 -0
- package/dist/editor/fields/MarkdownField.js +1 -1
- package/dist/editor/hooks/create-branch-request.d.ts +6 -2
- package/dist/editor/hooks/create-branch-request.js +7 -2
- package/dist/editor/hooks/useBranchManager.d.ts +7 -2
- package/dist/editor/hooks/useBranchManager.js +252 -37
- package/dist/editor-url.d.ts +20 -0
- package/dist/editor-url.js +71 -0
- package/dist/git-manager.js +10 -6
- package/dist/github-service.d.ts +2 -2
- package/dist/github-service.js +4 -2
- package/dist/pr-comments.d.ts +33 -0
- package/dist/pr-comments.js +41 -0
- package/dist/schema/schema-store.d.ts +8 -8
- package/dist/services.d.ts +2 -17
- package/dist/services.js +38 -1
- package/dist/settings-workspace.d.ts +21 -7
- package/dist/settings-workspace.js +91 -16
- package/dist/submission-attribution.d.ts +38 -1
- package/dist/submission-attribution.js +197 -18
- package/dist/task-queue/cms-task-queue.d.ts +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/utils/content-serialize.js +7 -6
- package/dist/utils/markdown-body-splice.d.ts +15 -10
- package/dist/utils/markdown-body-splice.js +71 -28
- package/dist/utils/markdown-export-options.d.ts +13 -0
- package/dist/utils/markdown-export-options.js +13 -0
- package/dist/utils/markdown-restyle.d.ts +58 -0
- package/dist/utils/markdown-restyle.js +482 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/bundle-header.d.ts +13 -9
- package/dist/worker/bundle-header.js +17 -11
- package/dist/worker/git-process.d.ts +2 -2
- package/dist/worker/github-gateway-http.js +3 -2
- package/dist/worker/github-gateway-wire.d.ts +3 -0
- package/dist/worker/github-gateway-wire.js +1 -0
- package/dist/worker/github-gateway.d.ts +1 -1
- package/dist/worker/github-transfer.js +72 -2
- package/dist/worker/task-runner.js +53 -3
- package/package.json +2 -2
- package/dist/editor/fields/markdown-export-options.d.ts +0 -2
- 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
|
-
|
|
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, '<!--').replace(/--!?>/g, '-->');
|
|
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
|
-
/**
|
|
198
|
-
|
|
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
|
|
209
|
-
|
|
210
|
-
|
|
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,
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
221
|
-
|
|
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
|
|
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
|
-
|
|
242
|
-
|
|
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
|
|
524
|
-
* gray-matter leaves that blank line in the body, and the editor's body
|
|
525
|
-
* is restored here even when the splice falls back. A body under
|
|
526
|
-
* have, or under frontmatter with no body yet, gets one blank line,
|
|
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 =
|
|
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
|
|
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
|
|
7
|
-
* every block whose MEANING the edit left alone, and the
|
|
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
|
|
19
|
-
* that fails that check is retried without descending into lists,
|
|
20
|
-
*
|
|
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.
|
|
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
|
|
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
|
|
7
|
-
* every block whose MEANING the edit left alone, and the
|
|
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
|
|
19
|
-
* that fails that check is retried without descending into lists,
|
|
20
|
-
*
|
|
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
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
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
|
|
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(
|
|
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.
|
|
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 (
|
|
437
|
+
if (after === undefined)
|
|
410
438
|
return undefined;
|
|
411
439
|
const target = canonical(after);
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
|
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;
|