@bendyline/squisq 1.5.2 → 2.0.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 +63 -10
- package/dist/{Doc-BBVGq1_9.d.ts → Doc-ylqWKDc_.d.ts} +248 -37
- package/dist/{ImageEditDoc-FuyGtt6o.d.ts → ImageEditDoc-D828IXJ1.d.ts} +1 -1
- package/dist/annotationCoercion-Dr_DY9Hx.d.ts +86 -0
- package/dist/{chunk-VWUFZ6ZG.js → chunk-3E5F2XMR.js} +2 -29
- package/dist/chunk-3E5F2XMR.js.map +1 -0
- package/dist/chunk-4X3JQXNM.js +35 -0
- package/dist/chunk-4X3JQXNM.js.map +1 -0
- package/dist/{chunk-P3K7NLHF.js → chunk-5DKBFXPY.js} +31 -121
- package/dist/chunk-5DKBFXPY.js.map +1 -0
- package/dist/{chunk-DLXZMS5K.js → chunk-5J4HKRJF.js} +51 -3
- package/dist/chunk-5J4HKRJF.js.map +1 -0
- package/dist/chunk-5UYI3RJB.js +2503 -0
- package/dist/chunk-5UYI3RJB.js.map +1 -0
- package/dist/{chunk-BEQODJWV.js → chunk-66QO7XFB.js} +11 -291
- package/dist/chunk-66QO7XFB.js.map +1 -0
- package/dist/{chunk-KKNUBQ6Y.js → chunk-6PJRB2CF.js} +156 -91
- package/dist/chunk-6PJRB2CF.js.map +1 -0
- package/dist/{chunk-HWVFJAAH.js → chunk-AMIR72JP.js} +28 -10
- package/dist/chunk-AMIR72JP.js.map +1 -0
- package/dist/chunk-FVJUUTEM.js +74 -0
- package/dist/chunk-FVJUUTEM.js.map +1 -0
- package/dist/{chunk-5TYS5V3G.js → chunk-GF3BVNLU.js} +4127 -3538
- package/dist/chunk-GF3BVNLU.js.map +1 -0
- package/dist/{chunk-Q3ROPT5H.js → chunk-GZY66SDN.js} +1 -1
- package/dist/{chunk-Q3ROPT5H.js.map → chunk-GZY66SDN.js.map} +1 -1
- package/dist/chunk-JOQYPRDZ.js +127 -0
- package/dist/chunk-JOQYPRDZ.js.map +1 -0
- package/dist/{chunk-A4O7GIWE.js → chunk-LYNJEOAC.js} +10 -73
- package/dist/chunk-LYNJEOAC.js.map +1 -0
- package/dist/{chunk-LDQ2HJIX.js → chunk-PXLVXTLW.js} +252 -54
- package/dist/chunk-PXLVXTLW.js.map +1 -0
- package/dist/{chunk-TQWLI6S2.js → chunk-Q7LROV2H.js} +4 -2
- package/dist/{chunk-TQWLI6S2.js.map → chunk-Q7LROV2H.js.map} +1 -1
- package/dist/{chunk-T5UK6YOB.js → chunk-QTSJOY54.js} +60 -17
- package/dist/chunk-QTSJOY54.js.map +1 -0
- package/dist/chunk-QVANWDPH.js +2358 -0
- package/dist/chunk-QVANWDPH.js.map +1 -0
- package/dist/{chunk-GPLTCSXO.js → chunk-R2GC6RLD.js} +7 -7
- package/dist/chunk-R2GC6RLD.js.map +1 -0
- package/dist/chunk-R2YZTCLO.js +292 -0
- package/dist/chunk-R2YZTCLO.js.map +1 -0
- package/dist/chunk-S7A5D7ZJ.js +174 -0
- package/dist/chunk-S7A5D7ZJ.js.map +1 -0
- package/dist/chunk-T3TI7GSZ.js +884 -0
- package/dist/chunk-T3TI7GSZ.js.map +1 -0
- package/dist/{chunk-LTN6I7QW.js → chunk-TUWMK62B.js} +55 -4
- package/dist/chunk-TUWMK62B.js.map +1 -0
- package/dist/{chunk-IMSCRLLV.js → chunk-VR7UPJ7C.js} +35 -25
- package/dist/chunk-VR7UPJ7C.js.map +1 -0
- package/dist/doc/index.d.ts +2334 -5
- package/dist/doc/index.js +127 -33
- package/dist/generate/index.d.ts +7 -2
- package/dist/generate/index.js +1 -1
- package/dist/imageEdit/index.d.ts +9 -6
- package/dist/imageEdit/index.js +3 -3
- package/dist/index.d.ts +12 -10
- package/dist/index.js +246 -80
- package/dist/jsonForm/index.d.ts +7 -3
- package/dist/jsonForm/index.js +9 -4
- package/dist/markdown/index.d.ts +6 -70
- package/dist/markdown/index.js +13 -9
- package/dist/narration/index.d.ts +618 -0
- package/dist/narration/index.js +82 -0
- package/dist/recommend/index.d.ts +15 -1
- package/dist/recommend/index.js +2 -2
- package/dist/schemas/index.d.ts +28 -6
- package/dist/schemas/index.js +15 -13
- package/dist/spatial/index.d.ts +0 -1
- package/dist/spatial/index.js +1 -1
- package/dist/storage/index.js +1 -1
- package/dist/{themeLibrary-CehcJhzz.d.ts → themeLibrary-RjVgLpUi.d.ts} +10 -11
- package/dist/timing/index.js +5 -3
- package/dist/transform/index.d.ts +35 -24
- package/dist/transform/index.js +9 -10
- package/dist/{types-DlAZ7MW4.d.ts → types-BwMPl2CB.d.ts} +1 -1
- package/dist/{types-D98li1gV.d.ts → types-C4kiRn_k.d.ts} +3 -1
- package/dist/versions/index.d.ts +2 -1
- package/dist/versions/index.js +2 -2
- package/package.json +6 -6
- package/src/__tests__/applyNarrationTiming.test.ts +190 -0
- package/src/__tests__/applyRenderStyle.test.ts +3 -3
- package/src/__tests__/asciiDiagramBattle.test.ts +246 -0
- package/src/__tests__/asciiDiagramDetect.test.ts +162 -0
- package/src/__tests__/asciiDiagramMapping.test.ts +38 -0
- package/src/__tests__/asciiDiagramParse.test.ts +233 -0
- package/src/__tests__/asciiDiagramPipeline.test.ts +273 -0
- package/src/__tests__/asciiDiagramRender.test.ts +162 -0
- package/src/__tests__/asciiDiagramRepair.test.ts +84 -0
- package/src/__tests__/asciiTimelineDetect.test.ts +157 -0
- package/src/__tests__/asciiTimelineParse.test.ts +205 -0
- package/src/__tests__/asciiTimelinePipeline.test.ts +349 -0
- package/src/__tests__/asciiTimelineRender.test.ts +376 -0
- package/src/__tests__/attrTokens.test.ts +5 -0
- package/src/__tests__/contentContainer.test.ts +43 -0
- package/src/__tests__/customTemplatesFrontmatter.test.ts +0 -15
- package/src/__tests__/dataTable.test.ts +25 -15
- package/src/__tests__/diagramBlock.test.ts +3 -3
- package/src/__tests__/diagramText.test.ts +26 -0
- package/src/__tests__/docToMarkdownMedia.test.ts +83 -0
- package/src/__tests__/drawingBlock.test.ts +3 -3
- package/src/__tests__/fixtures/asciiDiagramBattle.ts +512 -0
- package/src/__tests__/fixtures/asciiDiagrams.ts +287 -0
- package/src/__tests__/fixtures/asciiTimelines.ts +35 -0
- package/src/__tests__/fixtures/treeviewFixtures.ts +243 -0
- package/src/__tests__/getLayers.test.ts +32 -18
- package/src/__tests__/getLayersFallback.test.ts +8 -8
- package/src/__tests__/imageEditVersions.test.ts +2 -0
- package/src/__tests__/jsonForm.pointer.test.ts +47 -1
- package/src/__tests__/layoutBlock.test.ts +3 -3
- package/src/__tests__/majorApiTypes.test.ts +18 -0
- package/src/__tests__/markdown.test.ts +41 -0
- package/src/__tests__/matchFontFamily.test.ts +114 -0
- package/src/__tests__/materializeBlockLayers.test.ts +135 -0
- package/src/__tests__/materializeTestUtils.ts +17 -0
- package/src/__tests__/narrationAlign.test.ts +150 -0
- package/src/__tests__/narrationFeatures.test.ts +71 -0
- package/src/__tests__/narrationNuclei.test.ts +45 -0
- package/src/__tests__/narrationPacing.test.ts +189 -0
- package/src/__tests__/narrationScript.test.ts +149 -0
- package/src/__tests__/narrationSessionRepro.test.ts +62 -0
- package/src/__tests__/narrationSidecar.test.ts +88 -0
- package/src/__tests__/narrationSyllables.test.ts +65 -0
- package/src/__tests__/narrationTestSignals.ts +167 -0
- package/src/__tests__/narrationVad.test.ts +66 -0
- package/src/__tests__/resolveDocTheme.test.ts +16 -2
- package/src/__tests__/rootExports.test.ts +18 -0
- package/src/__tests__/scopedContentContainer.test.ts +20 -0
- package/src/__tests__/shapeGeometry.test.ts +12 -0
- package/src/__tests__/templateAnnotationParse.test.ts +10 -0
- package/src/__tests__/templateInputs.test.ts +69 -0
- package/src/__tests__/templateMetadata.test.ts +2 -1
- package/src/__tests__/templates.test.ts +143 -17
- package/src/__tests__/themeCompile.test.ts +49 -22
- package/src/__tests__/transformProvenance.test.ts +160 -0
- package/src/__tests__/transformV2.test.ts +40 -14
- package/src/__tests__/treeviewBattle.test.ts +76 -0
- package/src/__tests__/treeviewDetect.test.ts +168 -0
- package/src/__tests__/treeviewParse.test.ts +118 -0
- package/src/__tests__/treeviewPipeline.test.ts +183 -0
- package/src/__tests__/treeviewRender.test.ts +91 -0
- package/src/__tests__/versions.test.ts +44 -0
- package/src/doc/applyNarrationTiming.ts +244 -0
- package/src/doc/asciiDiagram/chars.ts +210 -0
- package/src/doc/asciiDiagram/detect.ts +175 -0
- package/src/doc/asciiDiagram/index.ts +38 -0
- package/src/doc/asciiDiagram/mapping.ts +225 -0
- package/src/doc/asciiDiagram/parse.ts +1095 -0
- package/src/doc/asciiDiagram/render.ts +963 -0
- package/src/doc/asciiDiagram/repair.ts +400 -0
- package/src/doc/asciiDiagram/types.ts +65 -0
- package/src/doc/asciiTimeline/detect.ts +138 -0
- package/src/doc/asciiTimeline/index.ts +26 -0
- package/src/doc/asciiTimeline/mapping.ts +43 -0
- package/src/doc/asciiTimeline/parse.ts +870 -0
- package/src/doc/asciiTimeline/render.ts +427 -0
- package/src/doc/asciiTimeline/types.ts +73 -0
- package/src/doc/asciiTimeline/wrappedFlow.ts +427 -0
- package/src/doc/audioMapping.ts +60 -46
- package/src/doc/customTemplatesFrontmatter.ts +0 -41
- package/src/doc/docToMarkdown.ts +70 -2
- package/src/doc/index.ts +76 -4
- package/src/doc/markdownToDoc.ts +135 -1
- package/src/doc/materializeBlockLayers.ts +304 -0
- package/src/doc/mediaAnnotations.ts +20 -0
- package/src/doc/resolveDocTheme.ts +8 -7
- package/src/doc/templateInputs.ts +185 -2
- package/src/doc/templates/__tests__/customTemplateRegistry.test.ts +22 -8
- package/src/doc/templates/diagramBlock.ts +237 -38
- package/src/doc/templates/fallbackBlock.ts +1 -1
- package/src/doc/templates/index.ts +134 -279
- package/src/doc/templates/inputDescriptors.ts +20 -1
- package/src/doc/templates/metadata.ts +10 -0
- package/src/doc/templates/persistentLayers.ts +12 -126
- package/src/doc/templates/registry.ts +88 -0
- package/src/doc/templates/templateNames.ts +22 -0
- package/src/doc/templates/timelineBlock.ts +613 -0
- package/src/doc/templates/treeBlock.ts +131 -0
- package/src/doc/treeview/chars.ts +72 -0
- package/src/doc/treeview/detect.ts +145 -0
- package/src/doc/treeview/index.ts +30 -0
- package/src/doc/treeview/mapping.ts +95 -0
- package/src/doc/treeview/parse.ts +201 -0
- package/src/doc/treeview/render.ts +41 -0
- package/src/doc/treeview/types.ts +43 -0
- package/src/doc/utils/applyRenderStyle.ts +2 -9
- package/src/doc/utils/diagramText.ts +78 -0
- package/src/doc/utils/shapeGeometry.ts +67 -5
- package/src/doc/validate.ts +1 -1
- package/src/generate/templateMapper.ts +7 -0
- package/src/imageEdit/export.ts +6 -5
- package/src/imageEdit/versionPaths.ts +13 -26
- package/src/imageEdit/versions.ts +21 -100
- package/src/index.ts +1 -0
- package/src/internal/immutable.ts +58 -0
- package/src/jsonForm/index.ts +9 -1
- package/src/jsonForm/pointer.ts +68 -23
- package/src/markdown/attrTokens.ts +7 -5
- package/src/markdown/index.ts +1 -0
- package/src/markdown/stringify.ts +4 -9
- package/src/markdown/utils.ts +36 -14
- package/src/narration/align.ts +514 -0
- package/src/narration/features.ts +231 -0
- package/src/narration/index.ts +86 -0
- package/src/narration/nuclei.ts +118 -0
- package/src/narration/pacing.ts +211 -0
- package/src/narration/script.ts +239 -0
- package/src/narration/session.ts +100 -0
- package/src/narration/sidecar.ts +187 -0
- package/src/narration/syllables.ts +85 -0
- package/src/narration/trace.ts +50 -0
- package/src/narration/types.ts +299 -0
- package/src/narration/vad.ts +131 -0
- package/src/recommend/numberHighlight.ts +25 -0
- package/src/recommend/templates.ts +108 -12
- package/src/schemas/BlockTemplates.ts +156 -7
- package/src/schemas/Doc.ts +72 -9
- package/src/schemas/Media.ts +12 -2
- package/src/schemas/Theme.ts +45 -25
- package/src/schemas/Viewport.ts +2 -2
- package/src/schemas/fontStacks.ts +79 -0
- package/src/schemas/themeCompile.ts +15 -0
- package/src/schemas/themeConstants.ts +2 -0
- package/src/schemas/themeLibrary.ts +25 -19
- package/src/schemas/themeValidator.ts +1 -1
- package/src/spatial/Geohash.ts +0 -3
- package/src/storage/ContentContainer.ts +17 -9
- package/src/storage/ScopedContentContainer.ts +45 -15
- package/src/transform/applyTransform.ts +5 -5
- package/src/transform/index.ts +3 -1
- package/src/transform/registry.ts +169 -49
- package/src/transform/templateSelector.ts +21 -2
- package/src/transform/timingAllocator.ts +152 -21
- package/src/transform/types.ts +14 -1
- package/src/versions/gitignore.ts +34 -0
- package/src/versions/index.ts +2 -0
- package/src/versions/operations.ts +18 -104
- package/src/versions/paths.ts +13 -28
- package/src/versions/snapshotStore.ts +202 -0
- package/src/versions/types.ts +3 -1
- package/dist/chunk-5TYS5V3G.js.map +0 -1
- package/dist/chunk-A4O7GIWE.js.map +0 -1
- package/dist/chunk-BEQODJWV.js.map +0 -1
- package/dist/chunk-DLXZMS5K.js.map +0 -1
- package/dist/chunk-GPLTCSXO.js.map +0 -1
- package/dist/chunk-HWVFJAAH.js.map +0 -1
- package/dist/chunk-IMSCRLLV.js.map +0 -1
- package/dist/chunk-JELIRVDP.js +0 -24
- package/dist/chunk-JELIRVDP.js.map +0 -1
- package/dist/chunk-KKNUBQ6Y.js.map +0 -1
- package/dist/chunk-LDQ2HJIX.js.map +0 -1
- package/dist/chunk-LTN6I7QW.js.map +0 -1
- package/dist/chunk-P3K7NLHF.js.map +0 -1
- package/dist/chunk-T5UK6YOB.js.map +0 -1
- package/dist/chunk-UY7KGQ5R.js +0 -192
- package/dist/chunk-UY7KGQ5R.js.map +0 -1
- package/dist/chunk-VLJJHDAC.js +0 -218
- package/dist/chunk-VLJJHDAC.js.map +0 -1
- package/dist/chunk-VWUFZ6ZG.js.map +0 -1
- package/dist/story/index.d.ts +0 -1801
- package/dist/story/index.js +0 -279
- package/src/doc/getLayers.ts +0 -195
- /package/dist/{story → narration}/index.js.map +0 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tree Template
|
|
3
|
+
*
|
|
4
|
+
* Renders a hierarchical filesystem-style treeview (folder/file icons +
|
|
5
|
+
* connector rails, collapsible in the live player) from an ASCII tree fence.
|
|
6
|
+
* Emits a single `TreeLayer` (foreignObject HTML, like `dataTable`) plus an
|
|
7
|
+
* optional title + themed background.
|
|
8
|
+
*
|
|
9
|
+
* Data source: `input.items` (a nested `TreeLayerItem[]`), populated from
|
|
10
|
+
* `templateData.items` which the pipeline derives from the tree fence.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Layer, TreeLayerItem } from '../../schemas/Doc.js';
|
|
14
|
+
import type { TreeBlockInput, TemplateContext } from '../../schemas/BlockTemplates.js';
|
|
15
|
+
import {
|
|
16
|
+
getThemeFont,
|
|
17
|
+
resolveColorScheme,
|
|
18
|
+
shouldUseShadow,
|
|
19
|
+
themedFontSize,
|
|
20
|
+
themedSurfaceGradient,
|
|
21
|
+
} from '../utils/themeUtils.js';
|
|
22
|
+
import { withAlpha } from '../../schemas/colorUtils.js';
|
|
23
|
+
import { createBackgroundLayer } from './captionUtils.js';
|
|
24
|
+
|
|
25
|
+
/** Defensive coercion of untyped `templateData.items` into valid tree items. */
|
|
26
|
+
function coerceItems(raw: unknown): TreeLayerItem[] {
|
|
27
|
+
if (!Array.isArray(raw)) return [];
|
|
28
|
+
const out: TreeLayerItem[] = [];
|
|
29
|
+
for (const r of raw) {
|
|
30
|
+
if (typeof r !== 'object' || r === null) continue;
|
|
31
|
+
const item = r as Record<string, unknown>;
|
|
32
|
+
if (typeof item.label !== 'string') continue;
|
|
33
|
+
const children = coerceItems(item.children);
|
|
34
|
+
out.push({
|
|
35
|
+
id: typeof item.id === 'string' && item.id ? item.id : slug(item.label),
|
|
36
|
+
label: item.label,
|
|
37
|
+
children,
|
|
38
|
+
...(item.isDir === true || children.length > 0 ? { isDir: true } : {}),
|
|
39
|
+
...(typeof item.comment === 'string' && item.comment ? { comment: item.comment } : {}),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function slug(s: string): string {
|
|
46
|
+
return (
|
|
47
|
+
s
|
|
48
|
+
.toLowerCase()
|
|
49
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
50
|
+
.replace(/^-+|-+$/g, '') || 'node'
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function treeBlock(input: TreeBlockInput, context: TemplateContext): Layer[] {
|
|
55
|
+
const { theme, viewport } = context;
|
|
56
|
+
const colors = resolveColorScheme(context, input.colorScheme);
|
|
57
|
+
const items = coerceItems(input.items);
|
|
58
|
+
|
|
59
|
+
const titleFontSize = themedFontSize(48, context, true);
|
|
60
|
+
const rowFontSize = themedFontSize(28, context, false);
|
|
61
|
+
|
|
62
|
+
const layers: Layer[] = [createBackgroundLayer('bg', themedSurfaceGradient(context, 170))];
|
|
63
|
+
|
|
64
|
+
if (items.length === 0) {
|
|
65
|
+
layers.push({
|
|
66
|
+
type: 'text',
|
|
67
|
+
id: 'tree-empty',
|
|
68
|
+
content: {
|
|
69
|
+
text: input.title ?? 'Empty tree',
|
|
70
|
+
style: {
|
|
71
|
+
fontSize: titleFontSize,
|
|
72
|
+
fontFamily: getThemeFont(context, 'title'),
|
|
73
|
+
color: theme.colors.textMuted,
|
|
74
|
+
textAlign: 'center',
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
position: { x: '50%', y: '50%', anchor: 'center' },
|
|
78
|
+
});
|
|
79
|
+
return layers;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const titleBandPct = input.title ? (titleFontSize * 2.2 * 100) / viewport.height : 0;
|
|
83
|
+
|
|
84
|
+
if (input.title) {
|
|
85
|
+
layers.push({
|
|
86
|
+
type: 'text',
|
|
87
|
+
id: 'title',
|
|
88
|
+
content: {
|
|
89
|
+
text: input.title,
|
|
90
|
+
style: {
|
|
91
|
+
fontSize: titleFontSize,
|
|
92
|
+
fontFamily: getThemeFont(context, 'title'),
|
|
93
|
+
fontWeight: 'bold',
|
|
94
|
+
color: theme.colors.text,
|
|
95
|
+
textAlign: 'center',
|
|
96
|
+
shadow: shouldUseShadow(context),
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
position: { x: '50%', y: `${8 + titleBandPct / 2}%`, width: '80%', anchor: 'center' },
|
|
100
|
+
animation: { type: 'fadeIn', duration: 0.8 },
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
layers.push({
|
|
105
|
+
type: 'tree',
|
|
106
|
+
id: 'tree',
|
|
107
|
+
content: {
|
|
108
|
+
items,
|
|
109
|
+
style: {
|
|
110
|
+
rowColor: theme.colors.text,
|
|
111
|
+
dirColor: colors.accent ?? theme.colors.primary,
|
|
112
|
+
connectorColor: withAlpha(theme.colors.text, 0.25),
|
|
113
|
+
iconColor: colors.accent ?? theme.colors.primary,
|
|
114
|
+
commentColor: theme.colors.textMuted,
|
|
115
|
+
fontSize: rowFontSize,
|
|
116
|
+
fontFamily: getThemeFont(context, 'body'),
|
|
117
|
+
monoFontFamily: getThemeFont(context, 'mono'),
|
|
118
|
+
indentPx: Math.round(rowFontSize * 1.4),
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
position: {
|
|
122
|
+
x: '10%',
|
|
123
|
+
y: `${8 + titleBandPct}%`,
|
|
124
|
+
width: '80%',
|
|
125
|
+
height: `${Math.max(60, 90 - titleBandPct)}%`,
|
|
126
|
+
},
|
|
127
|
+
animation: { type: 'fadeIn', duration: 1, delay: input.title ? 0.4 : 0 },
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
return layers;
|
|
131
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Character classification + render vocabularies for ASCII tree art.
|
|
3
|
+
*
|
|
4
|
+
* The parser accepts a superset (Unicode light/heavy/rounded branches plus
|
|
5
|
+
* ASCII `|-- ` / `` `-- `` / `+-- `); the renderer emits one `TreeVocab`.
|
|
6
|
+
* Internal to the treeview module.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Vertical rail glyphs (an ancestor line continuing downward). */
|
|
10
|
+
const RAIL_CHARS = new Set(['│', '┃', '╎', '╏', '║', '|']);
|
|
11
|
+
/** Branch-tee glyphs (a node that has a following sibling). */
|
|
12
|
+
const BRANCH_TEE = new Set(['├', '┣', '┠', '┝', '╠', '╞']);
|
|
13
|
+
/** Branch-elbow glyphs (the last child). */
|
|
14
|
+
const BRANCH_ELBOW = new Set(['└', '┗', '╰', '╚', '╘']);
|
|
15
|
+
/** Horizontal dash glyphs used in a branch arm. */
|
|
16
|
+
const DASH_CHARS = new Set(['─', '━', '═', '-']);
|
|
17
|
+
|
|
18
|
+
export function isRailChar(ch: string): boolean {
|
|
19
|
+
return RAIL_CHARS.has(ch);
|
|
20
|
+
}
|
|
21
|
+
export function isBranchTee(ch: string): boolean {
|
|
22
|
+
return BRANCH_TEE.has(ch);
|
|
23
|
+
}
|
|
24
|
+
export function isBranchElbow(ch: string): boolean {
|
|
25
|
+
return BRANCH_ELBOW.has(ch);
|
|
26
|
+
}
|
|
27
|
+
export function isUnicodeBranch(ch: string): boolean {
|
|
28
|
+
return BRANCH_TEE.has(ch) || BRANCH_ELBOW.has(ch);
|
|
29
|
+
}
|
|
30
|
+
export function isDashChar(ch: string): boolean {
|
|
31
|
+
return DASH_CHARS.has(ch);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Structural glyphs used for the style vote. */
|
|
35
|
+
export function isUnicodeTreeChar(ch: string): boolean {
|
|
36
|
+
return (
|
|
37
|
+
BRANCH_TEE.has(ch) ||
|
|
38
|
+
BRANCH_ELBOW.has(ch) ||
|
|
39
|
+
ch === '│' ||
|
|
40
|
+
ch === '┃' ||
|
|
41
|
+
ch === '─' ||
|
|
42
|
+
ch === '━'
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
export function isAsciiTreeChar(ch: string): boolean {
|
|
46
|
+
return ch === '|' || ch === '`' || ch === '+' || ch === '-';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface TreeVocab {
|
|
50
|
+
/** Branch to a node that has a later sibling (`├── `). */
|
|
51
|
+
tee: string;
|
|
52
|
+
/** Branch to the last child (`└── `). */
|
|
53
|
+
elbow: string;
|
|
54
|
+
/** Ancestor rail column (`│ `). */
|
|
55
|
+
rail: string;
|
|
56
|
+
/** Ended-ancestor gap column (` `). */
|
|
57
|
+
gap: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const UNICODE_TREE_VOCAB: TreeVocab = {
|
|
61
|
+
tee: '├── ',
|
|
62
|
+
elbow: '└── ',
|
|
63
|
+
rail: '│ ',
|
|
64
|
+
gap: ' ',
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const ASCII_TREE_VOCAB: TreeVocab = {
|
|
68
|
+
tee: '|-- ',
|
|
69
|
+
elbow: '`-- ',
|
|
70
|
+
rail: '| ',
|
|
71
|
+
gap: ' ',
|
|
72
|
+
};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ASCII tree detection: is this fence content a file-tree / outline?
|
|
3
|
+
*
|
|
4
|
+
* The mirror image of the diagram detector. A diagram has ≥2 closed boxes;
|
|
5
|
+
* a tree has ZERO closed boxes plus connector-branch structure. The two
|
|
6
|
+
* partition the `├──` glyph space cleanly, so a fence is at most one of
|
|
7
|
+
* them. Auto-detection is deliberately conservative (requires connector
|
|
8
|
+
* branches — the unambiguous AI file-tree signal); the parser still handles
|
|
9
|
+
* pure-indentation renditions for explicitly `{[tree]}`-annotated fences.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { MarkdownCodeBlock } from '../../markdown/types.js';
|
|
13
|
+
import { parseAsciiDiagramWithStats } from '../asciiDiagram/parse.js';
|
|
14
|
+
import { detectAsciiTimeline } from '../asciiTimeline/detect.js';
|
|
15
|
+
import { parseTreeWithStats } from './parse.js';
|
|
16
|
+
import type { TreeDetection } from './types.js';
|
|
17
|
+
|
|
18
|
+
/** Fence languages eligible for tree detection (plus no language at all). */
|
|
19
|
+
export const TREE_FENCE_LANGS: ReadonlySet<string> = new Set([
|
|
20
|
+
'text',
|
|
21
|
+
'txt',
|
|
22
|
+
'plaintext',
|
|
23
|
+
'plain',
|
|
24
|
+
'ascii',
|
|
25
|
+
'tree',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const MAX_LINES = 400;
|
|
29
|
+
const MIN_LINES = 2;
|
|
30
|
+
const MIN_CONNECTOR_LINES = 2;
|
|
31
|
+
const MIN_NODES = 2;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Box-drawing corners a file tree NEVER contains: top-left/right and
|
|
35
|
+
* bottom-right in light, heavy, double, and rounded weights. Bottom-LEFT
|
|
36
|
+
* corners (└ ┗ ╰ ╚ ╘) are excluded — the tree codec uses them as elbows.
|
|
37
|
+
*/
|
|
38
|
+
const BOX_CORNER_RE = /[┌┐┘┏┓┛╔╗╝╭╮╯]/u;
|
|
39
|
+
|
|
40
|
+
export function isEligibleTreeFenceLang(lang: string | null | undefined): boolean {
|
|
41
|
+
if (lang === null || lang === undefined) return true;
|
|
42
|
+
const normalized = lang.trim().toLowerCase();
|
|
43
|
+
return normalized === '' || TREE_FENCE_LANGS.has(normalized);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* True when the fence LANGUAGE is the explicit `tree` tag — an author-set
|
|
48
|
+
* "this is a tree" marker that survives markdown ↔ Tiptap round-trips (the
|
|
49
|
+
* language class round-trips; fence meta does not). Detection of a tagged
|
|
50
|
+
* fence is lenient (no connector requirement) so a flattened/degenerate
|
|
51
|
+
* tree stays a tree.
|
|
52
|
+
*/
|
|
53
|
+
export function isExplicitTreeLang(lang: string | null | undefined): boolean {
|
|
54
|
+
return typeof lang === 'string' && lang.trim().toLowerCase() === 'tree';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface DetectTreeOptions {
|
|
58
|
+
/** The fence is explicitly `tree`-tagged: skip the connector requirement. */
|
|
59
|
+
explicit?: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function detectTree(text: string, opts: DetectTreeOptions = {}): TreeDetection {
|
|
63
|
+
const explicit = opts.explicit === true;
|
|
64
|
+
const lines = text.replace(/\r\n?/g, '\n').split('\n');
|
|
65
|
+
const nonBlank = lines.filter((l) => l.trim().length > 0);
|
|
66
|
+
// A `tree`-tagged fence can be a single-node tree (e.g. the outline edited
|
|
67
|
+
// down to one item); an untagged fence needs ≥2 lines to auto-detect.
|
|
68
|
+
const minLines = explicit ? 1 : MIN_LINES;
|
|
69
|
+
if (nonBlank.length < minLines) {
|
|
70
|
+
return { isTree: false, reasons: [`too-few-lines(${nonBlank.length})`] };
|
|
71
|
+
}
|
|
72
|
+
if (lines.length > MAX_LINES) {
|
|
73
|
+
return { isTree: false, reasons: [`too-many-lines(${lines.length})`] };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Markdown/GFM table shape → not a tree (even when explicitly tagged; it
|
|
77
|
+
// wouldn't parse into a sensible hierarchy anyway).
|
|
78
|
+
const pipeRows = nonBlank.filter((l) => /^\s*\|.*\|\s*$/.test(l)).length;
|
|
79
|
+
const hasGfmSeparator = nonBlank.some((l) => /^\s*\|?[\s:|-]+\|?\s*$/.test(l) && l.includes('-'));
|
|
80
|
+
if (pipeRows > nonBlank.length / 2 && hasGfmSeparator) {
|
|
81
|
+
return { isTree: false, reasons: ['markdown-table'] };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Box-drawing CORNERS are an unambiguous box-diagram signal. A file tree /
|
|
85
|
+
// outline is built only from tees (├), elbows (└ ┗ ╰), rails (│), and dashes
|
|
86
|
+
// (─) — it never contains a TOP corner (┌ ┐) or a BOTTOM-RIGHT corner (┘).
|
|
87
|
+
// A fence with those is a diagram, even a MALFORMED one whose borders are
|
|
88
|
+
// misaligned so the tracer finds <2 closed boxes (e.g. hand-drawn art
|
|
89
|
+
// knocked out of alignment by a later rename that widened the labels).
|
|
90
|
+
// Without this, such art falls through to a mangled treeview. (Bottom-LEFT
|
|
91
|
+
// corners └ ┗ ╰ ╚ ╘ are deliberately excluded — they double as tree elbows.)
|
|
92
|
+
if (BOX_CORNER_RE.test(text)) {
|
|
93
|
+
return { isTree: false, reasons: ['has-box-corners'] };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Timeline callout elbows can resemble tree branches. Give a qualifying
|
|
97
|
+
// marker+axis timeline ownership before the loose tree parser sees it.
|
|
98
|
+
if (!explicit && detectAsciiTimeline(text).isTimeline) {
|
|
99
|
+
return { isTree: false, reasons: ['is-timeline'] };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Mutual exclusion with the diagram codec: a fence with ≥2 closed boxes is
|
|
103
|
+
// a diagram, not a tree.
|
|
104
|
+
const boxes = parseAsciiDiagramWithStats(text).diagram.nodes.length;
|
|
105
|
+
if (boxes >= 2) {
|
|
106
|
+
return { isTree: false, reasons: [`has-boxes(${boxes})`] };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const { tree, stats } = parseTreeWithStats(text);
|
|
110
|
+
const nodeCount = countNodes(tree.roots);
|
|
111
|
+
const minNodes = explicit ? 1 : MIN_NODES;
|
|
112
|
+
if (nodeCount < minNodes) {
|
|
113
|
+
return { isTree: false, reasons: [`too-few-nodes(${nodeCount})`] };
|
|
114
|
+
}
|
|
115
|
+
if (!explicit) {
|
|
116
|
+
// Auto-detection of an UNTAGGED fence requires the connector signal
|
|
117
|
+
// (pure-indentation art could be prose or a list). A `tree`-tagged fence
|
|
118
|
+
// trusts the author and skips this.
|
|
119
|
+
if (stats.connectorLines < MIN_CONNECTOR_LINES) {
|
|
120
|
+
return { isTree: false, reasons: [`too-few-connectors(${stats.connectorLines})`] };
|
|
121
|
+
}
|
|
122
|
+
if (stats.looseLines > stats.contentLines * 0.25) {
|
|
123
|
+
return { isTree: false, reasons: [`loose-ratio(${stats.looseLines}/${stats.contentLines})`] };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
isTree: true,
|
|
129
|
+
tree,
|
|
130
|
+
reasons: [`nodes(${nodeCount})`, explicit ? 'explicit' : `connectors(${stats.connectorLines})`],
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Convenience gate for pipeline callers: lang allowlist + content detection. */
|
|
135
|
+
export function isTreeFence(node: MarkdownCodeBlock): boolean {
|
|
136
|
+
if (node.type !== 'code') return false;
|
|
137
|
+
if (!isEligibleTreeFenceLang(node.lang)) return false;
|
|
138
|
+
return detectTree(node.value, { explicit: isExplicitTreeLang(node.lang) }).isTree;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function countNodes(nodes: readonly import('./types.js').TreeNode[]): number {
|
|
142
|
+
let n = 0;
|
|
143
|
+
for (const node of nodes) n += 1 + countNodes(node.children);
|
|
144
|
+
return n;
|
|
145
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ASCII treeview codec: detect / parse / render file-tree & outline art,
|
|
3
|
+
* plus mapping helpers between the tree model and the template/layer data.
|
|
4
|
+
*
|
|
5
|
+
* The fence text is the source of truth end-to-end: the editor parses it
|
|
6
|
+
* live for an interactive outline and re-renders it after each edit; the
|
|
7
|
+
* doc pipeline derives `templateData.items` from it so the player renders
|
|
8
|
+
* the same tree. Peer to the ASCII diagram codec.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export { parseTree, parseTreeWithStats } from './parse.js';
|
|
12
|
+
export type { TreeLineStats } from './parse.js';
|
|
13
|
+
export { renderTree } from './render.js';
|
|
14
|
+
export type { RenderTreeOptions } from './render.js';
|
|
15
|
+
export {
|
|
16
|
+
detectTree,
|
|
17
|
+
isTreeFence,
|
|
18
|
+
isEligibleTreeFenceLang,
|
|
19
|
+
isExplicitTreeLang,
|
|
20
|
+
TREE_FENCE_LANGS,
|
|
21
|
+
} from './detect.js';
|
|
22
|
+
export type { DetectTreeOptions } from './detect.js';
|
|
23
|
+
export {
|
|
24
|
+
treeToTemplateData,
|
|
25
|
+
treeFromTemplateData,
|
|
26
|
+
treeFromMarkdownList,
|
|
27
|
+
findFirstList,
|
|
28
|
+
} from './mapping.js';
|
|
29
|
+
export type { TreeItem } from './mapping.js';
|
|
30
|
+
export type { Tree, TreeNode, TreeDetection } from './types.js';
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mapping between the tree model and the template/layer data model, plus a
|
|
3
|
+
* nested-markdown-list → tree walker (none exists in core — `extractListItems`
|
|
4
|
+
* flattens, losing depth).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { MarkdownBlockNode, MarkdownList, MarkdownListItem } from '../../markdown/types.js';
|
|
8
|
+
import { extractPlainText } from '../../markdown/utils.js';
|
|
9
|
+
import type { Tree, TreeNode } from './types.js';
|
|
10
|
+
|
|
11
|
+
/** JSON-serializable tree item carried on `templateData.items` / a `TreeLayer`. */
|
|
12
|
+
export interface TreeItem {
|
|
13
|
+
id: string;
|
|
14
|
+
label: string;
|
|
15
|
+
isDir?: boolean;
|
|
16
|
+
comment?: string;
|
|
17
|
+
children: TreeItem[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Tree model → template/layer items (structure-preserving). */
|
|
21
|
+
export function treeToTemplateData(tree: Tree): { items: TreeItem[] } {
|
|
22
|
+
return { items: tree.roots.map(toItem) };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function toItem(node: TreeNode): TreeItem {
|
|
26
|
+
return {
|
|
27
|
+
id: node.id,
|
|
28
|
+
label: node.label,
|
|
29
|
+
...(node.isDir ? { isDir: true } : {}),
|
|
30
|
+
...(node.comment ? { comment: node.comment } : {}),
|
|
31
|
+
children: node.children.map(toItem),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Template/layer items → tree model (editor write-back / render). */
|
|
36
|
+
export function treeFromTemplateData(
|
|
37
|
+
items: readonly TreeItem[],
|
|
38
|
+
options: { style?: 'unicode' | 'ascii' } = {},
|
|
39
|
+
): Tree {
|
|
40
|
+
const roots = items.map(fromItem);
|
|
41
|
+
return { roots, style: options.style ?? 'unicode', warnings: [] };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function fromItem(item: TreeItem): TreeNode {
|
|
45
|
+
const children = Array.isArray(item.children) ? item.children.map(fromItem) : [];
|
|
46
|
+
return {
|
|
47
|
+
id: typeof item.id === 'string' && item.id ? item.id : 'node',
|
|
48
|
+
label: typeof item.label === 'string' ? item.label : '',
|
|
49
|
+
children,
|
|
50
|
+
isDir: item.isDir === true || children.length > 0,
|
|
51
|
+
...(item.comment ? { comment: item.comment } : {}),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Recursively convert a nested markdown bullet list into a tree. Used by
|
|
57
|
+
* `deriveTemplateInputs` when an author explicitly `{[tree]}`-annotates a
|
|
58
|
+
* block whose body is a nested list rather than a fence.
|
|
59
|
+
*/
|
|
60
|
+
export function treeFromMarkdownList(list: MarkdownList): Tree {
|
|
61
|
+
const usedIds = new Map<string, number>();
|
|
62
|
+
const slugify = (s: string): string =>
|
|
63
|
+
s
|
|
64
|
+
.toLowerCase()
|
|
65
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
66
|
+
.replace(/^-+|-+$/g, '') || 'node';
|
|
67
|
+
const idFor = (label: string): string => {
|
|
68
|
+
const base = slugify(label.replace(/\/$/, ''));
|
|
69
|
+
const count = usedIds.get(base) ?? 0;
|
|
70
|
+
usedIds.set(base, count + 1);
|
|
71
|
+
return count === 0 ? base : `${base}-${count + 1}`;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const itemToNode = (item: MarkdownListItem): TreeNode => {
|
|
75
|
+
// The item's own label = its first non-list block's plain text.
|
|
76
|
+
const labelNode = item.children.find((c) => c.type !== 'list');
|
|
77
|
+
const label = labelNode ? extractPlainText(labelNode).trim() : '';
|
|
78
|
+
const childLists = item.children.filter((c): c is MarkdownList => c.type === 'list');
|
|
79
|
+
const children: TreeNode[] = [];
|
|
80
|
+
for (const cl of childLists) for (const ci of cl.children) children.push(itemToNode(ci));
|
|
81
|
+
return {
|
|
82
|
+
id: idFor(label),
|
|
83
|
+
label,
|
|
84
|
+
children,
|
|
85
|
+
isDir: label.endsWith('/') || children.length > 0,
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return { roots: list.children.map(itemToNode), style: 'unicode', warnings: [] };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Find the first top-level markdown list in a block's body, if any. */
|
|
93
|
+
export function findFirstList(contents: MarkdownBlockNode[] | undefined): MarkdownList | undefined {
|
|
94
|
+
return contents?.find((n): n is MarkdownList => n.type === 'list');
|
|
95
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ASCII tree parser: file-tree / outline art → nested nodes.
|
|
3
|
+
*
|
|
4
|
+
* Pure, deterministic, never throws. Handles the many renditions AI emits:
|
|
5
|
+
* Unicode connectors (`├── `/`└── `/`│ `), ASCII (`|-- `/`` `-- ``/`+-- `),
|
|
6
|
+
* bullet-indented (`- `/`* `), and pure indentation. Depth is recovered by
|
|
7
|
+
* finding where each line's LABEL begins (after any connector/indent
|
|
8
|
+
* prefix) and running the standard outline indent-stack — this unifies all
|
|
9
|
+
* renditions and tolerates ragged dedents.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
isBranchElbow,
|
|
14
|
+
isBranchTee,
|
|
15
|
+
isDashChar,
|
|
16
|
+
isRailChar,
|
|
17
|
+
isAsciiTreeChar,
|
|
18
|
+
isUnicodeTreeChar,
|
|
19
|
+
} from './chars.js';
|
|
20
|
+
import type { Tree, TreeNode } from './types.js';
|
|
21
|
+
|
|
22
|
+
/** Internal per-line analysis fed to the reconstruction stack. */
|
|
23
|
+
export interface TreeLineStats {
|
|
24
|
+
/** Non-blank content lines. */
|
|
25
|
+
contentLines: number;
|
|
26
|
+
/** Lines carrying a connector branch (`├──`/`└──`/`|--`/`` `--``/`+--`). */
|
|
27
|
+
connectorLines: number;
|
|
28
|
+
/** Lines that could not be analyzed as a node (loose text). */
|
|
29
|
+
looseLines: number;
|
|
30
|
+
/** Distinct label-start columns observed (a hierarchy has ≥2). */
|
|
31
|
+
indentLevels: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface AnalyzedLine {
|
|
35
|
+
indent: number; // column where the label begins
|
|
36
|
+
label: string;
|
|
37
|
+
comment?: string;
|
|
38
|
+
hadConnector: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function parseTree(text: string): Tree {
|
|
42
|
+
return parseTreeWithStats(text).tree;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function parseTreeWithStats(text: string): { tree: Tree; stats: TreeLineStats } {
|
|
46
|
+
const warnings: string[] = [];
|
|
47
|
+
const rawLines = text.replace(/\r\n?/g, '\n').split('\n');
|
|
48
|
+
|
|
49
|
+
const style = detectStyle(rawLines);
|
|
50
|
+
const stats: TreeLineStats = {
|
|
51
|
+
contentLines: 0,
|
|
52
|
+
connectorLines: 0,
|
|
53
|
+
looseLines: 0,
|
|
54
|
+
indentLevels: 0,
|
|
55
|
+
};
|
|
56
|
+
const indentSet = new Set<number>();
|
|
57
|
+
|
|
58
|
+
const analyzed: AnalyzedLine[] = [];
|
|
59
|
+
for (const raw of rawLines) {
|
|
60
|
+
if (raw.trim().length === 0) continue;
|
|
61
|
+
stats.contentLines++;
|
|
62
|
+
const a = analyzeLine(raw);
|
|
63
|
+
if (!a || a.label.length === 0) {
|
|
64
|
+
stats.looseLines++;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (a.hadConnector) stats.connectorLines++;
|
|
68
|
+
indentSet.add(a.indent);
|
|
69
|
+
analyzed.push(a);
|
|
70
|
+
}
|
|
71
|
+
stats.indentLevels = indentSet.size;
|
|
72
|
+
|
|
73
|
+
// ---- Reconstruct the hierarchy via an indent stack --------------------
|
|
74
|
+
const roots: TreeNode[] = [];
|
|
75
|
+
const stack: Array<{ indent: number; node: TreeNode }> = [];
|
|
76
|
+
for (const a of analyzed) {
|
|
77
|
+
const node: TreeNode = {
|
|
78
|
+
id: '',
|
|
79
|
+
label: a.label,
|
|
80
|
+
children: [],
|
|
81
|
+
...(a.comment ? { comment: a.comment } : {}),
|
|
82
|
+
};
|
|
83
|
+
// Pop ancestors that are at the same or deeper indent.
|
|
84
|
+
while (stack.length > 0 && stack[stack.length - 1].indent >= a.indent) stack.pop();
|
|
85
|
+
if (stack.length === 0) {
|
|
86
|
+
roots.push(node);
|
|
87
|
+
} else {
|
|
88
|
+
stack[stack.length - 1].node.children.push(node);
|
|
89
|
+
}
|
|
90
|
+
stack.push({ indent: a.indent, node });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---- Post-pass: isDir + stable ids ------------------------------------
|
|
94
|
+
const usedIds = new Map<string, number>();
|
|
95
|
+
const finalize = (node: TreeNode): void => {
|
|
96
|
+
node.isDir = node.label.endsWith('/') || node.children.length > 0;
|
|
97
|
+
const base = slugify(stripTrailingSlash(node.label));
|
|
98
|
+
const count = usedIds.get(base) ?? 0;
|
|
99
|
+
usedIds.set(base, count + 1);
|
|
100
|
+
node.id = count === 0 ? base : `${base}-${count + 1}`;
|
|
101
|
+
for (const child of node.children) finalize(child);
|
|
102
|
+
};
|
|
103
|
+
for (const root of roots) finalize(root);
|
|
104
|
+
|
|
105
|
+
if (analyzed.length > 0 && stats.looseLines > 0) {
|
|
106
|
+
warnings.push(`loose-lines(${stats.looseLines})`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { tree: { roots, style, warnings }, stats };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Line analysis
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Strip a line's connector / indent prefix and return the column where the
|
|
118
|
+
* label begins plus the cleaned label + trailing comment. Returns null for
|
|
119
|
+
* a rails-only spacer line.
|
|
120
|
+
*/
|
|
121
|
+
function analyzeLine(line: string): AnalyzedLine | null {
|
|
122
|
+
const chars = Array.from(line);
|
|
123
|
+
let i = 0;
|
|
124
|
+
let hadConnector = false;
|
|
125
|
+
|
|
126
|
+
// Consume leading whitespace + vertical rails. A rail is a rail glyph NOT
|
|
127
|
+
// immediately followed by a dash (which would make it an ASCII branch).
|
|
128
|
+
for (;;) {
|
|
129
|
+
while (i < chars.length && (chars[i] === ' ' || chars[i] === '\t')) i++;
|
|
130
|
+
const ch = chars[i];
|
|
131
|
+
if (ch === undefined) return null; // blank / rails-only
|
|
132
|
+
if (isRailChar(ch) && !isDashChar(chars[i + 1] ?? '')) {
|
|
133
|
+
i++;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Optional branch: a Unicode tee/elbow + dashes, or ASCII `|`/`` ` ``/`+`
|
|
140
|
+
// + dashes. The label follows (after any trailing spaces).
|
|
141
|
+
const ch = chars[i];
|
|
142
|
+
if (ch !== undefined) {
|
|
143
|
+
if (isBranchTee(ch) || isBranchElbow(ch)) {
|
|
144
|
+
i++;
|
|
145
|
+
while (i < chars.length && (isDashChar(chars[i]) || isRailChar(chars[i]))) i++;
|
|
146
|
+
hadConnector = true;
|
|
147
|
+
} else if ((ch === '|' || ch === '`' || ch === '+') && isDashChar(chars[i + 1] ?? '')) {
|
|
148
|
+
i++;
|
|
149
|
+
while (i < chars.length && isDashChar(chars[i])) i++;
|
|
150
|
+
hadConnector = true;
|
|
151
|
+
} else if ((ch === '-' || ch === '*' || ch === '+') && (chars[i + 1] ?? '') === ' ') {
|
|
152
|
+
// Markdown-style bullet (indent-based rendition).
|
|
153
|
+
i++;
|
|
154
|
+
hadConnector = false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
while (i < chars.length && (chars[i] === ' ' || chars[i] === '\t')) i++;
|
|
158
|
+
|
|
159
|
+
const indent = i;
|
|
160
|
+
let label = chars.slice(i).join('').replace(/\s+$/u, '');
|
|
161
|
+
if (label.length === 0) return null;
|
|
162
|
+
|
|
163
|
+
// Trailing comment: ` # …`, ` <-- …`, or ` // …` (needs a leading space).
|
|
164
|
+
let comment: string | undefined;
|
|
165
|
+
const commentMatch = /\s+(?:#|<--|\/\/)\s?(.*)$/.exec(label);
|
|
166
|
+
if (commentMatch) {
|
|
167
|
+
comment = commentMatch[1].trim() || undefined;
|
|
168
|
+
label = label.slice(0, commentMatch.index).replace(/\s+$/u, '');
|
|
169
|
+
}
|
|
170
|
+
if (label.length === 0) return null;
|
|
171
|
+
|
|
172
|
+
return { indent, label, hadConnector, ...(comment ? { comment } : {}) };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// Helpers
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
function detectStyle(lines: string[]): 'unicode' | 'ascii' {
|
|
180
|
+
let unicode = 0;
|
|
181
|
+
let ascii = 0;
|
|
182
|
+
for (const line of lines) {
|
|
183
|
+
for (const ch of line) {
|
|
184
|
+
if (isUnicodeTreeChar(ch)) unicode++;
|
|
185
|
+
else if (isAsciiTreeChar(ch)) ascii++;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return ascii > unicode ? 'ascii' : 'unicode';
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function stripTrailingSlash(s: string): string {
|
|
192
|
+
return s.endsWith('/') ? s.slice(0, -1) : s;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function slugify(s: string): string {
|
|
196
|
+
const slug = s
|
|
197
|
+
.toLowerCase()
|
|
198
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
199
|
+
.replace(/^-+|-+$/g, '');
|
|
200
|
+
return slug || 'node';
|
|
201
|
+
}
|