@plannotator/ui 0.47.0 → 0.50.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/HANDOFF.md +147 -0
- package/LICENSE +191 -0
- package/README.md +56 -1
- package/components/AISettingsTab.tsx +1 -1
- package/components/AnnotationPanel.tsx +44 -3
- package/components/BlockRenderer.tsx +55 -1
- package/components/CompletionOverlay.tsx +23 -1
- package/components/DiffFileTree.tsx +363 -0
- package/components/ImageLightbox.tsx +66 -0
- package/components/PRPlatformIcon.tsx +22 -0
- package/components/ProviderIcons.tsx +15 -3
- package/components/QuestionProgressChip.tsx +66 -0
- package/components/QuestionsPanelSection.tsx +121 -0
- package/components/Settings.tsx +25 -1
- package/components/Viewer.tsx +170 -34
- package/components/ai/AIProviderBar.tsx +5 -5
- package/components/ai/DocumentAIChatPanel.tsx +28 -3
- package/components/ai/SessionAskNotice.tsx +89 -0
- package/components/blocks/QuestionBlock.tsx +591 -0
- package/components/html-viewer/bridge-script.asset.js +85 -8
- package/components/html-viewer/bridge-script.ts +85 -8
- package/config/settings.ts +17 -0
- package/configure.ts +12 -0
- package/hooks/useAIChat.ts +74 -9
- package/hooks/useAIProviderConfig.ts +32 -14
- package/hooks/useAnnotationHighlighter.ts +4 -0
- package/hooks/useDiffFileTreeExpansion.ts +92 -0
- package/hooks/usePinpoint.ts +10 -0
- package/hooks/useScrollKeyRouting.ts +157 -0
- package/hooks/useUndoHistory.ts +17 -0
- package/hooks/useVimDocumentFocus.ts +2 -1
- package/package.json +3 -2
- package/styles.css +1 -1
- package/types.ts +7 -0
- package/utils/aiPrompt.ts +31 -0
- package/utils/aiProvider.ts +68 -0
- package/utils/autoUpdateNotice.ts +59 -0
- package/utils/diffFileTree.ts +203 -0
- package/utils/htmlLinkNavigation.ts +87 -3
- package/utils/parser.ts +125 -563
- package/utils/questionAnswers.ts +227 -0
- package/utils/syntaxTheme.ts +33 -0
package/utils/parser.ts
CHANGED
|
@@ -2,8 +2,32 @@ import type { Block, Annotation, CodeAnnotation, EditorAnnotation, ImageAttachme
|
|
|
2
2
|
import { planDenyFeedback } from '@plannotator/core/feedback-templates';
|
|
3
3
|
import { resolveReplyParents } from '@plannotator/core/annotation-threads';
|
|
4
4
|
import { diagramAnchorLocationLine, parseDiagramAnchor } from '@plannotator/core/diagram-anchor';
|
|
5
|
+
import {
|
|
6
|
+
formatQuestionAnswerLines,
|
|
7
|
+
formatQuestionAnswersSection,
|
|
8
|
+
indexQuestionBlocks,
|
|
9
|
+
parseQuestionAnswer,
|
|
10
|
+
questionExportItems,
|
|
11
|
+
type QuestionAnswer,
|
|
12
|
+
type QuestionExportItem,
|
|
13
|
+
} from '@plannotator/core/question-block';
|
|
14
|
+
import {
|
|
15
|
+
DIRECTIVE_OPEN_RE,
|
|
16
|
+
HTML_BLOCK_TAGS,
|
|
17
|
+
codeFenceCloseIndex,
|
|
18
|
+
directiveCloseIndex,
|
|
19
|
+
htmlBlockEndAt,
|
|
20
|
+
resolveReferenceLinks,
|
|
21
|
+
scanDisplayMath,
|
|
22
|
+
splitFrontmatter,
|
|
23
|
+
type TagCloseIndex,
|
|
24
|
+
} from '@plannotator/core/markdown-structure';
|
|
5
25
|
import { skillReferenceExportBlock } from './skillReferences';
|
|
6
26
|
|
|
27
|
+
// The structural helpers moved to `@plannotator/core/markdown-structure` so
|
|
28
|
+
// `findQuestionBlocks` (core) splits a document exactly as this parser does.
|
|
29
|
+
export { HTML_BLOCK_TAGS, resolveReferenceLinks };
|
|
30
|
+
|
|
7
31
|
/**
|
|
8
32
|
* Parsed YAML frontmatter value: scalar string, array, or nested map.
|
|
9
33
|
*/
|
|
@@ -128,30 +152,11 @@ function parseKeyValue(str: string): { key: string; value: string } | null {
|
|
|
128
152
|
* line references stay accurate).
|
|
129
153
|
*/
|
|
130
154
|
export function extractFrontmatter(markdown: string): { frontmatter: Frontmatter | null; content: string; contentStartLine: number } {
|
|
131
|
-
const
|
|
132
|
-
if (
|
|
155
|
+
const { raw: frontmatterRaw, content: afterFrontmatter, contentStartLine } = splitFrontmatter(markdown);
|
|
156
|
+
if (frontmatterRaw === null) {
|
|
133
157
|
return { frontmatter: null, content: markdown, contentStartLine: 1 };
|
|
134
158
|
}
|
|
135
159
|
|
|
136
|
-
// Find the closing ---
|
|
137
|
-
const endIndex = trimmed.indexOf('\n---', 3);
|
|
138
|
-
if (endIndex === -1) {
|
|
139
|
-
return { frontmatter: null, content: markdown, contentStartLine: 1 };
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// Extract frontmatter content (between the --- delimiters)
|
|
143
|
-
const frontmatterRaw = trimmed.slice(4, endIndex).trim();
|
|
144
|
-
const rawAfterFrontmatter = trimmed.slice(endIndex + 4);
|
|
145
|
-
const afterFrontmatter = rawAfterFrontmatter.trimStart();
|
|
146
|
-
|
|
147
|
-
// Compute the 1-based line where content begins in the original file.
|
|
148
|
-
// Account for: leading whitespace trimmed from original, the frontmatter
|
|
149
|
-
// block itself, and any blank lines between closing --- and first content.
|
|
150
|
-
const leadingChars = markdown.length - trimmed.length;
|
|
151
|
-
const consumedInTrimmed = endIndex + 4 + (rawAfterFrontmatter.length - afterFrontmatter.length);
|
|
152
|
-
const consumedTotal = leadingChars + consumedInTrimmed;
|
|
153
|
-
const contentStartLine = (markdown.slice(0, consumedTotal).match(/\n/g) || []).length + 1;
|
|
154
|
-
|
|
155
160
|
// Parse simple YAML (key: value pairs, indentation-aware)
|
|
156
161
|
const frontmatter: Frontmatter = {};
|
|
157
162
|
const mapStack: { indent: number; map: { [key: string]: FrontmatterValue } }[] = [
|
|
@@ -280,32 +285,6 @@ export function extractFrontmatter(markdown: string): { frontmatter: Frontmatter
|
|
|
280
285
|
return { frontmatter, content: afterFrontmatter, contentStartLine };
|
|
281
286
|
}
|
|
282
287
|
|
|
283
|
-
/**
|
|
284
|
-
* Tag names that trigger a raw HTML block per CommonMark §4.6, Type 6.
|
|
285
|
-
* A line starting with `<tag` or `</tag` (where `tag` is in this set) opens
|
|
286
|
-
* an HTML block that continues verbatim until a blank line or EOF.
|
|
287
|
-
*
|
|
288
|
-
* Inline-only tags (`kbd`, `sub`, `sup`, `mark`, etc.) are NOT here — a line
|
|
289
|
-
* that happens to start with one of those still goes through the paragraph
|
|
290
|
-
* path and renders as escaped text, matching prior behavior.
|
|
291
|
-
*/
|
|
292
|
-
export const HTML_BLOCK_TAGS: ReadonlySet<string> = new Set([
|
|
293
|
-
'details', 'summary',
|
|
294
|
-
'div', 'section', 'article', 'aside', 'header', 'footer',
|
|
295
|
-
'blockquote', 'pre',
|
|
296
|
-
'table', 'thead', 'tbody', 'tr', 'td', 'th',
|
|
297
|
-
'ul', 'ol', 'li', 'p',
|
|
298
|
-
// Media: GitHub embeds screenshots/videos as raw HTML on their own line.
|
|
299
|
-
'img', 'video', 'picture',
|
|
300
|
-
]);
|
|
301
|
-
|
|
302
|
-
/** Void elements — no closing tag, so the block is a single line (don't scan
|
|
303
|
-
* ahead for a `</tag>` that will never come). */
|
|
304
|
-
const VOID_HTML_TAGS: ReadonlySet<string> = new Set([
|
|
305
|
-
'img', 'br', 'hr', 'source', 'input', 'wbr', 'area', 'col', 'embed',
|
|
306
|
-
]);
|
|
307
|
-
|
|
308
|
-
const HTML_BLOCK_OPEN_RE = /^<\/?([a-zA-Z][a-zA-Z0-9]*)(?:\s|>|\/|$)/;
|
|
309
288
|
|
|
310
289
|
export interface ParseMarkdownOptions {
|
|
311
290
|
/**
|
|
@@ -317,339 +296,6 @@ export interface ParseMarkdownOptions {
|
|
|
317
296
|
frontmatter?: boolean;
|
|
318
297
|
}
|
|
319
298
|
|
|
320
|
-
// CommonMark bounds a link label to 999 characters. Reusing that bound here
|
|
321
|
-
// also caps the worst-case backtracking cost of the bracket-matching groups
|
|
322
|
-
// below to a constant per starting position, turning a document with a very
|
|
323
|
-
// long run of unmatched `[` characters (a real hazard within the 2MB annotate
|
|
324
|
-
// cap) into a linear scan instead of a quadratic one. A label longer than
|
|
325
|
-
// this is a deliberate, documented degradation: it is neither collected as a
|
|
326
|
-
// definition nor resolved as a reference, so it is simply left untouched
|
|
327
|
-
// rather than partially or incorrectly rewritten.
|
|
328
|
-
const MAX_REF_LABEL_CHARS = 999;
|
|
329
|
-
// Same reasoning applied to the inline-code-span alternative: bounding how far
|
|
330
|
-
// a lazy scan for a closing backtick run can travel keeps a line with many
|
|
331
|
-
// stray, unterminated backticks linear too. 5000 is far beyond any realistic
|
|
332
|
-
// inline code span, so legitimate spans are unaffected.
|
|
333
|
-
const MAX_CODE_SPAN_CHARS = 5000;
|
|
334
|
-
// Defense-in-depth cap on the number of definitions collected from a single
|
|
335
|
-
// document. A pathological document could otherwise grow the map without
|
|
336
|
-
// bound; this keeps that growth bounded even though ordinary documents never
|
|
337
|
-
// approach it.
|
|
338
|
-
const MAX_TRACKED_DEFINITIONS = 20_000;
|
|
339
|
-
|
|
340
|
-
// A link reference definition: `[label]: destination "optional title"`, with up
|
|
341
|
-
// to three leading spaces. The destination is a bare token or an <...> form; any
|
|
342
|
-
// trailing text must be a quoted or parenthesized title, otherwise the line is
|
|
343
|
-
// ordinary prose (so `[Reminder]: call the bank` is NOT a definition). Matches
|
|
344
|
-
// the CommonMark shape closely enough for the simplified parser. `\r?` before
|
|
345
|
-
// the end anchor tolerates a CRLF source (lines are split on `\n` only, so a
|
|
346
|
-
// CRLF line keeps its trailing `\r`).
|
|
347
|
-
const REFERENCE_DEFINITION_RE = new RegExp(
|
|
348
|
-
`^ {0,3}\\[([^\\]]{1,${MAX_REF_LABEL_CHARS}})\\]:[ \\t]*(?:<([^>]*)>|(\\S+))[ \\t]*(?:"[^"]*"|'[^']*'|\\([^)]*\\))?[ \\t]*\\r?$`,
|
|
349
|
-
);
|
|
350
|
-
|
|
351
|
-
// One left-to-right pass over a line. The first alternative matches a whole
|
|
352
|
-
// inline code span (balanced backtick run) so its contents are skipped; the
|
|
353
|
-
// second matches a reference link/image: optional `!`, the bracketed text, then
|
|
354
|
-
// an optional second bracket for the full (`[label]`) or collapsed (`[]`) forms.
|
|
355
|
-
// A bare `[text]` is the shortcut form, resolved only when it names a definition
|
|
356
|
-
// and is not actually an inline link. Groups: 1 code ticks, 2 `!`, 3 text,
|
|
357
|
-
// 4 second bracket, 5 label.
|
|
358
|
-
const REFERENCE_LINK_RE = new RegExp(
|
|
359
|
-
`(\`+)[^\\n]{0,${MAX_CODE_SPAN_CHARS}}?\\1|(!?)\\[([^\\]]{1,${MAX_REF_LABEL_CHARS}})\\](\\[([^\\]]{0,${MAX_REF_LABEL_CHARS}})\\])?`,
|
|
360
|
-
'g',
|
|
361
|
-
);
|
|
362
|
-
|
|
363
|
-
// CommonMark label matching is case-insensitive and collapses internal runs of
|
|
364
|
-
// whitespace.
|
|
365
|
-
const normalizeRefLabel = (label: string): string =>
|
|
366
|
-
label.trim().replace(/\s+/g, ' ').toLowerCase();
|
|
367
|
-
|
|
368
|
-
/**
|
|
369
|
-
* One pass over the lines that marks every line the block parser (below) will
|
|
370
|
-
* render as code or raw HTML — fenced code blocks and HTML blocks — so link
|
|
371
|
-
* reference definitions and references inside them are left completely
|
|
372
|
-
* untouched. This reuses the exact same conditions the block parser itself
|
|
373
|
-
* uses (not a looser approximation), so the two can never disagree about
|
|
374
|
-
* where code/HTML starts and ends:
|
|
375
|
-
*
|
|
376
|
-
* - Fences: `trimmed.startsWith('```')` after a full `.trim()` — the block
|
|
377
|
-
* parser has no minimum-indent exemption, so ANY indentation (a fence
|
|
378
|
-
* nested inside a list item, or simply indented 4+ spaces) still opens a
|
|
379
|
-
* code block, and this must too. Only backtick fences are recognized —
|
|
380
|
-
* the block parser has no `~~~` support, so this doesn't either (a `~~~`
|
|
381
|
-
* line is ordinary text to both).
|
|
382
|
-
* - Raw HTML blocks: the same `HTML_BLOCK_OPEN_RE`/`HTML_BLOCK_TAGS`/
|
|
383
|
-
* `VOID_HTML_TAGS` the block parser uses, with the same three extents
|
|
384
|
-
* (blank-line termination for a leading close tag, single-line for void
|
|
385
|
-
* tags, balanced-depth scanning otherwise) — so a definition sitting
|
|
386
|
-
* inside `<details>…</details>` or `<pre>…</pre>` is protected exactly as
|
|
387
|
-
* far as the block parser's own HTML block extends.
|
|
388
|
-
*/
|
|
389
|
-
/**
|
|
390
|
-
* Per-tag-name index backing `findHtmlBlockEnd`. `augmented` is the running
|
|
391
|
-
* open-tag-count-minus-close-tag-count prefix sum for this tag name, with a
|
|
392
|
-
* virtual baseline of 0 prepended at index 0 — so `augmented[k]` is the sum
|
|
393
|
-
* through line `k-1` (the depth baseline a block opening at line `k` must
|
|
394
|
-
* return to) and `augmented[k+1]` is the sum through line `k`.
|
|
395
|
-
* `nextAtOrBelow[m]` is the classic "next element at or below this one"
|
|
396
|
-
* index over `augmented`: the smallest `m' > m` with `augmented[m'] <=
|
|
397
|
-
* augmented[m]`, or -1 if none exists.
|
|
398
|
-
*/
|
|
399
|
-
interface TagCloseIndex {
|
|
400
|
-
augmented: number[];
|
|
401
|
-
nextAtOrBelow: number[];
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
/**
|
|
405
|
-
* Builds a `TagCloseIndex` for one tag name in a single O(N) pass (plus a
|
|
406
|
-
* classic O(N) monotonic-stack pass for `nextAtOrBelow` — each index is
|
|
407
|
-
* pushed and popped at most once, so the two passes together are linear in
|
|
408
|
-
* the document's line count, independent of how many opening/closing tags
|
|
409
|
-
* it contains).
|
|
410
|
-
*/
|
|
411
|
-
function buildTagCloseIndex(lines: string[], tagName: string): TagCloseIndex {
|
|
412
|
-
const openRe = new RegExp(`<${tagName}(?:\\s|>|/|$)`, 'gi');
|
|
413
|
-
const closeRe = new RegExp(`</${tagName}\\s*>`, 'gi');
|
|
414
|
-
const n = lines.length;
|
|
415
|
-
const augmented = new Array<number>(n + 1);
|
|
416
|
-
augmented[0] = 0;
|
|
417
|
-
let running = 0;
|
|
418
|
-
for (let k = 0; k < n; k++) {
|
|
419
|
-
running += (lines[k].match(openRe) || []).length;
|
|
420
|
-
running -= (lines[k].match(closeRe) || []).length;
|
|
421
|
-
augmented[k + 1] = running;
|
|
422
|
-
}
|
|
423
|
-
const nextAtOrBelow = new Array<number>(n + 1).fill(-1);
|
|
424
|
-
const stack: number[] = [];
|
|
425
|
-
for (let m = n; m >= 0; m--) {
|
|
426
|
-
while (stack.length && augmented[stack[stack.length - 1]] > augmented[m]) stack.pop();
|
|
427
|
-
nextAtOrBelow[m] = stack.length ? stack[stack.length - 1] : -1;
|
|
428
|
-
stack.push(m);
|
|
429
|
-
}
|
|
430
|
-
return { augmented, nextAtOrBelow };
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
/**
|
|
434
|
-
* Shared helper computing the last line index of a balanced open/close-tag
|
|
435
|
-
* HTML block that opens at `startIndex` with the given already-computed
|
|
436
|
-
* `depth` (the opening line's own open-tag count minus close-tag count).
|
|
437
|
-
* Used by both `markProtectedLines` (the resolver's protection pass) and
|
|
438
|
-
* `parseMarkdownToBlocks` (the block parser) so the two can never disagree
|
|
439
|
-
* about a multi-line HTML block's extent, and so a fix here lives in exactly
|
|
440
|
-
* one place instead of two copies drifting apart.
|
|
441
|
-
*
|
|
442
|
-
* History: naively scanning line-by-line from `startIndex` until depth
|
|
443
|
-
* returns to zero (or giving up at end-of-document) is O(N^2) for a
|
|
444
|
-
* document with many consecutive unclosed openers (e.g. thousands of bare
|
|
445
|
-
* `<div>` lines), since every one of them re-scans to EOF. A first fix
|
|
446
|
-
* added an O(1) "does a close exist anywhere" pre-check plus a fixed
|
|
447
|
-
* line-count cap on the residual scan — but that cap silently truncated
|
|
448
|
-
* VALID blocks longer than it, and removing the cap alone reopened a
|
|
449
|
-
* closely related O(N^2) case: N unclosed openers followed by a SINGLE
|
|
450
|
-
* trailing close still all pass the "a close exists somewhere" pre-check,
|
|
451
|
-
* so every one of them still scans forward (mostly to EOF) before giving up.
|
|
452
|
-
*
|
|
453
|
-
* Fixed properly here with a per-tag-name prefix-sum index
|
|
454
|
-
* (`buildTagCloseIndex`, O(N), built once per tag name and cached per
|
|
455
|
-
* document — see `closeCache`): finding "the exact line where a block
|
|
456
|
-
* starting at `startIndex` closes, if ever" is exactly the classic "next
|
|
457
|
-
* smaller-or-equal element" query against that prefix sum, which the index
|
|
458
|
-
* answers in O(1). No scanning happens per opener at all — not for a block
|
|
459
|
-
* that never closes, not for one that closes after any number of
|
|
460
|
-
* intervening lines, however many. This is provably linear overall (a
|
|
461
|
-
* document with T distinct protected tag names costs O(T * N) to index,
|
|
462
|
-
* and T is bounded by the small, fixed `HTML_BLOCK_TAGS` set) and can never
|
|
463
|
-
* truncate a valid block, because it always finds the block's real end
|
|
464
|
-
* (however far away) rather than giving up at a fixed distance.
|
|
465
|
-
*
|
|
466
|
-
* Returns `startIndex` unchanged when the block never closes: depth <= 0,
|
|
467
|
-
* or the running depth never returns to exactly zero anywhere in the rest
|
|
468
|
-
* of the document (whether because no close exists at all, or one exists
|
|
469
|
-
* but is insufficient to bring the count back to exactly the opener's own
|
|
470
|
-
* baseline — e.g. an unbalanced/self-closing tag).
|
|
471
|
-
*/
|
|
472
|
-
function findHtmlBlockEnd(
|
|
473
|
-
lines: string[],
|
|
474
|
-
startIndex: number,
|
|
475
|
-
tagName: string,
|
|
476
|
-
depth: number,
|
|
477
|
-
closeCache: Map<string, TagCloseIndex>,
|
|
478
|
-
): number {
|
|
479
|
-
if (depth <= 0) return startIndex;
|
|
480
|
-
let index = closeCache.get(tagName);
|
|
481
|
-
if (!index) {
|
|
482
|
-
index = buildTagCloseIndex(lines, tagName);
|
|
483
|
-
closeCache.set(tagName, index);
|
|
484
|
-
}
|
|
485
|
-
const { augmented, nextAtOrBelow } = index;
|
|
486
|
-
const m = nextAtOrBelow[startIndex];
|
|
487
|
-
if (m === -1) return startIndex;
|
|
488
|
-
return augmented[m] === augmented[startIndex] ? m - 1 : startIndex;
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
const markProtectedLines = (lines: string[]): boolean[] => {
|
|
492
|
-
const isProtected = new Array<boolean>(lines.length).fill(false);
|
|
493
|
-
let fenceLen = 0; // 0 = not currently inside a fence
|
|
494
|
-
const closeCache = new Map<string, TagCloseIndex>();
|
|
495
|
-
for (let i = 0; i < lines.length; i++) {
|
|
496
|
-
if (fenceLen > 0) {
|
|
497
|
-
isProtected[i] = true;
|
|
498
|
-
if (new RegExp('^\\s*`{' + fenceLen + ',}').test(lines[i])) fenceLen = 0;
|
|
499
|
-
continue;
|
|
500
|
-
}
|
|
501
|
-
const trimmed = lines[i].trim();
|
|
502
|
-
if (trimmed.startsWith('```')) {
|
|
503
|
-
fenceLen = trimmed.match(/^`+/)![0].length;
|
|
504
|
-
isProtected[i] = true;
|
|
505
|
-
continue;
|
|
506
|
-
}
|
|
507
|
-
const htmlTagMatch = trimmed.match(HTML_BLOCK_OPEN_RE);
|
|
508
|
-
if (htmlTagMatch && HTML_BLOCK_TAGS.has(htmlTagMatch[1].toLowerCase())) {
|
|
509
|
-
const tagName = htmlTagMatch[1].toLowerCase();
|
|
510
|
-
const isCloseTag = trimmed.startsWith('</');
|
|
511
|
-
isProtected[i] = true;
|
|
512
|
-
if (isCloseTag) {
|
|
513
|
-
while (i + 1 < lines.length && lines[i + 1].trim() !== '') {
|
|
514
|
-
i++;
|
|
515
|
-
isProtected[i] = true;
|
|
516
|
-
}
|
|
517
|
-
} else if (VOID_HTML_TAGS.has(tagName)) {
|
|
518
|
-
while (!lines[i].includes('>') && i + 1 < lines.length && lines[i + 1].trim() !== '') {
|
|
519
|
-
i++;
|
|
520
|
-
isProtected[i] = true;
|
|
521
|
-
}
|
|
522
|
-
} else {
|
|
523
|
-
const openRe = new RegExp(`<${tagName}(?:\\s|>|/|$)`, 'gi');
|
|
524
|
-
const closeRe = new RegExp(`</${tagName}\\s*>`, 'gi');
|
|
525
|
-
const depth = (lines[i].match(openRe) || []).length - (lines[i].match(closeRe) || []).length;
|
|
526
|
-
const end = findHtmlBlockEnd(lines, i, tagName, depth, closeCache);
|
|
527
|
-
if (end > i) {
|
|
528
|
-
for (let idx = i + 1; idx <= end; idx++) isProtected[idx] = true;
|
|
529
|
-
i = end;
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
return isProtected;
|
|
535
|
-
};
|
|
536
|
-
|
|
537
|
-
/** Resolve reference links/images in one non-code, non-HTML line. A single
|
|
538
|
-
* left-to-right pass: an inline code span is matched as a whole and returned
|
|
539
|
-
* verbatim, so a reference-looking pattern inside backticks is never
|
|
540
|
-
* rewritten; only bracketed references outside code are resolved. Every label
|
|
541
|
-
* that actually resolves against a definition is recorded into `usedLabels`,
|
|
542
|
-
* so the caller can tell a genuinely consumed definition from an unused one. */
|
|
543
|
-
const resolveRefsInLine = (
|
|
544
|
-
line: string,
|
|
545
|
-
defs: Map<string, string>,
|
|
546
|
-
usedLabels: Set<string>,
|
|
547
|
-
): string => {
|
|
548
|
-
if (!line.includes('[')) return line;
|
|
549
|
-
return line.replace(
|
|
550
|
-
REFERENCE_LINK_RE,
|
|
551
|
-
(match, codeTicks, bang, text, secondBracket, label, offset: number, whole: string) => {
|
|
552
|
-
if (codeTicks !== undefined) return match; // inline code span: keep verbatim
|
|
553
|
-
let refLabel: string;
|
|
554
|
-
if (secondBracket === undefined) {
|
|
555
|
-
// Shortcut `[text]`: not a link when an inline `(...)` destination
|
|
556
|
-
// follows (that is an inline link the existing renderer already draws).
|
|
557
|
-
if (whole[offset + match.length] === '(') return match;
|
|
558
|
-
// Nor when it is a task-list checkbox marker at the start of a list
|
|
559
|
-
// item (`- [x]`); the checkbox parser owns that `[x]`, and resolving it
|
|
560
|
-
// against a stray `x`/`X` definition would clobber the item.
|
|
561
|
-
if (/^[ xX]$/.test(text) && /^\s*(?:[-*+]|\d+[.)])\s+$/.test(whole.slice(0, offset))) {
|
|
562
|
-
return match;
|
|
563
|
-
}
|
|
564
|
-
refLabel = text;
|
|
565
|
-
} else {
|
|
566
|
-
refLabel = label === '' ? text : label;
|
|
567
|
-
}
|
|
568
|
-
const normalized = normalizeRefLabel(refLabel);
|
|
569
|
-
const dest = defs.get(normalized);
|
|
570
|
-
// An unknown reference stays literal, matching CommonMark and avoiding
|
|
571
|
-
// false links for bracketed prose like `[TODO]` or array indices.
|
|
572
|
-
if (!dest) return match;
|
|
573
|
-
usedLabels.add(normalized);
|
|
574
|
-
return `${bang}[${text}](${dest})`;
|
|
575
|
-
},
|
|
576
|
-
);
|
|
577
|
-
};
|
|
578
|
-
|
|
579
|
-
/**
|
|
580
|
-
* Resolve CommonMark link reference definitions and reference links into inline
|
|
581
|
-
* `[text](url)` links, so the shared inline renderer draws them instead of
|
|
582
|
-
* showing raw `[text][id]` and `[id]: url` text (issue #923). Definitions and
|
|
583
|
-
* references inside fenced code blocks, raw HTML blocks, and inline code spans
|
|
584
|
-
* are left untouched. A definition-shaped line is only ever blanked when its
|
|
585
|
-
* label was actually consumed by a resolved reference outside a protected
|
|
586
|
-
* region — an unused definition, or one referenced only from inside code/HTML,
|
|
587
|
-
* stays visible exactly as written. Blanked lines keep block start-line
|
|
588
|
-
* numbers accurate (and their own CRLF ending, so line endings round-trip).
|
|
589
|
-
* GFM footnote definitions (`[^label]: ...`) are never treated as link
|
|
590
|
-
* definitions. No-op (returns the input) when the document defines no
|
|
591
|
-
* (non-footnote) references.
|
|
592
|
-
*/
|
|
593
|
-
export const resolveReferenceLinks = (markdown: string): string => {
|
|
594
|
-
if (!markdown.includes('[')) return markdown;
|
|
595
|
-
const lines = markdown.split('\n');
|
|
596
|
-
const isProtected = markProtectedLines(lines);
|
|
597
|
-
const defs = new Map<string, string>();
|
|
598
|
-
// The normalized label a definition-shaped line defines, or null if the
|
|
599
|
-
// line isn't a definition (or is a footnote definition, which is never
|
|
600
|
-
// collected/blanked).
|
|
601
|
-
const defLabelByLine = new Array<string | null>(lines.length).fill(null);
|
|
602
|
-
// A definition cannot interrupt a paragraph (CommonMark 4.7): a line matching
|
|
603
|
-
// the definition shape is only a definition when it can start a block, i.e.
|
|
604
|
-
// the previous line is the document start, blank, a protected code/HTML
|
|
605
|
-
// line (each is its own block), or itself a definition. Otherwise the line
|
|
606
|
-
// is paragraph continuation text and must be left untouched, or a bare
|
|
607
|
-
// `[word]: token` under a sentence would be silently deleted.
|
|
608
|
-
let canStartDefinition = true;
|
|
609
|
-
for (let i = 0; i < lines.length; i++) {
|
|
610
|
-
if (isProtected[i]) {
|
|
611
|
-
canStartDefinition = true;
|
|
612
|
-
continue;
|
|
613
|
-
}
|
|
614
|
-
const blank = lines[i].trim() === '';
|
|
615
|
-
const match = canStartDefinition && !blank ? lines[i].match(REFERENCE_DEFINITION_RE) : null;
|
|
616
|
-
if (match) {
|
|
617
|
-
const rawLabel = match[1];
|
|
618
|
-
// GFM footnote definition ([^label]: ...) — not a link reference
|
|
619
|
-
// definition. Leave it out of `defs` entirely so it can never be
|
|
620
|
-
// collected, blanked, or accidentally satisfy a footnote reference's
|
|
621
|
-
// lookup; it stays block-starting like any other definition line.
|
|
622
|
-
if (!rawLabel.startsWith('^') && defs.size < MAX_TRACKED_DEFINITIONS) {
|
|
623
|
-
const label = normalizeRefLabel(rawLabel);
|
|
624
|
-
const dest = match[2] !== undefined ? match[2] : match[3];
|
|
625
|
-
// First definition wins, per CommonMark.
|
|
626
|
-
if (label && dest && !defs.has(label)) defs.set(label, dest);
|
|
627
|
-
defLabelByLine[i] = label;
|
|
628
|
-
}
|
|
629
|
-
// A run of definitions stays eligible; canStartDefinition remains true.
|
|
630
|
-
} else {
|
|
631
|
-
// Blank keeps a new block startable; any other non-definition line starts
|
|
632
|
-
// (or continues) a paragraph, so a following definition-shaped line is text.
|
|
633
|
-
canStartDefinition = blank;
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
if (defs.size === 0) return markdown;
|
|
637
|
-
const usedLabels = new Set<string>();
|
|
638
|
-
// Resolve references first; definition-shaped lines are passed through
|
|
639
|
-
// unresolved (never fed to resolveRefsInLine) so a definition's own
|
|
640
|
-
// `[label]` can never be mistaken for a reference to itself.
|
|
641
|
-
const resolved = lines.map((line, i) =>
|
|
642
|
-
isProtected[i] || defLabelByLine[i] !== null ? line : resolveRefsInLine(line, defs, usedLabels),
|
|
643
|
-
);
|
|
644
|
-
return resolved
|
|
645
|
-
.map((line, i) => {
|
|
646
|
-
const label = defLabelByLine[i];
|
|
647
|
-
if (label === null || !usedLabels.has(label)) return line;
|
|
648
|
-
// Blank in place, preserving this line's own CRLF ending if it had one.
|
|
649
|
-
return line.endsWith('\r') ? '\r' : '';
|
|
650
|
-
})
|
|
651
|
-
.join('\n');
|
|
652
|
-
};
|
|
653
299
|
|
|
654
300
|
/**
|
|
655
301
|
* The block list for a whole-file diagram source (`plannotator annotate
|
|
@@ -861,22 +507,17 @@ export const parseMarkdownToBlocks = (markdown: string, options?: ParseMarkdownO
|
|
|
861
507
|
continue;
|
|
862
508
|
}
|
|
863
509
|
|
|
864
|
-
// Code blocks (naive)
|
|
510
|
+
// Code blocks (naive). Count backticks in the opening fence to support
|
|
511
|
+
// nested fences (e.g. ```` wrapping ```); the extent is shared with core.
|
|
865
512
|
if (trimmed.startsWith('```')) {
|
|
866
513
|
flush();
|
|
867
514
|
const codeStartLine = currentLineNum;
|
|
868
|
-
// Count backticks in opening fence to support nested fences (e.g. ```` wrapping ```)
|
|
869
515
|
const fenceLen = trimmed.match(/^`+/)?.[0].length ?? 3;
|
|
870
|
-
const closingFence = new RegExp('^\\s*`{' + fenceLen + ',}');
|
|
871
516
|
// Extract language from fence (e.g., ```rust → "rust")
|
|
872
517
|
const language = trimmed.slice(fenceLen).trim() || undefined;
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
i
|
|
876
|
-
while(i < lines.length && !closingFence.test(lines[i])) {
|
|
877
|
-
codeContent.push(lines[i]);
|
|
878
|
-
i++;
|
|
879
|
-
}
|
|
518
|
+
const close = codeFenceCloseIndex(lines, i);
|
|
519
|
+
const codeContent = lines.slice(i + 1, close);
|
|
520
|
+
i = close;
|
|
880
521
|
blocks.push({
|
|
881
522
|
id: `block-${currentId++}`,
|
|
882
523
|
type: 'code',
|
|
@@ -888,127 +529,32 @@ export const parseMarkdownToBlocks = (markdown: string, options?: ParseMarkdownO
|
|
|
888
529
|
continue;
|
|
889
530
|
}
|
|
890
531
|
|
|
891
|
-
// Display math: $$ ... $$ — only when
|
|
892
|
-
// unclosed
|
|
893
|
-
//
|
|
894
|
-
//
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
let closed = false;
|
|
901
|
-
let closeLine = i;
|
|
902
|
-
// Find the closing $$ anywhere on the opening line (not just at its end),
|
|
903
|
-
// so `$$x$$.` or `$$x$$ trailing` close correctly instead of running on.
|
|
904
|
-
const inlineClose = afterOpen.indexOf('$$');
|
|
905
|
-
if (inlineClose !== -1) {
|
|
906
|
-
const body = afterOpen.slice(0, inlineClose).trim();
|
|
907
|
-
if (body) mathLines.push(body);
|
|
908
|
-
remainder = afterOpen.slice(inlineClose + 2).trim();
|
|
909
|
-
closed = true;
|
|
910
|
-
} else {
|
|
911
|
-
const scanned: string[] = afterOpen.trim() ? [afterOpen.trim()] : [];
|
|
912
|
-
let j = i;
|
|
913
|
-
while (j + 1 < lines.length) {
|
|
914
|
-
j++;
|
|
915
|
-
// A blank line ends the search: real $$…$$ has no blank line before its
|
|
916
|
-
// close, so a blank means this opener was never closed. Stopping here
|
|
917
|
-
// also prevents matching a stray $$ far below (e.g. inside a later code
|
|
918
|
-
// fence) and swallowing everything in between.
|
|
919
|
-
if (lines[j].trim() === '') break;
|
|
920
|
-
const closeAt = lines[j].indexOf('$$');
|
|
921
|
-
if (closeAt !== -1) {
|
|
922
|
-
const before = lines[j].slice(0, closeAt);
|
|
923
|
-
if (before.trim()) scanned.push(before);
|
|
924
|
-
remainder = lines[j].slice(closeAt + 2).trim();
|
|
925
|
-
closed = true;
|
|
926
|
-
closeLine = j;
|
|
927
|
-
break;
|
|
928
|
-
}
|
|
929
|
-
scanned.push(lines[j]);
|
|
930
|
-
}
|
|
931
|
-
if (closed) for (const s of scanned) mathLines.push(s);
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
if (closed) {
|
|
935
|
-
flush();
|
|
936
|
-
i = closeLine;
|
|
937
|
-
blocks.push({
|
|
938
|
-
id: `block-${currentId++}`,
|
|
939
|
-
type: 'math',
|
|
940
|
-
content: mathLines.join('\n'),
|
|
941
|
-
order: currentId,
|
|
942
|
-
startLine: mathStartLine,
|
|
943
|
-
sourceLineCount: i + contentStartLine - mathStartLine + 1,
|
|
944
|
-
});
|
|
945
|
-
// Trailing text after the closing $$ isn't math — reprocess it as its own
|
|
946
|
-
// line so it renders normally instead of being swallowed into the block.
|
|
947
|
-
if (remainder) {
|
|
948
|
-
lines[i] = remainder;
|
|
949
|
-
i--;
|
|
950
|
-
}
|
|
951
|
-
continue;
|
|
952
|
-
}
|
|
953
|
-
// No closing $$ found — not display math; fall through to normal handling.
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
// Display math: \[ ... \] — same unclosed-guard as $$ above: only treat as
|
|
957
|
-
// math when the closing \] exists, so a stray `\[deprecated\]`-style line or
|
|
958
|
-
// an unclosed `\[` doesn't swallow the rest of the document.
|
|
959
|
-
if (trimmed.startsWith('\\[')) {
|
|
532
|
+
// Display math: $$ ... $$ or \[ ... \] — only when the close actually
|
|
533
|
+
// exists. An unclosed opener (a stray delimiter, informal money like
|
|
534
|
+
// "$$100k for infra", a `\[deprecated\]`-style line) must NOT swallow the
|
|
535
|
+
// rest of the document: scanDisplayMath scans ahead without committing
|
|
536
|
+
// and answers null, and the line falls through as ordinary text.
|
|
537
|
+
const mathDelimiter = trimmed.startsWith('$$') ? '$$' : trimmed.startsWith('\\[') ? '\\[' : null;
|
|
538
|
+
const math = mathDelimiter ? scanDisplayMath(lines, i, mathDelimiter) : null;
|
|
539
|
+
if (math) {
|
|
540
|
+
flush();
|
|
960
541
|
const mathStartLine = currentLineNum;
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
while (j + 1 < lines.length) {
|
|
976
|
-
j++;
|
|
977
|
-
// Blank line ends the search (see the $$ branch): unclosed \[ must not
|
|
978
|
-
// swallow later blocks or match a stray \] inside a later code fence.
|
|
979
|
-
if (lines[j].trim() === '') break;
|
|
980
|
-
const closeAt = lines[j].indexOf('\\]');
|
|
981
|
-
if (closeAt !== -1) {
|
|
982
|
-
const before = lines[j].slice(0, closeAt);
|
|
983
|
-
if (before.trim()) scanned.push(before);
|
|
984
|
-
remainder = lines[j].slice(closeAt + 2).trim();
|
|
985
|
-
closed = true;
|
|
986
|
-
closeLine = j;
|
|
987
|
-
break;
|
|
988
|
-
}
|
|
989
|
-
scanned.push(lines[j]);
|
|
990
|
-
}
|
|
991
|
-
if (closed) for (const s of scanned) mathLines.push(s);
|
|
992
|
-
}
|
|
993
|
-
|
|
994
|
-
if (closed) {
|
|
995
|
-
flush();
|
|
996
|
-
i = closeLine;
|
|
997
|
-
blocks.push({
|
|
998
|
-
id: `block-${currentId++}`,
|
|
999
|
-
type: 'math',
|
|
1000
|
-
content: mathLines.join('\n'),
|
|
1001
|
-
order: currentId,
|
|
1002
|
-
startLine: mathStartLine,
|
|
1003
|
-
sourceLineCount: i + contentStartLine - mathStartLine + 1,
|
|
1004
|
-
});
|
|
1005
|
-
if (remainder) {
|
|
1006
|
-
lines[i] = remainder;
|
|
1007
|
-
i--;
|
|
1008
|
-
}
|
|
1009
|
-
continue;
|
|
542
|
+
i = math.closeLine;
|
|
543
|
+
blocks.push({
|
|
544
|
+
id: `block-${currentId++}`,
|
|
545
|
+
type: 'math',
|
|
546
|
+
content: math.body.join('\n'),
|
|
547
|
+
order: currentId,
|
|
548
|
+
startLine: mathStartLine,
|
|
549
|
+
sourceLineCount: i + contentStartLine - mathStartLine + 1,
|
|
550
|
+
});
|
|
551
|
+
// Trailing text after the close isn't math — reprocess it as its own
|
|
552
|
+
// line so it renders normally instead of being swallowed into the block.
|
|
553
|
+
if (math.remainder) {
|
|
554
|
+
lines[i] = math.remainder;
|
|
555
|
+
i--;
|
|
1010
556
|
}
|
|
1011
|
-
|
|
557
|
+
continue;
|
|
1012
558
|
}
|
|
1013
559
|
|
|
1014
560
|
// Tables (lines starting with |)
|
|
@@ -1039,27 +585,17 @@ export const parseMarkdownToBlocks = (markdown: string, options?: ParseMarkdownO
|
|
|
1039
585
|
continue;
|
|
1040
586
|
}
|
|
1041
587
|
|
|
1042
|
-
// Raw HTML blocks. A line starting with a known block-level HTML tag
|
|
1043
|
-
// opens an HTML block. For opening tags we accumulate until the matching
|
|
1044
|
-
// close tag is balanced (so `<details>…blank line…</details>` renders as
|
|
1045
|
-
// one unit, matching GitHub's flavored behavior rather than strict
|
|
1046
|
-
// CommonMark §4.6 Type 6 blank-line termination). For a line that starts
|
|
1047
|
-
// with a close tag, we fall back to blank-line termination. Content is
|
|
1048
|
-
// sanitized at render time, not here.
|
|
1049
588
|
// Directive container: `:::kind` opens, `:::` closes. Inline kind is
|
|
1050
589
|
// restricted to simple identifiers (letters, digits, hyphens). Body is
|
|
1051
590
|
// accumulated verbatim and rendered with inline markdown.
|
|
1052
|
-
const directiveOpen = trimmed.match(
|
|
591
|
+
const directiveOpen = trimmed.match(DIRECTIVE_OPEN_RE);
|
|
1053
592
|
if (directiveOpen) {
|
|
1054
593
|
flush();
|
|
1055
594
|
const directiveStartLine = currentLineNum;
|
|
1056
595
|
const kind = directiveOpen[1].toLowerCase();
|
|
1057
|
-
const
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
if (lines[i].trim() === ':::') break;
|
|
1061
|
-
bodyLines.push(lines[i]);
|
|
1062
|
-
}
|
|
596
|
+
const close = directiveCloseIndex(lines, i);
|
|
597
|
+
const bodyLines = lines.slice(i + 1, close);
|
|
598
|
+
i = close;
|
|
1063
599
|
blocks.push({
|
|
1064
600
|
id: `block-${currentId++}`,
|
|
1065
601
|
type: 'directive',
|
|
@@ -1071,43 +607,19 @@ export const parseMarkdownToBlocks = (markdown: string, options?: ParseMarkdownO
|
|
|
1071
607
|
continue;
|
|
1072
608
|
}
|
|
1073
609
|
|
|
1074
|
-
|
|
1075
|
-
|
|
610
|
+
// Raw HTML blocks. A line starting with a known block-level HTML tag
|
|
611
|
+
// opens an HTML block. For opening tags we accumulate until the matching
|
|
612
|
+
// close tag is balanced (so `<details>…blank line…</details>` renders as
|
|
613
|
+
// one unit, matching GitHub's flavored behavior rather than strict
|
|
614
|
+
// CommonMark §4.6 Type 6 blank-line termination). For a line that starts
|
|
615
|
+
// with a close tag, we fall back to blank-line termination. Content is
|
|
616
|
+
// sanitized at render time, not here.
|
|
617
|
+
const htmlEnd = htmlBlockEndAt(lines, i, htmlCloseCache);
|
|
618
|
+
if (htmlEnd !== -1) {
|
|
1076
619
|
flush();
|
|
1077
620
|
const htmlStartLine = currentLineNum;
|
|
1078
|
-
const
|
|
1079
|
-
|
|
1080
|
-
const htmlLines: string[] = [line];
|
|
1081
|
-
|
|
1082
|
-
if (isCloseTag) {
|
|
1083
|
-
while (i + 1 < lines.length && lines[i + 1].trim() !== '') {
|
|
1084
|
-
i++;
|
|
1085
|
-
htmlLines.push(lines[i]);
|
|
1086
|
-
}
|
|
1087
|
-
} else if (VOID_HTML_TAGS.has(tagName)) {
|
|
1088
|
-
// Void element (e.g. <img>): no closing tag, but attributes can wrap across
|
|
1089
|
-
// lines. Consume until the line that actually closes the tag with `>` so a
|
|
1090
|
-
// multi-line <img> isn't truncated to a bare `<img` fragment.
|
|
1091
|
-
while (!lines[i].includes('>') && i + 1 < lines.length && lines[i + 1].trim() !== '') {
|
|
1092
|
-
i++;
|
|
1093
|
-
htmlLines.push(lines[i]);
|
|
1094
|
-
}
|
|
1095
|
-
} else {
|
|
1096
|
-
const openRe = new RegExp(`<${tagName}(?:\\s|>|/|$)`, 'gi');
|
|
1097
|
-
const closeRe = new RegExp(`</${tagName}\\s*>`, 'gi');
|
|
1098
|
-
const depth = (line.match(openRe) || []).length - (line.match(closeRe) || []).length;
|
|
1099
|
-
// Scan ahead for the matching close tag via the shared, bounded/
|
|
1100
|
-
// linear helper (see its doc comment for why a naive per-opener scan
|
|
1101
|
-
// is quadratic). If none is ever found — a self-closing <video/>, or
|
|
1102
|
-
// an unclosed <picture>/<div> — do NOT swallow the rest of the
|
|
1103
|
-
// document into this block; keep it to the opening line.
|
|
1104
|
-
const end = findHtmlBlockEnd(lines, i, tagName, depth, htmlCloseCache);
|
|
1105
|
-
if (end > i) {
|
|
1106
|
-
for (let k = i + 1; k <= end; k++) htmlLines.push(lines[k]);
|
|
1107
|
-
i = end;
|
|
1108
|
-
}
|
|
1109
|
-
}
|
|
1110
|
-
|
|
621
|
+
const htmlLines = lines.slice(i, htmlEnd + 1);
|
|
622
|
+
i = htmlEnd;
|
|
1111
623
|
blocks.push({
|
|
1112
624
|
id: `block-${currentId++}`,
|
|
1113
625
|
type: 'html',
|
|
@@ -1380,6 +892,11 @@ const additionalTargetsExportBlock = (ann: any): string => {
|
|
|
1380
892
|
* panel chrome does not use it.
|
|
1381
893
|
*/
|
|
1382
894
|
export const exportAnnotationEntry = (ann: any, opts: ElementContextExportOptions = { includeRoute: true }): string => {
|
|
895
|
+
// An answer to a `:::question` block: the question and the answer lines.
|
|
896
|
+
const answer = ann?.questionAnswer == null ? null : parseQuestionAnswer(ann.questionAnswer);
|
|
897
|
+
if (answer) {
|
|
898
|
+
return `Answer to the question "${safeInline(answer.prompt, 400)}"\n${formatQuestionAnswerLines(answer)}`;
|
|
899
|
+
}
|
|
1383
900
|
let output = '';
|
|
1384
901
|
switch (ann?.type) {
|
|
1385
902
|
case 'DELETION':
|
|
@@ -1431,15 +948,47 @@ const lineLabelForAnnotation = (blocks: Block[], ann: any): string | null => {
|
|
|
1431
948
|
return `lines ${block.startLine}–${end}`;
|
|
1432
949
|
};
|
|
1433
950
|
|
|
951
|
+
/** Separate valid question answers from ordinary feedback. A row whose
|
|
952
|
+
* `questionAnswer` fails validation stays ordinary feedback (its one-line
|
|
953
|
+
* text still reads), so nothing is ever dropped. */
|
|
954
|
+
const splitQuestionAnswers = (list: any[]): { answers: QuestionAnswer[]; annotations: any[] } => {
|
|
955
|
+
const answers: QuestionAnswer[] = [];
|
|
956
|
+
const annotations: any[] = [];
|
|
957
|
+
for (const ann of list) {
|
|
958
|
+
const answer = ann?.questionAnswer == null ? null : parseQuestionAnswer(ann.questionAnswer);
|
|
959
|
+
if (answer) answers.push(answer);
|
|
960
|
+
else annotations.push(ann);
|
|
961
|
+
}
|
|
962
|
+
return { answers, annotations };
|
|
963
|
+
};
|
|
964
|
+
|
|
965
|
+
/** Export items when a document's blocks are unknown: the answers' own
|
|
966
|
+
* prompts and lines, numbered in answer order. */
|
|
967
|
+
const questionItemsFromAnswers = (answers: QuestionAnswer[]): QuestionExportItem[] =>
|
|
968
|
+
answers.map((a, i) => ({
|
|
969
|
+
key: a.key,
|
|
970
|
+
number: i + 1,
|
|
971
|
+
prompt: a.prompt,
|
|
972
|
+
...(a.sourceLine ? { line: a.sourceLine } : {}),
|
|
973
|
+
recommendedLabels: [],
|
|
974
|
+
settled: false,
|
|
975
|
+
}));
|
|
976
|
+
|
|
1434
977
|
export const exportAnnotations = (
|
|
1435
978
|
blocks: Block[],
|
|
1436
|
-
|
|
979
|
+
allAnnotations: any[],
|
|
1437
980
|
globalAttachments: ImageAttachment[] = [],
|
|
1438
981
|
title: string = 'Plan Feedback',
|
|
1439
982
|
subject: string = 'plan',
|
|
1440
983
|
opts: ExportAnnotationsOptions = {},
|
|
1441
984
|
): string => {
|
|
1442
|
-
|
|
985
|
+
// Answers to `:::question` blocks are printed first, in their own section,
|
|
986
|
+
// and never counted as numbered feedback.
|
|
987
|
+
const { answers, annotations } = splitQuestionAnswers(allAnnotations);
|
|
988
|
+
const answersSection = answers.length > 0
|
|
989
|
+
? formatQuestionAnswersSection(questionExportItems(indexQuestionBlocks(blocks)), answers, { headingLevel: 2 })
|
|
990
|
+
: '';
|
|
991
|
+
if (annotations.length === 0 && globalAttachments.length === 0 && !answersSection) {
|
|
1443
992
|
return 'No changes detected.';
|
|
1444
993
|
}
|
|
1445
994
|
|
|
@@ -1461,6 +1010,8 @@ export const exportAnnotations = (
|
|
|
1461
1010
|
output += `> Note: Line numbers below refer to the converted markdown, not the original HTML/URL source.\n\n`;
|
|
1462
1011
|
}
|
|
1463
1012
|
|
|
1013
|
+
output += answersSection;
|
|
1014
|
+
|
|
1464
1015
|
// Add global reference images section if any
|
|
1465
1016
|
if (globalAttachments.length > 0) {
|
|
1466
1017
|
output += `## Reference Images\n`;
|
|
@@ -1673,11 +1224,20 @@ export const exportLinkedDocAnnotations = (
|
|
|
1673
1224
|
// One injection per export, across all linked documents.
|
|
1674
1225
|
const injectedSkills = new Set<string>();
|
|
1675
1226
|
|
|
1676
|
-
for (const [filepath, { annotations, globalAttachments, blocks: docBlocks, isConverted }] of docAnnotations) {
|
|
1677
|
-
if (
|
|
1227
|
+
for (const [filepath, { annotations: docAnnotationList, globalAttachments, blocks: docBlocks, markdown: docMarkdown, isConverted }] of docAnnotations) {
|
|
1228
|
+
if (docAnnotationList.length === 0 && globalAttachments.length === 0) continue;
|
|
1229
|
+
const { answers, annotations } = splitQuestionAnswers(docAnnotationList);
|
|
1678
1230
|
|
|
1679
1231
|
output += `## ${filepath}${isConverted ? ' (converted from HTML — line numbers refer to converted markdown)' : ''}\n\n`;
|
|
1680
1232
|
|
|
1233
|
+
if (answers.length > 0) {
|
|
1234
|
+
const questionBlocks = docBlocks ?? (docMarkdown !== undefined ? parseMarkdownToBlocks(docMarkdown) : null);
|
|
1235
|
+
const items = questionBlocks
|
|
1236
|
+
? questionExportItems(indexQuestionBlocks(questionBlocks))
|
|
1237
|
+
: questionItemsFromAnswers(answers);
|
|
1238
|
+
output += formatQuestionAnswersSection(items, answers, { headingLevel: 3 });
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1681
1241
|
if (globalAttachments.length > 0) {
|
|
1682
1242
|
output += `### Reference Images\n`;
|
|
1683
1243
|
output += `Please review these reference images (use the Read tool to view):\n`;
|
|
@@ -1693,7 +1253,9 @@ export const exportLinkedDocAnnotations = (
|
|
|
1693
1253
|
return a.startOffset - b.startOffset;
|
|
1694
1254
|
});
|
|
1695
1255
|
|
|
1696
|
-
|
|
1256
|
+
if (annotations.length > 0 || answers.length === 0) {
|
|
1257
|
+
output += `I've reviewed this document and have ${annotations.length} piece${annotations.length !== 1 ? 's' : ''} of feedback:\n\n`;
|
|
1258
|
+
}
|
|
1697
1259
|
|
|
1698
1260
|
sortedAnns.forEach((ann, index) => {
|
|
1699
1261
|
output += `### ${index + 1}. `;
|