@plannotator/ui 0.22.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 +60 -0
- package/assets/diff-options.png +0 -0
- package/assets/icon-codex.png +0 -0
- package/assets/look-flat.png +0 -0
- package/assets/look-grid.png +0 -0
- package/assets/review-sections.png +0 -0
- package/assets/review-tree.png +0 -0
- package/assets/workspaces.webp +0 -0
- package/components/AISettingsTab.tsx +145 -0
- package/components/ActionMenu.tsx +101 -0
- package/components/AgentControls.tsx +216 -0
- package/components/AgentsTab.tsx +1294 -0
- package/components/AnnotationPanel.tsx +731 -0
- package/components/AnnotationSidebar.tsx +86 -0
- package/components/AnnotationToolbar.tsx +323 -0
- package/components/AnnotationToolstrip.tsx +355 -0
- package/components/ApproveDropdown.tsx +170 -0
- package/components/AttachmentsButton.tsx +410 -0
- package/components/BlockRenderer.tsx +163 -0
- package/components/BorderTrail.tsx +38 -0
- package/components/CodeFilePicker.tsx +64 -0
- package/components/CodeFilePopout.tsx +602 -0
- package/components/CodePathValidationContext.tsx +14 -0
- package/components/CommentPopover.tsx +512 -0
- package/components/CompletionOverlay.tsx +99 -0
- package/components/ConfirmDialog.tsx +128 -0
- package/components/DocBadges.tsx +231 -0
- package/components/EditorAnnotationCard.tsx +65 -0
- package/components/ExportModal.tsx +565 -0
- package/components/FloatingQuickLabelPicker.tsx +144 -0
- package/components/GitHubIcon.tsx +16 -0
- package/components/GitLabIcon.tsx +30 -0
- package/components/GraphvizBlock.tsx +511 -0
- package/components/ImageAnnotator/Canvas.tsx +118 -0
- package/components/ImageAnnotator/Toolbar.tsx +218 -0
- package/components/ImageAnnotator/index.tsx +272 -0
- package/components/ImageAnnotator/types.ts +39 -0
- package/components/ImageAnnotator/utils.ts +147 -0
- package/components/ImageThumbnail.tsx +126 -0
- package/components/ImportModal.tsx +146 -0
- package/components/InlineMarkdown.tsx +1094 -0
- package/components/KeyboardShortcuts.tsx +208 -0
- package/components/Landing.tsx +496 -0
- package/components/ListItemBody.tsx +62 -0
- package/components/ListMarker.tsx +73 -0
- package/components/LookAndFeelAnnouncementDialog.tsx +230 -0
- package/components/MarkdownEditor.tsx +44 -0
- package/components/MenuVersionSection.tsx +91 -0
- package/components/MermaidBlock.tsx +579 -0
- package/components/ModeToggle.tsx +68 -0
- package/components/OpenInAppButton.tsx +299 -0
- package/components/OverlayScrollArea.tsx +82 -0
- package/components/PermissionModeSetup.tsx +92 -0
- package/components/PinpointOverlay.tsx +99 -0
- package/components/PlanAIAnnouncementDialog.tsx +187 -0
- package/components/PlanHeaderMenu.tsx +299 -0
- package/components/PopoutDialog.tsx +91 -0
- package/components/Popover.tsx +27 -0
- package/components/ProviderIcons.tsx +51 -0
- package/components/PullRequestIcon.tsx +12 -0
- package/components/QuickLabelDropdown.tsx +63 -0
- package/components/RenderedMarkdown.tsx +54 -0
- package/components/RepoIcon.tsx +12 -0
- package/components/ResizeHandle.tsx +86 -0
- package/components/ReviewAgentsIcon.tsx +18 -0
- package/components/SearchableSelect.tsx +157 -0
- package/components/Settings.tsx +2220 -0
- package/components/SparklesIcon.tsx +45 -0
- package/components/StickyHeaderLane.tsx +260 -0
- package/components/TableOfContents.tsx +157 -0
- package/components/TaterSpritePullup.tsx +34 -0
- package/components/TaterSpriteRunning.tsx +54 -0
- package/components/TaterSpriteSitting.tsx +37 -0
- package/components/TextShimmer.tsx +57 -0
- package/components/ThemeProvider.tsx +162 -0
- package/components/ThemeTab.tsx +139 -0
- package/components/ToolbarButtons.tsx +116 -0
- package/components/Tooltip.tsx +45 -0
- package/components/Viewer.tsx +977 -0
- package/components/ai/AIProviderBar.tsx +95 -0
- package/components/ai/DocumentAIChatPanel.tsx +313 -0
- package/components/blocks/AlertBlock.tsx +58 -0
- package/components/blocks/Callout.tsx +60 -0
- package/components/blocks/CodeBlock.tsx +75 -0
- package/components/blocks/HtmlBlock.tsx +122 -0
- package/components/blocks/MathBlock.tsx +36 -0
- package/components/blocks/TableBlock.tsx +146 -0
- package/components/blocks/TablePopout.tsx +279 -0
- package/components/blocks/TableToolbar.tsx +153 -0
- package/components/blocks/proseBody.tsx +102 -0
- package/components/core/button.tsx +44 -0
- package/components/core/textarea.tsx +26 -0
- package/components/diagramLanguages.ts +14 -0
- package/components/goal-setup/GoalSetupSurface.tsx +1354 -0
- package/components/html-viewer/HtmlViewer.tsx +386 -0
- package/components/html-viewer/bridge-script.ts +505 -0
- package/components/html-viewer/index.ts +1 -0
- package/components/html-viewer/useHtmlAnnotation.ts +400 -0
- package/components/icons/AgentIcons.tsx +66 -0
- package/components/icons/AppIcon.tsx +56 -0
- package/components/icons/MessagesIcon.tsx +11 -0
- package/components/icons/ObsidianIcons.tsx +208 -0
- package/components/icons/app/android-studio.svg +369 -0
- package/components/icons/app/antigravity.svg +97 -0
- package/components/icons/app/cursor.svg +16 -0
- package/components/icons/app/file-explorer.svg +20 -0
- package/components/icons/app/finder.png +0 -0
- package/components/icons/app/ghostty.svg +13 -0
- package/components/icons/app/iterm2.svg +13 -0
- package/components/icons/app/powershell.svg +14 -0
- package/components/icons/app/sublime-text.svg +17 -0
- package/components/icons/app/terminal.png +0 -0
- package/components/icons/app/textmate.png +0 -0
- package/components/icons/app/vscode.svg +39 -0
- package/components/icons/app/warp.png +0 -0
- package/components/icons/app/xcode.png +0 -0
- package/components/icons/app/zed-dark.svg +15 -0
- package/components/icons/app/zed.svg +15 -0
- package/components/icons/themeIcons.tsx +47 -0
- package/components/mermaidSvg.ts +33 -0
- package/components/plan-diff/PlanCleanDiffView.tsx +884 -0
- package/components/plan-diff/PlanDiffBadge.tsx +48 -0
- package/components/plan-diff/PlanDiffModeSwitcher.tsx +103 -0
- package/components/plan-diff/PlanDiffViewer.tsx +214 -0
- package/components/plan-diff/PlanRawDiffView.tsx +102 -0
- package/components/plan-diff/VSCodeIcon.tsx +132 -0
- package/components/settings/HooksTab.tsx +208 -0
- package/components/sidebar/ArchiveBrowser.tsx +98 -0
- package/components/sidebar/CountBadge.tsx +12 -0
- package/components/sidebar/FileBrowser.tsx +498 -0
- package/components/sidebar/MessagesBrowser.tsx +109 -0
- package/components/sidebar/SidebarContainer.tsx +353 -0
- package/components/sidebar/SidebarTabs.tsx +155 -0
- package/components/sidebar/VersionBrowser.tsx +141 -0
- package/components/types.d.ts +7 -0
- package/components/ui/badge.tsx +44 -0
- package/components/ui/button.tsx +83 -0
- package/components/ui/card.tsx +58 -0
- package/components/ui/dialog.tsx +106 -0
- package/components/ui/dropdown-menu.tsx +240 -0
- package/components/ui/state-pill.tsx +45 -0
- package/components/ui/tabs.tsx +46 -0
- package/components/ui/textarea.tsx +25 -0
- package/config/configStore.ts +214 -0
- package/config/index.ts +4 -0
- package/config/reviewView.ts +42 -0
- package/config/settings.ts +310 -0
- package/config/useConfig.ts +20 -0
- package/configure.ts +69 -0
- package/globals.d.ts +14 -0
- package/hooks/pfm/useCodeFilePopout.ts +111 -0
- package/hooks/useAIChat.ts +532 -0
- package/hooks/useAIProviderConfig.ts +115 -0
- package/hooks/useActiveSection.ts +78 -0
- package/hooks/useAgentJobs.ts +306 -0
- package/hooks/useAgentSettings.ts +579 -0
- package/hooks/useAgents.ts +90 -0
- package/hooks/useAnnotationDraft.ts +523 -0
- package/hooks/useAnnotationHighlighter.ts +1170 -0
- package/hooks/useArchive.ts +171 -0
- package/hooks/useAutoClose.ts +104 -0
- package/hooks/useCodeAnnotationDraft.ts +194 -0
- package/hooks/useCodeFilePopout.ts +1 -0
- package/hooks/useDismissOnOutsideAndEscape.ts +50 -0
- package/hooks/useDraggable.ts +108 -0
- package/hooks/useEditorAnnotations.ts +64 -0
- package/hooks/useExternalAnnotationHighlights.ts +105 -0
- package/hooks/useExternalAnnotations.ts +268 -0
- package/hooks/useFileBrowser.ts +402 -0
- package/hooks/useInputMethodSwitch.ts +89 -0
- package/hooks/useIsMobile.ts +17 -0
- package/hooks/useLinkedDoc.ts +494 -0
- package/hooks/useOverlayViewport.ts +36 -0
- package/hooks/usePinpoint.ts +182 -0
- package/hooks/usePlanDiff.ts +176 -0
- package/hooks/usePrintMode.ts +27 -0
- package/hooks/useResizablePanel.ts +173 -0
- package/hooks/useScrollViewport.ts +38 -0
- package/hooks/useSharing.ts +441 -0
- package/hooks/useSidebar.ts +55 -0
- package/hooks/useUpdateCheck.ts +132 -0
- package/hooks/useValidatedCodePaths.ts +94 -0
- package/icons/GitUser.tsx +17 -0
- package/lib/utils.ts +11 -0
- package/package.json +117 -0
- package/plannotator.webp +0 -0
- package/print.css +427 -0
- package/shortcuts/code-review/ai.shortcuts.ts +25 -0
- package/shortcuts/code-review/allFilesDiff.shortcuts.ts +46 -0
- package/shortcuts/code-review/annotationToolbar.shortcuts.ts +30 -0
- package/shortcuts/code-review/fileTree.shortcuts.ts +35 -0
- package/shortcuts/code-review/prComments.shortcuts.ts +18 -0
- package/shortcuts/code-review/suggestionModal.shortcuts.ts +25 -0
- package/shortcuts/code-review/tourDialog.shortcuts.ts +18 -0
- package/shortcuts/core.ts +399 -0
- package/shortcuts/index.ts +21 -0
- package/shortcuts/plan-review/annotationPanel.shortcuts.ts +25 -0
- package/shortcuts/plan-review/annotationToolbar.shortcuts.ts +39 -0
- package/shortcuts/plan-review/commentPopover.shortcuts.ts +21 -0
- package/shortcuts/plan-review/goalSetup.shortcuts.ts +10 -0
- package/shortcuts/plan-review/imageAnnotator.shortcuts.ts +42 -0
- package/shortcuts/plan-review/inputMethod.shortcuts.ts +30 -0
- package/shortcuts/plan-review/sidebar.shortcuts.ts +34 -0
- package/shortcuts/plan-review/viewer.shortcuts.ts +24 -0
- package/shortcuts/runtime.ts +259 -0
- package/sprite_package_additional/index.html +34 -0
- package/sprite_package_additional/sprite.png +0 -0
- package/sprite_package_new/index.html +34 -0
- package/sprite_package_new/sprite.png +0 -0
- package/sprite_package_pulluphang/index.html +34 -0
- package/sprite_package_pulluphang/sprite.png +0 -0
- package/styles.css +1 -0
- package/theme.css +893 -0
- package/themes/adwaita.css +62 -0
- package/themes/andromeeda.css +66 -0
- package/themes/aurora-x.css +66 -0
- package/themes/ayu-dark.css +66 -0
- package/themes/caffeine.css +113 -0
- package/themes/catppuccin.css +62 -0
- package/themes/claude-plus.css +60 -0
- package/themes/cursor-hc.css +34 -0
- package/themes/cursor-midnight.css +34 -0
- package/themes/cursor.css +62 -0
- package/themes/dark-plus.css +66 -0
- package/themes/doom-64.css +109 -0
- package/themes/dracula.css +33 -0
- package/themes/everforest-hard.css +62 -0
- package/themes/everforest-soft.css +62 -0
- package/themes/everforest.css +62 -0
- package/themes/github.css +66 -0
- package/themes/gruvbox.css +62 -0
- package/themes/houston.css +66 -0
- package/themes/kanagawa-dragon.css +34 -0
- package/themes/kanagawa-lotus.css +34 -0
- package/themes/kanagawa-wave.css +34 -0
- package/themes/laserwave.css +66 -0
- package/themes/material.css +62 -0
- package/themes/min.css +66 -0
- package/themes/monokai-pro.css +34 -0
- package/themes/neutral.css +59 -0
- package/themes/night-owl.css +66 -0
- package/themes/nord.css +66 -0
- package/themes/one-dark-pro.css +66 -0
- package/themes/one-light.css +66 -0
- package/themes/paulmillr.css +34 -0
- package/themes/plannotator.css +60 -0
- package/themes/plastic.css +66 -0
- package/themes/poimandres.css +66 -0
- package/themes/quantum-rose.css +109 -0
- package/themes/red.css +66 -0
- package/themes/rose-pine.css +62 -0
- package/themes/simple.css +124 -0
- package/themes/slack.css +66 -0
- package/themes/snazzy-light.css +66 -0
- package/themes/soft-pop.css +60 -0
- package/themes/solar-dusk.css +109 -0
- package/themes/solarized.css +66 -0
- package/themes/synthwave-84.css +34 -0
- package/themes/terminal.css +62 -0
- package/themes/tinacious.css +57 -0
- package/themes/tokyo-night.css +62 -0
- package/themes/vesper.css +62 -0
- package/themes/vitesse-black.css +66 -0
- package/themes/vitesse.css +62 -0
- package/types.ts +264 -0
- package/utils/agentSwitch.ts +71 -0
- package/utils/aiChatFormat.ts +33 -0
- package/utils/aiPrompt.ts +29 -0
- package/utils/aiProvider.ts +217 -0
- package/utils/anchors.ts +11 -0
- package/utils/annotateAgentTerminal.ts +29 -0
- package/utils/annotationHelpers.ts +101 -0
- package/utils/bear.ts +59 -0
- package/utils/blockTargeting.ts +240 -0
- package/utils/callback.ts +99 -0
- package/utils/commentContent.ts +8 -0
- package/utils/defaultNotesApp.ts +20 -0
- package/utils/diffFonts.ts +33 -0
- package/utils/editorMode.ts +31 -0
- package/utils/fileBrowser.ts +40 -0
- package/utils/generateId.ts +7 -0
- package/utils/generateIdentity.ts +29 -0
- package/utils/identity.ts +112 -0
- package/utils/inlineTransforms.ts +41 -0
- package/utils/inputMethod.ts +17 -0
- package/utils/lookAndFeelAnnouncement.ts +18 -0
- package/utils/obsidian.ts +201 -0
- package/utils/octarine.ts +53 -0
- package/utils/parser.ts +991 -0
- package/utils/permissionMode.ts +77 -0
- package/utils/planAIAnnouncement.ts +17 -0
- package/utils/planAgentInstructions.ts +137 -0
- package/utils/planDiffEngine.ts +590 -0
- package/utils/planSave.ts +49 -0
- package/utils/platform.ts +11 -0
- package/utils/quickLabels.ts +77 -0
- package/utils/reviewAgentInstructions.ts +182 -0
- package/utils/sanitizeHtml.ts +45 -0
- package/utils/sharing.ts +350 -0
- package/utils/slugify.ts +37 -0
- package/utils/storage.ts +141 -0
- package/utils/themeRegistry.ts +567 -0
- package/utils/uiPreferences.ts +34 -0
- package/utils/upload.ts +56 -0
- package/utils/wideMode.ts +48 -0
package/utils/parser.ts
ADDED
|
@@ -0,0 +1,991 @@
|
|
|
1
|
+
import { Block, type Annotation, type CodeAnnotation, type EditorAnnotation, type ImageAttachment } from '../types';
|
|
2
|
+
import { planDenyFeedback } from '@plannotator/core/feedback-templates';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Parsed YAML frontmatter as key-value pairs.
|
|
6
|
+
*/
|
|
7
|
+
export interface Frontmatter {
|
|
8
|
+
[key: string]: string | string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Extract YAML frontmatter from markdown if present.
|
|
13
|
+
* Returns the parsed frontmatter, the remaining markdown, and the 1-based
|
|
14
|
+
* line number where content begins in the original file (so downstream
|
|
15
|
+
* line references stay accurate).
|
|
16
|
+
*/
|
|
17
|
+
export function extractFrontmatter(markdown: string): { frontmatter: Frontmatter | null; content: string; contentStartLine: number } {
|
|
18
|
+
const trimmed = markdown.trimStart();
|
|
19
|
+
if (!trimmed.startsWith('---')) {
|
|
20
|
+
return { frontmatter: null, content: markdown, contentStartLine: 1 };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Find the closing ---
|
|
24
|
+
const endIndex = trimmed.indexOf('\n---', 3);
|
|
25
|
+
if (endIndex === -1) {
|
|
26
|
+
return { frontmatter: null, content: markdown, contentStartLine: 1 };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Extract frontmatter content (between the --- delimiters)
|
|
30
|
+
const frontmatterRaw = trimmed.slice(4, endIndex).trim();
|
|
31
|
+
const rawAfterFrontmatter = trimmed.slice(endIndex + 4);
|
|
32
|
+
const afterFrontmatter = rawAfterFrontmatter.trimStart();
|
|
33
|
+
|
|
34
|
+
// Compute the 1-based line where content begins in the original file.
|
|
35
|
+
// Account for: leading whitespace trimmed from original, the frontmatter
|
|
36
|
+
// block itself, and any blank lines between closing --- and first content.
|
|
37
|
+
const leadingChars = markdown.length - trimmed.length;
|
|
38
|
+
const consumedInTrimmed = endIndex + 4 + (rawAfterFrontmatter.length - afterFrontmatter.length);
|
|
39
|
+
const consumedTotal = leadingChars + consumedInTrimmed;
|
|
40
|
+
const contentStartLine = (markdown.slice(0, consumedTotal).match(/\n/g) || []).length + 1;
|
|
41
|
+
|
|
42
|
+
// Parse simple YAML (key: value pairs)
|
|
43
|
+
const frontmatter: Frontmatter = {};
|
|
44
|
+
let currentKey: string | null = null;
|
|
45
|
+
let currentArray: string[] | null = null;
|
|
46
|
+
|
|
47
|
+
for (const line of frontmatterRaw.split('\n')) {
|
|
48
|
+
const trimmedLine = line.trim();
|
|
49
|
+
|
|
50
|
+
// Array item (- value)
|
|
51
|
+
if (trimmedLine.startsWith('- ') && currentKey) {
|
|
52
|
+
const value = trimmedLine.slice(2).trim();
|
|
53
|
+
if (!currentArray) {
|
|
54
|
+
currentArray = [];
|
|
55
|
+
frontmatter[currentKey] = currentArray;
|
|
56
|
+
}
|
|
57
|
+
currentArray.push(value);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Key: value pair
|
|
62
|
+
const colonIndex = trimmedLine.indexOf(':');
|
|
63
|
+
if (colonIndex > 0) {
|
|
64
|
+
currentKey = trimmedLine.slice(0, colonIndex).trim();
|
|
65
|
+
const value = trimmedLine.slice(colonIndex + 1).trim();
|
|
66
|
+
currentArray = null;
|
|
67
|
+
|
|
68
|
+
if (value) {
|
|
69
|
+
frontmatter[currentKey] = value;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { frontmatter, content: afterFrontmatter, contentStartLine };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Tag names that trigger a raw HTML block per CommonMark §4.6, Type 6.
|
|
79
|
+
* A line starting with `<tag` or `</tag` (where `tag` is in this set) opens
|
|
80
|
+
* an HTML block that continues verbatim until a blank line or EOF.
|
|
81
|
+
*
|
|
82
|
+
* Inline-only tags (`kbd`, `sub`, `sup`, `mark`, etc.) are NOT here — a line
|
|
83
|
+
* that happens to start with one of those still goes through the paragraph
|
|
84
|
+
* path and renders as escaped text, matching prior behavior.
|
|
85
|
+
*/
|
|
86
|
+
export const HTML_BLOCK_TAGS: ReadonlySet<string> = new Set([
|
|
87
|
+
'details', 'summary',
|
|
88
|
+
'div', 'section', 'article', 'aside', 'header', 'footer',
|
|
89
|
+
'blockquote', 'pre',
|
|
90
|
+
'table', 'thead', 'tbody', 'tr', 'td', 'th',
|
|
91
|
+
'ul', 'ol', 'li', 'p',
|
|
92
|
+
// Media: GitHub embeds screenshots/videos as raw HTML on their own line.
|
|
93
|
+
'img', 'video', 'picture',
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
/** Void elements — no closing tag, so the block is a single line (don't scan
|
|
97
|
+
* ahead for a `</tag>` that will never come). */
|
|
98
|
+
const VOID_HTML_TAGS: ReadonlySet<string> = new Set([
|
|
99
|
+
'img', 'br', 'hr', 'source', 'input', 'wbr', 'area', 'col', 'embed',
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
const HTML_BLOCK_OPEN_RE = /^<\/?([a-zA-Z][a-zA-Z0-9]*)(?:\s|>|\/|$)/;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A simplified markdown parser that splits content into linear blocks.
|
|
106
|
+
* For a production app, we would use a robust AST walker (remark),
|
|
107
|
+
* but for this demo, we want predictable text-anchoring.
|
|
108
|
+
*/
|
|
109
|
+
export const parseMarkdownToBlocks = (markdown: string): Block[] => {
|
|
110
|
+
const { content: cleanMarkdown, contentStartLine } = extractFrontmatter(markdown);
|
|
111
|
+
const lines = cleanMarkdown.split('\n');
|
|
112
|
+
const blocks: Block[] = [];
|
|
113
|
+
let currentId = 0;
|
|
114
|
+
|
|
115
|
+
let buffer: string[] = [];
|
|
116
|
+
let currentType: Block['type'] = 'paragraph';
|
|
117
|
+
let currentLevel = 0;
|
|
118
|
+
let bufferStartLine = contentStartLine;
|
|
119
|
+
let lastLineWasBlank = false;
|
|
120
|
+
|
|
121
|
+
const flush = () => {
|
|
122
|
+
if (buffer.length > 0) {
|
|
123
|
+
const content = buffer.join('\n');
|
|
124
|
+
blocks.push({
|
|
125
|
+
id: `block-${currentId++}`,
|
|
126
|
+
type: currentType,
|
|
127
|
+
content: content,
|
|
128
|
+
level: currentLevel,
|
|
129
|
+
order: currentId,
|
|
130
|
+
startLine: bufferStartLine
|
|
131
|
+
});
|
|
132
|
+
buffer = [];
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
for (let i = 0; i < lines.length; i++) {
|
|
137
|
+
const line = lines[i];
|
|
138
|
+
const trimmed = line.trim();
|
|
139
|
+
const currentLineNum = i + contentStartLine;
|
|
140
|
+
const prevLineWasBlank = lastLineWasBlank;
|
|
141
|
+
lastLineWasBlank = false;
|
|
142
|
+
|
|
143
|
+
// Headings
|
|
144
|
+
if (trimmed.startsWith('#')) {
|
|
145
|
+
flush();
|
|
146
|
+
const level = trimmed.match(/^#+/)?.[0].length || 1;
|
|
147
|
+
blocks.push({
|
|
148
|
+
id: `block-${currentId++}`,
|
|
149
|
+
type: 'heading',
|
|
150
|
+
content: trimmed.replace(/^#+\s*/, ''),
|
|
151
|
+
level,
|
|
152
|
+
order: currentId,
|
|
153
|
+
startLine: currentLineNum
|
|
154
|
+
});
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Horizontal Rule
|
|
159
|
+
if (trimmed === '---' || trimmed === '***') {
|
|
160
|
+
flush();
|
|
161
|
+
blocks.push({
|
|
162
|
+
id: `block-${currentId++}`,
|
|
163
|
+
type: 'hr',
|
|
164
|
+
content: '',
|
|
165
|
+
order: currentId,
|
|
166
|
+
startLine: currentLineNum
|
|
167
|
+
});
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// List Items (Simple detection)
|
|
172
|
+
const listMatch = trimmed.match(/^(\*|-|(\d+)\.)\s/);
|
|
173
|
+
if (listMatch) {
|
|
174
|
+
flush(); // Treat each list item as a separate block for easier annotation
|
|
175
|
+
// Calculate indentation level from leading whitespace
|
|
176
|
+
const leadingWhitespace = line.match(/^(\s*)/)?.[1] || '';
|
|
177
|
+
// Count spaces (2 spaces = 1 level) or tabs (1 tab = 1 level)
|
|
178
|
+
const spaceCount = leadingWhitespace.replace(/\t/g, ' ').length;
|
|
179
|
+
const listLevel = Math.floor(spaceCount / 2);
|
|
180
|
+
|
|
181
|
+
// Distinguish numeric markers (\d+.) from bullet markers (* / -)
|
|
182
|
+
const ordered = listMatch[2] !== undefined;
|
|
183
|
+
const orderedStart = ordered ? parseInt(listMatch[2]!, 10) : undefined;
|
|
184
|
+
|
|
185
|
+
// Remove list marker
|
|
186
|
+
let content = trimmed.slice(listMatch[0].length);
|
|
187
|
+
|
|
188
|
+
// Check for checkbox syntax: [ ] or [x] or [X]
|
|
189
|
+
let checked: boolean | undefined = undefined;
|
|
190
|
+
const checkboxMatch = content.match(/^\[([ xX])\]\s*/);
|
|
191
|
+
if (checkboxMatch) {
|
|
192
|
+
checked = checkboxMatch[1].toLowerCase() === 'x';
|
|
193
|
+
content = content.replace(/^\[([ xX])\]\s*/, '');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
blocks.push({
|
|
197
|
+
id: `block-${currentId++}`,
|
|
198
|
+
type: 'list-item',
|
|
199
|
+
content,
|
|
200
|
+
level: listLevel,
|
|
201
|
+
checked,
|
|
202
|
+
ordered: ordered || undefined,
|
|
203
|
+
orderedStart,
|
|
204
|
+
order: currentId,
|
|
205
|
+
startLine: currentLineNum
|
|
206
|
+
});
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Blockquotes — consecutive `>` lines merge into one block so wrapped
|
|
211
|
+
// paragraph quotes render as a single continuous quote box. A blank line
|
|
212
|
+
// breaks the blockquote so the next `>` starts a fresh one.
|
|
213
|
+
//
|
|
214
|
+
// Exception: if the stripped content starts with a block-level marker
|
|
215
|
+
// (list item, heading, code fence, nested blockquote) we do NOT merge.
|
|
216
|
+
// Our flat block model can't render a list-inside-a-quote as an actual
|
|
217
|
+
// nested list, so merging would flatten the markers into run-on inline
|
|
218
|
+
// text. Leaving them as separate blockquote blocks preserves each line's
|
|
219
|
+
// visual identity (a stacked-box layout) — imperfect but legible. A
|
|
220
|
+
// proper recursive blockquote parser is tracked as a follow-up.
|
|
221
|
+
if (trimmed.startsWith('>')) {
|
|
222
|
+
flush();
|
|
223
|
+
const stripped = trimmed.replace(/^>\s*/, '');
|
|
224
|
+
// List markers require trailing whitespace to avoid matching inline
|
|
225
|
+
// text like "-hyphen" or "1.5 seconds"; headings, code fences, and
|
|
226
|
+
// nested blockquote markers don't require it (``` can be followed
|
|
227
|
+
// directly by a language tag, # can start a dense heading).
|
|
228
|
+
const blockMarkerRe = /^(?:(?:\*|-|\d+\.)\s|#|```|>)/;
|
|
229
|
+
const hasBlockMarker = blockMarkerRe.test(stripped);
|
|
230
|
+
const prevBlock = blocks.length > 0 ? blocks[blocks.length - 1] : null;
|
|
231
|
+
// Don't merge into a previous blockquote whose content itself starts
|
|
232
|
+
// with a block marker — otherwise a `> some text` line following a
|
|
233
|
+
// `> 1. item` line would get glued onto the list-item block.
|
|
234
|
+
const prevIsMarkerQuote =
|
|
235
|
+
prevBlock?.type === 'blockquote' && blockMarkerRe.test(prevBlock.content);
|
|
236
|
+
// Alerts own their body: once a blockquote is tagged as an alert,
|
|
237
|
+
// subsequent `>` lines always merge into it (until a blank line).
|
|
238
|
+
// Without this, `> [!NOTE]\n> - item` splits the list item off into
|
|
239
|
+
// a separate plain quote, losing the callout.
|
|
240
|
+
const prevIsAlert = prevBlock?.type === 'blockquote' && !!prevBlock.alertKind;
|
|
241
|
+
const shouldMergeIntoAlert = prevIsAlert && !prevLineWasBlank;
|
|
242
|
+
const shouldMergeNormal =
|
|
243
|
+
!hasBlockMarker &&
|
|
244
|
+
!prevIsMarkerQuote &&
|
|
245
|
+
!prevLineWasBlank &&
|
|
246
|
+
prevBlock?.type === 'blockquote';
|
|
247
|
+
if (shouldMergeIntoAlert || shouldMergeNormal) {
|
|
248
|
+
prevBlock!.content = prevBlock!.content
|
|
249
|
+
? prevBlock!.content + '\n' + stripped
|
|
250
|
+
: stripped;
|
|
251
|
+
} else {
|
|
252
|
+
// GitHub alert marker: a blockquote whose first line is [!KIND].
|
|
253
|
+
// We strip the marker from content and tag the block; rendering decides the style.
|
|
254
|
+
const alertMatch = stripped.match(/^\[!(NOTE|TIP|WARNING|CAUTION|IMPORTANT)\]\s*$/i);
|
|
255
|
+
blocks.push({
|
|
256
|
+
id: `block-${currentId++}`,
|
|
257
|
+
type: 'blockquote',
|
|
258
|
+
content: alertMatch ? '' : stripped,
|
|
259
|
+
alertKind: alertMatch
|
|
260
|
+
? (alertMatch[1].toLowerCase() as 'note' | 'tip' | 'warning' | 'caution' | 'important')
|
|
261
|
+
: undefined,
|
|
262
|
+
order: currentId,
|
|
263
|
+
startLine: currentLineNum
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Code blocks (naive)
|
|
270
|
+
if (trimmed.startsWith('```')) {
|
|
271
|
+
flush();
|
|
272
|
+
const codeStartLine = currentLineNum;
|
|
273
|
+
// Count backticks in opening fence to support nested fences (e.g. ```` wrapping ```)
|
|
274
|
+
const fenceLen = trimmed.match(/^`+/)?.[0].length ?? 3;
|
|
275
|
+
const closingFence = new RegExp('^\\s*`{' + fenceLen + ',}');
|
|
276
|
+
// Extract language from fence (e.g., ```rust → "rust")
|
|
277
|
+
const language = trimmed.slice(fenceLen).trim() || undefined;
|
|
278
|
+
// Fast forward until end of code block
|
|
279
|
+
let codeContent = [];
|
|
280
|
+
i++; // Skip start fence
|
|
281
|
+
while(i < lines.length && !closingFence.test(lines[i])) {
|
|
282
|
+
codeContent.push(lines[i]);
|
|
283
|
+
i++;
|
|
284
|
+
}
|
|
285
|
+
blocks.push({
|
|
286
|
+
id: `block-${currentId++}`,
|
|
287
|
+
type: 'code',
|
|
288
|
+
content: codeContent.join('\n'),
|
|
289
|
+
language,
|
|
290
|
+
order: currentId,
|
|
291
|
+
startLine: codeStartLine
|
|
292
|
+
});
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Display math: $$ ... $$ — only when a closing $$ actually exists. An
|
|
297
|
+
// unclosed $$ (a stray delimiter or informal money like "$$100k for infra")
|
|
298
|
+
// must NOT swallow the rest of the document: scan ahead without committing,
|
|
299
|
+
// and if there's no close, fall through and treat the line as ordinary text.
|
|
300
|
+
if (trimmed.startsWith('$$')) {
|
|
301
|
+
const mathStartLine = currentLineNum;
|
|
302
|
+
const afterOpen = trimmed.slice(2);
|
|
303
|
+
const mathLines: string[] = [];
|
|
304
|
+
let remainder = '';
|
|
305
|
+
let closed = false;
|
|
306
|
+
let closeLine = i;
|
|
307
|
+
// Find the closing $$ anywhere on the opening line (not just at its end),
|
|
308
|
+
// so `$$x$$.` or `$$x$$ trailing` close correctly instead of running on.
|
|
309
|
+
const inlineClose = afterOpen.indexOf('$$');
|
|
310
|
+
if (inlineClose !== -1) {
|
|
311
|
+
const body = afterOpen.slice(0, inlineClose).trim();
|
|
312
|
+
if (body) mathLines.push(body);
|
|
313
|
+
remainder = afterOpen.slice(inlineClose + 2).trim();
|
|
314
|
+
closed = true;
|
|
315
|
+
} else {
|
|
316
|
+
const scanned: string[] = afterOpen.trim() ? [afterOpen.trim()] : [];
|
|
317
|
+
let j = i;
|
|
318
|
+
while (j + 1 < lines.length) {
|
|
319
|
+
j++;
|
|
320
|
+
// A blank line ends the search: real $$…$$ has no blank line before its
|
|
321
|
+
// close, so a blank means this opener was never closed. Stopping here
|
|
322
|
+
// also prevents matching a stray $$ far below (e.g. inside a later code
|
|
323
|
+
// fence) and swallowing everything in between.
|
|
324
|
+
if (lines[j].trim() === '') break;
|
|
325
|
+
const closeAt = lines[j].indexOf('$$');
|
|
326
|
+
if (closeAt !== -1) {
|
|
327
|
+
const before = lines[j].slice(0, closeAt);
|
|
328
|
+
if (before.trim()) scanned.push(before);
|
|
329
|
+
remainder = lines[j].slice(closeAt + 2).trim();
|
|
330
|
+
closed = true;
|
|
331
|
+
closeLine = j;
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
scanned.push(lines[j]);
|
|
335
|
+
}
|
|
336
|
+
if (closed) for (const s of scanned) mathLines.push(s);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (closed) {
|
|
340
|
+
flush();
|
|
341
|
+
i = closeLine;
|
|
342
|
+
blocks.push({
|
|
343
|
+
id: `block-${currentId++}`,
|
|
344
|
+
type: 'math',
|
|
345
|
+
content: mathLines.join('\n'),
|
|
346
|
+
order: currentId,
|
|
347
|
+
startLine: mathStartLine,
|
|
348
|
+
sourceLineCount: i + contentStartLine - mathStartLine + 1,
|
|
349
|
+
});
|
|
350
|
+
// Trailing text after the closing $$ isn't math — reprocess it as its own
|
|
351
|
+
// line so it renders normally instead of being swallowed into the block.
|
|
352
|
+
if (remainder) {
|
|
353
|
+
lines[i] = remainder;
|
|
354
|
+
i--;
|
|
355
|
+
}
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
// No closing $$ found — not display math; fall through to normal handling.
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Display math: \[ ... \] — same unclosed-guard as $$ above: only treat as
|
|
362
|
+
// math when the closing \] exists, so a stray `\[deprecated\]`-style line or
|
|
363
|
+
// an unclosed `\[` doesn't swallow the rest of the document.
|
|
364
|
+
if (trimmed.startsWith('\\[')) {
|
|
365
|
+
const mathStartLine = currentLineNum;
|
|
366
|
+
const afterOpen = trimmed.slice(2);
|
|
367
|
+
const mathLines: string[] = [];
|
|
368
|
+
let remainder = '';
|
|
369
|
+
let closed = false;
|
|
370
|
+
let closeLine = i;
|
|
371
|
+
const inlineClose = afterOpen.indexOf('\\]');
|
|
372
|
+
if (inlineClose !== -1) {
|
|
373
|
+
const body = afterOpen.slice(0, inlineClose).trim();
|
|
374
|
+
if (body) mathLines.push(body);
|
|
375
|
+
remainder = afterOpen.slice(inlineClose + 2).trim();
|
|
376
|
+
closed = true;
|
|
377
|
+
} else {
|
|
378
|
+
const scanned: string[] = afterOpen.trim() ? [afterOpen.trim()] : [];
|
|
379
|
+
let j = i;
|
|
380
|
+
while (j + 1 < lines.length) {
|
|
381
|
+
j++;
|
|
382
|
+
// Blank line ends the search (see the $$ branch): unclosed \[ must not
|
|
383
|
+
// swallow later blocks or match a stray \] inside a later code fence.
|
|
384
|
+
if (lines[j].trim() === '') break;
|
|
385
|
+
const closeAt = lines[j].indexOf('\\]');
|
|
386
|
+
if (closeAt !== -1) {
|
|
387
|
+
const before = lines[j].slice(0, closeAt);
|
|
388
|
+
if (before.trim()) scanned.push(before);
|
|
389
|
+
remainder = lines[j].slice(closeAt + 2).trim();
|
|
390
|
+
closed = true;
|
|
391
|
+
closeLine = j;
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
394
|
+
scanned.push(lines[j]);
|
|
395
|
+
}
|
|
396
|
+
if (closed) for (const s of scanned) mathLines.push(s);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (closed) {
|
|
400
|
+
flush();
|
|
401
|
+
i = closeLine;
|
|
402
|
+
blocks.push({
|
|
403
|
+
id: `block-${currentId++}`,
|
|
404
|
+
type: 'math',
|
|
405
|
+
content: mathLines.join('\n'),
|
|
406
|
+
order: currentId,
|
|
407
|
+
startLine: mathStartLine,
|
|
408
|
+
sourceLineCount: i + contentStartLine - mathStartLine + 1,
|
|
409
|
+
});
|
|
410
|
+
if (remainder) {
|
|
411
|
+
lines[i] = remainder;
|
|
412
|
+
i--;
|
|
413
|
+
}
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
// No closing \] found — not display math; fall through to normal handling.
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Tables (lines starting with |)
|
|
420
|
+
if (trimmed.startsWith('|')) {
|
|
421
|
+
flush();
|
|
422
|
+
const tableStartLine = currentLineNum;
|
|
423
|
+
const tableLines: string[] = [line];
|
|
424
|
+
|
|
425
|
+
// Collect all consecutive table lines
|
|
426
|
+
while (i + 1 < lines.length) {
|
|
427
|
+
const nextLine = lines[i + 1].trim();
|
|
428
|
+
// Continue if line starts with | (table row or separator)
|
|
429
|
+
if (nextLine.startsWith('|')) {
|
|
430
|
+
i++;
|
|
431
|
+
tableLines.push(lines[i]);
|
|
432
|
+
} else {
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
blocks.push({
|
|
438
|
+
id: `block-${currentId++}`,
|
|
439
|
+
type: 'table',
|
|
440
|
+
content: tableLines.join('\n'),
|
|
441
|
+
order: currentId,
|
|
442
|
+
startLine: tableStartLine
|
|
443
|
+
});
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Raw HTML blocks. A line starting with a known block-level HTML tag
|
|
448
|
+
// opens an HTML block. For opening tags we accumulate until the matching
|
|
449
|
+
// close tag is balanced (so `<details>…blank line…</details>` renders as
|
|
450
|
+
// one unit, matching GitHub's flavored behavior rather than strict
|
|
451
|
+
// CommonMark §4.6 Type 6 blank-line termination). For a line that starts
|
|
452
|
+
// with a close tag, we fall back to blank-line termination. Content is
|
|
453
|
+
// sanitized at render time, not here.
|
|
454
|
+
// Directive container: `:::kind` opens, `:::` closes. Inline kind is
|
|
455
|
+
// restricted to simple identifiers (letters, digits, hyphens). Body is
|
|
456
|
+
// accumulated verbatim and rendered with inline markdown.
|
|
457
|
+
const directiveOpen = trimmed.match(/^:::\s*([a-zA-Z][a-zA-Z0-9-]*)\s*$/);
|
|
458
|
+
if (directiveOpen) {
|
|
459
|
+
flush();
|
|
460
|
+
const directiveStartLine = currentLineNum;
|
|
461
|
+
const kind = directiveOpen[1].toLowerCase();
|
|
462
|
+
const bodyLines: string[] = [];
|
|
463
|
+
while (i + 1 < lines.length) {
|
|
464
|
+
i++;
|
|
465
|
+
if (lines[i].trim() === ':::') break;
|
|
466
|
+
bodyLines.push(lines[i]);
|
|
467
|
+
}
|
|
468
|
+
blocks.push({
|
|
469
|
+
id: `block-${currentId++}`,
|
|
470
|
+
type: 'directive',
|
|
471
|
+
content: bodyLines.join('\n'),
|
|
472
|
+
directiveKind: kind,
|
|
473
|
+
order: currentId,
|
|
474
|
+
startLine: directiveStartLine,
|
|
475
|
+
});
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const htmlTagMatch = trimmed.match(HTML_BLOCK_OPEN_RE);
|
|
480
|
+
if (htmlTagMatch && HTML_BLOCK_TAGS.has(htmlTagMatch[1].toLowerCase())) {
|
|
481
|
+
flush();
|
|
482
|
+
const htmlStartLine = currentLineNum;
|
|
483
|
+
const tagName = htmlTagMatch[1].toLowerCase();
|
|
484
|
+
const isCloseTag = trimmed.startsWith('</');
|
|
485
|
+
const htmlLines: string[] = [line];
|
|
486
|
+
|
|
487
|
+
if (isCloseTag) {
|
|
488
|
+
while (i + 1 < lines.length && lines[i + 1].trim() !== '') {
|
|
489
|
+
i++;
|
|
490
|
+
htmlLines.push(lines[i]);
|
|
491
|
+
}
|
|
492
|
+
} else if (VOID_HTML_TAGS.has(tagName)) {
|
|
493
|
+
// Void element (e.g. <img>): no closing tag, but attributes can wrap across
|
|
494
|
+
// lines. Consume until the line that actually closes the tag with `>` so a
|
|
495
|
+
// multi-line <img> isn't truncated to a bare `<img` fragment.
|
|
496
|
+
while (!lines[i].includes('>') && i + 1 < lines.length && lines[i + 1].trim() !== '') {
|
|
497
|
+
i++;
|
|
498
|
+
htmlLines.push(lines[i]);
|
|
499
|
+
}
|
|
500
|
+
} else {
|
|
501
|
+
const openRe = new RegExp(`<${tagName}(?:\\s|>|/|$)`, 'gi');
|
|
502
|
+
const closeRe = new RegExp(`</${tagName}\\s*>`, 'gi');
|
|
503
|
+
const depth = (line.match(openRe) || []).length - (line.match(closeRe) || []).length;
|
|
504
|
+
if (depth > 0) {
|
|
505
|
+
// Scan ahead for the matching close tag. If none is ever found — a
|
|
506
|
+
// self-closing <video/>, or an unclosed <picture>/<div> — do NOT swallow
|
|
507
|
+
// the rest of the document into this block; keep it to the opening line.
|
|
508
|
+
let j = i;
|
|
509
|
+
let d = depth;
|
|
510
|
+
const scanned: string[] = [];
|
|
511
|
+
while (d > 0 && j + 1 < lines.length) {
|
|
512
|
+
j++;
|
|
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
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
blocks.push({
|
|
525
|
+
id: `block-${currentId++}`,
|
|
526
|
+
type: 'html',
|
|
527
|
+
content: htmlLines.join('\n'),
|
|
528
|
+
order: currentId,
|
|
529
|
+
startLine: htmlStartLine,
|
|
530
|
+
});
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Empty lines separate paragraphs
|
|
535
|
+
if (trimmed === '') {
|
|
536
|
+
flush();
|
|
537
|
+
currentType = 'paragraph';
|
|
538
|
+
lastLineWasBlank = true;
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
// List continuation: indented line after a list item merges into it.
|
|
542
|
+
// Tight (no blank line): 1+ whitespace, joined with \n (same paragraph).
|
|
543
|
+
// Loose (after blank line): 2+ spaces, joined with \n\n (new paragraph within the item).
|
|
544
|
+
if (
|
|
545
|
+
buffer.length === 0 &&
|
|
546
|
+
blocks.length > 0 &&
|
|
547
|
+
blocks[blocks.length - 1].type === 'list-item' &&
|
|
548
|
+
(prevLineWasBlank ? /^\s{2,}/ : /^\s+/).test(line)
|
|
549
|
+
) {
|
|
550
|
+
const sep = prevLineWasBlank ? '\n\n' : '\n';
|
|
551
|
+
blocks[blocks.length - 1].content += sep + trimmed;
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Accumulate paragraph text
|
|
556
|
+
if (buffer.length === 0) {
|
|
557
|
+
bufferStartLine = currentLineNum;
|
|
558
|
+
}
|
|
559
|
+
buffer.push(line);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
flush(); // Final flush
|
|
563
|
+
|
|
564
|
+
return blocks;
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Compute the display index for each list item in a contiguous list group.
|
|
569
|
+
*
|
|
570
|
+
* Returns a parallel array where each entry is either:
|
|
571
|
+
* - a positive integer (the numeral to render for an ordered item), or
|
|
572
|
+
* - null (the item is unordered, render a bullet symbol).
|
|
573
|
+
*
|
|
574
|
+
* Semantics:
|
|
575
|
+
* - A run of consecutive ordered items at the same level increments
|
|
576
|
+
* sequentially. The first item in a run uses its `orderedStart` (the
|
|
577
|
+
* number from the source markdown); subsequent items renumber from there
|
|
578
|
+
* so `1. / 2. / 5.` renders as 1, 2, 3 (matches CommonMark).
|
|
579
|
+
* - An unordered item at level L breaks the ordered streak at L. The next
|
|
580
|
+
* ordered item at L restarts from its own `orderedStart`.
|
|
581
|
+
* - Visiting a level shallower than the current one truncates deeper-level
|
|
582
|
+
* state, so re-entering that depth later starts fresh. Top-level numbering
|
|
583
|
+
* continues across nested children of any kind.
|
|
584
|
+
*/
|
|
585
|
+
export const computeListIndices = (blocks: Block[]): (number | null)[] => {
|
|
586
|
+
const counters: number[] = [];
|
|
587
|
+
const lastOrderedAtLevel: boolean[] = [];
|
|
588
|
+
|
|
589
|
+
return blocks.map(block => {
|
|
590
|
+
const lvl = block.level || 0;
|
|
591
|
+
// Sibling change at any deeper level resets those levels.
|
|
592
|
+
counters.length = lvl + 1;
|
|
593
|
+
lastOrderedAtLevel.length = lvl + 1;
|
|
594
|
+
|
|
595
|
+
if (!block.ordered) {
|
|
596
|
+
lastOrderedAtLevel[lvl] = false;
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (lastOrderedAtLevel[lvl]) {
|
|
601
|
+
counters[lvl] = (counters[lvl] ?? 0) + 1;
|
|
602
|
+
} else {
|
|
603
|
+
counters[lvl] = block.orderedStart ?? 1;
|
|
604
|
+
}
|
|
605
|
+
lastOrderedAtLevel[lvl] = true;
|
|
606
|
+
return counters[lvl];
|
|
607
|
+
});
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
/** A run of blocks to render: a single block, or consecutive list items grouped
|
|
611
|
+
* together so list numbering/indent can be computed across the run. */
|
|
612
|
+
export type RenderGroup =
|
|
613
|
+
| { type: 'single'; block: Block }
|
|
614
|
+
| { type: 'list-group'; blocks: Block[]; key: string };
|
|
615
|
+
|
|
616
|
+
/** Groups consecutive list-item blocks so a list renders as one unit. */
|
|
617
|
+
export function groupBlocks(blocks: Block[]): RenderGroup[] {
|
|
618
|
+
const groups: RenderGroup[] = [];
|
|
619
|
+
let i = 0;
|
|
620
|
+
while (i < blocks.length) {
|
|
621
|
+
if (blocks[i].type === 'list-item') {
|
|
622
|
+
const listBlocks: Block[] = [];
|
|
623
|
+
while (i < blocks.length && blocks[i].type === 'list-item') {
|
|
624
|
+
listBlocks.push(blocks[i]);
|
|
625
|
+
i++;
|
|
626
|
+
}
|
|
627
|
+
groups.push({ type: 'list-group', blocks: listBlocks, key: `list-${listBlocks[0].id}` });
|
|
628
|
+
} else {
|
|
629
|
+
groups.push({ type: 'single', block: blocks[i] });
|
|
630
|
+
i++;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
return groups;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** Wrap feedback output with the deny preamble for pasting into agent sessions */
|
|
637
|
+
export const wrapFeedbackForAgent = (feedback: string): string =>
|
|
638
|
+
planDenyFeedback(feedback);
|
|
639
|
+
|
|
640
|
+
export interface ExportAnnotationsOptions {
|
|
641
|
+
sourceConverted?: boolean;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** Compute the end line of a block from its content and type. */
|
|
645
|
+
const blockEndLine = (block: Block): number => {
|
|
646
|
+
if (block.sourceLineCount && block.sourceLineCount > 0) {
|
|
647
|
+
return block.startLine + block.sourceLineCount - 1;
|
|
648
|
+
}
|
|
649
|
+
if (!block.content) return block.startLine;
|
|
650
|
+
const contentLines = block.content.split('\n').length;
|
|
651
|
+
if (block.type === 'code') return block.startLine + contentLines + 1;
|
|
652
|
+
if (block.type === 'directive') return block.startLine + contentLines + 1;
|
|
653
|
+
if (block.alertKind) return block.startLine + contentLines;
|
|
654
|
+
return block.startLine + contentLines - 1;
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
/** Resolve the source-line label for a single annotation.
|
|
658
|
+
* Returns null for global comments, diff-view annotations, or missing blocks. */
|
|
659
|
+
const lineLabelForAnnotation = (blocks: Block[], ann: any): string | null => {
|
|
660
|
+
if (!ann.blockId || ann.type === 'GLOBAL_COMMENT') return null;
|
|
661
|
+
if (typeof ann.blockId === 'string' && ann.blockId.startsWith('diff-block-')) return null;
|
|
662
|
+
const block = blocks.find(b => b.id === ann.blockId);
|
|
663
|
+
if (!block || typeof block.startLine !== 'number') return null;
|
|
664
|
+
const end = blockEndLine(block);
|
|
665
|
+
if (end <= block.startLine) return `line ${block.startLine}`;
|
|
666
|
+
return `lines ${block.startLine}–${end}`;
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
export const exportAnnotations = (
|
|
670
|
+
blocks: Block[],
|
|
671
|
+
annotations: any[],
|
|
672
|
+
globalAttachments: ImageAttachment[] = [],
|
|
673
|
+
title: string = 'Plan Feedback',
|
|
674
|
+
subject: string = 'plan',
|
|
675
|
+
opts: ExportAnnotationsOptions = {},
|
|
676
|
+
): string => {
|
|
677
|
+
if (annotations.length === 0 && globalAttachments.length === 0) {
|
|
678
|
+
return 'No changes detected.';
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Sort annotations by block and offset
|
|
682
|
+
const sortedAnns = [...annotations].sort((a, b) => {
|
|
683
|
+
const blockA = blocks.findIndex(blk => blk.id === a.blockId);
|
|
684
|
+
const blockB = blocks.findIndex(blk => blk.id === b.blockId);
|
|
685
|
+
if (blockA !== blockB) return blockA - blockB;
|
|
686
|
+
return a.startOffset - b.startOffset;
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
let output = `# ${title}\n\n`;
|
|
690
|
+
|
|
691
|
+
if (opts.sourceConverted) {
|
|
692
|
+
output += `> Note: Line numbers below refer to the converted markdown, not the original HTML/URL source.\n\n`;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Add global reference images section if any
|
|
696
|
+
if (globalAttachments.length > 0) {
|
|
697
|
+
output += `## Reference Images\n`;
|
|
698
|
+
output += `Please review these reference images (use the Read tool to view):\n`;
|
|
699
|
+
globalAttachments.forEach((img, idx) => {
|
|
700
|
+
output += `${idx + 1}. [${img.name}] \`${img.path}\`\n`;
|
|
701
|
+
});
|
|
702
|
+
output += `\n`;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
if (annotations.length > 0) {
|
|
706
|
+
output += `I've reviewed this ${subject} and have ${annotations.length} piece${annotations.length > 1 ? 's' : ''} of feedback:\n\n`;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
sortedAnns.forEach((ann, index) => {
|
|
710
|
+
output += `## ${index + 1}. `;
|
|
711
|
+
|
|
712
|
+
// Add diff context label if annotation was created in diff view
|
|
713
|
+
if (ann.diffContext) {
|
|
714
|
+
output += `[In diff content] `;
|
|
715
|
+
} else {
|
|
716
|
+
const lineLabel = lineLabelForAnnotation(blocks, ann);
|
|
717
|
+
if (lineLabel) output += `(${lineLabel}) `;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
switch (ann.type) {
|
|
721
|
+
case 'DELETION':
|
|
722
|
+
output += `Remove this\n`;
|
|
723
|
+
output += `\`\`\`\n${ann.originalText}\n\`\`\`\n`;
|
|
724
|
+
output += `> I don't want this in the ${subject}.\n`;
|
|
725
|
+
break;
|
|
726
|
+
|
|
727
|
+
case 'COMMENT':
|
|
728
|
+
if (ann.isQuickLabel) {
|
|
729
|
+
output += `[${ann.text}] Feedback on: "${ann.originalText}"\n`;
|
|
730
|
+
if (ann.quickLabelTip) {
|
|
731
|
+
output += `> ${ann.quickLabelTip}\n`;
|
|
732
|
+
}
|
|
733
|
+
} else {
|
|
734
|
+
output += `Feedback on: "${ann.originalText}"\n`;
|
|
735
|
+
output += `> ${ann.text}\n`;
|
|
736
|
+
}
|
|
737
|
+
break;
|
|
738
|
+
|
|
739
|
+
case 'GLOBAL_COMMENT':
|
|
740
|
+
output += `General feedback about the ${subject}\n`;
|
|
741
|
+
output += `> ${ann.text}\n`;
|
|
742
|
+
break;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// Add attached images for this annotation
|
|
746
|
+
if (ann.images && ann.images.length > 0) {
|
|
747
|
+
output += `**Attached images:**\n`;
|
|
748
|
+
ann.images.forEach((img: ImageAttachment) => {
|
|
749
|
+
output += `- [${img.name}] \`${img.path}\`\n`;
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
output += '\n';
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
output += `---\n`;
|
|
757
|
+
|
|
758
|
+
// Quick Label Summary
|
|
759
|
+
const labeledAnns = sortedAnns.filter((a: any) => a.isQuickLabel && a.text);
|
|
760
|
+
if (labeledAnns.length > 0) {
|
|
761
|
+
const grouped = new Map<string, number>();
|
|
762
|
+
labeledAnns.forEach((a: any) => {
|
|
763
|
+
grouped.set(a.text, (grouped.get(a.text) || 0) + 1);
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
output += `\n## Label Summary\n\n`;
|
|
767
|
+
for (const [text, count] of grouped) {
|
|
768
|
+
output += `- **${text}**: ${count}\n`;
|
|
769
|
+
}
|
|
770
|
+
output += '\n';
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
return output;
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
export interface LinkedDocAnnotationEntry {
|
|
777
|
+
annotations: Annotation[];
|
|
778
|
+
globalAttachments: ImageAttachment[];
|
|
779
|
+
markdown?: string;
|
|
780
|
+
blocks?: Block[];
|
|
781
|
+
isConverted?: boolean;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
export const exportLinkedDocAnnotations = (
|
|
785
|
+
docAnnotations: Map<string, LinkedDocAnnotationEntry>
|
|
786
|
+
): string => {
|
|
787
|
+
let output = `\n# Linked Document Feedback\n\nThe following feedback is on documents referenced in the plan.\n\n`;
|
|
788
|
+
|
|
789
|
+
for (const [filepath, { annotations, globalAttachments, blocks: docBlocks, isConverted }] of docAnnotations) {
|
|
790
|
+
if (annotations.length === 0 && globalAttachments.length === 0) continue;
|
|
791
|
+
|
|
792
|
+
output += `## ${filepath}${isConverted ? ' (converted from HTML — line numbers refer to converted markdown)' : ''}\n\n`;
|
|
793
|
+
|
|
794
|
+
if (globalAttachments.length > 0) {
|
|
795
|
+
output += `### Reference Images\n`;
|
|
796
|
+
output += `Please review these reference images (use the Read tool to view):\n`;
|
|
797
|
+
globalAttachments.forEach((img, idx) => {
|
|
798
|
+
output += `${idx + 1}. [${img.name}] \`${img.path}\`\n`;
|
|
799
|
+
});
|
|
800
|
+
output += `\n`;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// Sort annotations by block and offset
|
|
804
|
+
const sortedAnns = [...annotations].sort((a, b) => {
|
|
805
|
+
if (a.blockId !== b.blockId) return a.blockId.localeCompare(b.blockId);
|
|
806
|
+
return a.startOffset - b.startOffset;
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
output += `I've reviewed this document and have ${annotations.length} piece${annotations.length !== 1 ? 's' : ''} of feedback:\n\n`;
|
|
810
|
+
|
|
811
|
+
sortedAnns.forEach((ann, index) => {
|
|
812
|
+
output += `### ${index + 1}. `;
|
|
813
|
+
|
|
814
|
+
const lineLabel = docBlocks ? lineLabelForAnnotation(docBlocks, ann) : null;
|
|
815
|
+
if (lineLabel) output += `(${lineLabel}) `;
|
|
816
|
+
|
|
817
|
+
switch (ann.type) {
|
|
818
|
+
case 'DELETION':
|
|
819
|
+
output += `Remove this\n`;
|
|
820
|
+
output += `\`\`\`\n${ann.originalText}\n\`\`\`\n`;
|
|
821
|
+
output += `> I don't want this in the document.\n`;
|
|
822
|
+
break;
|
|
823
|
+
|
|
824
|
+
case 'COMMENT':
|
|
825
|
+
output += `Feedback on: "${ann.originalText}"\n`;
|
|
826
|
+
output += `> ${ann.text}\n`;
|
|
827
|
+
break;
|
|
828
|
+
|
|
829
|
+
case 'GLOBAL_COMMENT':
|
|
830
|
+
output += `General feedback about the document\n`;
|
|
831
|
+
output += `> ${ann.text}\n`;
|
|
832
|
+
break;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
if (ann.images && ann.images.length > 0) {
|
|
836
|
+
output += `**Attached images:**\n`;
|
|
837
|
+
ann.images.forEach((img: ImageAttachment) => {
|
|
838
|
+
output += `- [${img.name}] \`${img.path}\`\n`;
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
output += '\n';
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
output += `---\n`;
|
|
847
|
+
return output;
|
|
848
|
+
};
|
|
849
|
+
|
|
850
|
+
export const exportEditorAnnotations = (editorAnnotations: EditorAnnotation[]): string => {
|
|
851
|
+
if (editorAnnotations.length === 0) return '';
|
|
852
|
+
|
|
853
|
+
let output = `\n# Editor File Annotations\n\nThe following annotations reference code files in the project.\n\n`;
|
|
854
|
+
|
|
855
|
+
editorAnnotations.forEach((ann, index) => {
|
|
856
|
+
const lineRange = ann.lineStart === ann.lineEnd
|
|
857
|
+
? `line ${ann.lineStart}`
|
|
858
|
+
: `lines ${ann.lineStart}-${ann.lineEnd}`;
|
|
859
|
+
|
|
860
|
+
output += `## ${index + 1}. ${ann.filePath} (${lineRange})\n`;
|
|
861
|
+
output += `\`\`\`\n${ann.selectedText}\n\`\`\`\n`;
|
|
862
|
+
|
|
863
|
+
if (ann.comment) {
|
|
864
|
+
output += `> ${ann.comment}\n`;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
output += '\n';
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
output += `---\n`;
|
|
871
|
+
return output;
|
|
872
|
+
};
|
|
873
|
+
|
|
874
|
+
export const exportCodeFileAnnotations = (annotations: CodeAnnotation[]): string => {
|
|
875
|
+
if (annotations.length === 0) return '';
|
|
876
|
+
|
|
877
|
+
let output = `\n# Code File Feedback\n\nThe following feedback is on code files referenced from the reviewed document.\n\n`;
|
|
878
|
+
const sorted = [...annotations].sort((a, b) => {
|
|
879
|
+
if (a.filePath !== b.filePath) return a.filePath.localeCompare(b.filePath);
|
|
880
|
+
if (a.lineStart !== b.lineStart) return a.lineStart - b.lineStart;
|
|
881
|
+
return a.createdAt - b.createdAt;
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
sorted.forEach((ann, index) => {
|
|
885
|
+
const lineRange = ann.lineStart === ann.lineEnd
|
|
886
|
+
? `line ${ann.lineStart}`
|
|
887
|
+
: `lines ${ann.lineStart}-${ann.lineEnd}`;
|
|
888
|
+
|
|
889
|
+
output += `## ${index + 1}. ${ann.filePath} (${lineRange})\n`;
|
|
890
|
+
if (ann.originalCode) {
|
|
891
|
+
output += `\`\`\`\n${ann.originalCode}\n\`\`\`\n`;
|
|
892
|
+
}
|
|
893
|
+
if (ann.text) {
|
|
894
|
+
output += `> ${ann.text}\n`;
|
|
895
|
+
}
|
|
896
|
+
if (ann.images && ann.images.length > 0) {
|
|
897
|
+
output += `**Attached images:**\n`;
|
|
898
|
+
ann.images.forEach((img) => {
|
|
899
|
+
output += `- [${img.name}] \`${img.path}\`\n`;
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
output += '\n';
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
output += `---\n`;
|
|
906
|
+
return output;
|
|
907
|
+
};
|
|
908
|
+
|
|
909
|
+
export interface MessageAnnotationEntry {
|
|
910
|
+
messageId: string;
|
|
911
|
+
text: string;
|
|
912
|
+
timestamp?: string;
|
|
913
|
+
annotations: Annotation[];
|
|
914
|
+
globalAttachments: ImageAttachment[];
|
|
915
|
+
blocks?: Block[];
|
|
916
|
+
linkedDocs?: Map<string, LinkedDocAnnotationEntry>;
|
|
917
|
+
codeAnnotations?: CodeAnnotation[];
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const MESSAGE_EXCERPT_MAX_CHARS = 1200;
|
|
921
|
+
|
|
922
|
+
const excerptMessageText = (text: string): string => {
|
|
923
|
+
const trimmed = text.trim();
|
|
924
|
+
if (trimmed.length <= MESSAGE_EXCERPT_MAX_CHARS) return trimmed;
|
|
925
|
+
return `${trimmed.slice(0, MESSAGE_EXCERPT_MAX_CHARS).trimEnd()}...`;
|
|
926
|
+
};
|
|
927
|
+
|
|
928
|
+
const fencedBlock = (text: string, language = ''): string => {
|
|
929
|
+
let fence = '```';
|
|
930
|
+
while (text.includes(fence)) fence += '`';
|
|
931
|
+
return `${fence}${language}\n${text}\n${fence}\n`;
|
|
932
|
+
};
|
|
933
|
+
|
|
934
|
+
export const exportMessageAnnotations = (entries: MessageAnnotationEntry[]): string => {
|
|
935
|
+
const nonEmpty = entries.filter((entry) => {
|
|
936
|
+
const linkedDocCount = entry.linkedDocs
|
|
937
|
+
? Array.from(entry.linkedDocs.values()).reduce(
|
|
938
|
+
(sum, doc) => sum + doc.annotations.length + doc.globalAttachments.length,
|
|
939
|
+
0
|
|
940
|
+
)
|
|
941
|
+
: 0;
|
|
942
|
+
return (
|
|
943
|
+
entry.annotations.length > 0 ||
|
|
944
|
+
entry.globalAttachments.length > 0 ||
|
|
945
|
+
(entry.codeAnnotations?.length ?? 0) > 0 ||
|
|
946
|
+
linkedDocCount > 0
|
|
947
|
+
);
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
if (nonEmpty.length === 0) {
|
|
951
|
+
return 'User reviewed the messages and has no feedback.';
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
let output = `# Message Feedback\n\nThe following feedback spans ${nonEmpty.length} assistant message${nonEmpty.length === 1 ? '' : 's'}. Each section includes an excerpt of the message it applies to.\n\n`;
|
|
955
|
+
|
|
956
|
+
nonEmpty.forEach((entry, index) => {
|
|
957
|
+
const label = entry.timestamp ? ` (${entry.timestamp})` : '';
|
|
958
|
+
output += `## Message ${index + 1}${label}\n\n`;
|
|
959
|
+
output += `Message excerpt:\n`;
|
|
960
|
+
output += fencedBlock(excerptMessageText(entry.text), 'markdown');
|
|
961
|
+
output += '\n';
|
|
962
|
+
|
|
963
|
+
if (entry.annotations.length > 0 || entry.globalAttachments.length > 0) {
|
|
964
|
+
output += exportAnnotations(
|
|
965
|
+
entry.blocks ?? parseMarkdownToBlocks(entry.text),
|
|
966
|
+
entry.annotations,
|
|
967
|
+
entry.globalAttachments,
|
|
968
|
+
`Feedback for Message ${index + 1}`,
|
|
969
|
+
'message',
|
|
970
|
+
);
|
|
971
|
+
output += '\n';
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
const hasLinkedDocFeedback = entry.linkedDocs
|
|
975
|
+
? Array.from(entry.linkedDocs.values()).some(
|
|
976
|
+
(doc) => doc.annotations.length > 0 || doc.globalAttachments.length > 0
|
|
977
|
+
)
|
|
978
|
+
: false;
|
|
979
|
+
if (entry.linkedDocs && hasLinkedDocFeedback) {
|
|
980
|
+
output += exportLinkedDocAnnotations(entry.linkedDocs);
|
|
981
|
+
output += '\n';
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
if (entry.codeAnnotations?.length) {
|
|
985
|
+
output += exportCodeFileAnnotations(entry.codeAnnotations);
|
|
986
|
+
output += '\n';
|
|
987
|
+
}
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
return output.trimEnd();
|
|
991
|
+
};
|