md-redline 0.1.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/LICENSE +21 -0
- package/README.md +169 -0
- package/bin/md-redline +255 -0
- package/bin/test-windows.ps1 +70 -0
- package/dist/assets/_baseFor-Ck08IaSF.js +1 -0
- package/dist/assets/arc-DI2g9LXK.js +1 -0
- package/dist/assets/architecture-YZFGNWBL-BDgMfc-b.js +1 -0
- package/dist/assets/architectureDiagram-Q4EWVU46-Dg1hcUEa.js +36 -0
- package/dist/assets/array-DOVTz2Mq.js +1 -0
- package/dist/assets/blockDiagram-DXYQGD6D-BAXkTCAk.js +132 -0
- package/dist/assets/c4Diagram-AHTNJAMY-BIkgwQSx.js +10 -0
- package/dist/assets/channel-DPCihw7y.js +1 -0
- package/dist/assets/chunk-2KRD3SAO-Dc_tBGsw.js +1 -0
- package/dist/assets/chunk-336JU56O-Dhi-ID9Y.js +2 -0
- package/dist/assets/chunk-426QAEUC-DnFdrNMW.js +1 -0
- package/dist/assets/chunk-4BX2VUAB-Z63FkGov.js +1 -0
- package/dist/assets/chunk-4TB4RGXK-BAiBlfyy.js +206 -0
- package/dist/assets/chunk-55IACEB6-BXDWXbxy.js +1 -0
- package/dist/assets/chunk-5FUZZQ4R-C72e1c_O.js +62 -0
- package/dist/assets/chunk-5PVQY5BW-BBHW_uCu.js +2 -0
- package/dist/assets/chunk-67CJDMHE-3Cf_D9m6.js +1 -0
- package/dist/assets/chunk-7N4EOEYR-DAXUXJ2c.js +1 -0
- package/dist/assets/chunk-AA7GKIK3-Dr7fOryc.js +1 -0
- package/dist/assets/chunk-BSJP7CBP-BmsSs1Nt.js +1 -0
- package/dist/assets/chunk-CIAEETIT-QDzV-X_Y.js +1 -0
- package/dist/assets/chunk-EDXVE4YY-C25WFHxY.js +1 -0
- package/dist/assets/chunk-ENJZ2VHE-_OzxcZOU.js +10 -0
- package/dist/assets/chunk-FMBD7UC4-CjsTKY4u.js +15 -0
- package/dist/assets/chunk-FOC6F5B3-g-xaH5nc.js +1 -0
- package/dist/assets/chunk-ICPOFSXX-iKiUSjDK.js +121 -0
- package/dist/assets/chunk-K5T4RW27-CKR-lPBN.js +94 -0
- package/dist/assets/chunk-KGLVRYIC-DRccT-B_.js +1 -0
- package/dist/assets/chunk-LIHQZDEY-DTbMwMXj.js +1 -0
- package/dist/assets/chunk-ORNJ4GCN-DlerdcWX.js +1 -0
- package/dist/assets/chunk-OYMX7WX6-Dekv1on2.js +231 -0
- package/dist/assets/chunk-QZHKN3VN-BHu0RdKl.js +1 -0
- package/dist/assets/chunk-U2HBQHQK-BvtlVHAg.js +70 -0
- package/dist/assets/chunk-X2U36JSP-BI_g8mub.js +1 -0
- package/dist/assets/chunk-XPW4576I-B39JkmSE.js +32 -0
- package/dist/assets/chunk-YZCP3GAM-BfPcXRm2.js +1 -0
- package/dist/assets/chunk-ZZ45TVLE-Bg4q68wZ.js +1 -0
- package/dist/assets/classDiagram-6PBFFD2Q-p73p727_.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-C4Ftpivp.js +1 -0
- package/dist/assets/clone-CI9aUwHe.js +1 -0
- package/dist/assets/cose-bilkent-S5V4N54A-7BpAeDh5.js +1 -0
- package/dist/assets/cytoscape.esm-DoTFyJaN.js +321 -0
- package/dist/assets/dagre-CilMRazv.js +1 -0
- package/dist/assets/dagre-KV5264BT-DDMqpjkB.js +4 -0
- package/dist/assets/defaultLocale-Ck2Xxk-C.js +1 -0
- package/dist/assets/diagram-5BDNPKRD-BFeyfnCx.js +10 -0
- package/dist/assets/diagram-G4DWMVQ6-DoqT-PtF.js +24 -0
- package/dist/assets/diagram-MMDJMWI5-BPV6KADk.js +43 -0
- package/dist/assets/diagram-TYMM5635-okvcTBtl.js +24 -0
- package/dist/assets/dist-C_eddq6m.js +1 -0
- package/dist/assets/erDiagram-SMLLAGMA-Dl-Ixy8n.js +85 -0
- package/dist/assets/flatten-B8XIuT0x.js +1 -0
- package/dist/assets/flowDiagram-DWJPFMVM-CsqWAx5r.js +162 -0
- package/dist/assets/ganttDiagram-T4ZO3ILL-mIt6zVeF.js +292 -0
- package/dist/assets/gitGraph-7Q5UKJZL-COXHGMvj.js +1 -0
- package/dist/assets/gitGraphDiagram-UUTBAWPF-syVqZJX_.js +106 -0
- package/dist/assets/graphlib-Bpd0q3yO.js +1 -0
- package/dist/assets/index-BoggyWS0.css +2 -0
- package/dist/assets/index-aLvjHQW4.js +104 -0
- package/dist/assets/info-OMHHGYJF-B-0wfxwL.js +1 -0
- package/dist/assets/infoDiagram-42DDH7IO-C0_uqsVa.js +2 -0
- package/dist/assets/init-Bft5Ffpj.js +1 -0
- package/dist/assets/isEmpty-BrFi5AqV.js +1 -0
- package/dist/assets/ishikawaDiagram-UXIWVN3A-CTjFbDBV.js +70 -0
- package/dist/assets/journeyDiagram-VCZTEJTY-BDBcej1q.js +139 -0
- package/dist/assets/kanban-definition-6JOO6SKY-Ylgzakw7.js +89 -0
- package/dist/assets/katex-Uj9wLT16.js +265 -0
- package/dist/assets/line-CRxEwpOv.js +1 -0
- package/dist/assets/linear-PDPfFByd.js +1 -0
- package/dist/assets/mermaid-parser.core-CY-XNOOy.js +4 -0
- package/dist/assets/mermaid.core-BPlTADIX.js +11 -0
- package/dist/assets/mindmap-definition-QFDTVHPH-TefzJnBM.js +96 -0
- package/dist/assets/ordinal-DIg8h6NI.js +1 -0
- package/dist/assets/packet-4T2RLAQJ-BW1T_A-C.js +1 -0
- package/dist/assets/path-DfRbCp9y.js +1 -0
- package/dist/assets/pie-ZZUOXDRM-DkKU-SFu.js +1 -0
- package/dist/assets/pieDiagram-DEJITSTG-BCXuaeEy.js +30 -0
- package/dist/assets/quadrantDiagram-34T5L4WZ-VSBAicWL.js +7 -0
- package/dist/assets/radar-PYXPWWZC-CYvTacKJ.js +1 -0
- package/dist/assets/reduce-CV2X8n1a.js +1 -0
- package/dist/assets/requirementDiagram-MS252O5E-4NeL9Z6J.js +84 -0
- package/dist/assets/rough.esm-Bbn_-PMU.js +1 -0
- package/dist/assets/sankeyDiagram-XADWPNL6-DMBSDnrH.js +10 -0
- package/dist/assets/sequenceDiagram-FGHM5R23-DVpzcZUi.js +157 -0
- package/dist/assets/src-PKe5NtkK.js +1 -0
- package/dist/assets/stateDiagram-FHFEXIEX-BkHTlCjL.js +1 -0
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-nMeWu9fP.js +1 -0
- package/dist/assets/timeline-definition-GMOUNBTQ-CyLt92nf.js +120 -0
- package/dist/assets/treeView-SZITEDCU-BUgcJ4eR.js +1 -0
- package/dist/assets/treemap-W4RFUUIX-BIWGQ4Pw.js +1 -0
- package/dist/assets/vennDiagram-DHZGUBPP-BCK0xB_m.js +34 -0
- package/dist/assets/wardley-RL74JXVD-DMZZRlby.js +1 -0
- package/dist/assets/wardleyDiagram-NUSXRM2D-BisBgfsF.js +20 -0
- package/dist/assets/xychartDiagram-5P7HB3ND-D_REDciv.js +7 -0
- package/dist/favicon.svg +15 -0
- package/dist/index.html +14 -0
- package/dist/screenshot.png +0 -0
- package/index.html +13 -0
- package/package.json +105 -0
- package/public/favicon.svg +15 -0
- package/public/screenshot.png +0 -0
- package/server/index.test.ts +814 -0
- package/server/index.ts +736 -0
- package/server/preferences.test.ts +126 -0
- package/server/preferences.ts +76 -0
- package/src/App.tsx +1620 -0
- package/src/components/ActionButton.tsx +41 -0
- package/src/components/CommandPalette.tsx +191 -0
- package/src/components/CommentCard.tsx +556 -0
- package/src/components/CommentForm.tsx +285 -0
- package/src/components/CommentSidebar.tsx +428 -0
- package/src/components/ConfirmDialog.tsx +64 -0
- package/src/components/ContextMenu.tsx +220 -0
- package/src/components/DragHandles.tsx +48 -0
- package/src/components/FileExplorer.tsx +251 -0
- package/src/components/FileOpener.tsx +304 -0
- package/src/components/IconButton.tsx +32 -0
- package/src/components/KeyboardShortcutsPanel.tsx +136 -0
- package/src/components/MarkdownViewer.tsx +682 -0
- package/src/components/RawView.tsx +798 -0
- package/src/components/SearchBar.tsx +129 -0
- package/src/components/Separator.tsx +7 -0
- package/src/components/SettingsPanel.tsx +813 -0
- package/src/components/SplitIconButton.tsx +133 -0
- package/src/components/TabBar.tsx +594 -0
- package/src/components/TableOfContents.tsx +70 -0
- package/src/components/ThemeSelector.tsx +159 -0
- package/src/components/Toast.tsx +99 -0
- package/src/components/Toolbar.tsx +161 -0
- package/src/components/iconButtonVariants.ts +19 -0
- package/src/components/rawView.test.ts +291 -0
- package/src/contexts/SettingsContext.tsx +120 -0
- package/src/hooks/useAuthor.test.ts +58 -0
- package/src/hooks/useAuthor.ts +69 -0
- package/src/hooks/useAutoResize.ts +20 -0
- package/src/hooks/useCommentCardTriggers.ts +20 -0
- package/src/hooks/useComments.test.ts +773 -0
- package/src/hooks/useComments.ts +332 -0
- package/src/hooks/useContextMenu.ts +48 -0
- package/src/hooks/useContextMenuItems.ts +392 -0
- package/src/hooks/useDiffSnapshot.test.ts +130 -0
- package/src/hooks/useDiffSnapshot.ts +67 -0
- package/src/hooks/useDragHandles.ts +417 -0
- package/src/hooks/useFileWatcher.ts +45 -0
- package/src/hooks/useHeadingTracking.ts +84 -0
- package/src/hooks/useMermaidRenderer.ts +75 -0
- package/src/hooks/useModalState.ts +22 -0
- package/src/hooks/usePageVisible.test.ts +69 -0
- package/src/hooks/usePageVisible.ts +19 -0
- package/src/hooks/usePaneLayout.test.ts +108 -0
- package/src/hooks/usePaneLayout.ts +102 -0
- package/src/hooks/useRecentFiles.test.ts +103 -0
- package/src/hooks/useRecentFiles.ts +99 -0
- package/src/hooks/useResizablePanel.test.ts +84 -0
- package/src/hooks/useResizablePanel.ts +118 -0
- package/src/hooks/useSearch.test.ts +72 -0
- package/src/hooks/useSearch.ts +53 -0
- package/src/hooks/useSelection.ts +48 -0
- package/src/hooks/useSessionPersistence.test.ts +59 -0
- package/src/hooks/useSessionPersistence.ts +43 -0
- package/src/hooks/useTabs.test.ts +127 -0
- package/src/hooks/useTabs.ts +561 -0
- package/src/hooks/useThemePersistence.ts +41 -0
- package/src/hooks/useToast.ts +27 -0
- package/src/index.css +1047 -0
- package/src/lib/agent-prompts.test.ts +34 -0
- package/src/lib/agent-prompts.ts +68 -0
- package/src/lib/comment-editor-state.ts +6 -0
- package/src/lib/comment-parser.test.ts +1959 -0
- package/src/lib/comment-parser.ts +1021 -0
- package/src/lib/diff.test.ts +164 -0
- package/src/lib/diff.ts +139 -0
- package/src/lib/heading-slugs.test.ts +85 -0
- package/src/lib/heading-slugs.ts +44 -0
- package/src/lib/http.test.ts +43 -0
- package/src/lib/http.ts +29 -0
- package/src/lib/mermaid-highlights.test.ts +517 -0
- package/src/lib/mermaid-highlights.ts +936 -0
- package/src/lib/mermaid-renderer.test.ts +114 -0
- package/src/lib/mermaid-renderer.ts +89 -0
- package/src/lib/path-utils.test.ts +17 -0
- package/src/lib/path-utils.ts +7 -0
- package/src/lib/platform.test.ts +58 -0
- package/src/lib/platform.ts +14 -0
- package/src/lib/preferences-client.test.ts +177 -0
- package/src/lib/preferences-client.ts +94 -0
- package/src/lib/selection-resolver.test.ts +118 -0
- package/src/lib/selection-resolver.ts +37 -0
- package/src/lib/settings.test.ts +152 -0
- package/src/lib/settings.ts +78 -0
- package/src/lib/shortcut-label.tsx +18 -0
- package/src/lib/themes.ts +21 -0
- package/src/lib/visible-text.test.ts +86 -0
- package/src/lib/visible-text.ts +77 -0
- package/src/main.tsx +22 -0
- package/src/markdown/pipeline.test.ts +82 -0
- package/src/markdown/pipeline.ts +33 -0
- package/src/types.test.ts +43 -0
- package/src/types.ts +46 -0
- package/tsconfig.app.json +28 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +26 -0
- package/vite.config.ts +50 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { SelectionInfo } from '../types';
|
|
2
|
+
import { getVisibleTextContent, getVisibleTextOffset } from './visible-text';
|
|
3
|
+
|
|
4
|
+
export function resolveSelection(containerEl: HTMLElement): SelectionInfo | null {
|
|
5
|
+
const sel = window.getSelection();
|
|
6
|
+
if (!sel || sel.isCollapsed) return null;
|
|
7
|
+
|
|
8
|
+
const rawText = sel.toString();
|
|
9
|
+
const text = rawText.trim();
|
|
10
|
+
if (!text || text.length < 2) return null;
|
|
11
|
+
|
|
12
|
+
const range = sel.getRangeAt(0);
|
|
13
|
+
if (!containerEl.contains(range.commonAncestorContainer)) return null;
|
|
14
|
+
|
|
15
|
+
// Get surrounding context from the rendered text
|
|
16
|
+
const fullText = getVisibleTextContent(containerEl);
|
|
17
|
+
const selStart = getVisibleTextOffset(containerEl, range.startContainer, range.startOffset);
|
|
18
|
+
|
|
19
|
+
// Adjust for leading whitespace that trim() removed so context windows
|
|
20
|
+
// align with the trimmed anchor text, not the raw selection boundaries.
|
|
21
|
+
const leadingTrim = rawText.length - rawText.trimStart().length;
|
|
22
|
+
const adjustedStart = selStart + leadingTrim;
|
|
23
|
+
const selEnd = adjustedStart + text.length;
|
|
24
|
+
|
|
25
|
+
const contextBefore = fullText.slice(Math.max(0, adjustedStart - 40), adjustedStart);
|
|
26
|
+
const contextAfter = fullText.slice(selEnd, selEnd + 40);
|
|
27
|
+
|
|
28
|
+
const rect = range.getBoundingClientRect();
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
text,
|
|
32
|
+
rect,
|
|
33
|
+
contextBefore,
|
|
34
|
+
contextAfter,
|
|
35
|
+
offset: adjustedStart,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { loadSettings, saveSettings, DEFAULT_SETTINGS, DEFAULT_TEMPLATES } from './settings';
|
|
3
|
+
|
|
4
|
+
// Mock localStorage
|
|
5
|
+
const store: Record<string, string> = {};
|
|
6
|
+
const localStorageMock = {
|
|
7
|
+
getItem: vi.fn((key: string) => store[key] ?? null),
|
|
8
|
+
setItem: vi.fn((key: string, value: string) => {
|
|
9
|
+
store[key] = value;
|
|
10
|
+
}),
|
|
11
|
+
removeItem: vi.fn((key: string) => {
|
|
12
|
+
delete store[key];
|
|
13
|
+
}),
|
|
14
|
+
clear: vi.fn(() => {
|
|
15
|
+
for (const key in store) delete store[key];
|
|
16
|
+
}),
|
|
17
|
+
get length() {
|
|
18
|
+
return Object.keys(store).length;
|
|
19
|
+
},
|
|
20
|
+
key: vi.fn((i: number) => Object.keys(store)[i] ?? null),
|
|
21
|
+
};
|
|
22
|
+
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true });
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
localStorageMock.clear();
|
|
26
|
+
vi.clearAllMocks();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('loadSettings', () => {
|
|
30
|
+
it('returns defaults when nothing is stored', () => {
|
|
31
|
+
expect(loadSettings()).toEqual(DEFAULT_SETTINGS);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('returns defaults when stored value is invalid JSON', () => {
|
|
35
|
+
store['md-redline-settings'] = 'not-json!!!';
|
|
36
|
+
expect(loadSettings()).toEqual(DEFAULT_SETTINGS);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('returns defaults for empty object', () => {
|
|
40
|
+
store['md-redline-settings'] = '{}';
|
|
41
|
+
const result = loadSettings();
|
|
42
|
+
expect(result).toEqual(DEFAULT_SETTINGS);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('preserves valid enableResolve value', () => {
|
|
46
|
+
store['md-redline-settings'] = JSON.stringify({ enableResolve: true });
|
|
47
|
+
expect(loadSettings().enableResolve).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('falls back to default when enableResolve is not a boolean', () => {
|
|
51
|
+
store['md-redline-settings'] = JSON.stringify({ enableResolve: 'yes' });
|
|
52
|
+
expect(loadSettings().enableResolve).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('preserves valid quickComment value', () => {
|
|
56
|
+
store['md-redline-settings'] = JSON.stringify({ quickComment: true });
|
|
57
|
+
expect(loadSettings().quickComment).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('falls back to default when quickComment is not a boolean', () => {
|
|
61
|
+
store['md-redline-settings'] = JSON.stringify({ quickComment: 42 });
|
|
62
|
+
expect(loadSettings().quickComment).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('preserves valid showTemplatesByDefault value', () => {
|
|
66
|
+
store['md-redline-settings'] = JSON.stringify({ showTemplatesByDefault: true });
|
|
67
|
+
expect(loadSettings().showTemplatesByDefault).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('falls back to default when showTemplatesByDefault is not a boolean', () => {
|
|
71
|
+
store['md-redline-settings'] = JSON.stringify({ showTemplatesByDefault: null });
|
|
72
|
+
expect(loadSettings().showTemplatesByDefault).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('preserves valid commentMaxLength', () => {
|
|
76
|
+
store['md-redline-settings'] = JSON.stringify({ commentMaxLength: 1000 });
|
|
77
|
+
expect(loadSettings().commentMaxLength).toBe(1000);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('falls back to default for zero commentMaxLength', () => {
|
|
81
|
+
store['md-redline-settings'] = JSON.stringify({ commentMaxLength: 0 });
|
|
82
|
+
expect(loadSettings().commentMaxLength).toBe(1000);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('falls back to default for negative commentMaxLength', () => {
|
|
86
|
+
store['md-redline-settings'] = JSON.stringify({ commentMaxLength: -10 });
|
|
87
|
+
expect(loadSettings().commentMaxLength).toBe(1000);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('falls back to default for non-numeric commentMaxLength', () => {
|
|
91
|
+
store['md-redline-settings'] = JSON.stringify({ commentMaxLength: 'big' });
|
|
92
|
+
expect(loadSettings().commentMaxLength).toBe(1000);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('preserves valid templates array', () => {
|
|
96
|
+
const templates = [{ label: 'Custom', text: 'Custom text' }];
|
|
97
|
+
store['md-redline-settings'] = JSON.stringify({ templates });
|
|
98
|
+
expect(loadSettings().templates).toEqual(templates);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('falls back to default templates when templates is not an array', () => {
|
|
102
|
+
store['md-redline-settings'] = JSON.stringify({ templates: 'not-an-array' });
|
|
103
|
+
expect(loadSettings().templates).toEqual(DEFAULT_TEMPLATES);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('handles missing new fields gracefully (migration from older version)', () => {
|
|
107
|
+
// Simulate stored settings from before enableResolve and quickComment existed
|
|
108
|
+
store['md-redline-settings'] = JSON.stringify({
|
|
109
|
+
templates: DEFAULT_TEMPLATES,
|
|
110
|
+
commentMaxLength: 1000,
|
|
111
|
+
showTemplatesByDefault: false,
|
|
112
|
+
});
|
|
113
|
+
const result = loadSettings();
|
|
114
|
+
expect(result.enableResolve).toBe(false);
|
|
115
|
+
expect(result.quickComment).toBe(false);
|
|
116
|
+
expect(result.commentMaxLength).toBe(1000);
|
|
117
|
+
expect(result.showTemplatesByDefault).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('preserves a full valid settings object', () => {
|
|
121
|
+
const full = {
|
|
122
|
+
templates: [{ label: 'A', text: 'B' }],
|
|
123
|
+
commentMaxLength: 750,
|
|
124
|
+
showTemplatesByDefault: true,
|
|
125
|
+
enableResolve: true,
|
|
126
|
+
quickComment: true,
|
|
127
|
+
};
|
|
128
|
+
store['md-redline-settings'] = JSON.stringify(full);
|
|
129
|
+
expect(loadSettings()).toEqual(full);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('saveSettings', () => {
|
|
134
|
+
it('persists settings to localStorage', () => {
|
|
135
|
+
saveSettings(DEFAULT_SETTINGS);
|
|
136
|
+
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
|
137
|
+
'md-redline-settings',
|
|
138
|
+
JSON.stringify(DEFAULT_SETTINGS),
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('round-trips through load', () => {
|
|
143
|
+
const custom = {
|
|
144
|
+
...DEFAULT_SETTINGS,
|
|
145
|
+
enableResolve: true,
|
|
146
|
+
quickComment: true,
|
|
147
|
+
commentMaxLength: 999,
|
|
148
|
+
};
|
|
149
|
+
saveSettings(custom);
|
|
150
|
+
expect(loadSettings()).toEqual(custom);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export interface CommentTemplate {
|
|
2
|
+
label: string;
|
|
3
|
+
text: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface AppSettings {
|
|
7
|
+
templates: CommentTemplate[];
|
|
8
|
+
commentMaxLength: number;
|
|
9
|
+
showTemplatesByDefault: boolean;
|
|
10
|
+
/** Enable resolve/reopen workflow for human-to-human review. When off, comments are simply deleted after being addressed. */
|
|
11
|
+
enableResolve: boolean;
|
|
12
|
+
/** Skip the "Comment" button and go straight to the comment form when text is selected. */
|
|
13
|
+
quickComment: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_TEMPLATES: CommentTemplate[] = [
|
|
17
|
+
{ label: 'Rewrite this', text: 'Rewrite this section — it needs to be clearer.' },
|
|
18
|
+
{ label: 'Add detail', text: 'Add more detail here.' },
|
|
19
|
+
{ label: 'Remove', text: 'Remove this — it is not needed.' },
|
|
20
|
+
{ label: 'Needs example', text: 'Add an example to illustrate this.' },
|
|
21
|
+
{ label: 'Too vague', text: 'This is too vague — be more specific.' },
|
|
22
|
+
{ label: 'Fix formatting', text: 'Fix the formatting in this section.' },
|
|
23
|
+
{ label: 'Factually wrong', text: 'This is factually incorrect — please verify and correct.' },
|
|
24
|
+
{ label: 'Out of scope', text: 'This is out of scope — remove or move to a separate doc.' },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_SETTINGS: AppSettings = {
|
|
28
|
+
templates: DEFAULT_TEMPLATES,
|
|
29
|
+
commentMaxLength: 1000,
|
|
30
|
+
showTemplatesByDefault: true,
|
|
31
|
+
enableResolve: false,
|
|
32
|
+
quickComment: false,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const STORAGE_KEY = 'md-redline-settings';
|
|
36
|
+
|
|
37
|
+
export function loadSettings(): AppSettings {
|
|
38
|
+
try {
|
|
39
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
40
|
+
if (!raw) return DEFAULT_SETTINGS;
|
|
41
|
+
const parsed = JSON.parse(raw);
|
|
42
|
+
return {
|
|
43
|
+
templates: Array.isArray(parsed.templates) ? parsed.templates : DEFAULT_SETTINGS.templates,
|
|
44
|
+
commentMaxLength:
|
|
45
|
+
typeof parsed.commentMaxLength === 'number' && parsed.commentMaxLength > 0
|
|
46
|
+
? parsed.commentMaxLength
|
|
47
|
+
: DEFAULT_SETTINGS.commentMaxLength,
|
|
48
|
+
showTemplatesByDefault:
|
|
49
|
+
typeof parsed.showTemplatesByDefault === 'boolean'
|
|
50
|
+
? parsed.showTemplatesByDefault
|
|
51
|
+
: DEFAULT_SETTINGS.showTemplatesByDefault,
|
|
52
|
+
enableResolve:
|
|
53
|
+
typeof parsed.enableResolve === 'boolean'
|
|
54
|
+
? parsed.enableResolve
|
|
55
|
+
: DEFAULT_SETTINGS.enableResolve,
|
|
56
|
+
quickComment:
|
|
57
|
+
typeof parsed.quickComment === 'boolean'
|
|
58
|
+
? parsed.quickComment
|
|
59
|
+
: DEFAULT_SETTINGS.quickComment,
|
|
60
|
+
};
|
|
61
|
+
} catch {
|
|
62
|
+
return DEFAULT_SETTINGS;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function saveSettings(settings: AppSettings): void {
|
|
67
|
+
try {
|
|
68
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
|
69
|
+
} catch {
|
|
70
|
+
// Storage unavailable
|
|
71
|
+
}
|
|
72
|
+
// Dual-write to disk (fire-and-forget)
|
|
73
|
+
import('./preferences-client')
|
|
74
|
+
.then(({ savePreferencesToDisk }) => {
|
|
75
|
+
savePreferencesToDisk({ settings: settings as unknown as Record<string, unknown> });
|
|
76
|
+
})
|
|
77
|
+
.catch(() => {});
|
|
78
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders a shortcut string with ⇧ scaled up to match ⌘ visually.
|
|
3
|
+
*/
|
|
4
|
+
export function StyledShortcut({ text }: { text: string }) {
|
|
5
|
+
if (!text.includes('\u21e7')) return <>{text}</>;
|
|
6
|
+
|
|
7
|
+
const parts = text.split('\u21e7');
|
|
8
|
+
return (
|
|
9
|
+
<>
|
|
10
|
+
{parts.map((part, i) => (
|
|
11
|
+
<span key={i}>
|
|
12
|
+
{i > 0 && <span className="text-[1.3em] leading-none align-baseline">{'\u21e7'}</span>}
|
|
13
|
+
{part}
|
|
14
|
+
</span>
|
|
15
|
+
))}
|
|
16
|
+
</>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface ThemeDef {
|
|
2
|
+
key: string;
|
|
3
|
+
label: string;
|
|
4
|
+
colors: [string, string, string];
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const LIGHT_THEMES: ThemeDef[] = [
|
|
8
|
+
{ key: 'light', label: 'Light', colors: ['#ffffff', '#4f46e5', '#f59e0b'] },
|
|
9
|
+
{ key: 'sepia', label: 'Sepia', colors: ['#faf6f1', '#8b5e3c', '#d4a04a'] },
|
|
10
|
+
{ key: 'solarized', label: 'Solarized', colors: ['#fdf6e3', '#268bd2', '#b58900'] },
|
|
11
|
+
{ key: 'github', label: 'GitHub', colors: ['#ffffff', '#0969da', '#bf8700'] },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
export const DARK_THEMES: ThemeDef[] = [
|
|
15
|
+
{ key: 'dark', label: 'Dark', colors: ['#0f172a', '#818cf8', '#f59e0b'] },
|
|
16
|
+
{ key: 'nord', label: 'Nord', colors: ['#2e3440', '#88c0d0', '#ebcb8b'] },
|
|
17
|
+
{ key: 'rose-pine', label: 'Rosé Pine', colors: ['#191724', '#c4a7e7', '#f6c177'] },
|
|
18
|
+
{ key: 'catppuccin', label: 'Catppuccin', colors: ['#1e1e2e', '#cba6f7', '#f9e2af'] },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export const ALL_THEMES: ThemeDef[] = [...LIGHT_THEMES, ...DARK_THEMES];
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import {
|
|
5
|
+
collectVisibleTextNodes,
|
|
6
|
+
getVisibleTextContent,
|
|
7
|
+
getVisibleTextOffset,
|
|
8
|
+
} from './visible-text';
|
|
9
|
+
|
|
10
|
+
describe('visible-text helpers', () => {
|
|
11
|
+
it('ignores hidden text containers such as svg defs and style tags', () => {
|
|
12
|
+
document.body.innerHTML = `
|
|
13
|
+
<div id="root">
|
|
14
|
+
Intro
|
|
15
|
+
<style>.x { color: red; }</style>
|
|
16
|
+
<svg>
|
|
17
|
+
<defs>marker text</defs>
|
|
18
|
+
<desc>diagram description</desc>
|
|
19
|
+
<text>Visible label</text>
|
|
20
|
+
</svg>
|
|
21
|
+
<script>console.log('ignore me')</script>
|
|
22
|
+
<span>Outro</span>
|
|
23
|
+
</div>
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
const root = document.getElementById('root')!;
|
|
27
|
+
expect(getVisibleTextContent(root).replace(/\s+/g, ' ').trim()).toBe(
|
|
28
|
+
'Intro Visible label Outro',
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('collects only visible text nodes', () => {
|
|
33
|
+
document.body.innerHTML = `
|
|
34
|
+
<div id="root">
|
|
35
|
+
<span>Alpha</span>
|
|
36
|
+
<svg><metadata>hidden</metadata><text>Beta</text></svg>
|
|
37
|
+
<template>Gamma</template>
|
|
38
|
+
<span>Delta</span>
|
|
39
|
+
</div>
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
const root = document.getElementById('root')!;
|
|
43
|
+
expect(
|
|
44
|
+
collectVisibleTextNodes(root)
|
|
45
|
+
.map((node) => node.textContent?.trim())
|
|
46
|
+
.filter(Boolean),
|
|
47
|
+
).toEqual(['Alpha', 'Beta', 'Delta']);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('computes offsets without counting hidden svg text', () => {
|
|
51
|
+
document.body.innerHTML =
|
|
52
|
+
'<div id="root"><span id="before">Alpha </span><svg><defs>hidden marker</defs><text>Beta</text></svg><span id="after"> Gamma</span></div>';
|
|
53
|
+
|
|
54
|
+
const root = document.getElementById('root')!;
|
|
55
|
+
const betaNode = root.querySelector('svg text')!.firstChild!;
|
|
56
|
+
const gammaNode = document.getElementById('after')!.firstChild!;
|
|
57
|
+
|
|
58
|
+
expect(getVisibleTextOffset(root, betaNode, 2)).toBe('Alpha '.length + 2);
|
|
59
|
+
expect(getVisibleTextOffset(root, gammaNode, 1)).toBe('Alpha Beta'.length + 1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('resolves Element targetNode with offset pointing to a child index', () => {
|
|
63
|
+
document.body.innerHTML =
|
|
64
|
+
'<div id="root"><span id="parent"><em>Hello</em><strong> World</strong></span></div>';
|
|
65
|
+
|
|
66
|
+
const root = document.getElementById('root')!;
|
|
67
|
+
const parent = document.getElementById('parent')!;
|
|
68
|
+
|
|
69
|
+
// offset=1 means the 2nd child node (<strong> World</strong>)
|
|
70
|
+
// Should resolve to the text node inside <strong> at offset 0
|
|
71
|
+
expect(getVisibleTextOffset(root, parent, 1)).toBe('Hello'.length);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('resolves Element targetNode when offset === childNodes.length (after last child)', () => {
|
|
75
|
+
// Use a parent whose children are bare text nodes so the "after last child"
|
|
76
|
+
// path resolves without needing to descend through wrapper elements.
|
|
77
|
+
document.body.innerHTML = '<div id="root"><span id="parent">Hello World</span></div>';
|
|
78
|
+
|
|
79
|
+
const root = document.getElementById('root')!;
|
|
80
|
+
const parent = document.getElementById('parent')!;
|
|
81
|
+
|
|
82
|
+
// parent has 1 child text node "Hello World"; offset 1 === childNodes.length
|
|
83
|
+
// means "after last child" — should return end of the text node (length 11)
|
|
84
|
+
expect(getVisibleTextOffset(root, parent, parent.childNodes.length)).toBe('Hello World'.length);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const HIDDEN_TEXT_ANCESTOR_TAGS = new Set([
|
|
2
|
+
'STYLE',
|
|
3
|
+
'SCRIPT',
|
|
4
|
+
'NOSCRIPT',
|
|
5
|
+
'TEMPLATE',
|
|
6
|
+
'TITLE',
|
|
7
|
+
'DESC',
|
|
8
|
+
'DEFS',
|
|
9
|
+
'METADATA',
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
function shouldIgnoreTextNode(node: Text): boolean {
|
|
13
|
+
let el = node.parentElement;
|
|
14
|
+
while (el) {
|
|
15
|
+
if (HIDDEN_TEXT_ANCESTOR_TAGS.has(el.tagName.toUpperCase())) return true;
|
|
16
|
+
el = el.parentElement;
|
|
17
|
+
}
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createVisibleTextWalker(root: Node): TreeWalker {
|
|
22
|
+
return document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
|
23
|
+
acceptNode: (node) =>
|
|
24
|
+
shouldIgnoreTextNode(node as Text) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function collectVisibleTextNodes(root: Node): Text[] {
|
|
29
|
+
const walker = createVisibleTextWalker(root);
|
|
30
|
+
const textNodes: Text[] = [];
|
|
31
|
+
let node: Text | null;
|
|
32
|
+
while ((node = walker.nextNode() as Text | null)) {
|
|
33
|
+
textNodes.push(node);
|
|
34
|
+
}
|
|
35
|
+
return textNodes;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getVisibleTextContent(root: Node): string {
|
|
39
|
+
return collectVisibleTextNodes(root)
|
|
40
|
+
.map((node) => node.textContent || '')
|
|
41
|
+
.join('');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function getVisibleTextOffset(root: Node, targetNode: Node, offset: number): number {
|
|
45
|
+
// When the target is an Element (not a Text node), the offset refers to
|
|
46
|
+
// the Nth child node. Resolve to the corresponding text node so the
|
|
47
|
+
// walker can match it.
|
|
48
|
+
let resolvedNode = targetNode;
|
|
49
|
+
let resolvedOffset = offset;
|
|
50
|
+
if (targetNode.nodeType !== Node.TEXT_NODE && targetNode.childNodes.length > 0) {
|
|
51
|
+
if (offset < targetNode.childNodes.length) {
|
|
52
|
+
resolvedNode = targetNode.childNodes[offset];
|
|
53
|
+
resolvedOffset = 0;
|
|
54
|
+
} else {
|
|
55
|
+
// offset === childNodes.length means "after last child" — point to end of last text node
|
|
56
|
+
const last = targetNode.childNodes[targetNode.childNodes.length - 1];
|
|
57
|
+
resolvedNode = last;
|
|
58
|
+
resolvedOffset = last.textContent?.length ?? 0;
|
|
59
|
+
}
|
|
60
|
+
// If we landed on another element, descend to its first text node
|
|
61
|
+
while (resolvedNode.nodeType !== Node.TEXT_NODE && resolvedNode.firstChild) {
|
|
62
|
+
resolvedNode = resolvedNode.firstChild;
|
|
63
|
+
resolvedOffset = 0;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let total = 0;
|
|
68
|
+
const walker = createVisibleTextWalker(root);
|
|
69
|
+
let node: Node | null;
|
|
70
|
+
while ((node = walker.nextNode())) {
|
|
71
|
+
if (node === resolvedNode) {
|
|
72
|
+
return total + resolvedOffset;
|
|
73
|
+
}
|
|
74
|
+
total += node.textContent?.length || 0;
|
|
75
|
+
}
|
|
76
|
+
return total;
|
|
77
|
+
}
|
package/src/main.tsx
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { StrictMode } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import { ThemeProvider } from 'next-themes';
|
|
4
|
+
import { SettingsProvider } from './contexts/SettingsContext';
|
|
5
|
+
import { ALL_THEMES } from './lib/themes';
|
|
6
|
+
import App from './App';
|
|
7
|
+
import './index.css';
|
|
8
|
+
|
|
9
|
+
createRoot(document.getElementById('root')!).render(
|
|
10
|
+
<StrictMode>
|
|
11
|
+
<ThemeProvider
|
|
12
|
+
attribute="data-theme"
|
|
13
|
+
defaultTheme="system"
|
|
14
|
+
themes={ALL_THEMES.map((t) => t.key)}
|
|
15
|
+
enableSystem={true}
|
|
16
|
+
>
|
|
17
|
+
<SettingsProvider>
|
|
18
|
+
<App />
|
|
19
|
+
</SettingsProvider>
|
|
20
|
+
</ThemeProvider>
|
|
21
|
+
</StrictMode>,
|
|
22
|
+
);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { renderMarkdown } from './pipeline';
|
|
3
|
+
|
|
4
|
+
describe('renderMarkdown', () => {
|
|
5
|
+
it('renders basic markdown (headings, paragraphs, bold, italic)', () => {
|
|
6
|
+
const md = '# Hello\n\nThis is **bold** and *italic*.';
|
|
7
|
+
const html = renderMarkdown(md);
|
|
8
|
+
expect(html).toContain('<h1>Hello</h1>');
|
|
9
|
+
expect(html).toContain('<strong>bold</strong>');
|
|
10
|
+
expect(html).toContain('<em>italic</em>');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('renders GFM tables correctly', () => {
|
|
14
|
+
const md = '| A | B |\n| --- | --- |\n| 1 | 2 |';
|
|
15
|
+
const html = renderMarkdown(md);
|
|
16
|
+
expect(html).toContain('<table>');
|
|
17
|
+
expect(html).toContain('<th>A</th>');
|
|
18
|
+
expect(html).toContain('<td>1</td>');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('renders strikethrough correctly', () => {
|
|
22
|
+
const md = '~~deleted~~';
|
|
23
|
+
const html = renderMarkdown(md);
|
|
24
|
+
expect(html).toContain('<del>deleted</del>');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('allows <mark> elements with className', () => {
|
|
28
|
+
const md = '<mark class="highlight">important</mark>';
|
|
29
|
+
const html = renderMarkdown(md);
|
|
30
|
+
expect(html).toContain('<mark');
|
|
31
|
+
expect(html).toContain('important</mark>');
|
|
32
|
+
expect(html).toContain('class="highlight"');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('strips <script> tags', () => {
|
|
36
|
+
const md = 'Hello <script>alert("xss")</script> world';
|
|
37
|
+
const html = renderMarkdown(md);
|
|
38
|
+
expect(html).not.toContain('<script>');
|
|
39
|
+
expect(html).not.toContain('alert');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('strips <style> tags', () => {
|
|
43
|
+
const md = 'Hello <style>body{display:none}</style> world';
|
|
44
|
+
const html = renderMarkdown(md);
|
|
45
|
+
expect(html).not.toContain('<style>');
|
|
46
|
+
// rehype-sanitize removes the tag but may leave text content; the key
|
|
47
|
+
// guarantee is the <style> element itself is gone so no CSS executes.
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('strips onclick and other event handler attributes', () => {
|
|
51
|
+
const md = '<div onclick="alert(1)">click me</div>';
|
|
52
|
+
const html = renderMarkdown(md);
|
|
53
|
+
expect(html).not.toContain('onclick');
|
|
54
|
+
expect(html).toContain('click me');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('preserves className attribute on elements', () => {
|
|
58
|
+
const md = '<span class="custom">text</span>';
|
|
59
|
+
const html = renderMarkdown(md);
|
|
60
|
+
expect(html).toContain('class="custom"');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('handles YAML frontmatter (should not appear in output)', () => {
|
|
64
|
+
const md = '---\ntitle: Test\nauthor: Someone\n---\n\n# Content';
|
|
65
|
+
const html = renderMarkdown(md);
|
|
66
|
+
expect(html).not.toContain('title: Test');
|
|
67
|
+
expect(html).not.toContain('author: Someone');
|
|
68
|
+
expect(html).toContain('<h1>Content</h1>');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('handles empty input', () => {
|
|
72
|
+
const html = renderMarkdown('');
|
|
73
|
+
expect(html).toBe('');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('handles fenced code blocks', () => {
|
|
77
|
+
const md = '```js\nconsole.log("hi");\n```';
|
|
78
|
+
const html = renderMarkdown(md);
|
|
79
|
+
expect(html).toContain('<code');
|
|
80
|
+
expect(html).toContain('console.log');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { unified } from 'unified';
|
|
2
|
+
import remarkParse from 'remark-parse';
|
|
3
|
+
import remarkFrontmatter from 'remark-frontmatter';
|
|
4
|
+
import remarkGfm from 'remark-gfm';
|
|
5
|
+
import remarkRehype from 'remark-rehype';
|
|
6
|
+
import rehypeRaw from 'rehype-raw';
|
|
7
|
+
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
|
|
8
|
+
import rehypeStringify from 'rehype-stringify';
|
|
9
|
+
|
|
10
|
+
// Allow mark elements (used for comment highlights) and data-* attributes
|
|
11
|
+
const sanitizeSchema = {
|
|
12
|
+
...defaultSchema,
|
|
13
|
+
tagNames: [...(defaultSchema.tagNames || []), 'mark'],
|
|
14
|
+
attributes: {
|
|
15
|
+
...defaultSchema.attributes,
|
|
16
|
+
mark: ['className', 'dataCommentIds'],
|
|
17
|
+
'*': [...(defaultSchema.attributes?.['*'] || []), 'className'],
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const processor = unified()
|
|
22
|
+
.use(remarkParse)
|
|
23
|
+
.use(remarkFrontmatter, ['yaml', 'toml'])
|
|
24
|
+
.use(remarkGfm)
|
|
25
|
+
.use(remarkRehype, { allowDangerousHtml: true })
|
|
26
|
+
.use(rehypeRaw)
|
|
27
|
+
.use(rehypeSanitize, sanitizeSchema)
|
|
28
|
+
.use(rehypeStringify);
|
|
29
|
+
|
|
30
|
+
export function renderMarkdown(markdown: string): string {
|
|
31
|
+
const file = processor.processSync(markdown);
|
|
32
|
+
return String(file);
|
|
33
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { getEffectiveStatus } from './types';
|
|
3
|
+
import type { MdComment } from './types';
|
|
4
|
+
|
|
5
|
+
function makeComment(overrides: Partial<MdComment> = {}): MdComment {
|
|
6
|
+
return {
|
|
7
|
+
id: 'test-id',
|
|
8
|
+
anchor: 'some text',
|
|
9
|
+
text: 'a comment',
|
|
10
|
+
author: 'User',
|
|
11
|
+
timestamp: '2024-01-01T00:00:00.000Z',
|
|
12
|
+
...overrides,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('getEffectiveStatus', () => {
|
|
17
|
+
it("returns 'open' when status is 'open'", () => {
|
|
18
|
+
expect(getEffectiveStatus(makeComment({ status: 'open' }))).toBe('open');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("returns 'resolved' when status is 'resolved'", () => {
|
|
22
|
+
expect(getEffectiveStatus(makeComment({ status: 'resolved' }))).toBe('resolved');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("returns 'resolved' when status is 'accepted' (legacy backward compat)", () => {
|
|
26
|
+
const comment = makeComment();
|
|
27
|
+
// Cast to bypass type checking since 'accepted' is a legacy value
|
|
28
|
+
(comment as unknown as Record<string, unknown>).status = 'accepted';
|
|
29
|
+
expect(getEffectiveStatus(comment)).toBe('resolved');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("returns 'resolved' when resolved boolean is true (legacy)", () => {
|
|
33
|
+
expect(getEffectiveStatus(makeComment({ resolved: true }))).toBe('resolved');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("returns 'open' when no status and no resolved field", () => {
|
|
37
|
+
expect(getEffectiveStatus(makeComment())).toBe('open');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("returns 'open' when status is undefined and resolved is false", () => {
|
|
41
|
+
expect(getEffectiveStatus(makeComment({ status: undefined, resolved: false }))).toBe('open');
|
|
42
|
+
});
|
|
43
|
+
});
|