@miliastry/quasar 1.0.6 → 1.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.
Files changed (273) hide show
  1. package/dist/Visuals/lyne.css +117 -6
  2. package/dist/Visuals/osu.css +36 -5
  3. package/dist/index.js +9664 -3282
  4. package/dist/index.mjs +9535 -3283
  5. package/dist/types/Analysis/Contracts/AnalysisReport.d.ts +22 -0
  6. package/dist/types/Analysis/Contracts/Contribution.d.ts +68 -0
  7. package/dist/types/Analysis/Contracts/Pass.d.ts +59 -0
  8. package/dist/types/Analysis/Contracts/PipelineContext.d.ts +34 -0
  9. package/dist/types/Analysis/Passes/Analysis/ColorUsageAnalyzer.d.ts +56 -0
  10. package/dist/types/Analysis/Passes/Analysis/GradientAnalyzer.d.ts +111 -0
  11. package/dist/types/Analysis/Passes/Analysis/MergeableColorAnalyzer.d.ts +26 -0
  12. package/dist/types/Analysis/Passes/Analysis/RainbowAnalyzer.d.ts +47 -0
  13. package/dist/types/Analysis/Passes/Analysis/SymbolAnalyzer.d.ts +89 -0
  14. package/dist/types/Analysis/Passes/Analysis/WaveAnalyzer.d.ts +44 -0
  15. package/dist/types/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.d.ts +4 -0
  16. package/dist/types/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.d.ts +4 -0
  17. package/dist/types/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.d.ts +4 -0
  18. package/dist/types/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.d.ts +4 -0
  19. package/dist/types/Analysis/Passes/Decision/DefaultDecision.d.ts +30 -0
  20. package/dist/types/Analysis/Passes/Decision/PaletteRemapDecision.d.ts +169 -0
  21. package/dist/types/Analysis/Passes/Decision/__tests__/DefaultDecision.test.d.ts +4 -0
  22. package/dist/types/Analysis/Passes/Transform/CollapseGradientTransform.d.ts +37 -0
  23. package/dist/types/Analysis/Passes/Transform/MergeColorsTransform.d.ts +28 -0
  24. package/dist/types/Analysis/Passes/Transform/RainbowCollapseTransform.d.ts +19 -0
  25. package/dist/types/Analysis/Passes/Transform/WaveCollapseTransform.d.ts +20 -0
  26. package/dist/types/Analysis/Passes/Utility/CharacterCountAnalyzer.d.ts +19 -0
  27. package/dist/types/Analysis/Pipeline/Pipeline.d.ts +69 -0
  28. package/dist/types/Analysis/Pipeline/PipelineBuilder.d.ts +31 -0
  29. package/dist/types/Analysis/Pipeline/PipelineStage.d.ts +17 -0
  30. package/dist/types/Analysis/Utils/color-utils.d.ts +60 -0
  31. package/dist/types/Analysis/__tests__/Integration.test.d.ts +8 -0
  32. package/dist/types/Analysis/__tests__/PaletteRemap.test.d.ts +14 -0
  33. package/dist/types/Analysis/__tests__/Pipeline.test.d.ts +10 -0
  34. package/dist/types/Analysis/__tests__/RoundTrip.test.d.ts +30 -0
  35. package/dist/types/Analysis/__tests__/SymbolAnalyzer.test.d.ts +11 -0
  36. package/dist/types/Analysis/index.d.ts +40 -0
  37. package/dist/types/BBCode/BBCodeDocumentModel.d.ts +92 -0
  38. package/dist/types/BBCode/BBCodeToGreenNode.d.ts +111 -0
  39. package/dist/types/BBCode/Parser.d.ts +78 -0
  40. package/dist/types/BBCode/index.d.ts +4 -0
  41. package/dist/types/Collab/positions.d.ts +55 -0
  42. package/dist/types/Commands/Command.d.ts +37 -0
  43. package/dist/types/Commands/CommandRegistry.d.ts +48 -0
  44. package/dist/types/Commands/DeleteNode.d.ts +7 -0
  45. package/dist/types/Commands/InsertText.d.ts +8 -0
  46. package/dist/types/Commands/SplitMerge.d.ts +9 -0
  47. package/dist/types/Commands/WrapInTag.d.ts +8 -0
  48. package/dist/types/Commands/index.d.ts +6 -0
  49. package/dist/types/Diff/TreeDiffer.d.ts +52 -0
  50. package/dist/types/Diff/__tests__/TreeDiffer.test.d.ts +1 -0
  51. package/dist/types/Diff/index.d.ts +2 -0
  52. package/dist/types/Edits/EditPlan.d.ts +144 -0
  53. package/dist/types/Edits/Optimizer.d.ts +78 -0
  54. package/dist/types/Edits/Rules/Rule.d.ts +91 -0
  55. package/dist/types/Edits/Rules/dropEmptyTags.d.ts +40 -0
  56. package/dist/types/Edits/Rules/dropRedundantNesting.d.ts +59 -0
  57. package/dist/types/Edits/Rules/index.d.ts +9 -0
  58. package/dist/types/Edits/Rules/mergeAdjacent.d.ts +93 -0
  59. package/dist/types/Edits/Rules/reorderWrappers.d.ts +42 -0
  60. package/dist/types/Edits/Rules/shortenHex.d.ts +30 -0
  61. package/dist/types/Edits/Rules/tagValue.d.ts +56 -0
  62. package/dist/types/Edits/Rules/unwrapInvisibleColor.d.ts +47 -0
  63. package/dist/types/Edits/applyEdits.d.ts +36 -0
  64. package/dist/types/Edits/index.d.ts +6 -0
  65. package/dist/types/Events/EventBus.d.ts +103 -0
  66. package/dist/types/Events/index.d.ts +2 -0
  67. package/dist/types/Formatter/Formatter.d.ts +32 -0
  68. package/dist/types/Formatter/index.d.ts +2 -0
  69. package/dist/types/HTML/HTMLDocumentModel.d.ts +13 -0
  70. package/dist/types/HTML/HTMLToGreenNode.d.ts +4 -0
  71. package/dist/types/Incremental/BracketIndex.d.ts +101 -0
  72. package/dist/types/Incremental/ChangeTracker.d.ts +69 -0
  73. package/dist/types/Incremental/IncrementalParser.d.ts +246 -0
  74. package/dist/types/Incremental/__tests__/BracketIndex.test.d.ts +1 -0
  75. package/dist/types/Incremental/__tests__/IncrementalParser.test.d.ts +1 -0
  76. package/dist/types/Incremental/index.d.ts +4 -0
  77. package/dist/types/Lexer/BBCodeLexer.d.ts +74 -0
  78. package/dist/types/Lexer/Lexer.d.ts +54 -0
  79. package/dist/types/Lexer/index.d.ts +4 -0
  80. package/dist/types/Linter/Linter.d.ts +58 -0
  81. package/dist/types/Linter/index.d.ts +2 -0
  82. package/dist/types/Markdown/MarkdownAST.d.ts +122 -0
  83. package/dist/types/Markdown/MarkdownDocumentModel.d.ts +15 -0
  84. package/dist/types/Markdown/MarkdownLexer.d.ts +155 -0
  85. package/dist/types/Markdown/MarkdownParser.d.ts +42 -0
  86. package/dist/types/Markdown/MarkdownToGreenNode.d.ts +6 -0
  87. package/dist/types/MilHibri/MilHibriDocumentModel.d.ts +21 -0
  88. package/dist/types/Model/DocumentModel.d.ts +318 -0
  89. package/dist/types/Model/NodeFactory.d.ts +29 -0
  90. package/dist/types/Model/TagRegistry.d.ts +165 -0
  91. package/dist/types/Model/index.d.ts +5 -0
  92. package/dist/types/Plugins/PluginAPI.d.ts +44 -0
  93. package/dist/types/Plugins/PluginRegistry.d.ts +81 -0
  94. package/dist/types/Plugins/index.d.ts +3 -0
  95. package/dist/types/Queries/QueryEngine.d.ts +32 -0
  96. package/dist/types/Queries/index.d.ts +1 -0
  97. package/dist/types/Reconciler/SurgicalReconciler.d.ts +23 -0
  98. package/dist/types/RenderPipeline/RenderPipeline.d.ts +46 -0
  99. package/dist/types/RenderPipeline/RenderTree.d.ts +55 -0
  100. package/dist/types/RenderPipeline/index.d.ts +4 -0
  101. package/dist/types/Repair/NestingRepair.d.ts +48 -0
  102. package/dist/types/Repair/index.d.ts +1 -0
  103. package/dist/types/Semantic/SemanticAnalyzer.d.ts +321 -0
  104. package/dist/types/Semantic/index.d.ts +2 -0
  105. package/dist/types/Symbols/SymbolTable.d.ts +47 -0
  106. package/dist/types/Symbols/index.d.ts +1 -0
  107. package/dist/types/Syntax/GreenNode.d.ts +154 -0
  108. package/dist/types/Syntax/GreenNodePool.d.ts +134 -0
  109. package/dist/types/Syntax/NodeMatcher.d.ts +75 -0
  110. package/dist/types/Syntax/RedNode.d.ts +280 -0
  111. package/dist/types/Syntax/RedNodeStore.d.ts +116 -0
  112. package/dist/types/Syntax/TreeBuilder.d.ts +54 -0
  113. package/dist/types/Syntax/__tests__/GreenNode.test.d.ts +1 -0
  114. package/dist/types/Syntax/__tests__/RedNode.test.d.ts +1 -0
  115. package/dist/types/Syntax/__tests__/RedNodeStore.test.d.ts +1 -0
  116. package/dist/types/Syntax/greenEdit.d.ts +64 -0
  117. package/dist/types/Syntax/hash.d.ts +17 -0
  118. package/dist/types/Syntax/index.d.ts +8 -0
  119. package/dist/types/Syntax/nodeAttr.d.ts +63 -0
  120. package/dist/types/Syntax/partition.d.ts +67 -0
  121. package/dist/types/Syntax/preserveNodeIds.d.ts +47 -0
  122. package/dist/types/Tokens/TokenResolver.d.ts +25 -0
  123. package/dist/types/Tokens/index.d.ts +1 -0
  124. package/dist/types/Transactions/Transaction.d.ts +36 -0
  125. package/dist/types/Transactions/UndoManager.d.ts +63 -0
  126. package/dist/types/Transactions/index.d.ts +3 -0
  127. package/dist/types/Transformers/ASTOptimizer.d.ts +34 -0
  128. package/dist/types/Transformers/GradientTransformer.d.ts +18 -0
  129. package/dist/types/Transformers/GrowTransformer.d.ts +16 -0
  130. package/dist/types/Transformers/RainbowTransformer.d.ts +17 -0
  131. package/dist/types/Transformers/SineWaveTransformer.d.ts +17 -0
  132. package/dist/types/Transformers/Transformer.d.ts +17 -0
  133. package/dist/types/Types/core.d.ts +104 -0
  134. package/dist/types/Types/diagnostics.d.ts +85 -0
  135. package/dist/types/Types/index.d.ts +11 -0
  136. package/dist/types/Types/operations.d.ts +109 -0
  137. package/dist/types/Types/queries.d.ts +67 -0
  138. package/dist/types/Types/symbols.d.ts +42 -0
  139. package/dist/types/Types/tokens.d.ts +75 -0
  140. package/dist/types/Utils/BBCodeGenerator.d.ts +21 -0
  141. package/dist/types/Utils/ColorMath.d.ts +92 -0
  142. package/dist/types/Utils/EffectMath.d.ts +643 -0
  143. package/dist/types/Utils/TagVocabulary.d.ts +104 -0
  144. package/dist/types/Utils/TagVocabulary.test.d.ts +1 -0
  145. package/dist/types/Utils/color.d.ts +29 -0
  146. package/dist/types/Utils/dom-to-svg.d.ts +28 -0
  147. package/dist/types/Utils/dom-to-svg.test.d.ts +1 -0
  148. package/dist/types/Utils/treeTransformers.d.ts +160 -0
  149. package/dist/types/Visitors/BBBlocksExporter.d.ts +27 -0
  150. package/dist/types/Visitors/BBCodeExporter.d.ts +90 -0
  151. package/dist/types/Visitors/BlockPatcher.d.ts +91 -0
  152. package/dist/types/Visitors/DOMMorpher.d.ts +13 -0
  153. package/dist/types/Visitors/HTMLRenderer.d.ts +380 -0
  154. package/dist/types/Visitors/JSONExporter.d.ts +42 -0
  155. package/dist/types/Visitors/MarkdownExporter.d.ts +28 -0
  156. package/dist/types/Visitors/SVGRenderer.d.ts +16 -0
  157. package/dist/types/Visitors/TiptapExporter.d.ts +40 -0
  158. package/dist/types/Visitors/Visitor.d.ts +35 -0
  159. package/dist/types/Visitors/index.d.ts +9 -0
  160. package/dist/types/Visuals/BoxDrawer.d.ts +49 -0
  161. package/dist/types/Visuals/index.d.ts +38 -0
  162. package/dist/types/index.d.ts +154 -0
  163. package/package.json +14 -10
  164. package/src/Analysis/Passes/Analysis/ColorUsageAnalyzer.ts +103 -0
  165. package/src/Analysis/Passes/Analysis/GradientAnalyzer.ts +294 -20
  166. package/src/Analysis/Passes/Analysis/SymbolAnalyzer.ts +257 -0
  167. package/src/Analysis/Passes/Decision/PaletteRemapDecision.ts +425 -0
  168. package/src/Analysis/Pipeline/Pipeline.ts +15 -2
  169. package/src/Analysis/index.ts +8 -2
  170. package/src/BBCode/BBCodeDocumentModel.ts +3 -0
  171. package/src/BBCode/BBCodeToGreenNode.ts +18 -12
  172. package/src/BBCode/Parser.ts +38 -1
  173. package/src/Edits/EditPlan.ts +290 -0
  174. package/src/Edits/Optimizer.ts +185 -0
  175. package/src/Edits/Rules/Rule.ts +145 -0
  176. package/src/Edits/Rules/dropEmptyTags.ts +110 -0
  177. package/src/Edits/Rules/dropRedundantNesting.ts +120 -0
  178. package/src/Edits/Rules/index.ts +28 -0
  179. package/src/Edits/Rules/mergeAdjacent.ts +224 -0
  180. package/src/Edits/Rules/reorderWrappers.ts +154 -0
  181. package/src/Edits/Rules/shortenHex.ts +79 -0
  182. package/src/Edits/Rules/tagValue.ts +105 -0
  183. package/src/Edits/Rules/unwrapInvisibleColor.ts +93 -0
  184. package/src/Edits/applyEdits.ts +54 -0
  185. package/src/Edits/index.ts +22 -0
  186. package/src/Events/EventBus.ts +18 -0
  187. package/src/HTML/HTMLToGreenNode.ts +185 -37
  188. package/src/Incremental/BracketIndex.ts +249 -0
  189. package/src/Incremental/IncrementalParser.ts +464 -41
  190. package/src/Lexer/BBCodeLexer.ts +25 -3
  191. package/src/Lexer/index.ts +1 -1
  192. package/src/Markdown/MarkdownAST.ts +65 -2
  193. package/src/Markdown/MarkdownLexer.ts +69 -14
  194. package/src/Markdown/MarkdownParser.ts +749 -62
  195. package/src/Markdown/MarkdownToGreenNode.ts +148 -27
  196. package/src/MilHibri/MilHibriDocumentModel.ts +56 -0
  197. package/src/Model/DocumentModel.ts +138 -5
  198. package/src/Model/TagRegistry.ts +129 -134
  199. package/src/Reconciler/SurgicalReconciler.ts +460 -0
  200. package/src/Repair/NestingRepair.ts +116 -0
  201. package/src/Repair/index.ts +1 -0
  202. package/src/Semantic/SemanticAnalyzer.ts +1802 -65
  203. package/src/Syntax/GreenNodePool.ts +12 -12
  204. package/src/Syntax/RedNode.ts +17 -3
  205. package/src/Syntax/RedNodeStore.ts +9 -1
  206. package/src/Syntax/nodeAttr.ts +172 -0
  207. package/src/Tokens/TokenResolver.ts +55 -0
  208. package/src/Tokens/index.ts +1 -0
  209. package/src/Types/core.ts +15 -0
  210. package/src/Utils/ColorMath.ts +276 -55
  211. package/src/Utils/EffectMath.ts +2462 -0
  212. package/src/Utils/TagVocabulary.ts +141 -0
  213. package/src/Utils/dom-to-svg.ts +28 -12
  214. package/src/Visitors/BBBlocksExporter.ts +15 -1
  215. package/src/Visitors/BBCodeExporter.ts +319 -32
  216. package/src/Visitors/BlockPatcher.ts +87 -11
  217. package/src/Visitors/DOMMorpher.ts +15 -5
  218. package/src/Visitors/HTMLRenderer.ts +374 -151
  219. package/src/Visitors/MarkdownExporter.ts +98 -41
  220. package/src/Visitors/TiptapExporter.ts +1 -0
  221. package/src/Visuals/index.ts +7 -4
  222. package/src/Visuals/lyne.css +117 -6
  223. package/src/Visuals/osu.css +36 -5
  224. package/src/index.ts +172 -5
  225. package/dist/index.d.mts +0 -4612
  226. package/dist/index.d.ts +0 -4612
  227. package/src/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.ts +0 -135
  228. package/src/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.ts +0 -84
  229. package/src/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.ts +0 -99
  230. package/src/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.ts +0 -119
  231. package/src/Analysis/Passes/Decision/__tests__/DefaultDecision.test.ts +0 -179
  232. package/src/Analysis/__tests__/Integration.test.ts +0 -162
  233. package/src/Analysis/__tests__/Pipeline.test.ts +0 -133
  234. package/src/Diff/__tests__/TreeDiffer.test.ts +0 -65
  235. package/src/Incremental/__tests__/IncrementalParser.test.ts +0 -164
  236. package/src/Syntax/__tests__/GreenNode.test.ts +0 -33
  237. package/src/Syntax/__tests__/RedNode.test.ts +0 -81
  238. package/src/Syntax/__tests__/RedNodeStore.test.ts +0 -104
  239. package/src/Tests/ASTOptimizerIdempotence.test.ts +0 -77
  240. package/src/Tests/BlockPatcher.test.ts +0 -437
  241. package/src/Tests/BlockPatcherWindowed.test.ts +0 -364
  242. package/src/Tests/BoxDrawer.test.ts +0 -217
  243. package/src/Tests/BoxRichTitle.test.ts +0 -105
  244. package/src/Tests/Chars500kBenchmark.test.ts +0 -151
  245. package/src/Tests/Chars500kEdits.test.ts +0 -321
  246. package/src/Tests/CollabPositions.test.ts +0 -146
  247. package/src/Tests/CompilerPathProfiling.test.ts +0 -186
  248. package/src/Tests/DOMMorpher.test.ts +0 -142
  249. package/src/Tests/DomPatchPerf.test.ts +0 -60
  250. package/src/Tests/EffectSegments.snapshot.json +0 -616
  251. package/src/Tests/EffectSegments.test.ts +0 -68
  252. package/src/Tests/FindNodeAtOffset.test.ts +0 -65
  253. package/src/Tests/Fuzzer.test.ts +0 -166
  254. package/src/Tests/GreenNodePool.test.ts +0 -153
  255. package/src/Tests/Lexer.test.ts +0 -238
  256. package/src/Tests/LyneMode.test.ts +0 -291
  257. package/src/Tests/ModelCoherence.test.ts +0 -180
  258. package/src/Tests/Partition.test.ts +0 -238
  259. package/src/Tests/PluginTags.test.ts +0 -150
  260. package/src/Tests/ProblematicSection.test.ts +0 -46
  261. package/src/Tests/ProblematicSectionHTML.test.ts +0 -58
  262. package/src/Tests/RedReuse.test.ts +0 -134
  263. package/src/Tests/ReproDelete20k.test.ts +0 -62
  264. package/src/Tests/SemanticValidators.test.ts +0 -136
  265. package/src/Tests/StableNodeIds.test.ts +0 -210
  266. package/src/Tests/StudioColorBloat.test.ts +0 -25
  267. package/src/Tests/StudioDebugText.test.ts +0 -27
  268. package/src/Tests/StudioTrailingChar.test.ts +0 -25
  269. package/src/Tests/StudioValidText.test.ts +0 -25
  270. package/src/Tests/UrlImgBug.test.ts +0 -23
  271. package/src/Tests/VisualBuilderFidelity.test.ts +0 -105
  272. package/src/Tests/referenceDocument.ts +0 -119
  273. package/src/Utils/dom-to-svg.test.ts +0 -86
@@ -1,10 +1,35 @@
1
- import { MarkdownToken, MarkdownFenceToken } from './MarkdownLexer';
2
- import { MarkdownNode, MarkdownDocument, MarkdownParagraph, MarkdownText, MarkdownHeading } from './MarkdownAST';
1
+ import { MarkdownToken, MarkdownFenceToken, MarkdownContainerToken, scanMarkdown } from './MarkdownLexer';
2
+ import {
3
+ MarkdownNode,
4
+ MarkdownDocument,
5
+ MarkdownParagraph,
6
+ MarkdownText,
7
+ MarkdownHeading,
8
+ MarkdownStrikethrough,
9
+ MarkdownUnderline,
10
+ MarkdownColor,
11
+ MarkdownFontSize,
12
+ MarkdownFont,
13
+ MarkdownAlign,
14
+ MarkdownBox,
15
+ MarkdownSeparator,
16
+ } from './MarkdownAST';
17
+
18
+ interface BBCodeTagInfo {
19
+ tagName: string;
20
+ attrValue?: string;
21
+ tokenEndIndex: number;
22
+ }
23
+
24
+ const BLOCK_BBCODE_TAGS = new Set([
25
+ 'box', 'boxw', 'spoilerbox', 'centre', 'center', 'right', 'left', 'align', 'quote', 'notice', 'wnotice', 'code',
26
+ 'columns', 'tables', 'row', 'th', 'col', 'glass', 'card', 'neon-box', 'scroll', 'separator', 'list', 'group', 'svg', 'imagemap', 'image',
27
+ ]);
3
28
 
4
29
  /**
5
30
  * DocumentEngine — MarkdownParser
6
31
  *
7
- * Basic implementation to parse a token stream into an AST.
32
+ * Parses Markdown tokens with hybrid BBCode embedding support into a typed AST.
8
33
  */
9
34
  export class MarkdownParser {
10
35
  private tokens: MarkdownToken[];
@@ -25,12 +50,8 @@ export class MarkdownParser {
25
50
  while (this.match('newline')) newlines++;
26
51
 
27
52
  if (newlines > 0) {
28
- // Since blocks like parseParagraph consume their trailing newline,
29
- // seeing 1 newline here means there were 2 newlines in the source (a blank line).
30
- // 1 newline here -> 1 empty_line
31
- // 2 newlines here -> 2 empty_lines
32
53
  for (let i = 0; i < newlines; i++) {
33
- doc.children.push({ type: 'empty_line' } as any);
54
+ doc.children.push({ type: 'empty_line' });
34
55
  }
35
56
  }
36
57
 
@@ -50,6 +71,13 @@ export class MarkdownParser {
50
71
 
51
72
  const token = this.peek();
52
73
 
74
+ // Horizontal Rule (---)
75
+ if (token.kind === 'hr' || (token.kind === 'dash' && ('value' in token && token.value === '---'))) {
76
+ this.advance();
77
+ this.match('newline');
78
+ return { type: 'separator' };
79
+ }
80
+
53
81
  if (token.kind === 'hash') {
54
82
  return this.parseHeading();
55
83
  }
@@ -57,16 +85,14 @@ export class MarkdownParser {
57
85
  if (token.kind === 'gt') {
58
86
  const saved = this.current;
59
87
  this.advance(); // consume gt
60
- const next1 = this.peek();
61
- if (next1 && next1.kind === 'text' && ('value' in next1) && (next1.value as string).trim() === '') {
88
+ while (!this.isAtEnd() && this.peek()?.kind === 'text' && ('value' in this.peek()) && (this.peek() as any).value.trim() === '') {
62
89
  this.advance(); // consume space
63
90
  }
64
- const next2 = this.peek();
65
- if (next2 && next2.kind === 'bracket_open') {
66
- const next3 = this.tokens[this.current + 1];
67
- if (next3 && next3.kind === 'bang') {
68
- this.current = saved; // restore
69
- return this.parseNotice();
91
+ if (this.check('bracket_open')) {
92
+ const nextToken = this.tokens[this.current + 1];
93
+ if (nextToken && (nextToken.kind === 'bang' || ('value' in nextToken && nextToken.value === '!'))) {
94
+ this.current = saved; // restore
95
+ return this.parseNotice();
70
96
  }
71
97
  }
72
98
  this.current = saved; // restore
@@ -76,13 +102,23 @@ export class MarkdownParser {
76
102
  if (token.kind === 'fence') {
77
103
  return this.parseFence();
78
104
  }
105
+
106
+ // Fenced Container Divs (::: details Title, ::: center, ::: warning)
107
+ if (token.kind === 'container') {
108
+ return this.parseContainer();
109
+ }
79
110
 
80
- if (token.kind === 'dash' || token.kind === 'star') {
81
- // Must be followed by a space to be a list!
82
- const next = this.tokens[this.current + 1];
83
- if (next && next.kind === 'text' && ('value' in next) && (next.value as string).startsWith(' ')) {
84
- return this.parseList();
85
- }
111
+ // Lists (unordered or ordered)
112
+ if (this.isListStart()) {
113
+ return this.parseList();
114
+ }
115
+
116
+ // Embedded Block BBCode tags (e.g. [box=Title]...[/box], [centre]...[/centre])
117
+ if (token.kind === 'bracket_open') {
118
+ const bbTag = this.peekBBCodeOpenTag(this.current);
119
+ if (bbTag && BLOCK_BBCODE_TAGS.has(bbTag.tagName)) {
120
+ return this.parseBBCodeBlock(bbTag);
121
+ }
86
122
  }
87
123
 
88
124
  // Default to paragraph
@@ -93,20 +129,18 @@ export class MarkdownParser {
93
129
  const hash = this.advance();
94
130
  const level = hash.kind === 'hash' ? Math.min(hash.value.length, 6) : 1;
95
131
 
96
- // We don't want to blindly consume a text token if it contains actual text.
97
- // Let's just let parseInline handle it. The renderer can deal with leading spaces,
98
- // or we can trim the first child if it's text.
99
132
  const children: MarkdownNode[] = [];
100
133
  while (!this.isAtEnd() && !this.check('newline')) {
101
134
  const inlineNode = this.parseInline();
102
135
  if (inlineNode) {
103
136
  children.push(inlineNode);
137
+ } else {
138
+ break;
104
139
  }
105
140
  }
106
141
 
107
- // Trim leading space from the first child if it's text
108
142
  if (children.length > 0 && children[0].type === 'text') {
109
- children[0].value = (children[0] as any).value.replace(/^\\s+/, '');
143
+ children[0].value = (children[0] as MarkdownText).value.replace(/^\s+/, '');
110
144
  }
111
145
 
112
146
  this.match('newline');
@@ -120,26 +154,113 @@ export class MarkdownParser {
120
154
 
121
155
  private parseBlockquote(): MarkdownNode {
122
156
  const children: MarkdownNode[] = [];
123
-
124
- while (!this.isAtEnd()) {
125
- if (this.match('gt')) {
126
- // blockquote line prefix
157
+ this.advance(); // consume initial gt
158
+ while (!this.isAtEnd() && this.peek()?.kind === 'text' && ('value' in this.peek()) && (this.peek() as any).value.trim() === '') {
159
+ this.advance();
160
+ }
161
+
162
+ // Parse the first line's inline nodes to check for author attribution (e.g. **Author wrote:**, **Author:**, **Author**)
163
+ const firstLineNodes: MarkdownNode[] = [];
164
+ while (!this.isAtEnd() && !this.check('newline')) {
165
+ const inlineNode = this.parseInline();
166
+ if (inlineNode) firstLineNodes.push(inlineNode);
167
+ else break;
168
+ }
169
+
170
+ let source: string | undefined;
171
+
172
+ const getPlainText = (n: MarkdownNode): string => {
173
+ if ('value' in n && typeof (n as any).value === 'string') return (n as any).value;
174
+ if ('children' in n && Array.isArray((n as any).children)) {
175
+ return (n as any).children.map(getPlainText).join('');
127
176
  }
128
-
177
+ return '';
178
+ };
179
+
180
+ // Trim trailing whitespace nodes from the first line
181
+ const trimmed = [...firstLineNodes];
182
+ while (
183
+ trimmed.length > 0 &&
184
+ trimmed[trimmed.length - 1].type === 'text' &&
185
+ (trimmed[trimmed.length - 1] as any).value.trim() === ''
186
+ ) {
187
+ trimmed.pop();
188
+ }
189
+
190
+ if (trimmed.length > 0) {
191
+ // 1. Single bold node: **Author**, **Author wrote:**, **Author:**
192
+ if (trimmed.length === 1 && trimmed[0].type === 'strong') {
193
+ const raw = getPlainText(trimmed[0]).trim();
194
+ const cleaned = raw.replace(/\s*wrote:\s*$/i, '').replace(/:\s*$/, '').trim();
195
+ if (cleaned) {
196
+ source = cleaned;
197
+ }
198
+ }
199
+ // 2. Bold node followed by "wrote:", "wrote", ":", etc.
200
+ else if (
201
+ trimmed.length === 2 &&
202
+ trimmed[0].type === 'strong' &&
203
+ trimmed[1].type === 'text' &&
204
+ /^\s*(wrote:|wrote|:)\s*$/i.test((trimmed[1] as any).value)
205
+ ) {
206
+ const cleaned = getPlainText(trimmed[0]).trim();
207
+ if (cleaned) {
208
+ source = cleaned;
209
+ }
210
+ }
211
+ // 3. Plain text patterns: [quote=Author] or Author wrote:
212
+ else {
213
+ const fullLine = trimmed.map(getPlainText).join('').trim();
214
+ const quoteMatch = fullLine.match(/^\[quote=["']?([^\]"']+)["']?\]$/i);
215
+ if (quoteMatch) {
216
+ source = quoteMatch[1].trim();
217
+ } else {
218
+ const wroteMatch = fullLine.match(/^(\*\*)?([^*\n]+?)\1\s*wrote:\s*$/i);
219
+ if (wroteMatch) {
220
+ source = wroteMatch[2].trim();
221
+ }
222
+ }
223
+ }
224
+ }
225
+
226
+ if (!source) {
227
+ children.push(...firstLineNodes);
228
+ }
229
+
230
+ while (!this.isAtEnd()) {
129
231
  if (this.check('newline')) {
130
232
  this.advance();
131
233
  if (this.check('newline') || this.isAtEnd()) {
132
234
  break; // Empty line ends blockquote
133
235
  }
134
- children.push({ type: 'text', value: '\n' });
236
+ if (children.length > 0) {
237
+ children.push({ type: 'text', value: '\n' });
238
+ }
239
+ }
240
+
241
+ if (this.match('gt')) {
242
+ // blockquote line prefix
243
+ while (!this.isAtEnd() && this.peek()?.kind === 'text' && ('value' in this.peek()) && (this.peek() as any).value.trim() === '') {
244
+ this.advance();
245
+ }
246
+ }
247
+
248
+ if (this.check('newline')) {
249
+ // will be handled on next loop iteration
135
250
  } else {
136
251
  const inlineNode = this.parseInline();
137
252
  if (inlineNode) children.push(inlineNode);
253
+ else break;
138
254
  }
139
255
  }
140
256
 
257
+ if (children.length > 0 && children[0].type === 'text') {
258
+ (children[0] as MarkdownText).value = (children[0] as MarkdownText).value.replace(/^\s+/, '');
259
+ }
260
+
141
261
  return {
142
262
  type: 'blockquote',
263
+ source,
143
264
  children
144
265
  };
145
266
  }
@@ -147,16 +268,40 @@ export class MarkdownParser {
147
268
  private parseNotice(): MarkdownNode {
148
269
  const children: MarkdownNode[] = [];
149
270
  this.advance(); // consume gt
271
+ while (!this.isAtEnd() && this.peek()?.kind === 'text' && ('value' in this.peek()) && (this.peek() as any).value.trim() === '') {
272
+ this.advance();
273
+ }
150
274
 
151
- // consume the [!NOTE] part
275
+ // consume the [!NOTE] or [!NOTE]- Title part
276
+ let headerText = '';
277
+ let isCollapsible = false;
278
+ let title = '';
279
+
152
280
  while (!this.isAtEnd() && !this.check('newline')) {
153
- if (this.match('bracket_close')) break;
154
- this.advance();
281
+ if (this.match('bracket_close')) {
282
+ // Check if immediately followed by - or + (Obsidian collapsible syntax)
283
+ if (this.match('dash')) isCollapsible = true;
284
+ else if (this.match('plus')) isCollapsible = true;
285
+
286
+ // Read rest of title line
287
+ while (!this.isAtEnd() && !this.check('newline')) {
288
+ const t = this.advance();
289
+ title += this.tokenValue(t);
290
+ }
291
+ break;
292
+ }
293
+ const t = this.advance();
294
+ headerText += this.tokenValue(t);
155
295
  }
156
296
 
297
+ this.match('newline');
298
+
157
299
  while (!this.isAtEnd()) {
158
300
  if (this.match('gt')) {
159
301
  // blockquote line prefix
302
+ while (!this.isAtEnd() && this.peek()?.kind === 'text' && ('value' in this.peek()) && (this.peek() as any).value.trim() === '') {
303
+ this.advance();
304
+ }
160
305
  }
161
306
  if (this.check('newline')) {
162
307
  this.advance();
@@ -165,22 +310,87 @@ export class MarkdownParser {
165
310
  } else {
166
311
  const inlineNode = this.parseInline();
167
312
  if (inlineNode) children.push(inlineNode);
313
+ else break;
168
314
  }
169
315
  }
170
316
 
317
+ if (isCollapsible) {
318
+ const cleanTitle = title.trim() || 'Details';
319
+ return { type: 'box', title: cleanTitle, rawTitle: cleanTitle, children };
320
+ }
321
+
171
322
  return { type: 'notice', children };
172
323
  }
324
+
325
+ private parseContainer(): MarkdownNode {
326
+ const token = this.advance() as MarkdownContainerToken;
327
+ const info = token.info ? token.info.trim() : '';
328
+ const firstSpace = info.indexOf(' ');
329
+ const tag = (firstSpace === -1 ? info : info.slice(0, firstSpace)).toLowerCase();
330
+ const rest = firstSpace === -1 ? '' : info.slice(firstSpace + 1).trim();
331
+
332
+ this.match('newline');
333
+ const children: MarkdownNode[] = [];
334
+
335
+ while (!this.isAtEnd()) {
336
+ if (this.check('container')) {
337
+ this.advance(); // closing :::
338
+ this.match('newline');
339
+ break;
340
+ }
341
+ if (this.check('newline')) {
342
+ this.advance();
343
+ while (this.match('newline')) {
344
+ children.push({ type: 'empty_line' });
345
+ }
346
+ } else {
347
+ const block = this.parseBlock();
348
+ if (block) children.push(block);
349
+ else break;
350
+ }
351
+ }
352
+
353
+ switch (tag) {
354
+ case 'details':
355
+ case 'box':
356
+ case 'boxw':
357
+ return { type: 'box', title: rest || 'Details', rawTitle: rest, children };
358
+ case 'spoilerbox':
359
+ return { type: 'spoilerbox', title: rest || 'Spoiler', rawTitle: rest, children };
360
+ case 'center':
361
+ case 'centre':
362
+ return { type: 'center', children };
363
+ case 'right':
364
+ return { type: 'right', children };
365
+ case 'left':
366
+ return { type: 'left', children };
367
+ case 'info':
368
+ case 'note':
369
+ case 'warning':
370
+ case 'tip':
371
+ case 'caution':
372
+ case 'notice':
373
+ case 'wnotice':
374
+ return { type: 'notice', color: rest ? rest : undefined, children };
375
+ case 'quote':
376
+ return { type: 'blockquote', source: rest || undefined, children };
377
+ default:
378
+ return { type: 'box', title: info || 'Box', rawTitle: info, children };
379
+ }
380
+ }
173
381
 
174
382
  private tokenValue(token: MarkdownToken): string {
383
+ if (!token) return '';
175
384
  if ('value' in token) return (token as any).value;
176
385
  switch (token.kind) {
177
386
  case 'bracket_open': return '[';
178
387
  case 'bracket_close': return ']';
388
+ case 'brace_open': return '{';
389
+ case 'brace_close': return '}';
179
390
  case 'paren_open': return '(';
180
391
  case 'paren_close': return ')';
181
392
  case 'bang': return '!';
182
393
  case 'gt': return '>';
183
- case 'plus': return '+';
184
394
  case 'dot': return '.';
185
395
  case 'hr': return '---';
186
396
  default: return '';
@@ -194,33 +404,76 @@ export class MarkdownParser {
194
404
  while (!this.isAtEnd()) {
195
405
  if (this.check('fence')) {
196
406
  this.advance(); // close fence
407
+ this.match('newline');
197
408
  break;
198
409
  }
199
410
  const p = this.advance();
200
411
  code += this.tokenValue(p);
201
412
  }
202
- return { type: 'code_block', lang: value, value: code.trim() };
413
+ if (code.startsWith('\r\n')) code = code.slice(2);
414
+ else if (code.startsWith('\n')) code = code.slice(1);
415
+ if (code.endsWith('\r\n')) code = code.slice(0, -2);
416
+ else if (code.endsWith('\n')) code = code.slice(0, -1);
417
+
418
+ return { type: 'code_block', lang: value, value: code };
419
+ }
420
+
421
+ private isListStart(): boolean {
422
+ if (this.isAtEnd()) return false;
423
+ const token = this.peek();
424
+ if (token.kind === 'dash' || token.kind === 'star') {
425
+ const next = this.tokens[this.current + 1];
426
+ if (next && next.kind === 'text' && ('value' in next) && (next.value as string).startsWith(' ')) {
427
+ return true;
428
+ }
429
+ }
430
+ return this.isOrderedListStart();
431
+ }
432
+
433
+ private isOrderedListStart(): boolean {
434
+ if (this.isAtEnd()) return false;
435
+ const token = this.peek();
436
+ if (token.kind === 'text' && /^\d+$/.test(('value' in token ? (token as any).value : '').trim())) {
437
+ const next1 = this.tokens[this.current + 1];
438
+ if (next1 && next1.kind === 'dot') {
439
+ const next2 = this.tokens[this.current + 2];
440
+ if (next2 && next2.kind === 'text' && ('value' in next2) && (next2.value as string).startsWith(' ')) {
441
+ return true;
442
+ }
443
+ }
444
+ }
445
+ return false;
203
446
  }
204
447
 
205
448
  private parseList(): MarkdownNode {
206
449
  const items: any[] = [];
207
- while (!this.isAtEnd() && (this.check('dash') || this.check('star'))) {
208
- this.advance(); // consume dash/star
450
+ const ordered = this.isOrderedListStart();
451
+
452
+ while (!this.isAtEnd()) {
453
+ if (ordered) {
454
+ if (!this.isOrderedListStart()) break;
455
+ this.advance(); // number
456
+ this.advance(); // dot
457
+ } else {
458
+ if (!this.check('dash') && !this.check('star')) break;
459
+ this.advance(); // dash or star
460
+ }
461
+
209
462
  const children: MarkdownNode[] = [];
210
463
  while (!this.isAtEnd() && !this.check('newline')) {
211
464
  const inlineNode = this.parseInline();
212
465
  if (inlineNode) children.push(inlineNode);
466
+ else break;
213
467
  }
214
- // Trim leading space from the first child if it's text
215
468
  if (children.length > 0 && children[0].type === 'text') {
216
- children[0].value = (children[0] as any).value.replace(/^\\s+/, '');
469
+ children[0].value = (children[0] as MarkdownText).value.replace(/^\s+/, '');
217
470
  }
218
471
  items.push({ type: 'list_item', children });
219
472
  if (this.match('newline')) {
220
473
  if (this.check('newline')) break; // End of list on double newline
221
474
  }
222
475
  }
223
- return { type: 'list', ordered: false, children: items };
476
+ return { type: 'list', ordered, children: items };
224
477
  }
225
478
 
226
479
  private parseParagraph(): MarkdownParagraph {
@@ -229,21 +482,30 @@ export class MarkdownParser {
229
482
  while (!this.isAtEnd()) {
230
483
  if (this.check('newline')) {
231
484
  this.advance();
232
- // Two newlines mean end of paragraph
233
485
  if (this.check('newline') || this.isAtEnd()) {
234
486
  break;
235
487
  } else {
236
- // A single newline could mean lazy continuation, BUT we must check if a new block starts!
237
- if (this.check('hash') || this.check('gt') || this.check('fence') || this.check('hr') || this.check('dash')) {
238
- break; // Paragraph interrupted by block
488
+ if (this.check('hash') || this.check('gt') || this.check('fence') || this.check('hr') || this.check('container')) {
489
+ break;
490
+ }
491
+ if (this.isListStart()) {
492
+ break;
493
+ }
494
+ // Also break if block BBCode starts
495
+ if (this.check('bracket_open')) {
496
+ const bbTag = this.peekBBCodeOpenTag(this.current);
497
+ if (bbTag && BLOCK_BBCODE_TAGS.has(bbTag.tagName)) {
498
+ break;
499
+ }
239
500
  }
240
- // Single newline treated as space in paragraph
241
501
  children.push({ type: 'text', value: '\n' });
242
502
  }
243
503
  } else {
244
504
  const inlineNode = this.parseInline();
245
505
  if (inlineNode) {
246
506
  children.push(inlineNode);
507
+ } else {
508
+ break;
247
509
  }
248
510
  }
249
511
  }
@@ -254,7 +516,7 @@ export class MarkdownParser {
254
516
  };
255
517
  }
256
518
 
257
- private parseInline(terminator?: { kind: string, value?: string }): MarkdownNode | null {
519
+ public parseInline(terminator?: { kind: string, value?: string }): MarkdownNode | null {
258
520
  if (this.isAtEnd()) return null;
259
521
 
260
522
  if (terminator) {
@@ -264,9 +526,54 @@ export class MarkdownParser {
264
526
  }
265
527
  }
266
528
 
529
+ // Check for BBCode close tag matching an active parent
530
+ if (this.check('bracket_open')) {
531
+ const peekClose = this.peekBBCodeCloseTag(this.current);
532
+ if (peekClose) {
533
+ return null;
534
+ }
535
+
536
+ // Check for BBCode open tag
537
+ const openTag = this.peekBBCodeOpenTag(this.current);
538
+ if (openTag) {
539
+ return this.parseBBCodeInline(openTag);
540
+ }
541
+ }
542
+
267
543
  const token = this.advance();
544
+
545
+ // Inline Code (`code`)
546
+ if (token.kind === 'backtick') {
547
+ const val = ('value' in token) ? token.value as string : '`';
548
+ if (val === '`') {
549
+ const savedCurrent = this.current;
550
+ let code = '';
551
+ let closed = false;
552
+
553
+ while (!this.isAtEnd()) {
554
+ const next = this.peek();
555
+ if (next.kind === 'newline') break;
556
+
557
+ if (next.kind === 'backtick' && ('value' in next && next.value === '`')) {
558
+ this.advance();
559
+ closed = true;
560
+ break;
561
+ }
562
+
563
+ const p = this.advance();
564
+ code += this.tokenValue(p);
565
+ }
566
+
567
+ if (closed) {
568
+ return { type: 'code_inline', value: code };
569
+ } else {
570
+ this.current = savedCurrent;
571
+ return { type: 'text', value: '`' };
572
+ }
573
+ }
574
+ }
268
575
 
269
- // Bold and Italic
576
+ // Bold and Italic (*, **, _, __)
270
577
  if (token.kind === 'star' || token.kind === 'underscore') {
271
578
  const val = ('value' in token) ? token.value as string : '';
272
579
  if (val === '**' || val === '__' || val === '*' || val === '_') {
@@ -278,10 +585,10 @@ export class MarkdownParser {
278
585
 
279
586
  while (!this.isAtEnd()) {
280
587
  const next = this.peek();
281
- if (next.kind === 'newline') break; // Don't span across lines in this basic parser
588
+ if (next.kind === 'newline') break;
282
589
 
283
590
  if (next.kind === token.kind && ('value' in next && next.value === val)) {
284
- this.advance(); // consume closing token
591
+ this.advance();
285
592
  closed = true;
286
593
  break;
287
594
  }
@@ -297,14 +604,13 @@ export class MarkdownParser {
297
604
  if (closed) {
298
605
  return { type, children };
299
606
  } else {
300
- // Backtrack and treat as text
301
607
  this.current = savedCurrent;
302
608
  return { type: 'text', value: val };
303
609
  }
304
610
  }
305
611
  }
306
612
 
307
- // Strikethrough
613
+ // Strikethrough (~~)
308
614
  if (token.kind === 'tilde') {
309
615
  const val = ('value' in token) ? token.value as string : '';
310
616
  if (val === '~~') {
@@ -328,7 +634,7 @@ export class MarkdownParser {
328
634
  }
329
635
 
330
636
  if (closed) {
331
- return { type: 'strikethrough', children } as any;
637
+ return { type: 'strikethrough', children };
332
638
  } else {
333
639
  this.current = savedCurrent;
334
640
  return { type: 'text', value: val };
@@ -336,6 +642,68 @@ export class MarkdownParser {
336
642
  }
337
643
  }
338
644
 
645
+ // Underline (++) (CriticMarkup / markdown-it-ins)
646
+ if (token.kind === 'plus' && ('value' in token && token.value === '++')) {
647
+ const savedCurrent = this.current;
648
+ const children: MarkdownNode[] = [];
649
+ let closed = false;
650
+ while (!this.isAtEnd()) {
651
+ const next = this.peek();
652
+ if (next.kind === 'newline') break;
653
+ if (next.kind === 'plus' && ('value' in next && next.value === '++')) {
654
+ this.advance();
655
+ closed = true;
656
+ break;
657
+ }
658
+ const child = this.parseInline({ kind: 'plus', value: '++' });
659
+ if (child) children.push(child);
660
+ else break;
661
+ }
662
+ if (closed) {
663
+ return { type: 'underline', children };
664
+ } else {
665
+ this.current = savedCurrent;
666
+ return { type: 'text', value: '++' };
667
+ }
668
+ }
669
+
670
+ // Alignment arrow (-> text <- or -> text ->)
671
+ if (token.kind === 'arrow_right') {
672
+ const savedCurrent = this.current;
673
+ const children: MarkdownNode[] = [];
674
+ let alignType: 'center' | 'right' | null = null;
675
+ while (!this.isAtEnd()) {
676
+ const next = this.peek();
677
+ if (next.kind === 'newline') break;
678
+ if (next.kind === 'arrow_left') {
679
+ this.advance();
680
+ alignType = 'center';
681
+ break;
682
+ }
683
+ if (next.kind === 'arrow_right') {
684
+ this.advance();
685
+ alignType = 'right';
686
+ break;
687
+ }
688
+ const child = this.parseInline();
689
+ if (child) children.push(child);
690
+ else break;
691
+ }
692
+ if (alignType) {
693
+ if (children.length > 0 && children[0].type === 'text') {
694
+ children[0].value = (children[0] as MarkdownText).value.replace(/^\s+/, '');
695
+ }
696
+ if (children.length > 0 && children[children.length - 1].type === 'text') {
697
+ const last = children[children.length - 1] as MarkdownText;
698
+ last.value = last.value.replace(/\s+$/, '');
699
+ }
700
+ return { type: alignType, children };
701
+ } else {
702
+ this.current = savedCurrent;
703
+ return { type: 'text', value: '->' };
704
+ }
705
+ }
706
+
339
707
  // Spoiler (||)
340
708
  if (token.kind === 'text' && token.value === '||') {
341
709
  const savedCurrent = this.current;
@@ -361,8 +729,9 @@ export class MarkdownParser {
361
729
  }
362
730
  }
363
731
 
364
- // Link or Image
732
+ // Link, Image, or Generic Attribute Span [text]{attributes}
365
733
  if (token.kind === 'bracket_open' || token.kind === 'bang') {
734
+ const savedCurrent = this.current;
366
735
  const isImage = token.kind === 'bang';
367
736
  if (isImage) {
368
737
  if (!this.match('bracket_open')) {
@@ -370,7 +739,6 @@ export class MarkdownParser {
370
739
  }
371
740
  }
372
741
 
373
- const savedCurrent = this.current;
374
742
  let text = '';
375
743
  let closedBracket = false;
376
744
 
@@ -380,7 +748,7 @@ export class MarkdownParser {
380
748
  break;
381
749
  }
382
750
  const p = this.advance();
383
- text += ('value' in p) ? (p as any).value : '';
751
+ text += this.tokenValue(p);
384
752
  }
385
753
 
386
754
  if (closedBracket && this.match('paren_open')) {
@@ -392,7 +760,7 @@ export class MarkdownParser {
392
760
  break;
393
761
  }
394
762
  const p = this.advance();
395
- url += ('value' in p) ? (p as any).value : '';
763
+ url += this.tokenValue(p);
396
764
  }
397
765
 
398
766
  if (closedParen) {
@@ -403,10 +771,28 @@ export class MarkdownParser {
403
771
  }
404
772
  }
405
773
  }
774
+
775
+ // Generic Attribute Span [text]{.underline} or [text]{color="#ff0055"} or [text]{#ff0055}
776
+ if (closedBracket && this.match('brace_open')) {
777
+ let attrString = '';
778
+ let closedBrace = false;
779
+ while (!this.isAtEnd() && !this.check('newline')) {
780
+ if (this.match('brace_close')) {
781
+ closedBrace = true;
782
+ break;
783
+ }
784
+ const p = this.advance();
785
+ attrString += this.tokenValue(p);
786
+ }
787
+
788
+ if (closedBrace) {
789
+ return this.createAttributeSpanNode(text, attrString.trim());
790
+ }
791
+ }
406
792
 
407
- // Fallback
793
+ // Fallback: restore cursor to before bracket was consumed
408
794
  this.current = savedCurrent;
409
- return { type: 'text', value: isImage ? '![' : '[' };
795
+ return { type: 'text', value: isImage ? '!' : '[' };
410
796
  }
411
797
 
412
798
  return {
@@ -415,6 +801,308 @@ export class MarkdownParser {
415
801
  };
416
802
  }
417
803
 
804
+ private createAttributeSpanNode(rawText: string, attrString: string): MarkdownNode {
805
+ const innerTokens = scanMarkdown(rawText);
806
+ const innerDoc = new MarkdownParser(innerTokens).parseInline();
807
+ const children: MarkdownNode[] = innerDoc ? [innerDoc] : [{ type: 'text', value: rawText }];
808
+
809
+ // 1. Shorthand class: .underline
810
+ if (attrString === '.underline' || attrString.includes('.underline')) {
811
+ return { type: 'underline', children };
812
+ }
813
+
814
+ // 2. Shorthand hex color: #ff0055 or #f00
815
+ if (/^#[0-9a-fA-F]{3,8}$/.test(attrString)) {
816
+ return { type: 'color', color: attrString, children };
817
+ }
818
+
819
+ // 3. Key-value attributes: color="...", size="...", font="..."
820
+ let resultNode: MarkdownNode = { type: 'paragraph', children };
821
+ let wrapped = false;
822
+
823
+ // Match color=... or style="color:..."
824
+ const colorMatch = attrString.match(/color=["']?([^"'\s}]+)["']?/) || attrString.match(/style=["'][^"']*color:\s*([^;"'\s]+)/);
825
+ if (colorMatch) {
826
+ resultNode = { type: 'color', color: colorMatch[1], children };
827
+ wrapped = true;
828
+ }
829
+
830
+ // Match size=... or style="font-size:..."
831
+ const sizeMatch = attrString.match(/size=["']?([^"'\s}]+)["']?/) || attrString.match(/style=["'][^"']*font-size:\s*([^;"'\s]+)/);
832
+ if (sizeMatch) {
833
+ const currentChildren = wrapped ? [resultNode] : children;
834
+ resultNode = { type: 'font_size', size: sizeMatch[1], children: currentChildren };
835
+ wrapped = true;
836
+ }
837
+
838
+ // Match font=... or style="font-family:..."
839
+ const fontMatch = attrString.match(/font(?:-family)?=["']?([^"'}]+)["']?/);
840
+ if (fontMatch) {
841
+ const currentChildren = wrapped ? [resultNode] : children;
842
+ resultNode = { type: 'font', font: fontMatch[1].trim(), children: currentChildren };
843
+ wrapped = true;
844
+ }
845
+
846
+ if (wrapped) {
847
+ return resultNode;
848
+ }
849
+
850
+ return { type: 'text', value: `[${rawText}]{${attrString}}` };
851
+ }
852
+
853
+ // --- BBCode Embedding Support ---
854
+
855
+ private peekBBCodeOpenTag(startIndex: number): BBCodeTagInfo | null {
856
+ if (this.tokens[startIndex]?.kind !== 'bracket_open') return null;
857
+ let idx = startIndex + 1;
858
+ let tagContent = '';
859
+
860
+ while (idx < this.tokens.length) {
861
+ const t = this.tokens[idx];
862
+ if (t.kind === 'newline') return null;
863
+ if (t.kind === 'bracket_close') {
864
+ // Evaluate tagContent
865
+ const trimmed = tagContent.trim();
866
+ if (trimmed.startsWith('/')) return null; // closing tag
867
+
868
+ const eqIdx = trimmed.indexOf('=');
869
+ const tagName = (eqIdx >= 0 ? trimmed.slice(0, eqIdx) : trimmed).trim().toLowerCase();
870
+ let attrValue = eqIdx >= 0 ? trimmed.slice(eqIdx + 1).trim() : undefined;
871
+ if (attrValue && ((attrValue.startsWith('"') && attrValue.endsWith('"')) || (attrValue.startsWith("'") && attrValue.endsWith("'")))) {
872
+ attrValue = attrValue.slice(1, -1);
873
+ }
874
+
875
+ // If immediately followed by (url) or {attributes}, it is a Markdown construct, not BBCode
876
+ if (idx + 1 < this.tokens.length) {
877
+ const nextToken = this.tokens[idx + 1];
878
+ if (nextToken.kind === 'paren_open' || nextToken.kind === 'brace_open') {
879
+ return null;
880
+ }
881
+ }
882
+
883
+ if (/^[a-z0-9_-]+$/i.test(tagName)) {
884
+ return { tagName, attrValue, tokenEndIndex: idx };
885
+ }
886
+ return null;
887
+ }
888
+ tagContent += this.tokenValue(t);
889
+ idx++;
890
+ }
891
+
892
+ return null;
893
+ }
894
+
895
+ private peekBBCodeCloseTag(startIndex: number): { tagName: string; tokenEndIndex: number } | null {
896
+ if (this.tokens[startIndex]?.kind !== 'bracket_open') return null;
897
+ let idx = startIndex + 1;
898
+ let tagContent = '';
899
+
900
+ while (idx < this.tokens.length) {
901
+ const t = this.tokens[idx];
902
+ if (t.kind === 'newline') return null;
903
+ if (t.kind === 'bracket_close') {
904
+ const trimmed = tagContent.trim();
905
+ if (trimmed.startsWith('/')) {
906
+ const tagName = trimmed.slice(1).trim().toLowerCase();
907
+ if (/^[a-z0-9_-]+$/i.test(tagName)) {
908
+ return { tagName, tokenEndIndex: idx };
909
+ }
910
+ }
911
+ return null;
912
+ }
913
+ tagContent += this.tokenValue(t);
914
+ idx++;
915
+ }
916
+
917
+ return null;
918
+ }
919
+
920
+ private isMatchingCloseTag(closeTagName: string, openTagName: string): boolean {
921
+ const c = closeTagName.toLowerCase();
922
+ const o = openTagName.toLowerCase();
923
+ if (c === o) return true;
924
+ if ((c === 'centre' || c === 'center') && (o === 'centre' || o === 'center')) return true;
925
+ if ((c === 'u' || c === 'underline') && (o === 'u' || o === 'underline')) return true;
926
+ if ((c === 's' || c === 'strike' || c === 'strikethrough') && (o === 's' || o === 'strike' || o === 'strikethrough')) return true;
927
+ if ((c === 'b' || c === 'bold') && (o === 'b' || o === 'bold')) return true;
928
+ if ((c === 'i' || c === 'italic') && (o === 'i' || o === 'italic')) return true;
929
+ if ((c === 'box' || c === 'boxw') && (o === 'box' || o === 'boxw')) return true;
930
+ return false;
931
+ }
932
+
933
+ private parseBBCodeBlock(openTag: BBCodeTagInfo): MarkdownNode {
934
+ const t = openTag.tagName.toLowerCase();
935
+ if (t === 'code') {
936
+ this.current = openTag.tokenEndIndex + 1;
937
+ let raw = '';
938
+ while (!this.isAtEnd()) {
939
+ if (this.check('bracket_open')) {
940
+ const closeTag = this.peekBBCodeCloseTag(this.current);
941
+ if (closeTag && closeTag.tagName.toLowerCase() === 'code') {
942
+ this.current = closeTag.tokenEndIndex + 1;
943
+ this.match('newline');
944
+ break;
945
+ }
946
+ }
947
+ const p = this.advance();
948
+ raw += this.tokenValue(p);
949
+ }
950
+ if (raw.startsWith('\r\n')) raw = raw.slice(2);
951
+ else if (raw.startsWith('\n')) raw = raw.slice(1);
952
+ if (raw.endsWith('\r\n')) raw = raw.slice(0, -2);
953
+ else if (raw.endsWith('\n')) raw = raw.slice(0, -1);
954
+ return { type: 'code_block', lang: '', value: raw };
955
+ }
956
+
957
+ this.current = openTag.tokenEndIndex + 1;
958
+ this.match('newline');
959
+
960
+ const children: MarkdownNode[] = [];
961
+
962
+ while (!this.isAtEnd()) {
963
+ if (this.check('bracket_open')) {
964
+ const closeTag = this.peekBBCodeCloseTag(this.current);
965
+ if (closeTag && this.isMatchingCloseTag(closeTag.tagName, openTag.tagName)) {
966
+ this.current = closeTag.tokenEndIndex + 1;
967
+ this.match('newline');
968
+ break;
969
+ }
970
+ }
971
+
972
+ if (this.check('newline')) {
973
+ this.advance();
974
+ while (this.match('newline')) {
975
+ children.push({ type: 'empty_line' });
976
+ }
977
+ } else {
978
+ const block = this.parseBlock();
979
+ if (block) children.push(block);
980
+ else break;
981
+ }
982
+ }
983
+
984
+ return this.createBBCodeNode(openTag.tagName, openTag.attrValue, children);
985
+ }
986
+
987
+ private parseBBCodeInline(openTag: BBCodeTagInfo): MarkdownNode {
988
+ const t = openTag.tagName.toLowerCase();
989
+ if (t === 'c') {
990
+ this.current = openTag.tokenEndIndex + 1;
991
+ let raw = '';
992
+ while (!this.isAtEnd()) {
993
+ if (this.check('bracket_open')) {
994
+ const closeTag = this.peekBBCodeCloseTag(this.current);
995
+ if (closeTag && closeTag.tagName.toLowerCase() === 'c') {
996
+ this.current = closeTag.tokenEndIndex + 1;
997
+ break;
998
+ }
999
+ }
1000
+ if (this.check('newline')) break;
1001
+ const p = this.advance();
1002
+ raw += this.tokenValue(p);
1003
+ }
1004
+ return { type: 'code_inline', value: raw };
1005
+ }
1006
+
1007
+ if (t === 'raw' || t === 'noparse' || t === 'plain') {
1008
+ this.current = openTag.tokenEndIndex + 1;
1009
+ let raw = '';
1010
+ while (!this.isAtEnd()) {
1011
+ if (this.check('bracket_open')) {
1012
+ const closeTag = this.peekBBCodeCloseTag(this.current);
1013
+ if (closeTag && this.isMatchingCloseTag(closeTag.tagName, openTag.tagName)) {
1014
+ this.current = closeTag.tokenEndIndex + 1;
1015
+ break;
1016
+ }
1017
+ }
1018
+ if (this.check('newline')) {
1019
+ this.advance();
1020
+ raw += '\n';
1021
+ } else {
1022
+ const p = this.advance();
1023
+ raw += this.tokenValue(p);
1024
+ }
1025
+ }
1026
+ return { type: 'bbcode_tag', tagName: t === 'noparse' ? 'raw' : t, attrValue: openTag.attrValue, children: [{ type: 'text', value: raw }] };
1027
+ }
1028
+
1029
+ this.current = openTag.tokenEndIndex + 1;
1030
+ const children: MarkdownNode[] = [];
1031
+
1032
+ while (!this.isAtEnd()) {
1033
+ if (this.check('bracket_open')) {
1034
+ const closeTag = this.peekBBCodeCloseTag(this.current);
1035
+ if (closeTag && this.isMatchingCloseTag(closeTag.tagName, openTag.tagName)) {
1036
+ this.current = closeTag.tokenEndIndex + 1;
1037
+ break;
1038
+ }
1039
+ }
1040
+
1041
+ if (this.check('newline')) {
1042
+ this.advance();
1043
+ children.push({ type: 'text', value: '\n' });
1044
+ } else {
1045
+ const child = this.parseInline();
1046
+ if (child) children.push(child);
1047
+ else break;
1048
+ }
1049
+ }
1050
+
1051
+ return this.createBBCodeNode(openTag.tagName, openTag.attrValue, children);
1052
+ }
1053
+
1054
+ private createBBCodeNode(tagName: string, attrValue: string | undefined, children: MarkdownNode[]): MarkdownNode {
1055
+ const t = tagName.toLowerCase();
1056
+ switch (t) {
1057
+ case 'b':
1058
+ case 'bold':
1059
+ return { type: 'strong', children };
1060
+ case 'i':
1061
+ case 'italic':
1062
+ return { type: 'emphasis', children };
1063
+ case 'u':
1064
+ case 'underline':
1065
+ return { type: 'underline', children };
1066
+ case 's':
1067
+ case 'strike':
1068
+ case 'strikethrough':
1069
+ return { type: 'strikethrough', children };
1070
+ case 'c':
1071
+ return { type: 'code_inline', value: children.map(c => ('value' in c ? (c as any).value : '')).join('') };
1072
+ case 'color':
1073
+ case 'colour':
1074
+ return { type: 'color', color: attrValue || '#ffffff', children };
1075
+ case 'size':
1076
+ return { type: 'font_size', size: attrValue || '100', children };
1077
+ case 'font':
1078
+ return { type: 'font', font: attrValue || 'sans-serif', children };
1079
+ case 'centre':
1080
+ case 'center':
1081
+ return { type: 'center', children };
1082
+ case 'right':
1083
+ return { type: 'right', children };
1084
+ case 'left':
1085
+ return { type: 'left', children };
1086
+ case 'align':
1087
+ return { type: (attrValue === 'center' || attrValue === 'right' || attrValue === 'left' ? attrValue : 'center') as any, children };
1088
+ case 'box':
1089
+ case 'boxw':
1090
+ return { type: 'box', title: attrValue || 'Box', rawTitle: attrValue, children };
1091
+ case 'spoilerbox':
1092
+ return { type: 'spoilerbox', title: attrValue || 'Spoiler', rawTitle: attrValue, children };
1093
+ case 'spoiler':
1094
+ return { type: 'spoiler', children };
1095
+ case 'quote':
1096
+ return { type: 'blockquote', source: attrValue, children };
1097
+ case 'notice':
1098
+ return { type: 'notice', color: attrValue, children };
1099
+ case 'wnotice':
1100
+ return { type: 'wnotice', color: attrValue, children };
1101
+ default:
1102
+ return { type: 'bbcode_tag', tagName: t, attrValue, children };
1103
+ }
1104
+ }
1105
+
418
1106
  // --- Helpers ---
419
1107
 
420
1108
  private peek(): MarkdownToken {
@@ -445,7 +1133,6 @@ export class MarkdownParser {
445
1133
  return true;
446
1134
  }
447
1135
  return false;
448
-
449
1136
  }
450
1137
  }
451
1138