@miliastry/quasar 1.1.0 → 1.2.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.
@@ -28,7 +28,7 @@ import { type InterningMode } from '../Syntax/GreenNodePool';
28
28
  import { GreenNode } from '../Syntax/GreenNode';
29
29
  import { RedNode } from '../Syntax/RedNode';
30
30
  import { type BBCodeDialect } from './BBCodeToGreenNode';
31
- import { HTMLRenderer } from '../Visitors/HTMLRenderer';
31
+ import { HTMLRenderer, type HTMLRendererOptions } from '../Visitors/HTMLRenderer';
32
32
  export interface BBCodeDocumentModelOptions extends DocumentModelOptions {
33
33
  /** Source BBCode text to parse */
34
34
  source?: string;
@@ -64,6 +64,15 @@ export declare class BBCodeDocumentModel extends DocumentModel {
64
64
  get dialect(): BBCodeDialect;
65
65
  get root(): RedNode | null;
66
66
  toHTML(renderer?: HTMLRenderer): string;
67
+ /**
68
+ * Fast-path read-only rendering for forum posts, comments, and static views.
69
+ *
70
+ * Completely bypasses semantic analysis (linter), undo stack allocations, and
71
+ * editor node ids (idMode: 'none') to maximize throughput and minimize memory.
72
+ */
73
+ static renderForum(source: string, options?: HTMLRendererOptions & {
74
+ dialect?: BBCodeDialect;
75
+ }): string;
67
76
  /**
68
77
  * Parse BBCode text directly to a GreenNode tree using the
69
78
  * DocumentEngine's built-in BBCode Lexer + Parser.
@@ -90,3 +99,4 @@ export declare class BBCodeDocumentModel extends DocumentModel {
90
99
  */
91
100
  protected exportSource(root: RedNode): string;
92
101
  }
102
+ export declare const renderForumBBCode: typeof BBCodeDocumentModel.renderForum;
@@ -11,3 +11,9 @@
11
11
  * - Zero extra wrapper <div> elements
12
12
  */
13
13
  export declare function morphHTML(container: HTMLElement, newHTML: string): void;
14
+ /**
15
+ * Reconcile two HTML elements of the same tag in-place.
16
+ * Syncs attributes, preserves interactive states (<details open>, .open classes),
17
+ * and recursively morphs child nodes.
18
+ */
19
+ export declare function morphElement(oldEl: Element, newEl: Element): void;
@@ -15,8 +15,19 @@ import { type TokenResolverFn, type TokenSource } from '../Tokens';
15
15
  export interface HTMLRendererOptions {
16
16
  /**
17
17
  * Replicate osu! forum BBCode spacing quirks.
18
- * Defaults to true for full compatibility with Miliastry.
19
- * Set to false for a more logical, predictable rendering engine.
18
+ *
19
+ * NO LO LEE NADIE desde que el motor implementa las reglas reales de
20
+ * consumo de saltos (ver {@link HTMLRenderer.NEWLINE_RULES}). Sólo gobernaba
21
+ * dos heurísticas sobre `[code]` que resultaron ser falsas — osu no se come
22
+ * ningún salto ANTES de un bloque, y el resto de las reglas nunca estuvo
23
+ * condicionado. El espaciado ya no es opcional: Miliastry es "osu con
24
+ * esteroides" y rompe líneas igual, así que apagarlo no tendría a qué
25
+ * volver.
26
+ *
27
+ * Se conserva porque es API pública del renderer y quitarlo rompería a quien
28
+ * lo pase. Es candidato a borrarse en la próxima ruptura de versión.
29
+ *
30
+ * @deprecated Sin efecto. El espaciado de osu es ahora incondicional.
20
31
  */
21
32
  osuBehaviour?: boolean;
22
33
  /** Registry for resolving custom tags */
@@ -153,6 +164,19 @@ export declare class HTMLRenderer extends Visitor<string> {
153
164
  * LYNE's forum renderer applies.
154
165
  */
155
166
  private tableDepth;
167
+ /**
168
+ * ¿Estamos emitiendo el vocabulario de clases de osu!?
169
+ *
170
+ * osu estiliza box, spoilerbox, notice, imagemap, youtube, los alineados y
171
+ * los perfiles POR NOMBRE DE CLASE, no por estilo inline. Sobre una userpage
172
+ * real el HTML de Quasar salía sin estilo porque emitía su propio
173
+ * vocabulario (`<details>`, `.notice`, `.imagemap-container`…). Bajo
174
+ * `dialect: 'osu'` se emiten las clases y la estructura de osu; el resto de
175
+ * dialectos conserva la suya, que es la que sus hojas de estilo esperan.
176
+ */
177
+ private isOsu;
178
+ /** osu recorta los saltos pegados a la apertura y al cierre de box/notice. */
179
+ private static trimOsuEdges;
156
180
  private idAttr;
157
181
  /**
158
182
  * Kinds that carry `data-node-id`.
@@ -193,12 +217,50 @@ export declare class HTMLRenderer extends Visitor<string> {
193
217
  */
194
218
  renderChildren(node: RedNode): string;
195
219
  private renderNode;
196
- /** osu! quirk: newlines immediately preceding a [code] block are completely ignored */
197
- private isNextCodeBlock;
198
- /** Checks if this is the LAST empty_line right before a code block (skipping only spacing) */
199
- private isImmediateEmptyLineBeforeCode;
200
- private isPrevBlockBoundary;
201
- private isTrailingBlockBoundary;
220
+ /** How many newlines a construct swallows around its own tags. */
221
+ private static readonly NEWLINE_RULES;
222
+ /**
223
+ * What a Miliastry-only block does. This is what the old
224
+ * `isPrevBlockBoundary` / `isTrailingBlockBoundary` pair did for every block:
225
+ * eat the first newline after the open, every newline before the close, and
226
+ * the first newline after the close.
227
+ */
228
+ private static readonly LEGACY_BLOCK_RULE;
229
+ /**
230
+ * Containers whose opening tag occupies no source text, so a backwards scan
231
+ * has to walk straight through them.
232
+ */
233
+ private static readonly WIDTHLESS_OPEN;
234
+ /**
235
+ * Same for the closing side. `list_item` is here because `[/*]` is never
236
+ * written: an item ends where the next `[*]` or the `[/list]` begins, so
237
+ * `\s*\[/list\]` sees the newline that Quasar stores inside the item.
238
+ */
239
+ private static readonly WIDTHLESS_CLOSE;
240
+ private newlineRule;
241
+ private static isNewlineNode;
242
+ private static isBlankText;
243
+ /**
244
+ * Whether this `spacing` / `empty_line` leaf is eaten by a neighbouring tag
245
+ * and therefore renders nothing.
246
+ *
247
+ * Each leaf is exactly ONE source newline (the parser splits a run into one
248
+ * node per `\n`), so the four scans below can be read straight off the
249
+ * regexes they mirror. A newline eaten by any of them is eaten: osu's passes
250
+ * run in a fixed order, but since a consumed newline is consumed whichever
251
+ * pass claimed it, the union is enough — the per-pass order only matters for
252
+ * a budget that could be spent elsewhere, and budgets here are counted from
253
+ * the tag outwards, exactly as `\n?\n?` counts.
254
+ */
255
+ private isNewlineSwallowed;
256
+ /** `\[box\]\n*`, `\[quote\]\s*`, `[centre]\n`. */
257
+ private eatenByOpeningTag;
258
+ /** `\n*\[/box\]`, `\s*\[/quote\]`, `\s*\[/list\]`. */
259
+ private eatenByClosingTag;
260
+ /** `\[/box\]\n?`, `\[/list\]\n?\n?`. */
261
+ private eatenAfterClosingTag;
262
+ /** `\s*\[\*\]` — the only pass that eats whitespace BEFORE an opening tag. */
263
+ private eatenBeforeOpeningTag;
202
264
  private renderError;
203
265
  private wrapInline;
204
266
  private wrapBlock;
@@ -208,6 +270,30 @@ export declare class HTMLRenderer extends Visitor<string> {
208
270
  /** Keyword-or-number CSS values (font-weight, font-style, text-decoration). */
209
271
  private isCssKeyword;
210
272
  /** Read a metadata field, falling back to the raw tag attribute. */
273
+ /**
274
+ * Lo único que osu! acepta en `[color=…]`.
275
+ *
276
+ * Su `BBCodeForDB::parseColour` sella el tag con un uid sólo si el valor
277
+ * matchea `#[[:xdigit:]]{6}` o `[[:alpha:]]+` — nada más. No valida que el
278
+ * nombre sea un color CSS de verdad (`banana` pasa), pero `#fff`, `#ffffffff`,
279
+ * `rgb(...)`, `$token` o un hex sin `#` no pasan. Sin uid, la segunda pasada
280
+ * no ve el tag y el opener *y* el closer quedan como texto en la página.
281
+ */
282
+ private static readonly OSU_COLOR_RE;
283
+ /**
284
+ * `[color]` con el vocabulario de cada dialecto.
285
+ *
286
+ * Miliastry (y Lyne) aceptan a propósito más que osu: `#RGB`, `#RGBA`, un
287
+ * `$token` de diseño, nombres propios. Bajo `dialect: 'osu'` eso es una
288
+ * mentira: el editor pintaría color donde la página publicada muestra el
289
+ * BBCode crudo. Así que replicamos lo que hace osu — literal el opener,
290
+ * literal el closer, y los hijos renderizados normalmente en el medio.
291
+ *
292
+ * El chequeo mira el texto crudo del atributo, no el valor saneado: osu
293
+ * matchea sobre la fuente, así que `[color="#ffffff"]` (con comillas) también
294
+ * se le escapa.
295
+ */
296
+ private renderColor;
211
297
  private colorStyle;
212
298
  private fontSizeStyle;
213
299
  private fontStyle;
@@ -252,12 +338,30 @@ export declare class HTMLRenderer extends Visitor<string> {
252
338
  private renderRaw;
253
339
  private renderPlain;
254
340
  private renderAlign;
341
+ /**
342
+ * `[centre]` / `[left]` / `[right]` (y `[align=…]`).
343
+ *
344
+ * osu! no usa `text-align` inline: estiliza el bloque por nombre de clase,
345
+ * con la grafía británica `centre`. Fuera del dialecto osu el estilo inline
346
+ * se mantiene, porque ni Miliastry ni Lyne traen esas reglas.
347
+ */
348
+ private renderAlignAs;
255
349
  private renderEffect;
256
350
  private renderAnim;
257
351
  private renderContainer;
258
352
  private static readonly STYLE_PROP_WHITELIST;
259
353
  private renderStyleTag;
260
354
  private renderQuote;
355
+ /**
356
+ * La estructura exacta que `bbcode-spoilerbox` de osu-web espera.
357
+ *
358
+ * El toggle de osu es JS: `js-spoilerbox__link` es el gancho del click y
359
+ * `js-spoilerbox__body` el panel que abre. Si falta cualquiera de las dos
360
+ * clases el box queda mudo, así que la estructura no es decorativa.
361
+ */
362
+ private renderOsuSpoilerbox;
363
+ /** El rótulo de un box bajo osu: el del autor, o `SPOILER` en mayúsculas. */
364
+ private osuBoxTitle;
261
365
  private renderSpoilerbox;
262
366
  private renderBox;
263
367
  /**
@@ -268,6 +372,14 @@ export declare class HTMLRenderer extends Visitor<string> {
268
372
  * escrito y devolvía `[box=Box]`.
269
373
  */
270
374
  private bareTitleAttr;
375
+ /**
376
+ * ¿El título del box lo escribió el autor, o es el relleno del parser?
377
+ *
378
+ * `BBCodeToGreenNode` ya deja `metadata.title = 'Box'`/`'Spoiler'` para un
379
+ * tag pelado, así que el `fallback` de `renderTitle` nunca llega a usarse:
380
+ * quien quiera otro rótulo por defecto tiene que preguntar por aquí.
381
+ */
382
+ private hasOwnTitle;
271
383
  private renderTitle;
272
384
  /**
273
385
  * `[box=Title:#hex]`, `[tables=striped:#hex]`, `[columns=2:#hex]` → un
@@ -2,7 +2,7 @@ export { Visitor } from './Visitor';
2
2
  export type { VisitorContext } from './Visitor';
3
3
  export { BBCodeExporter } from './BBCodeExporter';
4
4
  export { HTMLRenderer } from './HTMLRenderer';
5
- export { morphHTML } from './DOMMorpher';
5
+ export { morphHTML, morphElement } from './DOMMorpher';
6
6
  export { MarkdownExporter } from './MarkdownExporter';
7
7
  export { JSONExporter } from './JSONExporter';
8
8
  export { SVGRenderer } from './SVGRenderer';
@@ -29,13 +29,18 @@ export interface BoxDrawerOptions {
29
29
  easing?: string;
30
30
  }
31
31
  /**
32
- * Animate one `<details>` to its opposite state, reversing cleanly when it is
33
- * already mid-animation.
34
- *
35
- * Exported so hosts that toggle boxes programmatically (a keyboard command, an
36
- * "expand all" action) go through the same animation as a click.
32
+ * Animate one `<details>` to its opposite state. Exported so hosts that toggle
33
+ * boxes programmatically go through the same animation as a click.
37
34
  */
38
35
  export declare function toggleBoxWithDrawer(details: HTMLDetailsElement, options?: BoxDrawerOptions): void;
36
+ /**
37
+ * Lo mismo para la caja del dialecto osu, que no es un `<details>`: es
38
+ * `div.js-spoilerbox > a.js-spoilerbox__link + div.js-spoilerbox__body`, y su
39
+ * estado abierto es la clase `js-spoilerbox--open` en el contenedor. Marcado
40
+ * distinto, misma animación — y la misma razón para vivir aquí: Quasar es
41
+ * quien emite el marcado, así que también define cómo se abre.
42
+ */
43
+ export declare function toggleSpoilerboxWithDrawer(box: HTMLElement, options?: BoxDrawerOptions): void;
39
44
  /**
40
45
  * Intercept the native toggle of every box inside `root`.
41
46
  *
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Quasar Document Engine — Visuals / LyneAudio.ts
3
+ *
4
+ * Interactive runtime for Quasar's Lyne / SYNE custom audio player (.lx-audio).
5
+ * Supports both scoped root binding (bindLyneAudio) and global document delegation.
6
+ */
7
+ export declare function bindLyneAudio(root?: HTMLElement): () => void;
8
+ export declare const setupLyneAudioRuntime: typeof bindLyneAudio;
@@ -24,6 +24,7 @@
24
24
  */
25
25
  export { bindBoxDrawer, toggleBoxWithDrawer } from './BoxDrawer';
26
26
  export type { BoxDrawerOptions } from './BoxDrawer';
27
+ export { setupLyneAudioRuntime, bindLyneAudio } from './LyneAudio';
27
28
  export declare const visualThemes: readonly [{
28
29
  readonly id: 'osu';
29
30
  readonly name: 'osu! Forum Style';
@@ -86,7 +86,7 @@ export { Visitor, type VisitorContext } from './Visitors/Visitor';
86
86
  export { BBCodeExporter, MILIASTRY_ONLY_TAGS, LYNE_ONLY_TAGS, type ExportTarget, type BBCodeExporterOptions, type BBCodeExportOptions } from './Visitors/BBCodeExporter';
87
87
  export { BBBlocksExporter, type UIBBBlock } from './Visitors/BBBlocksExporter';
88
88
  export { HTMLRenderer, type HTMLRendererOptions } from './Visitors/HTMLRenderer';
89
- export { morphHTML } from './Visitors/DOMMorpher';
89
+ export { morphHTML, morphElement } from './Visitors/DOMMorpher';
90
90
  export { patchBlocksInto, clearPatchCache, type PatchBlocksOptions, type PatchBlocksStats } from './Visitors/BlockPatcher';
91
91
  export { SVGRenderer } from './Visitors/SVGRenderer';
92
92
  export { MarkdownExporter } from './Visitors/MarkdownExporter';
@@ -114,7 +114,7 @@ export { PluginRegistry, type PluginManifest, type PluginContribution } from './
114
114
  export { repairNesting, type NestingRepair, type OrphanCloser, type UnclosedOpener } from './Repair/NestingRepair';
115
115
  export { reconcileVisualDOMToBBCode, computeTextDelta, type SurgicalEdit as QuasarSurgicalEdit, type ReconcileResult } from './Reconciler/SurgicalReconciler';
116
116
  export * from './Edits';
117
- export { BBCodeDocumentModel } from './BBCode/BBCodeDocumentModel';
117
+ export { BBCodeDocumentModel, renderForumBBCode } from './BBCode/BBCodeDocumentModel';
118
118
  export type { BBCodeDocumentModelOptions } from './BBCode/BBCodeDocumentModel';
119
119
  export { bbBlocksToRedTree, bbBlocksToGreenTree, bbBlockToGreenNode, greenToRedNode, tagToNodeKind, nodeKindToTag, isBlockKind, BBCODE_TAG_NAMES, getBBCodeTagNames, type BBCodeDialect, } from './BBCode/BBCodeToGreenNode';
120
120
  export type { BBBlock } from './BBCode/BBCodeToGreenNode';
@@ -122,7 +122,7 @@ export { DocumentEventBus, type DocumentEvent, type DocumentEventHandler } from
122
122
  export { transformOffset, transformRange, type TransformBias } from './Collab/positions';
123
123
  export { visualThemes } from './Visuals/index';
124
124
  export type { VisualThemeId } from './Visuals/index';
125
- export { bindBoxDrawer, toggleBoxWithDrawer } from './Visuals/BoxDrawer';
125
+ export { bindBoxDrawer, toggleBoxWithDrawer, toggleSpoilerboxWithDrawer } from './Visuals/BoxDrawer';
126
126
  export type { BoxDrawerOptions } from './Visuals/BoxDrawer';
127
127
  export { applyGradient, applyGrow, applyRainbow, applyCentralGradient, applyMultiGradient, applyEffect, countTextLength } from './Utils/treeTransformers';
128
128
  export type { GradientEffect, GrowEffect, RainbowEffect, CentralGradientEffect, MultiGradientEffect, TreeEffect } from './Utils/treeTransformers';
@@ -152,3 +152,4 @@ export { nodeAttrValue, parseImgAttr, sanitizeColor, sanitizeFontSize, sanitizeF
152
152
  export type { ImgAttr } from './Syntax/nodeAttr';
153
153
  export { toTokenResolver, resolveTokenValue, } from './Tokens/index';
154
154
  export type { DocumentTokens, TokenResolverFn, TokenSource, } from './Tokens/index';
155
+ export { setupLyneAudioRuntime, bindLyneAudio } from './Visuals/LyneAudio';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miliastry/quasar",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Quasar Document Engine - Advanced BBCode/Markdown/HTML AST Engine",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -32,7 +32,7 @@ import { greenToRedNode, greenToRedNodeReusing, type BBCodeDialect } from './BBC
32
32
  import { parseTokensToGreen } from './Parser'
33
33
  import { scanBBCode } from '../Lexer/BBCodeLexer'
34
34
  import { BBCodeExporter } from '../Visitors/BBCodeExporter'
35
- import { HTMLRenderer } from '../Visitors/HTMLRenderer'
35
+ import { HTMLRenderer, type HTMLRendererOptions } from '../Visitors/HTMLRenderer'
36
36
 
37
37
  export interface BBCodeDocumentModelOptions extends DocumentModelOptions {
38
38
  /** Source BBCode text to parse */
@@ -110,6 +110,39 @@ export class BBCodeDocumentModel extends DocumentModel {
110
110
  return r.render(this.redRoot)
111
111
  }
112
112
 
113
+ /**
114
+ * Fast-path read-only rendering for forum posts, comments, and static views.
115
+ *
116
+ * Completely bypasses semantic analysis (linter), undo stack allocations, and
117
+ * editor node ids (idMode: 'none') to maximize throughput and minimize memory.
118
+ */
119
+ static renderForum(
120
+ source: string,
121
+ options: HTMLRendererOptions & { dialect?: BBCodeDialect } = {}
122
+ ): string {
123
+ const previousIdMode = HTMLRenderer.idMode
124
+ HTMLRenderer.idMode = 'none'
125
+ try {
126
+ const dialect = options.dialect ?? (options.theme === 'lyne' ? 'lyne' : 'miliastry')
127
+ const doc = new BBCodeDocumentModel({
128
+ source: source || ' ',
129
+ dialect,
130
+ autoAnalyze: false,
131
+ maxUndo: 0,
132
+ incremental: false,
133
+ })
134
+ if (!doc.redRoot) return ''
135
+ const renderer = new HTMLRenderer({
136
+ ...options,
137
+ dialect,
138
+ registry: options.registry ?? doc.tagRegistry,
139
+ })
140
+ return renderer.render(doc.redRoot)
141
+ } finally {
142
+ HTMLRenderer.idMode = previousIdMode
143
+ }
144
+ }
145
+
113
146
  /**
114
147
  * Parse BBCode text directly to a GreenNode tree using the
115
148
  * DocumentEngine's built-in BBCode Lexer + Parser.
@@ -176,3 +209,5 @@ export class BBCodeDocumentModel extends DocumentModel {
176
209
  return new BBCodeExporter(this.tagRegistry, exportTarget).export(root)
177
210
  }
178
211
  }
212
+
213
+ export const renderForumBBCode = BBCodeDocumentModel.renderForum
@@ -189,6 +189,14 @@ function domToGreenTree(root: HTMLElement): GreenNode {
189
189
  }
190
190
  });
191
191
  }
192
+ else if (el.classList.contains('lx-audio')) {
193
+ kind = 'audio';
194
+ const audioSrc = el.getAttribute('data-src') || el.querySelector('audio')?.getAttribute('src') || '';
195
+ if (audioSrc) {
196
+ children.push(greenLeaf('text', audioSrc));
197
+ currentOffset += audioSrc.length;
198
+ }
199
+ }
192
200
  else if (el.style.textAlign === 'center') kind = 'center';
193
201
  else if (el.style.textAlign === 'right') kind = 'right';
194
202
  else if (el.style.textAlign === 'left') kind = 'left';
@@ -2213,6 +2213,49 @@ export class SemanticAnalyzer {
2213
2213
  return diagnostics.length > 0 ? diagnostics : null
2214
2214
  },
2215
2215
  })
2216
+
2217
+ // ── Box missing '=' validator ────────────────────────────────
2218
+ // In osu!, [box] without '=' is not parsed as a collapsible box.
2219
+ // Miliastry can preview it as a default-titled box, but osu! forum compatibility
2220
+ // requires [box=] (or [box=Title]).
2221
+ this.register({
2222
+ code: 'box-missing-equals',
2223
+ severity: 'warning',
2224
+ kinds: ['box'],
2225
+ validate: (node, ctx) => {
2226
+ if (node.text.startsWith('=')) return null
2227
+ const openEnd = node.innerStart > node.range.start ? node.innerStart : node.range.start + 5
2228
+ if (node.range.start < 0 || openEnd > ctx.source.length) return null
2229
+ const openTag = ctx.source.slice(node.range.start, openEnd)
2230
+ if (openTag.includes('=')) return null
2231
+
2232
+ const fixes: DiagnosticFix[] = [
2233
+ {
2234
+ description: "Add '=' to [box]",
2235
+ isAutomatic: true,
2236
+ operations: [
2237
+ {
2238
+ kind: 'replace_text',
2239
+ range: { start: node.range.start, end: openEnd },
2240
+ newText: '[box=]',
2241
+ },
2242
+ ],
2243
+ },
2244
+ ]
2245
+
2246
+ return createDiagnostic(
2247
+ 'box-missing-equals',
2248
+ "[box] without '=' can be previewed in Miliastry, but osu! requires [box=] to parse it correctly",
2249
+ 'warning',
2250
+ {
2251
+ nodeId: node.id,
2252
+ nodeKind: node.kind,
2253
+ range: { start: node.range.start, end: openEnd },
2254
+ fixes,
2255
+ },
2256
+ )
2257
+ },
2258
+ })
2216
2259
  }
2217
2260
 
2218
2261
  /**
@@ -199,12 +199,49 @@ function hasAttrValue(value: unknown): boolean {
199
199
  return value !== undefined && value !== null && String(value) !== ''
200
200
  }
201
201
 
202
- function normalizeColorToHex(color: string): string {
202
+ /**
203
+ * Expande un cuerpo hexadecimal a los 6 dígitos que exige osu, o `null` si no
204
+ * es un hex de longitud reconocible (3, 4, 6 u 8).
205
+ *
206
+ * El canal alfa se descarta a propósito: osu no soporta transparencia en
207
+ * ninguna forma, así que la alternativa a perderlo es publicar un `[color=…]`
208
+ * que su parser rechaza y deja como texto literal en la página.
209
+ */
210
+ function expandHexForOsu(body: string): string | null {
211
+ if (!/^[0-9a-fA-F]+$/.test(body)) return null
212
+ switch (body.length) {
213
+ case 3:
214
+ return body[0] + body[0] + body[1] + body[1] + body[2] + body[2]
215
+ case 4:
216
+ // RGBA corto: expandimos RGB y tiramos el alfa.
217
+ return body[0] + body[0] + body[1] + body[1] + body[2] + body[2]
218
+ case 6:
219
+ // Ya es legal: se devuelve tal cual, sin tocar mayúsculas del autor.
220
+ return body
221
+ case 8:
222
+ // RGBA largo: los dos últimos dígitos son el alfa.
223
+ return body.slice(0, 6)
224
+ default:
225
+ return null
226
+ }
227
+ }
228
+
229
+ /**
230
+ * `target` decide si además de convertir `rgb()` hay que adaptar el hex a la
231
+ * gramática de osu (`BBCodeForDB::parseColour`), que sólo acepta `#` + 6
232
+ * dígitos hex o una secuencia puramente alfabética. Para 'miliastry' y 'lyne'
233
+ * el comportamiento es el de siempre: no se reescribe nada.
234
+ */
235
+ function normalizeColorToHex(color: string, target: ExportTarget = 'miliastry'): string {
203
236
  if (!color) return color
204
237
  const trimmed = color.trim()
205
238
  // Un hex ya es canónico: bajarlo a minúsculas reescribía `[color=#FF0000]`
206
239
  // del autor en cada exportación, y `Analysis/RoundTrip` fija lo contrario.
207
- if (trimmed.startsWith('#')) return trimmed
240
+ if (trimmed.startsWith('#')) {
241
+ if (target !== 'osu') return trimmed
242
+ const expanded = expandHexForOsu(trimmed.slice(1))
243
+ return expanded === null ? trimmed : `#${expanded}`
244
+ }
208
245
 
209
246
  const rgbMatch = trimmed.match(/^rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)$/i)
210
247
  if (rgbMatch) {
@@ -214,6 +251,15 @@ function normalizeColorToHex(color: string): string {
214
251
  return `#${r}${g}${b}`.toLowerCase()
215
252
  }
216
253
 
254
+ // Hex desnudo: osu exige el `#`, así que `ff0000` se publicaría roto. Sólo
255
+ // lo tratamos como hex si trae algún dígito; una palabra puramente
256
+ // alfabética (`red`, y también `beef`, que es hex válido) ya matchea la
257
+ // alternativa alfabética de osu y no hay que tocarla.
258
+ if (target === 'osu' && /\d/.test(trimmed)) {
259
+ const expanded = expandHexForOsu(trimmed)
260
+ if (expanded !== null) return `#${expanded}`
261
+ }
262
+
217
263
  return trimmed
218
264
  }
219
265
 
@@ -365,7 +411,7 @@ export class BBCodeExporter extends Visitor<string> {
365
411
  if (this.shouldResolveTokens() && col.startsWith('$')) {
366
412
  col = resolveTokenValue(col, this.tokenResolver)
367
413
  }
368
- out = `[color=${normalizeColorToHex(col)}]${out}[/color]`
414
+ out = `[color=${normalizeColorToHex(col, this.target)}]${out}[/color]`
369
415
  }
370
416
  if (style.fontSize) {
371
417
  let size = style.fontSize
@@ -476,7 +522,7 @@ export class BBCodeExporter extends Visitor<string> {
476
522
  if (this.shouldResolveTokens() && color.startsWith('$')) {
477
523
  color = resolveTokenValue(color, this.tokenResolver)
478
524
  }
479
- return `=${normalizeColorToHex(color)}`
525
+ return `=${normalizeColorToHex(color, this.target)}`
480
526
  } else if (node.kind === 'font' && hasAttrValue(node.metadata.font)) {
481
527
  let font = String(node.metadata.font)
482
528
  if (this.shouldResolveTokens() && font.startsWith('$')) {
@@ -589,7 +635,7 @@ export class BBCodeExporter extends Visitor<string> {
589
635
  if (this.shouldResolveTokens() && col.startsWith('$')) {
590
636
  col = resolveTokenValue(col, this.tokenResolver)
591
637
  }
592
- return `=${normalizeColorToHex(col)}`
638
+ return `=${normalizeColorToHex(col, this.target)}`
593
639
  }
594
640
  if (this.shouldResolveTokens() && text.startsWith('=')) {
595
641
  let val = text.slice(1)
@@ -41,7 +41,7 @@
41
41
 
42
42
  import { RedNode } from '../Syntax/RedNode'
43
43
  import { HTMLRenderer } from './HTMLRenderer'
44
- import { morphHTML } from './DOMMorpher'
44
+ import { morphHTML, morphElement } from './DOMMorpher'
45
45
  import type { TextChangeRange } from '../Incremental/ChangeTracker'
46
46
 
47
47
  export interface PatchBlocksOptions {
@@ -215,6 +215,21 @@ function blockKey(node: RedNode, index: number): string {
215
215
  return node.id ?? `__block_${index}`
216
216
  }
217
217
 
218
+ /**
219
+ * Blocks whose HTML is NOT a function of the node alone.
220
+ *
221
+ * A newline leaf renders `<br>` or nothing depending on which tags surround
222
+ * it — `[/box]\n` loses the newline, `text\n` keeps it (see
223
+ * `HTMLRenderer.isNewlineSwallowed`). The block cache is keyed by node
224
+ * identity, so reordering blocks without touching them keeps every id and
225
+ * every cached fragment while changing what the newlines between them mean.
226
+ * Re-rendering them unconditionally costs a couple of sibling hops and keeps
227
+ * the patched DOM equal to a full render, which is the whole contract.
228
+ */
229
+ function isContextSensitive(node: RedNode): boolean {
230
+ return node.kind === 'spacing' || node.kind === 'empty_line'
231
+ }
232
+
218
233
  /** Build a DOM node from an HTML fragment (first child), decoding entities. */
219
234
  function nodeFromHtml(html: string): Node {
220
235
  const t = document.createElement('template')
@@ -279,7 +294,6 @@ function shouldMorphInPlace(
279
294
  return (
280
295
  run.kind === 'element' &&
281
296
  !!run.node.id &&
282
- run.node.children.length > 0 &&
283
297
  canMorphInPlace(run.node) &&
284
298
  renderedTag(run.html) === element.tagName
285
299
  )
@@ -401,7 +415,7 @@ function reconcileKeyed(
401
415
  let patched = 0
402
416
  try {
403
417
  runs = buildRuns(blocks, keys, (node, key) => {
404
- if (cache.lastNode.get(key) === node) {
418
+ if (cache.lastNode.get(key) === node && !isContextSensitive(node)) {
405
419
  return { html: cache.lastHtml.get(key) ?? '', kind: cache.lastClass.get(key) ?? 'none' }
406
420
  }
407
421
  const html = renderer.render(node)
@@ -469,13 +483,19 @@ function reconcileKeyed(
469
483
  }
470
484
 
471
485
  if (element.nodeType === 1 && shouldMorphInPlace(element as Element, run)) {
472
- // Element with children and a matching tag: morph its inner content in
473
- // place, preserving the element (and runtime state like an open
474
- // `<details>`). Tag mismatch (kind change at a stable slot) or wrapper
475
- // kinds (code/svg/imagemap) go the replace path below.
476
- morphHTML(element as HTMLElement, renderer.renderChildren(run.node))
486
+ // Element with a matching tag: morph its attributes and children in
487
+ // place via morphElement, preserving the element identity and runtime state
488
+ // (such as an open <details>, its <summary> and title, or media playback).
489
+ // Tag mismatch (kind change at a stable slot) or wrapper kinds
490
+ // (code/svg/imagemap) go the replace path below.
491
+ const newEl = nodeFromHtml(run.html)
492
+ if (newEl && newEl.nodeType === 1 && (newEl as Element).tagName === (element as Element).tagName) {
493
+ morphElement(element as Element, newEl as Element)
494
+ } else {
495
+ container.replaceChild(newEl, element)
496
+ }
477
497
  } else {
478
- // Text run, an element leaf (img/video/audio/`<br>`), a kind whose tag
498
+ // Text run, an element leaf (img/video/audio/<br>), a kind whose tag
479
499
  // changed, or a wrapper renderer kind: the node's own structure must
480
500
  // change — replace outright.
481
501
  container.replaceChild(nodeFromHtml(run.html), element)
@@ -639,7 +659,7 @@ function reconcileWindowed(
639
659
  let runs: PatchRun[]
640
660
  try {
641
661
  runs = buildRuns(blocks, keys, (node, key) => {
642
- if (cache.lastNode.get(key) === node) {
662
+ if (cache.lastNode.get(key) === node && !isContextSensitive(node)) {
643
663
  return { html: cache.lastHtml.get(key) ?? '', kind: cache.lastClass.get(key) ?? 'none' }
644
664
  }
645
665
  const html = renderer.render(node)
@@ -909,7 +929,12 @@ function reconcileWindowed(
909
929
  }
910
930
 
911
931
  if (element.nodeType === 1 && shouldMorphInPlace(element as Element, run)) {
912
- morphHTML(element as HTMLElement, renderer.renderChildren(run.node))
932
+ const newEl = nodeFromHtml(run.html)
933
+ if (newEl && newEl.nodeType === 1 && (newEl as Element).tagName === (element as Element).tagName) {
934
+ morphElement(element as Element, newEl as Element)
935
+ } else {
936
+ container.replaceChild(newEl, element)
937
+ }
913
938
  } else {
914
939
  container.replaceChild(nodeFromHtml(run.html), element)
915
940
  }