@plannotator/ui 0.28.0 → 0.29.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/README.md +6 -0
- package/components/AISettingsTab.tsx +5 -4
- package/components/ActionMenu.tsx +5 -1
- package/components/AgentsTab.tsx +10 -23
- package/components/AnnotationPanel.tsx +9 -5
- package/components/AnnotationToolbar.tsx +5 -13
- package/components/AnnotationToolstrip.tsx +2 -2
- package/components/ApproveDropdown.tsx +1 -1
- package/components/BlockRenderer.tsx +1 -1
- package/components/CodeFilePopout.tsx +5 -4
- package/components/CommentPopover.tsx +391 -65
- package/components/DocBadges.tsx +29 -11
- package/components/ExportModal.tsx +16 -8
- package/components/GraphvizBlock.tsx +1 -1
- package/components/InlineMarkdown.tsx +30 -13
- package/components/KeyboardShortcuts.tsx +37 -2
- package/components/Landing.tsx +7 -7
- package/components/MenuVersionSection.tsx +4 -4
- package/components/MermaidBlock.tsx +1 -1
- package/components/ModeToggle.tsx +7 -6
- package/components/OpenInAppButton.tsx +2 -5
- package/components/PinpointOverlay.tsx +9 -7
- package/components/PlanHeaderMenu.tsx +8 -8
- package/components/PopoutDialog.tsx +6 -1
- package/components/ResizeHandle.tsx +1 -0
- package/components/Settings.tsx +172 -12
- package/components/SkillReferenceMenu.tsx +260 -0
- package/components/StickyHeaderLane.tsx +7 -0
- package/components/ThemeProvider.tsx +131 -32
- package/components/ThemeTab.tsx +123 -77
- package/components/ToolbarButtons.tsx +29 -8
- package/components/Viewer.tsx +396 -130
- package/components/VimKeyHud.tsx +695 -0
- package/components/VimModeAnnouncementDialog.tsx +557 -0
- package/components/VimModeOverlay.tsx +235 -0
- package/components/VimTargetReticle.tsx +284 -0
- package/components/ai/DocumentAIChatPanel.tsx +1 -1
- package/components/blocks/CodeBlock.tsx +18 -18
- package/components/blocks/TablePopout.tsx +7 -8
- package/components/blocks/TableToolbar.tsx +7 -8
- package/components/goal-setup/GoalSetupSurface.tsx +16 -3
- package/components/html-viewer/HtmlViewer.tsx +450 -47
- package/components/html-viewer/annotationNumbering.ts +37 -0
- package/components/html-viewer/bridge-script.ts +4051 -298
- package/components/html-viewer/composerYield.ts +51 -0
- package/components/html-viewer/srcdoc.ts +18 -3
- package/components/html-viewer/useHtmlAnnotation.ts +457 -32
- package/components/icons/themeIcons.tsx +1 -1
- package/components/plan-diff/PlanCleanDiffView.tsx +9 -9
- package/components/plan-diff/PlanDiffBadge.tsx +22 -1
- package/components/settings/HooksTab.tsx +12 -8
- package/components/sidebar/FileBrowser.tsx +4 -1
- package/components/themeModes.tsx +28 -0
- package/config/configStore.ts +76 -1
- package/config/settings.ts +152 -0
- package/configure.ts +9 -0
- package/globals.d.ts +7 -1
- package/hooks/useAIChat.ts +5 -2
- package/hooks/useAIProviderActivation.ts +47 -0
- package/hooks/useAIProviderConfig.ts +5 -1
- package/hooks/useAgentSettings.ts +64 -23
- package/hooks/useAgents.ts +4 -4
- package/hooks/useAnnotationHighlighter.ts +100 -3
- package/hooks/useArchive.ts +2 -1
- package/hooks/useFenceTheme.ts +17 -0
- package/hooks/useLinkedDoc.ts +68 -1
- package/hooks/usePinpoint.ts +76 -75
- package/hooks/usePlanDiff.ts +73 -2
- package/hooks/useSkillReferenceAutocomplete.ts +239 -0
- package/hooks/useUpdateCheck.ts +1 -2
- package/hooks/useVimDocumentFocus.ts +116 -0
- package/hooks/useVimSelection.ts +1063 -0
- package/package.json +4 -4
- package/print.css +14 -13
- package/shortcuts/core.ts +38 -13
- package/shortcuts/index.ts +10 -0
- package/shortcuts/plan-review/commentPopover.shortcuts.ts +7 -0
- package/shortcuts/plan-review/vimSelection.shortcuts.ts +251 -0
- package/shortcuts/runtime.ts +111 -12
- package/styles.css +1 -1
- package/theme.css +504 -0
- package/themes/colorblind.css +89 -0
- package/themes/plannotator.css +2 -2
- package/types.ts +93 -10
- package/utils/agentSwitch.ts +33 -7
- package/utils/blockTargeting.ts +462 -178
- package/utils/clipboard.ts +110 -0
- package/utils/codeBlockMark.ts +50 -0
- package/utils/codeHighlight.ts +293 -0
- package/utils/codexModels.ts +79 -0
- package/utils/domSelection.ts +84 -0
- package/utils/htmlChrome.ts +73 -0
- package/utils/inputMethod.ts +79 -6
- package/utils/parser.ts +517 -21
- package/utils/preferenceTtl.ts +15 -0
- package/utils/sharing.ts +0 -1
- package/utils/skillCatalog.ts +269 -0
- package/utils/skillReferences.ts +475 -0
- package/utils/syntaxTheme.ts +83 -0
- package/utils/themeRegistry.ts +154 -0
- package/utils/vimHud.ts +263 -0
- package/utils/vimModeAnnouncement.ts +23 -0
- package/utils/vimNavigation.ts +417 -0
- package/utils/vimReticle.ts +88 -0
- package/utils/vimScroll.ts +162 -0
package/utils/parser.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Block, Annotation, CodeAnnotation, EditorAnnotation, ImageAttachment } from '../types';
|
|
2
2
|
import { planDenyFeedback } from '@plannotator/core/feedback-templates';
|
|
3
|
+
import { skillReferenceExportBlock } from './skillReferences';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Parsed YAML frontmatter as key-value pairs.
|
|
@@ -8,6 +9,76 @@ export interface Frontmatter {
|
|
|
8
9
|
[key: string]: string | string[];
|
|
9
10
|
}
|
|
10
11
|
|
|
12
|
+
/** Number of leading whitespace characters on a line. */
|
|
13
|
+
function indentWidth(line: string): number {
|
|
14
|
+
return line.length - line.trimStart().length;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Strip the common leading indentation shared by all non-empty lines. */
|
|
18
|
+
function dedentLines(lines: string[]): string[] {
|
|
19
|
+
const indents = lines.filter((l) => l !== '').map(indentWidth);
|
|
20
|
+
const minIndent = indents.length ? Math.min(...indents) : 0;
|
|
21
|
+
return lines.map((l) => (l === '' ? '' : l.slice(minIndent)));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Fold YAML `>`-style scalar lines: adjacent non-empty lines join with a
|
|
26
|
+
* single space, and a run of N blank lines between paragraphs folds to N
|
|
27
|
+
* newlines.
|
|
28
|
+
*/
|
|
29
|
+
function foldScalarLines(lines: string[]): string {
|
|
30
|
+
let text = '';
|
|
31
|
+
let started = false;
|
|
32
|
+
let blanks = 0;
|
|
33
|
+
for (const l of lines) {
|
|
34
|
+
if (l === '') {
|
|
35
|
+
blanks++;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (!started) {
|
|
39
|
+
text = l;
|
|
40
|
+
started = true;
|
|
41
|
+
} else {
|
|
42
|
+
text += blanks > 0 ? '\n'.repeat(blanks) : ' ';
|
|
43
|
+
text += l;
|
|
44
|
+
}
|
|
45
|
+
blanks = 0;
|
|
46
|
+
}
|
|
47
|
+
return text;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Parse a YAML block scalar (`|` literal keeps newlines / `>` folded joins
|
|
52
|
+
* with spaces) whose body is the run of lines below `bodyStart` indented
|
|
53
|
+
* deeper than `keyIndent`. Trailing blank lines are dropped and chomping
|
|
54
|
+
* indicators are treated as strip. Returns the value and the index of the
|
|
55
|
+
* last line the scalar consumed.
|
|
56
|
+
*/
|
|
57
|
+
function parseBlockScalar(
|
|
58
|
+
lines: string[],
|
|
59
|
+
bodyStart: number,
|
|
60
|
+
keyIndent: number,
|
|
61
|
+
folded: boolean,
|
|
62
|
+
): { value: string; endIndex: number } {
|
|
63
|
+
const body: string[] = [];
|
|
64
|
+
let j = bodyStart;
|
|
65
|
+
for (; j < lines.length; j++) {
|
|
66
|
+
// CRLF sources split on '\n' leave a trailing '\r' that would otherwise
|
|
67
|
+
// survive into the folded value (every other parser path trims lines).
|
|
68
|
+
const bodyLine = lines[j].replace(/\r$/, '');
|
|
69
|
+
if (bodyLine.trim() === '') {
|
|
70
|
+
body.push('');
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (indentWidth(bodyLine) <= keyIndent) break; // dedent ends the block
|
|
74
|
+
body.push(bodyLine);
|
|
75
|
+
}
|
|
76
|
+
const dedented = dedentLines(body);
|
|
77
|
+
while (dedented.length && dedented[dedented.length - 1] === '') dedented.pop();
|
|
78
|
+
const value = (folded ? foldScalarLines(dedented) : dedented.join('\n')).trim();
|
|
79
|
+
return { value, endIndex: j - 1 };
|
|
80
|
+
}
|
|
81
|
+
|
|
11
82
|
/**
|
|
12
83
|
* Extract YAML frontmatter from markdown if present.
|
|
13
84
|
* Returns the parsed frontmatter, the remaining markdown, and the 1-based
|
|
@@ -44,8 +115,10 @@ export function extractFrontmatter(markdown: string): { frontmatter: Frontmatter
|
|
|
44
115
|
let currentKey: string | null = null;
|
|
45
116
|
let currentArray: string[] | null = null;
|
|
46
117
|
|
|
47
|
-
|
|
48
|
-
|
|
118
|
+
const lines = frontmatterRaw.split('\n');
|
|
119
|
+
for (let i = 0; i < lines.length; i++) {
|
|
120
|
+
const rawLine = lines[i];
|
|
121
|
+
const trimmedLine = rawLine.trim();
|
|
49
122
|
|
|
50
123
|
// Array item (- value)
|
|
51
124
|
if (trimmedLine.startsWith('- ') && currentKey) {
|
|
@@ -65,6 +138,27 @@ export function extractFrontmatter(markdown: string): { frontmatter: Frontmatter
|
|
|
65
138
|
const value = trimmedLine.slice(colonIndex + 1).trim();
|
|
66
139
|
currentArray = null;
|
|
67
140
|
|
|
141
|
+
// Block scalar: `|` (literal, keep newlines) or `>` (folded, join with
|
|
142
|
+
// spaces), each with optional chomping indicator (`-`/`+`). The value
|
|
143
|
+
// spans the following lines indented deeper than the key, e.g.
|
|
144
|
+
// description: >-
|
|
145
|
+
// line one
|
|
146
|
+
// line two
|
|
147
|
+
// Without this, the indicator (">-") was stored verbatim and the body
|
|
148
|
+
// silently dropped.
|
|
149
|
+
const blockScalar = value.match(/^([|>])[+-]?$/);
|
|
150
|
+
if (blockScalar) {
|
|
151
|
+
const { value: scalarValue, endIndex } = parseBlockScalar(
|
|
152
|
+
lines,
|
|
153
|
+
i + 1,
|
|
154
|
+
indentWidth(rawLine),
|
|
155
|
+
blockScalar[1] === '>',
|
|
156
|
+
);
|
|
157
|
+
frontmatter[currentKey] = scalarValue;
|
|
158
|
+
i = endIndex;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
68
162
|
if (value) {
|
|
69
163
|
frontmatter[currentKey] = value;
|
|
70
164
|
}
|
|
@@ -101,16 +195,372 @@ const VOID_HTML_TAGS: ReadonlySet<string> = new Set([
|
|
|
101
195
|
|
|
102
196
|
const HTML_BLOCK_OPEN_RE = /^<\/?([a-zA-Z][a-zA-Z0-9]*)(?:\s|>|\/|$)/;
|
|
103
197
|
|
|
198
|
+
export interface ParseMarkdownOptions {
|
|
199
|
+
/**
|
|
200
|
+
* Strip a leading `--- ... ---` pair as frontmatter (default true).
|
|
201
|
+
* Pass false for non-markdown plain-text sources (.yaml/.json/.txt/…)
|
|
202
|
+
* where the delimiters are real content — a multi-document YAML starts
|
|
203
|
+
* with them (see shouldStripFrontmatter in @plannotator/core/annotatable).
|
|
204
|
+
*/
|
|
205
|
+
frontmatter?: boolean;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// CommonMark bounds a link label to 999 characters. Reusing that bound here
|
|
209
|
+
// also caps the worst-case backtracking cost of the bracket-matching groups
|
|
210
|
+
// below to a constant per starting position, turning a document with a very
|
|
211
|
+
// long run of unmatched `[` characters (a real hazard within the 2MB annotate
|
|
212
|
+
// cap) into a linear scan instead of a quadratic one. A label longer than
|
|
213
|
+
// this is a deliberate, documented degradation: it is neither collected as a
|
|
214
|
+
// definition nor resolved as a reference, so it is simply left untouched
|
|
215
|
+
// rather than partially or incorrectly rewritten.
|
|
216
|
+
const MAX_REF_LABEL_CHARS = 999;
|
|
217
|
+
// Same reasoning applied to the inline-code-span alternative: bounding how far
|
|
218
|
+
// a lazy scan for a closing backtick run can travel keeps a line with many
|
|
219
|
+
// stray, unterminated backticks linear too. 5000 is far beyond any realistic
|
|
220
|
+
// inline code span, so legitimate spans are unaffected.
|
|
221
|
+
const MAX_CODE_SPAN_CHARS = 5000;
|
|
222
|
+
// Defense-in-depth cap on the number of definitions collected from a single
|
|
223
|
+
// document. A pathological document could otherwise grow the map without
|
|
224
|
+
// bound; this keeps that growth bounded even though ordinary documents never
|
|
225
|
+
// approach it.
|
|
226
|
+
const MAX_TRACKED_DEFINITIONS = 20_000;
|
|
227
|
+
|
|
228
|
+
// A link reference definition: `[label]: destination "optional title"`, with up
|
|
229
|
+
// to three leading spaces. The destination is a bare token or an <...> form; any
|
|
230
|
+
// trailing text must be a quoted or parenthesized title, otherwise the line is
|
|
231
|
+
// ordinary prose (so `[Reminder]: call the bank` is NOT a definition). Matches
|
|
232
|
+
// the CommonMark shape closely enough for the simplified parser. `\r?` before
|
|
233
|
+
// the end anchor tolerates a CRLF source (lines are split on `\n` only, so a
|
|
234
|
+
// CRLF line keeps its trailing `\r`).
|
|
235
|
+
const REFERENCE_DEFINITION_RE = new RegExp(
|
|
236
|
+
`^ {0,3}\\[([^\\]]{1,${MAX_REF_LABEL_CHARS}})\\]:[ \\t]*(?:<([^>]*)>|(\\S+))[ \\t]*(?:"[^"]*"|'[^']*'|\\([^)]*\\))?[ \\t]*\\r?$`,
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
// One left-to-right pass over a line. The first alternative matches a whole
|
|
240
|
+
// inline code span (balanced backtick run) so its contents are skipped; the
|
|
241
|
+
// second matches a reference link/image: optional `!`, the bracketed text, then
|
|
242
|
+
// an optional second bracket for the full (`[label]`) or collapsed (`[]`) forms.
|
|
243
|
+
// A bare `[text]` is the shortcut form, resolved only when it names a definition
|
|
244
|
+
// and is not actually an inline link. Groups: 1 code ticks, 2 `!`, 3 text,
|
|
245
|
+
// 4 second bracket, 5 label.
|
|
246
|
+
const REFERENCE_LINK_RE = new RegExp(
|
|
247
|
+
`(\`+)[^\\n]{0,${MAX_CODE_SPAN_CHARS}}?\\1|(!?)\\[([^\\]]{1,${MAX_REF_LABEL_CHARS}})\\](\\[([^\\]]{0,${MAX_REF_LABEL_CHARS}})\\])?`,
|
|
248
|
+
'g',
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
// CommonMark label matching is case-insensitive and collapses internal runs of
|
|
252
|
+
// whitespace.
|
|
253
|
+
const normalizeRefLabel = (label: string): string =>
|
|
254
|
+
label.trim().replace(/\s+/g, ' ').toLowerCase();
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* One pass over the lines that marks every line the block parser (below) will
|
|
258
|
+
* render as code or raw HTML — fenced code blocks and HTML blocks — so link
|
|
259
|
+
* reference definitions and references inside them are left completely
|
|
260
|
+
* untouched. This reuses the exact same conditions the block parser itself
|
|
261
|
+
* uses (not a looser approximation), so the two can never disagree about
|
|
262
|
+
* where code/HTML starts and ends:
|
|
263
|
+
*
|
|
264
|
+
* - Fences: `trimmed.startsWith('```')` after a full `.trim()` — the block
|
|
265
|
+
* parser has no minimum-indent exemption, so ANY indentation (a fence
|
|
266
|
+
* nested inside a list item, or simply indented 4+ spaces) still opens a
|
|
267
|
+
* code block, and this must too. Only backtick fences are recognized —
|
|
268
|
+
* the block parser has no `~~~` support, so this doesn't either (a `~~~`
|
|
269
|
+
* line is ordinary text to both).
|
|
270
|
+
* - Raw HTML blocks: the same `HTML_BLOCK_OPEN_RE`/`HTML_BLOCK_TAGS`/
|
|
271
|
+
* `VOID_HTML_TAGS` the block parser uses, with the same three extents
|
|
272
|
+
* (blank-line termination for a leading close tag, single-line for void
|
|
273
|
+
* tags, balanced-depth scanning otherwise) — so a definition sitting
|
|
274
|
+
* inside `<details>…</details>` or `<pre>…</pre>` is protected exactly as
|
|
275
|
+
* far as the block parser's own HTML block extends.
|
|
276
|
+
*/
|
|
277
|
+
/**
|
|
278
|
+
* Per-tag-name index backing `findHtmlBlockEnd`. `augmented` is the running
|
|
279
|
+
* open-tag-count-minus-close-tag-count prefix sum for this tag name, with a
|
|
280
|
+
* virtual baseline of 0 prepended at index 0 — so `augmented[k]` is the sum
|
|
281
|
+
* through line `k-1` (the depth baseline a block opening at line `k` must
|
|
282
|
+
* return to) and `augmented[k+1]` is the sum through line `k`.
|
|
283
|
+
* `nextAtOrBelow[m]` is the classic "next element at or below this one"
|
|
284
|
+
* index over `augmented`: the smallest `m' > m` with `augmented[m'] <=
|
|
285
|
+
* augmented[m]`, or -1 if none exists.
|
|
286
|
+
*/
|
|
287
|
+
interface TagCloseIndex {
|
|
288
|
+
augmented: number[];
|
|
289
|
+
nextAtOrBelow: number[];
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Builds a `TagCloseIndex` for one tag name in a single O(N) pass (plus a
|
|
294
|
+
* classic O(N) monotonic-stack pass for `nextAtOrBelow` — each index is
|
|
295
|
+
* pushed and popped at most once, so the two passes together are linear in
|
|
296
|
+
* the document's line count, independent of how many opening/closing tags
|
|
297
|
+
* it contains).
|
|
298
|
+
*/
|
|
299
|
+
function buildTagCloseIndex(lines: string[], tagName: string): TagCloseIndex {
|
|
300
|
+
const openRe = new RegExp(`<${tagName}(?:\\s|>|/|$)`, 'gi');
|
|
301
|
+
const closeRe = new RegExp(`</${tagName}\\s*>`, 'gi');
|
|
302
|
+
const n = lines.length;
|
|
303
|
+
const augmented = new Array<number>(n + 1);
|
|
304
|
+
augmented[0] = 0;
|
|
305
|
+
let running = 0;
|
|
306
|
+
for (let k = 0; k < n; k++) {
|
|
307
|
+
running += (lines[k].match(openRe) || []).length;
|
|
308
|
+
running -= (lines[k].match(closeRe) || []).length;
|
|
309
|
+
augmented[k + 1] = running;
|
|
310
|
+
}
|
|
311
|
+
const nextAtOrBelow = new Array<number>(n + 1).fill(-1);
|
|
312
|
+
const stack: number[] = [];
|
|
313
|
+
for (let m = n; m >= 0; m--) {
|
|
314
|
+
while (stack.length && augmented[stack[stack.length - 1]] > augmented[m]) stack.pop();
|
|
315
|
+
nextAtOrBelow[m] = stack.length ? stack[stack.length - 1] : -1;
|
|
316
|
+
stack.push(m);
|
|
317
|
+
}
|
|
318
|
+
return { augmented, nextAtOrBelow };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Shared helper computing the last line index of a balanced open/close-tag
|
|
323
|
+
* HTML block that opens at `startIndex` with the given already-computed
|
|
324
|
+
* `depth` (the opening line's own open-tag count minus close-tag count).
|
|
325
|
+
* Used by both `markProtectedLines` (the resolver's protection pass) and
|
|
326
|
+
* `parseMarkdownToBlocks` (the block parser) so the two can never disagree
|
|
327
|
+
* about a multi-line HTML block's extent, and so a fix here lives in exactly
|
|
328
|
+
* one place instead of two copies drifting apart.
|
|
329
|
+
*
|
|
330
|
+
* History: naively scanning line-by-line from `startIndex` until depth
|
|
331
|
+
* returns to zero (or giving up at end-of-document) is O(N^2) for a
|
|
332
|
+
* document with many consecutive unclosed openers (e.g. thousands of bare
|
|
333
|
+
* `<div>` lines), since every one of them re-scans to EOF. A first fix
|
|
334
|
+
* added an O(1) "does a close exist anywhere" pre-check plus a fixed
|
|
335
|
+
* line-count cap on the residual scan — but that cap silently truncated
|
|
336
|
+
* VALID blocks longer than it, and removing the cap alone reopened a
|
|
337
|
+
* closely related O(N^2) case: N unclosed openers followed by a SINGLE
|
|
338
|
+
* trailing close still all pass the "a close exists somewhere" pre-check,
|
|
339
|
+
* so every one of them still scans forward (mostly to EOF) before giving up.
|
|
340
|
+
*
|
|
341
|
+
* Fixed properly here with a per-tag-name prefix-sum index
|
|
342
|
+
* (`buildTagCloseIndex`, O(N), built once per tag name and cached per
|
|
343
|
+
* document — see `closeCache`): finding "the exact line where a block
|
|
344
|
+
* starting at `startIndex` closes, if ever" is exactly the classic "next
|
|
345
|
+
* smaller-or-equal element" query against that prefix sum, which the index
|
|
346
|
+
* answers in O(1). No scanning happens per opener at all — not for a block
|
|
347
|
+
* that never closes, not for one that closes after any number of
|
|
348
|
+
* intervening lines, however many. This is provably linear overall (a
|
|
349
|
+
* document with T distinct protected tag names costs O(T * N) to index,
|
|
350
|
+
* and T is bounded by the small, fixed `HTML_BLOCK_TAGS` set) and can never
|
|
351
|
+
* truncate a valid block, because it always finds the block's real end
|
|
352
|
+
* (however far away) rather than giving up at a fixed distance.
|
|
353
|
+
*
|
|
354
|
+
* Returns `startIndex` unchanged when the block never closes: depth <= 0,
|
|
355
|
+
* or the running depth never returns to exactly zero anywhere in the rest
|
|
356
|
+
* of the document (whether because no close exists at all, or one exists
|
|
357
|
+
* but is insufficient to bring the count back to exactly the opener's own
|
|
358
|
+
* baseline — e.g. an unbalanced/self-closing tag).
|
|
359
|
+
*/
|
|
360
|
+
function findHtmlBlockEnd(
|
|
361
|
+
lines: string[],
|
|
362
|
+
startIndex: number,
|
|
363
|
+
tagName: string,
|
|
364
|
+
depth: number,
|
|
365
|
+
closeCache: Map<string, TagCloseIndex>,
|
|
366
|
+
): number {
|
|
367
|
+
if (depth <= 0) return startIndex;
|
|
368
|
+
let index = closeCache.get(tagName);
|
|
369
|
+
if (!index) {
|
|
370
|
+
index = buildTagCloseIndex(lines, tagName);
|
|
371
|
+
closeCache.set(tagName, index);
|
|
372
|
+
}
|
|
373
|
+
const { augmented, nextAtOrBelow } = index;
|
|
374
|
+
const m = nextAtOrBelow[startIndex];
|
|
375
|
+
if (m === -1) return startIndex;
|
|
376
|
+
return augmented[m] === augmented[startIndex] ? m - 1 : startIndex;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const markProtectedLines = (lines: string[]): boolean[] => {
|
|
380
|
+
const isProtected = new Array<boolean>(lines.length).fill(false);
|
|
381
|
+
let fenceLen = 0; // 0 = not currently inside a fence
|
|
382
|
+
const closeCache = new Map<string, TagCloseIndex>();
|
|
383
|
+
for (let i = 0; i < lines.length; i++) {
|
|
384
|
+
if (fenceLen > 0) {
|
|
385
|
+
isProtected[i] = true;
|
|
386
|
+
if (new RegExp('^\\s*`{' + fenceLen + ',}').test(lines[i])) fenceLen = 0;
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
const trimmed = lines[i].trim();
|
|
390
|
+
if (trimmed.startsWith('```')) {
|
|
391
|
+
fenceLen = trimmed.match(/^`+/)![0].length;
|
|
392
|
+
isProtected[i] = true;
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
const htmlTagMatch = trimmed.match(HTML_BLOCK_OPEN_RE);
|
|
396
|
+
if (htmlTagMatch && HTML_BLOCK_TAGS.has(htmlTagMatch[1].toLowerCase())) {
|
|
397
|
+
const tagName = htmlTagMatch[1].toLowerCase();
|
|
398
|
+
const isCloseTag = trimmed.startsWith('</');
|
|
399
|
+
isProtected[i] = true;
|
|
400
|
+
if (isCloseTag) {
|
|
401
|
+
while (i + 1 < lines.length && lines[i + 1].trim() !== '') {
|
|
402
|
+
i++;
|
|
403
|
+
isProtected[i] = true;
|
|
404
|
+
}
|
|
405
|
+
} else if (VOID_HTML_TAGS.has(tagName)) {
|
|
406
|
+
while (!lines[i].includes('>') && i + 1 < lines.length && lines[i + 1].trim() !== '') {
|
|
407
|
+
i++;
|
|
408
|
+
isProtected[i] = true;
|
|
409
|
+
}
|
|
410
|
+
} else {
|
|
411
|
+
const openRe = new RegExp(`<${tagName}(?:\\s|>|/|$)`, 'gi');
|
|
412
|
+
const closeRe = new RegExp(`</${tagName}\\s*>`, 'gi');
|
|
413
|
+
const depth = (lines[i].match(openRe) || []).length - (lines[i].match(closeRe) || []).length;
|
|
414
|
+
const end = findHtmlBlockEnd(lines, i, tagName, depth, closeCache);
|
|
415
|
+
if (end > i) {
|
|
416
|
+
for (let idx = i + 1; idx <= end; idx++) isProtected[idx] = true;
|
|
417
|
+
i = end;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return isProtected;
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
/** Resolve reference links/images in one non-code, non-HTML line. A single
|
|
426
|
+
* left-to-right pass: an inline code span is matched as a whole and returned
|
|
427
|
+
* verbatim, so a reference-looking pattern inside backticks is never
|
|
428
|
+
* rewritten; only bracketed references outside code are resolved. Every label
|
|
429
|
+
* that actually resolves against a definition is recorded into `usedLabels`,
|
|
430
|
+
* so the caller can tell a genuinely consumed definition from an unused one. */
|
|
431
|
+
const resolveRefsInLine = (
|
|
432
|
+
line: string,
|
|
433
|
+
defs: Map<string, string>,
|
|
434
|
+
usedLabels: Set<string>,
|
|
435
|
+
): string => {
|
|
436
|
+
if (!line.includes('[')) return line;
|
|
437
|
+
return line.replace(
|
|
438
|
+
REFERENCE_LINK_RE,
|
|
439
|
+
(match, codeTicks, bang, text, secondBracket, label, offset: number, whole: string) => {
|
|
440
|
+
if (codeTicks !== undefined) return match; // inline code span: keep verbatim
|
|
441
|
+
let refLabel: string;
|
|
442
|
+
if (secondBracket === undefined) {
|
|
443
|
+
// Shortcut `[text]`: not a link when an inline `(...)` destination
|
|
444
|
+
// follows (that is an inline link the existing renderer already draws).
|
|
445
|
+
if (whole[offset + match.length] === '(') return match;
|
|
446
|
+
// Nor when it is a task-list checkbox marker at the start of a list
|
|
447
|
+
// item (`- [x]`); the checkbox parser owns that `[x]`, and resolving it
|
|
448
|
+
// against a stray `x`/`X` definition would clobber the item.
|
|
449
|
+
if (/^[ xX]$/.test(text) && /^\s*(?:[-*+]|\d+[.)])\s+$/.test(whole.slice(0, offset))) {
|
|
450
|
+
return match;
|
|
451
|
+
}
|
|
452
|
+
refLabel = text;
|
|
453
|
+
} else {
|
|
454
|
+
refLabel = label === '' ? text : label;
|
|
455
|
+
}
|
|
456
|
+
const normalized = normalizeRefLabel(refLabel);
|
|
457
|
+
const dest = defs.get(normalized);
|
|
458
|
+
// An unknown reference stays literal, matching CommonMark and avoiding
|
|
459
|
+
// false links for bracketed prose like `[TODO]` or array indices.
|
|
460
|
+
if (!dest) return match;
|
|
461
|
+
usedLabels.add(normalized);
|
|
462
|
+
return `${bang}[${text}](${dest})`;
|
|
463
|
+
},
|
|
464
|
+
);
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Resolve CommonMark link reference definitions and reference links into inline
|
|
469
|
+
* `[text](url)` links, so the shared inline renderer draws them instead of
|
|
470
|
+
* showing raw `[text][id]` and `[id]: url` text (issue #923). Definitions and
|
|
471
|
+
* references inside fenced code blocks, raw HTML blocks, and inline code spans
|
|
472
|
+
* are left untouched. A definition-shaped line is only ever blanked when its
|
|
473
|
+
* label was actually consumed by a resolved reference outside a protected
|
|
474
|
+
* region — an unused definition, or one referenced only from inside code/HTML,
|
|
475
|
+
* stays visible exactly as written. Blanked lines keep block start-line
|
|
476
|
+
* numbers accurate (and their own CRLF ending, so line endings round-trip).
|
|
477
|
+
* GFM footnote definitions (`[^label]: ...`) are never treated as link
|
|
478
|
+
* definitions. No-op (returns the input) when the document defines no
|
|
479
|
+
* (non-footnote) references.
|
|
480
|
+
*/
|
|
481
|
+
export const resolveReferenceLinks = (markdown: string): string => {
|
|
482
|
+
if (!markdown.includes('[')) return markdown;
|
|
483
|
+
const lines = markdown.split('\n');
|
|
484
|
+
const isProtected = markProtectedLines(lines);
|
|
485
|
+
const defs = new Map<string, string>();
|
|
486
|
+
// The normalized label a definition-shaped line defines, or null if the
|
|
487
|
+
// line isn't a definition (or is a footnote definition, which is never
|
|
488
|
+
// collected/blanked).
|
|
489
|
+
const defLabelByLine = new Array<string | null>(lines.length).fill(null);
|
|
490
|
+
// A definition cannot interrupt a paragraph (CommonMark 4.7): a line matching
|
|
491
|
+
// the definition shape is only a definition when it can start a block, i.e.
|
|
492
|
+
// the previous line is the document start, blank, a protected code/HTML
|
|
493
|
+
// line (each is its own block), or itself a definition. Otherwise the line
|
|
494
|
+
// is paragraph continuation text and must be left untouched, or a bare
|
|
495
|
+
// `[word]: token` under a sentence would be silently deleted.
|
|
496
|
+
let canStartDefinition = true;
|
|
497
|
+
for (let i = 0; i < lines.length; i++) {
|
|
498
|
+
if (isProtected[i]) {
|
|
499
|
+
canStartDefinition = true;
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
const blank = lines[i].trim() === '';
|
|
503
|
+
const match = canStartDefinition && !blank ? lines[i].match(REFERENCE_DEFINITION_RE) : null;
|
|
504
|
+
if (match) {
|
|
505
|
+
const rawLabel = match[1];
|
|
506
|
+
// GFM footnote definition ([^label]: ...) — not a link reference
|
|
507
|
+
// definition. Leave it out of `defs` entirely so it can never be
|
|
508
|
+
// collected, blanked, or accidentally satisfy a footnote reference's
|
|
509
|
+
// lookup; it stays block-starting like any other definition line.
|
|
510
|
+
if (!rawLabel.startsWith('^') && defs.size < MAX_TRACKED_DEFINITIONS) {
|
|
511
|
+
const label = normalizeRefLabel(rawLabel);
|
|
512
|
+
const dest = match[2] !== undefined ? match[2] : match[3];
|
|
513
|
+
// First definition wins, per CommonMark.
|
|
514
|
+
if (label && dest && !defs.has(label)) defs.set(label, dest);
|
|
515
|
+
defLabelByLine[i] = label;
|
|
516
|
+
}
|
|
517
|
+
// A run of definitions stays eligible; canStartDefinition remains true.
|
|
518
|
+
} else {
|
|
519
|
+
// Blank keeps a new block startable; any other non-definition line starts
|
|
520
|
+
// (or continues) a paragraph, so a following definition-shaped line is text.
|
|
521
|
+
canStartDefinition = blank;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
if (defs.size === 0) return markdown;
|
|
525
|
+
const usedLabels = new Set<string>();
|
|
526
|
+
// Resolve references first; definition-shaped lines are passed through
|
|
527
|
+
// unresolved (never fed to resolveRefsInLine) so a definition's own
|
|
528
|
+
// `[label]` can never be mistaken for a reference to itself.
|
|
529
|
+
const resolved = lines.map((line, i) =>
|
|
530
|
+
isProtected[i] || defLabelByLine[i] !== null ? line : resolveRefsInLine(line, defs, usedLabels),
|
|
531
|
+
);
|
|
532
|
+
return resolved
|
|
533
|
+
.map((line, i) => {
|
|
534
|
+
const label = defLabelByLine[i];
|
|
535
|
+
if (label === null || !usedLabels.has(label)) return line;
|
|
536
|
+
// Blank in place, preserving this line's own CRLF ending if it had one.
|
|
537
|
+
return line.endsWith('\r') ? '\r' : '';
|
|
538
|
+
})
|
|
539
|
+
.join('\n');
|
|
540
|
+
};
|
|
541
|
+
|
|
104
542
|
/**
|
|
105
543
|
* A simplified markdown parser that splits content into linear blocks.
|
|
106
544
|
* For a production app, we would use a robust AST walker (remark),
|
|
107
545
|
* but for this demo, we want predictable text-anchoring.
|
|
108
546
|
*/
|
|
109
|
-
export const parseMarkdownToBlocks = (markdown: string): Block[] => {
|
|
110
|
-
const { content:
|
|
547
|
+
export const parseMarkdownToBlocks = (markdown: string, options?: ParseMarkdownOptions): Block[] => {
|
|
548
|
+
const { content: rawContent, contentStartLine } =
|
|
549
|
+
options?.frontmatter === false
|
|
550
|
+
? { content: markdown, contentStartLine: 1 }
|
|
551
|
+
: extractFrontmatter(markdown);
|
|
552
|
+
// Resolve link reference definitions into inline links before splitting. This
|
|
553
|
+
// blanks definition lines in place, so line count (and every block's
|
|
554
|
+
// startLine) is preserved.
|
|
555
|
+
const cleanMarkdown = resolveReferenceLinks(rawContent);
|
|
111
556
|
const lines = cleanMarkdown.split('\n');
|
|
112
557
|
const blocks: Block[] = [];
|
|
113
558
|
let currentId = 0;
|
|
559
|
+
// Cache for findHtmlBlockEnd's per-tag-name prefix-sum index — scoped per
|
|
560
|
+
// parse call (per document) and shared across every HTML-block opener
|
|
561
|
+
// encountered below, so a document with many consecutive openers of the
|
|
562
|
+
// same tag only pays its one-time O(N) build cost once.
|
|
563
|
+
const htmlCloseCache = new Map<string, TagCloseIndex>();
|
|
114
564
|
|
|
115
565
|
let buffer: string[] = [];
|
|
116
566
|
let currentType: Block['type'] = 'paragraph';
|
|
@@ -501,23 +951,15 @@ export const parseMarkdownToBlocks = (markdown: string): Block[] => {
|
|
|
501
951
|
const openRe = new RegExp(`<${tagName}(?:\\s|>|/|$)`, 'gi');
|
|
502
952
|
const closeRe = new RegExp(`</${tagName}\\s*>`, 'gi');
|
|
503
953
|
const depth = (line.match(openRe) || []).length - (line.match(closeRe) || []).length;
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
scanned.push(lines[j]);
|
|
514
|
-
d += (lines[j].match(openRe) || []).length;
|
|
515
|
-
d -= (lines[j].match(closeRe) || []).length;
|
|
516
|
-
}
|
|
517
|
-
if (d === 0) {
|
|
518
|
-
i = j;
|
|
519
|
-
for (const s of scanned) htmlLines.push(s);
|
|
520
|
-
}
|
|
954
|
+
// Scan ahead for the matching close tag via the shared, bounded/
|
|
955
|
+
// linear helper (see its doc comment for why a naive per-opener scan
|
|
956
|
+
// is quadratic). If none is ever found — a self-closing <video/>, or
|
|
957
|
+
// an unclosed <picture>/<div> — do NOT swallow the rest of the
|
|
958
|
+
// document into this block; keep it to the opening line.
|
|
959
|
+
const end = findHtmlBlockEnd(lines, i, tagName, depth, htmlCloseCache);
|
|
960
|
+
if (end > i) {
|
|
961
|
+
for (let k = i + 1; k <= end; k++) htmlLines.push(lines[k]);
|
|
962
|
+
i = end;
|
|
521
963
|
}
|
|
522
964
|
}
|
|
523
965
|
|
|
@@ -656,6 +1098,32 @@ const blockEndLine = (block: Block): number => {
|
|
|
656
1098
|
|
|
657
1099
|
/** Resolve the source-line label for a single annotation.
|
|
658
1100
|
* Returns null for global comments, diff-view annotations, or missing blocks. */
|
|
1101
|
+
/** Multi-target raw-HTML comments: list every ADDITIONAL element the one
|
|
1102
|
+
* comment covers (the primary target is already quoted as `originalText`),
|
|
1103
|
+
* labeled with the semantic hover label plus a short excerpt so the agent
|
|
1104
|
+
* reading the feedback sees every referenced element. Emits nothing for
|
|
1105
|
+
* single-target annotations, keeping their output byte-identical. */
|
|
1106
|
+
const additionalTargetsExportBlock = (ann: any): string => {
|
|
1107
|
+
const targets = ann?.htmlAdditionalTargets;
|
|
1108
|
+
if (!Array.isArray(targets) || targets.length === 0) return '';
|
|
1109
|
+
// Leading blank line: the preceding comment line is a `> blockquote`, and
|
|
1110
|
+
// markdown lazy continuation would otherwise fold this block into it.
|
|
1111
|
+
let block = `\n**Also applies to ${targets.length} more element${targets.length > 1 ? 's' : ''}:**\n`;
|
|
1112
|
+
targets.forEach((target: any) => {
|
|
1113
|
+
// Labels and texts are page-controlled (aria-label etc.). The DTO
|
|
1114
|
+
// boundary already collapses label whitespace; do it here again (defense
|
|
1115
|
+
// in depth) so persisted pre-fix data can never smuggle newlines — and
|
|
1116
|
+
// with them fake markdown structure — into agent-read feedback.
|
|
1117
|
+
const rawLabel = typeof target?.label === 'string' ? target.label.replace(/\s+/g, ' ').trim() : '';
|
|
1118
|
+
const label = rawLabel ? `[${rawLabel}] ` : '';
|
|
1119
|
+
const raw = typeof target?.text === 'string' ? target.text : '';
|
|
1120
|
+
const excerpt = raw.replace(/\s+/g, ' ').trim();
|
|
1121
|
+
const clipped = excerpt.length > 120 ? `${excerpt.slice(0, 120)}…` : excerpt;
|
|
1122
|
+
block += `- ${label}"${clipped}"\n`;
|
|
1123
|
+
});
|
|
1124
|
+
return block;
|
|
1125
|
+
};
|
|
1126
|
+
|
|
659
1127
|
const lineLabelForAnnotation = (blocks: Block[], ann: any): string | null => {
|
|
660
1128
|
if (!ann.blockId || ann.type === 'GLOBAL_COMMENT') return null;
|
|
661
1129
|
if (typeof ann.blockId === 'string' && ann.blockId.startsWith('diff-block-')) return null;
|
|
@@ -686,6 +1154,10 @@ export const exportAnnotations = (
|
|
|
686
1154
|
return a.startOffset - b.startOffset;
|
|
687
1155
|
});
|
|
688
1156
|
|
|
1157
|
+
// One injection per export: a human-only skill referenced by several
|
|
1158
|
+
// comments has its instructions injected once (see skillReferenceExportBlock).
|
|
1159
|
+
const injectedSkills = new Set<string>();
|
|
1160
|
+
|
|
689
1161
|
let output = `# ${title}\n\n`;
|
|
690
1162
|
|
|
691
1163
|
if (opts.sourceConverted) {
|
|
@@ -742,6 +1214,17 @@ export const exportAnnotations = (
|
|
|
742
1214
|
break;
|
|
743
1215
|
}
|
|
744
1216
|
|
|
1217
|
+
// Multi-target raw-HTML comments list every additional covered element.
|
|
1218
|
+
output += additionalTargetsExportBlock(ann);
|
|
1219
|
+
|
|
1220
|
+
// Skill references in the comment text (no-op unless a catalog is
|
|
1221
|
+
// registered). An annotation carrying a `source` arrived through the
|
|
1222
|
+
// external-annotations API, not from the reviewer — it may list skills
|
|
1223
|
+
// but must never cause a human-only skill's instructions to be injected.
|
|
1224
|
+
if (!ann.isQuickLabel) {
|
|
1225
|
+
output += skillReferenceExportBlock(ann.text, injectedSkills, { external: !!ann.source });
|
|
1226
|
+
}
|
|
1227
|
+
|
|
745
1228
|
// Add attached images for this annotation
|
|
746
1229
|
if (ann.images && ann.images.length > 0) {
|
|
747
1230
|
output += `**Attached images:**\n`;
|
|
@@ -786,6 +1269,9 @@ export const exportLinkedDocAnnotations = (
|
|
|
786
1269
|
): string => {
|
|
787
1270
|
let output = `\n# Linked Document Feedback\n\nThe following feedback is on documents referenced in the plan.\n\n`;
|
|
788
1271
|
|
|
1272
|
+
// One injection per export, across all linked documents.
|
|
1273
|
+
const injectedSkills = new Set<string>();
|
|
1274
|
+
|
|
789
1275
|
for (const [filepath, { annotations, globalAttachments, blocks: docBlocks, isConverted }] of docAnnotations) {
|
|
790
1276
|
if (annotations.length === 0 && globalAttachments.length === 0) continue;
|
|
791
1277
|
|
|
@@ -832,6 +1318,12 @@ export const exportLinkedDocAnnotations = (
|
|
|
832
1318
|
break;
|
|
833
1319
|
}
|
|
834
1320
|
|
|
1321
|
+
// Multi-target raw-HTML comments list every additional covered element.
|
|
1322
|
+
output += additionalTargetsExportBlock(ann);
|
|
1323
|
+
|
|
1324
|
+
// External (tool-sourced) comments list skills but never inject.
|
|
1325
|
+
output += skillReferenceExportBlock(ann.text, injectedSkills, { external: !!ann.source });
|
|
1326
|
+
|
|
835
1327
|
if (ann.images && ann.images.length > 0) {
|
|
836
1328
|
output += `**Attached images:**\n`;
|
|
837
1329
|
ann.images.forEach((img: ImageAttachment) => {
|
|
@@ -875,6 +1367,8 @@ export const exportCodeFileAnnotations = (annotations: CodeAnnotation[]): string
|
|
|
875
1367
|
if (annotations.length === 0) return '';
|
|
876
1368
|
|
|
877
1369
|
let output = `\n# Code File Feedback\n\nThe following feedback is on code files referenced from the reviewed document.\n\n`;
|
|
1370
|
+
// One injection per export, across all code-file comments.
|
|
1371
|
+
const injectedSkills = new Set<string>();
|
|
878
1372
|
const sorted = [...annotations].sort((a, b) => {
|
|
879
1373
|
if (a.filePath !== b.filePath) return a.filePath.localeCompare(b.filePath);
|
|
880
1374
|
if (a.lineStart !== b.lineStart) return a.lineStart - b.lineStart;
|
|
@@ -893,6 +1387,8 @@ export const exportCodeFileAnnotations = (annotations: CodeAnnotation[]): string
|
|
|
893
1387
|
if (ann.text) {
|
|
894
1388
|
output += `> ${ann.text}\n`;
|
|
895
1389
|
}
|
|
1390
|
+
// External (tool-sourced) comments list skills but never inject.
|
|
1391
|
+
output += skillReferenceExportBlock(ann.text, injectedSkills, { external: !!ann.source });
|
|
896
1392
|
if (ann.images && ann.images.length > 0) {
|
|
897
1393
|
output += `**Attached images:**\n`;
|
|
898
1394
|
ann.images.forEach((img) => {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staleness window for raw-HTML session preferences (input method, chrome
|
|
3
|
+
* visibility, drawer state). An explicit choice sticks while the user is
|
|
4
|
+
* actively annotating HTML; after this long without a refresh the preference
|
|
5
|
+
* expires and the session opens on the product defaults again (Pinpoint,
|
|
6
|
+
* tools hidden, drawer closed). Timestamps refresh on explicit changes and on
|
|
7
|
+
* annotation activity, so regulars keep their setup and lapsed sessions reset.
|
|
8
|
+
*/
|
|
9
|
+
export const STALE_PREFERENCE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
10
|
+
|
|
11
|
+
/** True when a persisted `savedAt` is missing, malformed, or older than the TTL. */
|
|
12
|
+
export function isStalePreference(savedAt: unknown, now: number): boolean {
|
|
13
|
+
if (typeof savedAt !== "number" || !Number.isFinite(savedAt)) return true;
|
|
14
|
+
return now - savedAt > STALE_PREFERENCE_TTL_MS;
|
|
15
|
+
}
|
package/utils/sharing.ts
CHANGED
|
@@ -298,7 +298,6 @@ export async function createShortShareUrl(
|
|
|
298
298
|
}
|
|
299
299
|
// Service unavailable — expected for self-hosted setups without a paste backend.
|
|
300
300
|
// The caller is responsible for falling back to hash-based sharing silently.
|
|
301
|
-
console.debug('[sharing] Short URL service unavailable, using hash-based sharing:', e);
|
|
302
301
|
return null;
|
|
303
302
|
}
|
|
304
303
|
}
|