@miliastry/quasar 1.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.
Files changed (172) hide show
  1. package/LICENSE +119 -0
  2. package/README.md +45 -0
  3. package/dist/Visuals/lyne.css +1027 -0
  4. package/dist/Visuals/osu.css +257 -0
  5. package/dist/index.d.mts +4563 -0
  6. package/dist/index.d.ts +4563 -0
  7. package/dist/index.js +11291 -0
  8. package/dist/index.mjs +11197 -0
  9. package/package.json +51 -0
  10. package/src/Analysis/Contracts/AnalysisReport.ts +26 -0
  11. package/src/Analysis/Contracts/Contribution.ts +76 -0
  12. package/src/Analysis/Contracts/Pass.ts +76 -0
  13. package/src/Analysis/Contracts/PipelineContext.ts +48 -0
  14. package/src/Analysis/Passes/Analysis/GradientAnalyzer.ts +292 -0
  15. package/src/Analysis/Passes/Analysis/MergeableColorAnalyzer.ts +112 -0
  16. package/src/Analysis/Passes/Analysis/RainbowAnalyzer.ts +233 -0
  17. package/src/Analysis/Passes/Analysis/WaveAnalyzer.ts +211 -0
  18. package/src/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.ts +135 -0
  19. package/src/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.ts +84 -0
  20. package/src/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.ts +99 -0
  21. package/src/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.ts +119 -0
  22. package/src/Analysis/Passes/Decision/DefaultDecision.ts +139 -0
  23. package/src/Analysis/Passes/Decision/__tests__/DefaultDecision.test.ts +179 -0
  24. package/src/Analysis/Passes/Transform/CollapseGradientTransform.ts +176 -0
  25. package/src/Analysis/Passes/Transform/MergeColorsTransform.ts +126 -0
  26. package/src/Analysis/Passes/Transform/RainbowCollapseTransform.ts +83 -0
  27. package/src/Analysis/Passes/Transform/WaveCollapseTransform.ts +88 -0
  28. package/src/Analysis/Passes/Utility/CharacterCountAnalyzer.ts +45 -0
  29. package/src/Analysis/Pipeline/Pipeline.ts +133 -0
  30. package/src/Analysis/Pipeline/PipelineBuilder.ts +55 -0
  31. package/src/Analysis/Pipeline/PipelineStage.ts +19 -0
  32. package/src/Analysis/Utils/color-utils.ts +132 -0
  33. package/src/Analysis/__tests__/Integration.test.ts +162 -0
  34. package/src/Analysis/__tests__/Pipeline.test.ts +133 -0
  35. package/src/Analysis/index.ts +52 -0
  36. package/src/BBCode/BBCodeDocumentModel.ts +175 -0
  37. package/src/BBCode/BBCodeToGreenNode.ts +755 -0
  38. package/src/BBCode/Parser.ts +384 -0
  39. package/src/BBCode/index.ts +12 -0
  40. package/src/Collab/positions.ts +91 -0
  41. package/src/Commands/Command.ts +44 -0
  42. package/src/Commands/CommandRegistry.ts +78 -0
  43. package/src/Commands/DeleteNode.ts +20 -0
  44. package/src/Commands/InsertText.ts +21 -0
  45. package/src/Commands/SplitMerge.ts +28 -0
  46. package/src/Commands/WrapInTag.ts +21 -0
  47. package/src/Commands/index.ts +6 -0
  48. package/src/Diff/TreeDiffer.ts +264 -0
  49. package/src/Diff/__tests__/TreeDiffer.test.ts +65 -0
  50. package/src/Diff/index.ts +2 -0
  51. package/src/Events/EventBus.ts +160 -0
  52. package/src/Events/index.ts +2 -0
  53. package/src/Formatter/Formatter.ts +54 -0
  54. package/src/Formatter/index.ts +2 -0
  55. package/src/HTML/HTMLDocumentModel.ts +35 -0
  56. package/src/HTML/HTMLToGreenNode.ts +290 -0
  57. package/src/Incremental/ChangeTracker.ts +105 -0
  58. package/src/Incremental/IncrementalParser.ts +591 -0
  59. package/src/Incremental/__tests__/IncrementalParser.test.ts +164 -0
  60. package/src/Incremental/index.ts +4 -0
  61. package/src/Lexer/BBCodeLexer.ts +382 -0
  62. package/src/Lexer/Lexer.ts +181 -0
  63. package/src/Lexer/index.ts +10 -0
  64. package/src/Linter/Linter.ts +193 -0
  65. package/src/Linter/index.ts +2 -0
  66. package/src/Markdown/MarkdownAST.ts +112 -0
  67. package/src/Markdown/MarkdownDocumentModel.ts +55 -0
  68. package/src/Markdown/MarkdownLexer.ts +203 -0
  69. package/src/Markdown/MarkdownParser.ts +455 -0
  70. package/src/Markdown/MarkdownToGreenNode.ts +153 -0
  71. package/src/Model/DocumentModel.ts +694 -0
  72. package/src/Model/NodeFactory.ts +117 -0
  73. package/src/Model/TagRegistry.ts +495 -0
  74. package/src/Model/index.ts +5 -0
  75. package/src/Plugins/PluginAPI.ts +119 -0
  76. package/src/Plugins/PluginRegistry.ts +132 -0
  77. package/src/Plugins/index.ts +3 -0
  78. package/src/Queries/QueryEngine.ts +152 -0
  79. package/src/Queries/index.ts +1 -0
  80. package/src/RenderPipeline/RenderPipeline.ts +125 -0
  81. package/src/RenderPipeline/RenderTree.ts +134 -0
  82. package/src/RenderPipeline/index.ts +4 -0
  83. package/src/Semantic/SemanticAnalyzer.ts +506 -0
  84. package/src/Semantic/index.ts +2 -0
  85. package/src/Symbols/SymbolTable.ts +124 -0
  86. package/src/Symbols/index.ts +1 -0
  87. package/src/Syntax/GreenNode.ts +324 -0
  88. package/src/Syntax/GreenNodePool.ts +269 -0
  89. package/src/Syntax/NodeMatcher.ts +370 -0
  90. package/src/Syntax/RedNode.ts +569 -0
  91. package/src/Syntax/RedNodeStore.ts +184 -0
  92. package/src/Syntax/TreeBuilder.ts +214 -0
  93. package/src/Syntax/__tests__/GreenNode.test.ts +33 -0
  94. package/src/Syntax/__tests__/RedNode.test.ts +81 -0
  95. package/src/Syntax/__tests__/RedNodeStore.test.ts +104 -0
  96. package/src/Syntax/greenEdit.ts +110 -0
  97. package/src/Syntax/hash.ts +30 -0
  98. package/src/Syntax/index.ts +12 -0
  99. package/src/Syntax/partition.ts +161 -0
  100. package/src/Syntax/preserveNodeIds.ts +201 -0
  101. package/src/Tests/ASTOptimizerIdempotence.test.ts +77 -0
  102. package/src/Tests/BlockPatcher.test.ts +437 -0
  103. package/src/Tests/BlockPatcherWindowed.test.ts +364 -0
  104. package/src/Tests/BoxDrawer.test.ts +217 -0
  105. package/src/Tests/BoxRichTitle.test.ts +105 -0
  106. package/src/Tests/Chars500kBenchmark.test.ts +151 -0
  107. package/src/Tests/Chars500kEdits.test.ts +321 -0
  108. package/src/Tests/CollabPositions.test.ts +146 -0
  109. package/src/Tests/CompilerPathProfiling.test.ts +186 -0
  110. package/src/Tests/DOMMorpher.test.ts +142 -0
  111. package/src/Tests/DomPatchPerf.test.ts +60 -0
  112. package/src/Tests/EffectSegments.snapshot.json +616 -0
  113. package/src/Tests/EffectSegments.test.ts +68 -0
  114. package/src/Tests/FindNodeAtOffset.test.ts +65 -0
  115. package/src/Tests/Fuzzer.test.ts +166 -0
  116. package/src/Tests/GreenNodePool.test.ts +153 -0
  117. package/src/Tests/Lexer.test.ts +238 -0
  118. package/src/Tests/LyneMode.test.ts +187 -0
  119. package/src/Tests/ModelCoherence.test.ts +180 -0
  120. package/src/Tests/Partition.test.ts +238 -0
  121. package/src/Tests/PluginTags.test.ts +150 -0
  122. package/src/Tests/ProblematicSection.test.ts +46 -0
  123. package/src/Tests/ProblematicSectionHTML.test.ts +58 -0
  124. package/src/Tests/RedReuse.test.ts +134 -0
  125. package/src/Tests/ReproDelete20k.test.ts +62 -0
  126. package/src/Tests/SemanticValidators.test.ts +136 -0
  127. package/src/Tests/StableNodeIds.test.ts +210 -0
  128. package/src/Tests/StudioColorBloat.test.ts +25 -0
  129. package/src/Tests/StudioDebugText.test.ts +27 -0
  130. package/src/Tests/StudioTrailingChar.test.ts +25 -0
  131. package/src/Tests/StudioValidText.test.ts +25 -0
  132. package/src/Tests/UrlImgBug.test.ts +23 -0
  133. package/src/Tests/VisualBuilderFidelity.test.ts +105 -0
  134. package/src/Tests/referenceDocument.ts +119 -0
  135. package/src/Transactions/Transaction.ts +176 -0
  136. package/src/Transactions/UndoManager.ts +111 -0
  137. package/src/Transactions/index.ts +3 -0
  138. package/src/Transformers/ASTOptimizer.ts +315 -0
  139. package/src/Transformers/GradientTransformer.ts +143 -0
  140. package/src/Transformers/GrowTransformer.ts +115 -0
  141. package/src/Transformers/RainbowTransformer.ts +121 -0
  142. package/src/Transformers/SineWaveTransformer.ts +130 -0
  143. package/src/Transformers/Transformer.ts +22 -0
  144. package/src/Types/core.ts +270 -0
  145. package/src/Types/diagnostics.ts +156 -0
  146. package/src/Types/index.ts +23 -0
  147. package/src/Types/operations.ts +180 -0
  148. package/src/Types/queries.ts +121 -0
  149. package/src/Types/symbols.ts +69 -0
  150. package/src/Types/tokens.ts +186 -0
  151. package/src/Utils/BBCodeGenerator.ts +126 -0
  152. package/src/Utils/ColorMath.ts +276 -0
  153. package/src/Utils/color.ts +112 -0
  154. package/src/Utils/dom-to-svg.test.ts +86 -0
  155. package/src/Utils/dom-to-svg.ts +615 -0
  156. package/src/Utils/treeTransformers.ts +717 -0
  157. package/src/Visitors/BBBlocksExporter.ts +69 -0
  158. package/src/Visitors/BBCodeExporter.ts +318 -0
  159. package/src/Visitors/BlockPatcher.ts +963 -0
  160. package/src/Visitors/DOMMorpher.ts +134 -0
  161. package/src/Visitors/HTMLRenderer.ts +1077 -0
  162. package/src/Visitors/JSONExporter.ts +66 -0
  163. package/src/Visitors/MarkdownExporter.ts +99 -0
  164. package/src/Visitors/SVGRenderer.ts +35 -0
  165. package/src/Visitors/TiptapExporter.ts +145 -0
  166. package/src/Visitors/Visitor.ts +48 -0
  167. package/src/Visitors/index.ts +9 -0
  168. package/src/Visuals/BoxDrawer.ts +175 -0
  169. package/src/Visuals/index.ts +42 -0
  170. package/src/Visuals/lyne.css +1027 -0
  171. package/src/Visuals/osu.css +257 -0
  172. package/src/index.ts +197 -0
@@ -0,0 +1,150 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { BBCodeDocumentModel } from '../BBCode/BBCodeDocumentModel'
3
+ import { PluginAPI } from '../Plugins/PluginAPI'
4
+ import { HTMLRenderer } from '../Visitors/HTMLRenderer'
5
+ import { BBCodeExporter } from '../Visitors/BBCodeExporter'
6
+ import type { RedNode } from '../Syntax/RedNode'
7
+ import type { NodeKind } from '../Types/core'
8
+ import type { TagDefinition } from '../Model/TagRegistry'
9
+
10
+ /**
11
+ * A plugin tag must live the FULL life of a tag: lex → parse → tree → render
12
+ * → export → re-parse. Before `ParseOptions.extraTags`, a registered tag
13
+ * could render and export but never parse — the parser turned it into
14
+ * literal text, so the rest of the pipeline had nothing to work with. The
15
+ * platform's plugin promise starts at the parser or it doesn't start.
16
+ */
17
+
18
+ function findKind(root: RedNode, kind: string): RedNode | null {
19
+ let found: RedNode | null = null
20
+ root.walk(n => {
21
+ if (!found && n.kind === kind) found = n
22
+ })
23
+ return found
24
+ }
25
+
26
+ const blurTag: TagDefinition = {
27
+ name: 'blur',
28
+ kind: 'blur' as NodeKind,
29
+ label: 'Blur',
30
+ category: 'text',
31
+ isInline: true,
32
+ isSelfClosing: false,
33
+ canHaveChildren: true,
34
+ toRenderNode: (ctx) => ({
35
+ kind: 'blur',
36
+ text: '',
37
+ children: [{ kind: 'text', text: `⟦blur⟧${ctx.visitChildren(ctx.node)}⟦/blur⟧`, children: [], props: {} }],
38
+ props: {},
39
+ }),
40
+ }
41
+
42
+ const wiggleTag: TagDefinition = {
43
+ name: 'wiggle',
44
+ kind: 'wiggle' as NodeKind,
45
+ label: 'Wiggle',
46
+ isInline: true,
47
+ isSelfClosing: false,
48
+ canHaveChildren: true,
49
+ }
50
+
51
+ describe('plugin tags — full pipeline', () => {
52
+ it('parses a registered tag as a real container, with attrs and nesting', () => {
53
+ const model = new BBCodeDocumentModel({ source: '' })
54
+ model.tagRegistry.register(blurTag)
55
+ model.tagRegistry.register(wiggleTag)
56
+
57
+ model.rebuild('hola [blur]mundo [b]fuerte[/b][/blur] y [wiggle=3]algo[/wiggle]')
58
+
59
+ const blur = findKind(model.redRoot!, 'blur')!
60
+ expect(blur).toBeTruthy()
61
+ expect(findKind(blur, 'bold')).toBeTruthy() // nesting inside the plugin tag
62
+ const wiggle = findKind(model.redRoot!, 'wiggle')!
63
+ expect(wiggle.text).toBe('=3') // attrs preserved like any builtin
64
+
65
+ // The tree still partitions the source: the plugin tag owns its delimiters.
66
+ expect(blur.range.end - blur.range.start).toBe('[blur]mundo [b]fuerte[/b][/blur]'.length)
67
+ })
68
+
69
+ it('unregistered tags still fall to literal text — prose stays visible', () => {
70
+ const model = new BBCodeDocumentModel({ source: 'combo de [90 misses] con [Gateron Yellow]' })
71
+ expect(findKind(model.redRoot!, 'custom')).toBeNull()
72
+ const html = new HTMLRenderer().render(model.redRoot!)
73
+ expect(html).toContain('[90 misses]')
74
+ expect(html).toContain('[Gateron Yellow]')
75
+ })
76
+
77
+ it('renders through the registry handler, and safely without it', () => {
78
+ const model = new BBCodeDocumentModel({ source: '' })
79
+ model.tagRegistry.register(blurTag)
80
+ model.rebuild('[blur]contenido[/blur]')
81
+
82
+ const withRegistry = new HTMLRenderer({ registry: model.tagRegistry }).render(model.redRoot!)
83
+ expect(withRegistry).toContain('⟦blur⟧')
84
+ expect(withRegistry).toContain('contenido')
85
+
86
+ // Without the registry the wrapper is unknown, but content must survive.
87
+ const bare = new HTMLRenderer().render(model.redRoot!)
88
+ expect(bare).toContain('contenido')
89
+ })
90
+
91
+ it('exports under its registered name and round-trips', () => {
92
+ const model = new BBCodeDocumentModel({ source: '' })
93
+ model.tagRegistry.register(blurTag)
94
+ model.tagRegistry.register(wiggleTag)
95
+ const source = 'hola [blur]mundo[/blur] y [wiggle=3]algo[/wiggle]'
96
+ model.rebuild(source)
97
+
98
+ const exporter = new BBCodeExporter(model.tagRegistry)
99
+ const exported = exporter.export(model.redRoot!)
100
+ expect(exported).toBe(source)
101
+
102
+ // Fixpoint: parsing the export reproduces the same export.
103
+ model.rebuild(exported)
104
+ expect(exporter.export(model.redRoot!)).toBe(exported)
105
+ })
106
+
107
+ it('works through PluginAPI, and unregistering returns the tag to prose', () => {
108
+ const model = new BBCodeDocumentModel({ source: '' })
109
+ const api = new PluginAPI(model)
110
+ api.registerPlugin(
111
+ { name: 'blur-plugin', version: '1.0.0' },
112
+ { tags: [blurTag] },
113
+ )
114
+
115
+ model.rebuild('[blur]x[/blur]')
116
+ expect(findKind(model.redRoot!, 'blur')).toBeTruthy()
117
+
118
+ api.unregisterPlugin('blur-plugin')
119
+ model.rebuild('[blur]x[/blur]')
120
+ expect(findKind(model.redRoot!, 'blur')).toBeNull()
121
+ // Back to what unknown tags always were: visible text.
122
+ expect(new HTMLRenderer().render(model.redRoot!)).toContain('[blur]')
123
+ })
124
+
125
+ it('incremental editing with plugin tags matches a full rebuild', () => {
126
+ let src = ''
127
+ for (let i = 0; i < 60; i++) src += `parrafo ${i} con [blur]nucleo ${i}[/blur] normal\n\n`
128
+
129
+ const reuse = new BBCodeDocumentModel({ source: '' })
130
+ const control = new BBCodeDocumentModel({ source: '', incremental: false })
131
+ reuse.tagRegistry.register(blurTag)
132
+ control.tagRegistry.register(blurTag)
133
+ reuse.rebuild(src)
134
+ control.rebuild(src)
135
+
136
+ const renderer = new HTMLRenderer()
137
+ for (let edit = 0; edit < 30; edit++) {
138
+ const at = (edit * 137) % (reuse.source.length - 10)
139
+ const next = reuse.source.slice(0, at) + 'x' + reuse.source.slice(at)
140
+ reuse.applyTextUpdate(next)
141
+ control.applyTextUpdate(next)
142
+
143
+ // Sin los ids: son identidades por modelo, y aquí hay dos modelos
144
+ // independientes. Lo que debe coincidir es la estructura.
145
+ const sinIds = (html: string) => html.replace(/ data-node-id="[^"]*"/g, '')
146
+ expect(sinIds(renderer.render(reuse.redRoot!))).toBe(sinIds(renderer.render(control.redRoot!)))
147
+ expect(reuse.redRoot!.green).toBe(reuse.greenRoot)
148
+ }
149
+ })
150
+ })
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { TagRegistry } from '../Model/TagRegistry'
3
+ import { BBCodeExporter } from '../Visitors/BBCodeExporter'
4
+ import { processStudioAST } from '@miliastry/quasar-studio'
5
+ import { REFERENCE_DOCUMENT, REFERENCE_GRADIENT_LAYER } from './referenceDocument'
6
+
7
+ /**
8
+ * Round-tripping a document through the Studio must not invent or drop layout.
9
+ *
10
+ * The name of this suite always promised that, but the body only rendered the
11
+ * document and `console.log`ged the result — `expect` was imported and never
12
+ * called, so it could not fail. It also read an untracked `.milia` fixture,
13
+ * which is what finally broke it. Both are fixed here: the invariant its name
14
+ * describes is now actually asserted, against `referenceDocument.ts`.
15
+ *
16
+ * Braille blanks (`⠀`, U+2800) are load-bearing in these posts — authors use
17
+ * them as padding because osu! collapses ordinary whitespace. Gaining or losing
18
+ * one silently reflows somebody's profile.
19
+ */
20
+ describe('Problematic Section Export', () => {
21
+ const exportWithLayers = (source: string, layers: unknown[]) => {
22
+ const registry = new TagRegistry()
23
+ const { redRoot } = processStudioAST(source, layers as never, {})
24
+ return new BBCodeExporter(registry).export(redRoot)
25
+ }
26
+
27
+ const brailleCount = (s: string) => (s.match(/⠀/g) || []).length
28
+
29
+ it('preserves every braille blank through a plain round trip', () => {
30
+ const exported = exportWithLayers(REFERENCE_DOCUMENT, [])
31
+ expect(brailleCount(exported)).toBe(brailleCount(REFERENCE_DOCUMENT))
32
+ })
33
+
34
+ it('preserves every braille blank through a gradient layer', () => {
35
+ // A gradient splits text into one node per character; the padding must come
36
+ // out the far side with the same count it went in with.
37
+ const exported = exportWithLayers(REFERENCE_DOCUMENT, [REFERENCE_GRADIENT_LAYER])
38
+ expect(brailleCount(exported)).toBe(brailleCount(REFERENCE_DOCUMENT))
39
+ })
40
+
41
+ it('does not add a trailing blank line', () => {
42
+ const exported = exportWithLayers(REFERENCE_DOCUMENT, [])
43
+ const trailing = (s: string) => s.length - s.replace(/\n+$/, '').length
44
+ expect(trailing(exported)).toBe(trailing(REFERENCE_DOCUMENT))
45
+ })
46
+ })
@@ -0,0 +1,58 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { TagRegistry } from '../Model/TagRegistry'
3
+ import { HTMLRenderer } from '../Visitors/HTMLRenderer'
4
+ import { processStudioAST } from '@miliastry/quasar-studio'
5
+ import { REFERENCE_DOCUMENT, REFERENCE_GRADIENT_LAYER } from './referenceDocument'
6
+
7
+ /**
8
+ * A substantial document rendered with and without a Studio gradient layer.
9
+ *
10
+ * This used to assert nothing at all: it rendered both variants and wrote
11
+ * them to `orig.html` and `grad.html` in the repo root — tracked files, so
12
+ * every test run dirtied the working tree, and no failure was possible. What
13
+ * it should have been checking is below.
14
+ */
15
+ describe('Problematic Section HTML Output', () => {
16
+ // Was an untracked `.milia` read from disk; see `referenceDocument.ts`.
17
+ const text = REFERENCE_DOCUMENT
18
+ const layers = [REFERENCE_GRADIENT_LAYER]
19
+
20
+ const render = (studioLayers: unknown[]) => {
21
+ const registry = new TagRegistry()
22
+ const renderer = new HTMLRenderer({ registry })
23
+ const { redRoot } = processStudioAST(text, studioLayers as never, {})
24
+ return renderer.render(redRoot)
25
+ }
26
+
27
+ it('renders the document without a gradient layer', () => {
28
+ const html = render([])
29
+
30
+ expect(html.length).toBeGreaterThan(1000)
31
+ // Structure of the source survives to the output.
32
+ expect(html).toContain('data-node-id')
33
+ // No per-character colouring without a gradient layer.
34
+ expect(html).not.toMatch(/color:\s*#e8b04b/i)
35
+ })
36
+
37
+ it('a gradient layer colours the text, and only the text', () => {
38
+ const plain = render([])
39
+ const gradient = render(layers)
40
+
41
+ expect(gradient).not.toBe(plain)
42
+ // The layer's own stops reach the output as inline colours.
43
+ expect(gradient).toMatch(/color:\s*#e8b04b/i)
44
+ // Colorear no puede cambiar el TEXTO, que es la invariante de verdad
45
+ // (contar `data-node-id` no servía: el degradado parte el texto en un
46
+ // nodo por carácter, así que el número cambia legítimamente).
47
+ const texto = (s: string) => s.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim()
48
+ expect(texto(gradient)).toBe(texto(plain))
49
+ })
50
+
51
+ it('rendering is deterministic', () => {
52
+ // Node ids come from a monotonic per-process counter, so two parses of
53
+ // the same text legitimately number their nodes differently. Identity is
54
+ // not content: strip it and the output must be identical.
55
+ const withoutIds = (html: string) => html.replace(/ data-node-id="[^"]*"/g, '')
56
+ expect(withoutIds(render(layers))).toBe(withoutIds(render(layers)))
57
+ })
58
+ })
@@ -0,0 +1,134 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { BBCodeDocumentModel } from '../BBCode/BBCodeDocumentModel'
3
+ import { HTMLRenderer } from '../Visitors/HTMLRenderer'
4
+ import type { RedNode } from '../Syntax/RedNode'
5
+
6
+ /**
7
+ * Red-subtree reuse: on the incremental path, subtrees whose green is shared
8
+ * by reference must be ADOPTED (same RedNode objects) instead of rebuilt —
9
+ * building red was the largest phase of a keystroke.
10
+ *
11
+ * The property demanded is the engine's usual one: the resulting tree must be
12
+ * IDENTICAL — kind, text, range, structure, metadata — to what a full rebuild
13
+ * of the same final text produces. Ids are exempt (reuse is what makes them
14
+ * stable; a fresh rebuild has no history), and object identity is the point.
15
+ */
16
+
17
+ function bigDoc(): string {
18
+ // Comfortably above MIN_SOURCE_LENGTH (2500) so the incremental path engages.
19
+ let src = ''
20
+ for (let i = 0; i < 40; i++) {
21
+ src += `parrafo numero ${i} con [b]negrita[/b] y [color=#ff66ab]color[/color]\n\n`
22
+ }
23
+ src += '[notice]interior del notice con [i]cursiva[/i] y texto largo para editar[/notice]\n\n'
24
+ for (let i = 0; i < 20; i++) {
25
+ src += `cola numero ${i} despues del notice\n\n`
26
+ }
27
+ return src
28
+ }
29
+
30
+ /** Walk both trees asserting deep equality of everything but ids. */
31
+ function expectSameTree(a: RedNode, b: RedNode, path = 'root'): void {
32
+ expect(a.kind, `${path}: kind`).toBe(b.kind)
33
+ expect(a.text, `${path}: text`).toBe(b.text)
34
+ expect(a.range.start, `${path}: range.start`).toBe(b.range.start)
35
+ expect(a.range.end, `${path}: range.end`).toBe(b.range.end)
36
+ expect(JSON.stringify(a.metadata), `${path}: metadata`).toBe(JSON.stringify(b.metadata))
37
+ expect(a.children.length, `${path}: childCount`).toBe(b.children.length)
38
+ for (let i = 0; i < a.children.length; i++) {
39
+ expectSameTree(a.children[i], b.children[i], `${path}.${a.children[i].kind}[${i}]`)
40
+ }
41
+ }
42
+
43
+ function mulberry32(seed: number): () => number {
44
+ let a = seed >>> 0
45
+ return () => {
46
+ a |= 0; a = (a + 0x6D2B79F5) | 0
47
+ let t = Math.imul(a ^ (a >>> 15), 1 | a)
48
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
49
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
50
+ }
51
+ }
52
+
53
+ describe('red-subtree reuse', () => {
54
+ it('adopts untouched subtrees as the same objects, with correct shifted ranges', () => {
55
+ const model = new BBCodeDocumentModel({ source: bigDoc() })
56
+ const root = model.redRoot!
57
+ const noticeIdx = root.children.findIndex(c => c.kind === 'notice')
58
+ expect(noticeIdx).toBeGreaterThan(0)
59
+
60
+ const beforeBlock = root.children[noticeIdx - 2] // a paragraph before the notice
61
+ const afterBlock = root.children[root.children.length - 2]
62
+ const afterStart = afterBlock.range.start
63
+
64
+ // Type one char inside the notice.
65
+ const src = model.source
66
+ const at = src.indexOf('texto largo') + 5
67
+ model.applyTextUpdate(src.slice(0, at) + 'X' + src.slice(at))
68
+
69
+ const newRoot = model.redRoot!
70
+ expect(newRoot).not.toBe(root)
71
+
72
+ // Prefix subtree: same object, same position.
73
+ expect(newRoot.children[noticeIdx - 2]).toBe(beforeBlock)
74
+ // Suffix subtree: same object, range shifted by the inserted char.
75
+ const afterNow = newRoot.children[newRoot.children.length - 2]
76
+ expect(afterNow).toBe(afterBlock)
77
+ expect(afterNow.range.start).toBe(afterStart + 1)
78
+ // Adopted nodes are correctly parented into the new tree.
79
+ expect(afterNow.parent).toBe(newRoot)
80
+ expect(afterNow.index).toBe(newRoot.children.length - 2)
81
+ })
82
+
83
+ it('reuseRed: false keeps the old behavior (no shared objects)', () => {
84
+ const model = new BBCodeDocumentModel({ source: bigDoc(), reuseRed: false })
85
+ const root = model.redRoot!
86
+ const firstBlock = root.children[0]
87
+
88
+ const src = model.source
89
+ const at = src.indexOf('texto largo') + 5
90
+ model.applyTextUpdate(src.slice(0, at) + 'X' + src.slice(at))
91
+
92
+ expect(model.redRoot!.children[0]).not.toBe(firstBlock)
93
+ })
94
+
95
+ it('differential: reuse tree is identical to a full rebuild, over seeded edits', () => {
96
+ const renderer = new HTMLRenderer()
97
+ const rand = mulberry32(777)
98
+ const insertables = ['a', 'X', ' ', '\n', '[', ']', '[b]', '[/b]', '[i]', '[*]', '[notice]', '[/notice]']
99
+
100
+ const reuse = new BBCodeDocumentModel({ source: bigDoc() })
101
+ const control = new BBCodeDocumentModel({ source: bigDoc(), incremental: false })
102
+
103
+ for (let edit = 0; edit < 120; edit++) {
104
+ const src = reuse.source
105
+ const pos = Math.floor(rand() * (src.length + 1))
106
+ const next = rand() < 0.7
107
+ ? src.slice(0, pos) + insertables[Math.floor(rand() * insertables.length)] + src.slice(pos)
108
+ : src.slice(0, pos) + src.slice(Math.min(pos + 1 + Math.floor(rand() * 3), src.length))
109
+
110
+ reuse.applyTextUpdate(next)
111
+ control.applyTextUpdate(next)
112
+
113
+ expectSameTree(reuse.redRoot!, control.redRoot!)
114
+ expect(renderer.render(reuse.redRoot!).replace(/ data-node-id="[^"]*"/g, ''))
115
+ .toBe(renderer.render(control.redRoot!).replace(/ data-node-id="[^"]*"/g, ''))
116
+ expect(reuse.redRoot!.green).toBe(reuse.greenRoot)
117
+ }
118
+ })
119
+
120
+ it('ids stay unique after adoption plus fresh nodes', () => {
121
+ const model = new BBCodeDocumentModel({ source: bigDoc() })
122
+ const rand = mulberry32(4242)
123
+
124
+ for (let edit = 0; edit < 40; edit++) {
125
+ const src = model.source
126
+ const pos = Math.floor(rand() * (src.length + 1))
127
+ model.applyTextUpdate(src.slice(0, pos) + 'y' + src.slice(pos))
128
+
129
+ const ids: string[] = []
130
+ model.redRoot!.walk(n => { ids.push(String(n.id)) })
131
+ expect(new Set(ids).size).toBe(ids.length)
132
+ }
133
+ })
134
+ })
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Debug — dump the windowed reconcile decisions for the minimal delete-20k
3
+ * divergence: change range, old/new window keys, orphan decisions, walk.
4
+ */
5
+
6
+ import { describe, it } from 'vitest'
7
+ import { readFileSync, existsSync } from 'node:fs'
8
+ import { join } from 'node:path'
9
+ import { BBCodeDocumentModel } from '../BBCode/BBCodeDocumentModel'
10
+ import { HTMLRenderer } from '../Visitors/HTMLRenderer'
11
+ import { patchBlocksInto } from '../Visitors/BlockPatcher'
12
+
13
+ const renderer = new HTMLRenderer()
14
+
15
+ function loadFixture(): string {
16
+ const candidates = [
17
+ join(process.cwd(), 'packages', 'quasar', '500KCharsTest'),
18
+ join(process.cwd(), '500KCharsTest'),
19
+ join(__dirname, '..', '..', '500KCharsTest'),
20
+ ]
21
+ const p = candidates.find(c => existsSync(c))
22
+ if (!p) throw new Error(`Fixture not found in: ${candidates.join(', ')}`)
23
+ return readFileSync(p, 'utf-8')
24
+ }
25
+
26
+ const insertAt = (s: string, pos: number, text: string): string => s.slice(0, pos) + text + s.slice(pos)
27
+ function afterBlank(s: string, pos: number): number {
28
+ const i = s.indexOf('\n\n', pos)
29
+ return i === -1 ? s.length : i + 2
30
+ }
31
+
32
+ describe('windowed debug: minimal delete-20k', () => {
33
+ it('dumps orphan + walk decisions', () => {
34
+ ;(globalThis as { __BP_DEBUG__?: boolean }).__BP_DEBUG__ = true
35
+ let src = loadFixture()
36
+ let model = new BBCodeDocumentModel({ source: src })
37
+ const el = document.createElement('div')
38
+ patchBlocksInto(el, model.redRoot!, { renderer, minWindowedBlocks: 0 })
39
+
40
+ // Step 1: insert box at 50%
41
+ src = insertAt(src, afterBlank(src, Math.floor(src.length * 0.5)), '[box]caja nueva[/box]\n\n')
42
+ model.applyTextUpdate(src)
43
+ model.ensureAnalyzed()
44
+ patchBlocksInto(el, model.redRoot!, { renderer, minWindowedBlocks: 0 })
45
+
46
+ // Step 2: insert quote at 60%
47
+ src = insertAt(src, afterBlank(src, Math.floor(src.length * 0.6)), '[quote]cita nueva[/quote]\n\n')
48
+ model.applyTextUpdate(src)
49
+ model.ensureAnalyzed()
50
+ patchBlocksInto(el, model.redRoot!, { renderer, minWindowedBlocks: 0 })
51
+
52
+ // Step 3: delete 20k at 50% — the diverging edit
53
+ console.log('[dbg] --- STEP 3: delete 20k at 50% ---')
54
+ const pos = Math.floor(src.length * 0.5)
55
+ const next = src.slice(0, pos) + src.slice(pos + 20_000)
56
+ model.applyTextUpdate(next)
57
+ model.ensureAnalyzed()
58
+ console.log('[dbg] change source pos', pos)
59
+ patchBlocksInto(el, model.redRoot!, { renderer, minWindowedBlocks: 0 })
60
+ ;(globalThis as { __BP_DEBUG__?: boolean }).__BP_DEBUG__ = false
61
+ }, 300_000)
62
+ })
@@ -0,0 +1,136 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { REFERENCE_DOCUMENT_WITH_GRADIENT } from './referenceDocument'
3
+ import { BBCodeDocumentModel } from '../BBCode/BBCodeDocumentModel'
4
+
5
+ /**
6
+ * Regression: the built-in validators must actually fire.
7
+ *
8
+ * Three of the four were unreachable, so `analyze()` returned zero diagnostics
9
+ * on *any* document and `ErrorCheckerWindow` — a whole window whose job is
10
+ * listing problems — was permanently empty. None of them set `range` either, so
11
+ * even a firing diagnostic could not be clicked through to its position.
12
+ *
13
+ * unknown-tag required kind 'custom', but the parser turns unknown tags
14
+ * into `text` nodes before the analyzer sees them
15
+ * deprecated-tag looked up `deprecated[node.text]`, but `text` holds the
16
+ * tag's attributes, not its name
17
+ * nested-structure required non-text children of [code], but the parser
18
+ * keeps code content as raw text, so there never are any
19
+ *
20
+ * `unknown-tag` is still unreachable by design — see the note at the bottom.
21
+ */
22
+ function diagnose(source: string) {
23
+ const model = new BBCodeDocumentModel({ source })
24
+ const result = model.analyze()
25
+ return result.diagnostics.items
26
+ }
27
+
28
+ function codes(source: string): string[] {
29
+ return diagnose(source).map(d => d.code)
30
+ }
31
+
32
+ describe('SemanticAnalyzer — built-in validators', () => {
33
+ it('flags a deprecated tag exactly once, with its range', () => {
34
+ const source = '[strike]x[/strike]'
35
+ const found = diagnose(source)
36
+
37
+ expect(found.map(d => d.code)).toEqual(['deprecated-tag'])
38
+ // Several nodes share offset 0 here (document, paragraph, the tag itself),
39
+ // so an offset-only check reported this three times.
40
+ expect(found[0].range).toEqual({ start: 0, end: source.length })
41
+ expect(found[0].message).toContain('[s]')
42
+ })
43
+
44
+ it('does not flag the modern spelling', () => {
45
+ expect(codes('[s]x[/s]')).toEqual([])
46
+ })
47
+
48
+ it('flags BBCode tags inside a code block', () => {
49
+ expect(codes('[code][b]x[/b][/code]')).toEqual(['nested-tags-in-code', 'nested-tags-in-code'])
50
+ })
51
+
52
+ it('does not flag a code block without tags', () => {
53
+ expect(codes('[code]const a = 1[/code]')).toEqual([])
54
+ })
55
+
56
+ it('flags an empty tag', () => {
57
+ const found = diagnose('[b][/b]')
58
+ expect(found.map(d => d.code)).toEqual(['empty-tag'])
59
+ expect(found[0].range).not.toBeNull()
60
+ })
61
+
62
+ it('every diagnostic carries a usable range', () => {
63
+ // ErrorCheckerWindow guards on `if (err.range)` to jump to the problem, so
64
+ // a diagnostic without one is invisible in practice.
65
+ for (const source of ['[strike]x[/strike]', '[code][b]x[/b][/code]', '[b][/b]']) {
66
+ for (const d of diagnose(source)) {
67
+ expect(d.range).not.toBeNull()
68
+ expect(d.range!.start).toBeGreaterThanOrEqual(0)
69
+ expect(d.range!.end).toBeGreaterThan(d.range!.start)
70
+ expect(d.range!.end).toBeLessThanOrEqual(source.length)
71
+ }
72
+ }
73
+ })
74
+
75
+ describe('unclosed tags', () => {
76
+ // The one BBCode mistake people actually make. The legacy parser closes
77
+ // these silently by design, so without this nothing tells the author.
78
+ it('flags a tag never closed', () => {
79
+ expect(codes('[b]hola')).toEqual(['unclosed-tag'])
80
+ })
81
+
82
+ it('flags each unclosed tag separately', () => {
83
+ expect(codes('[b][i]hola')).toEqual(['unclosed-tag', 'unclosed-tag'])
84
+ })
85
+
86
+ it('flags a tag auto-closed by a mismatched close', () => {
87
+ // [i] is never closed; the legacy rules close it when [/b] arrives.
88
+ expect(codes('[b][i]x[/b]')).toEqual(['unclosed-tag'])
89
+ })
90
+
91
+ it('says nothing about properly closed tags', () => {
92
+ expect(codes('[b]hola[/b]')).toEqual([])
93
+ expect(codes('[b][i]x[/i][/b]')).toEqual([])
94
+ expect(codes('[color=red]x[/color]')).toEqual([])
95
+ expect(codes('[url=https://osu.ppy.sh]x[/url]')).toEqual([])
96
+ })
97
+
98
+ it('is case-insensitive about the closing tag', () => {
99
+ expect(codes('[B]x[/B]')).toEqual([])
100
+ })
101
+
102
+ it('does not flag [*], which has no closing form', () => {
103
+ expect(codes('[list]\n[*]uno\n[*]dos\n[/list]')).toEqual([])
104
+ })
105
+
106
+ it('does not flag brackets in ordinary prose', () => {
107
+ expect(codes('Combo de [90 misses] con teclado [Gateron]')).toEqual([])
108
+ expect(codes('hola[/b]')).toEqual([])
109
+ })
110
+ })
111
+
112
+ it('stays quiet on well-formed documents', () => {
113
+ // False positives are worse than silence: a checker that cries wolf on
114
+ // ordinary text gets ignored.
115
+ expect(codes('[b]hola[/b] [i]mundo[/i]')).toEqual([])
116
+ expect(codes('texto sin ninguna etiqueta')).toEqual([])
117
+ expect(codes('[url=https://osu.ppy.sh]enlace[/url]')).toEqual([])
118
+ })
119
+
120
+ it('produces no noise on the real reference document', () => {
121
+ const found = diagnose(REFERENCE_DOCUMENT_WITH_GRADIENT)
122
+ // This is a real, valid document: anything reported here is a false
123
+ // positive that would bury genuine problems in the checker window.
124
+ expect(found.filter(d => d.severity === 'error')).toEqual([])
125
+ expect(found.filter(d => d.severity === 'warning')).toEqual([])
126
+ })
127
+
128
+ // NOTE: `unknown-tag` is deliberately still dormant. The parser renders
129
+ // unrecognised tags as literal text on purpose — `[Gateron]`, `[90 misses]`
130
+ // are ordinary prose, not typos — so warning on them would be pure noise.
131
+ // Reviving it is a product decision, tracked as roadmap point 15.
132
+ it('does not warn about unknown tags (parser renders them as text)', () => {
133
+ expect(codes('[unknowntag]hola[/unknowntag]')).toEqual([])
134
+ expect(codes('Combo de [90 misses] con teclado [Gateron]')).toEqual([])
135
+ })
136
+ })