@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.
@@ -30,10 +30,21 @@ import {
30
30
  } from '../Tokens'
31
31
 
32
32
  export interface HTMLRendererOptions {
33
- /**
34
- * Replicate osu! forum BBCode spacing quirks.
35
- * Defaults to true for full compatibility with Miliastry.
36
- * Set to false for a more logical, predictable rendering engine.
33
+ /**
34
+ * Replicate osu! forum BBCode spacing quirks.
35
+ *
36
+ * NO LO LEE NADIE desde que el motor implementa las reglas reales de
37
+ * consumo de saltos (ver {@link HTMLRenderer.NEWLINE_RULES}). Sólo gobernaba
38
+ * dos heurísticas sobre `[code]` que resultaron ser falsas — osu no se come
39
+ * ningún salto ANTES de un bloque, y el resto de las reglas nunca estuvo
40
+ * condicionado. El espaciado ya no es opcional: Miliastry es "osu con
41
+ * esteroides" y rompe líneas igual, así que apagarlo no tendría a qué
42
+ * volver.
43
+ *
44
+ * Se conserva porque es API pública del renderer y quitarlo rompería a quien
45
+ * lo pase. Es candidato a borrarse en la próxima ruptura de versión.
46
+ *
47
+ * @deprecated Sin efecto. El espaciado de osu es ahora incondicional.
37
48
  */
38
49
  osuBehaviour?: boolean
39
50
  /** Registry for resolving custom tags */
@@ -219,6 +230,25 @@ export class HTMLRenderer extends Visitor<string> {
219
230
  */
220
231
  private tableDepth = 0
221
232
 
233
+ /**
234
+ * ¿Estamos emitiendo el vocabulario de clases de osu!?
235
+ *
236
+ * osu estiliza box, spoilerbox, notice, imagemap, youtube, los alineados y
237
+ * los perfiles POR NOMBRE DE CLASE, no por estilo inline. Sobre una userpage
238
+ * real el HTML de Quasar salía sin estilo porque emitía su propio
239
+ * vocabulario (`<details>`, `.notice`, `.imagemap-container`…). Bajo
240
+ * `dialect: 'osu'` se emiten las clases y la estructura de osu; el resto de
241
+ * dialectos conserva la suya, que es la que sus hojas de estilo esperan.
242
+ */
243
+ private isOsu(): boolean {
244
+ return this.options.dialect === 'osu'
245
+ }
246
+
247
+ /** osu recorta los saltos pegados a la apertura y al cierre de box/notice. */
248
+ private static trimOsuEdges(html: string): string {
249
+ return html.replace(/^[\t ]*\r?\n/, '').replace(/\r?\n[\t ]*$/, '')
250
+ }
251
+
222
252
  private idAttr(node: RedNode): string {
223
253
  if (HTMLRenderer.idMode === 'none') return ''
224
254
  if (HTMLRenderer.idMode === 'all') return ` data-node-id="${node.id}"`
@@ -340,7 +370,7 @@ export class HTMLRenderer extends Visitor<string> {
340
370
  case 'strikethrough': return this.wrapInline('s', node)
341
371
  case 'inline_code': return this.wrapInline('code', node, 'class="inline"')
342
372
  case 'spoiler': return this.wrapInline('span', node, 'class="spoiler"')
343
- case 'color': return this.wrapInline('span', node, this.colorStyle(node))
373
+ case 'color': return this.renderColor(node)
344
374
  case 'font_size': return this.wrapInline('span', node, this.fontSizeStyle(node))
345
375
  case 'font': return this.wrapInline('span', node, this.fontStyle(node))
346
376
  case 'url': return this.renderLink(node, 'url')
@@ -349,9 +379,9 @@ export class HTMLRenderer extends Visitor<string> {
349
379
  case 'image': return this.renderImage(node)
350
380
  case 'video': return this.renderVideo(node)
351
381
  case 'audio': return this.renderAudio(node)
352
- case 'center': return this.wrapBlock('div', node, 'style="text-align:center;"')
353
- case 'right': return this.wrapBlock('div', node, 'style="text-align:right;"')
354
- case 'left': return this.wrapBlock('div', node, 'style="text-align:left;"')
382
+ case 'center': return this.renderAlignAs(node, 'center')
383
+ case 'right': return this.renderAlignAs(node, 'right')
384
+ case 'left': return this.renderAlignAs(node, 'left')
355
385
  // Sigue siendo siempre `h2`, como antes: el nivel de BBCode no mapea al
356
386
  // de HTML y un `[heading=9]` daría un `<h9>` inválido. `data-bare-level`
357
387
  // sólo anota que el 2 lo puso el renderer, para que el camino de vuelta
@@ -441,12 +471,10 @@ export class HTMLRenderer extends Visitor<string> {
441
471
  case 'sinewave': return this.renderEffectSegments(node, 'sinewave')
442
472
  case 'paint': return this.renderEffectSegments(node, 'paint')
443
473
  case 'spacing':
444
- if (this.options.osuBehaviour && this.isNextCodeBlock(node)) return '\n'
445
- if (this.isTrailingBlockBoundary(node)) return '\n'
446
- return this.isPrevBlockBoundary(node) ? '\n' : `<br${this.idAttr(node)}>`
474
+ if (this.isNewlineSwallowed(node)) return '\n'
475
+ return `<br${this.idAttr(node)}>`
447
476
  case 'empty_line':
448
- if (this.options.osuBehaviour && this.isImmediateEmptyLineBeforeCode(node)) return '\n'
449
- if (this.isTrailingBlockBoundary(node)) return '\n'
477
+ if (this.isNewlineSwallowed(node)) return '\n'
450
478
  return `<div class="bb-empty-line"${this.idAttr(node)}><br></div>`
451
479
  case 'group': return this.wrapInline('span', node, 'class="group"')
452
480
  // Un párrafo no tiene etiqueta propia en BBCode, pero sí necesita un
@@ -481,65 +509,224 @@ export class HTMLRenderer extends Visitor<string> {
481
509
  }
482
510
  }
483
511
 
484
- // ─── Render Helpers ─────────────────────────────────────
512
+ // ─── Newline swallowing ─────────────────────────────────
513
+ //
514
+ // osu! turns newlines into `<br />` with one flat rule at the very end of
515
+ // `BBCodeFromDB::toHTML` — `str_replace("\n", '<br />')`. Every subtlety
516
+ // lives BEFORE that line: each block pass is a regex that eats the newlines
517
+ // touching its own tags, so those newlines are simply gone by the time the
518
+ // flat rule runs. The amount eaten differs per tag, and the asymmetries are
519
+ // not decorative:
520
+ //
521
+ // parseBox `\[box=…\]\n*` `\n*\[/box\]\n?`
522
+ // parseCode `\[code\]\n*` `\n*\[/code\]\n?`
523
+ // parseNotice `\[notice\]\n*` `\n*\[/notice\]\n?`
524
+ // parseList `\s*\[\*\]` `\s*\[/list\]\n?\n?`
525
+ // parseQuote `\[quote…\]\s*` `\s*\[/quote\]\n?\n?`
526
+ // parseHeading — `\[/heading\]\n?`
527
+ // parseImagemap — `\[/imagemap\]\n?`
528
+ // parseAlignment strtr of `[centre]\n` and `[/centre]\n` — exactly one
529
+ //
530
+ // Quasar used to approximate all of that with two neighbourhood heuristics
531
+ // (`isPrevBlockBoundary` / `isTrailingBlockBoundary`) that treated every
532
+ // block alike, so they over-ate at `[centre]`/`[/imagemap]` and under-ate at
533
+ // `[/list]`/`[/quote]`. This models the real rules instead.
534
+ //
535
+ // Deliberately NOT gated on the dialect: Miliastry is "osu with steroids"
536
+ // and has to break lines the same way. Blocks that only exist in Miliastry
537
+ // (tables, gallery, columns, scroll, …) have no osu counterpart to copy, so
538
+ // they keep the legacy behaviour via {@link HTMLRenderer.LEGACY_BLOCK_RULE}.
539
+
540
+ /** How many newlines a construct swallows around its own tags. */
541
+ private static readonly NEWLINE_RULES: Record<string, {
542
+ /** Newlines eaten right after the opening tag. */
543
+ afterOpen: 'all' | 'whitespace' | 'one' | 'none'
544
+ /** Newlines eaten right before the closing tag. */
545
+ beforeClose: 'all' | 'whitespace' | 'none'
546
+ /** Whitespace eaten right before the OPENING tag (`\s*\[\*\]`). */
547
+ beforeOpen: 'whitespace' | 'none'
548
+ /** Newlines eaten right after the closing tag. */
549
+ afterClose: number
550
+ }> = {
551
+ // `\n*` inside both edges, one newline after the close.
552
+ box: { afterOpen: 'all', beforeClose: 'all', beforeOpen: 'none', afterClose: 1 },
553
+ boxw: { afterOpen: 'all', beforeClose: 'all', beforeOpen: 'none', afterClose: 1 },
554
+ spoilerbox: { afterOpen: 'all', beforeClose: 'all', beforeOpen: 'none', afterClose: 1 },
555
+ notice: { afterOpen: 'all', beforeClose: 'all', beforeOpen: 'none', afterClose: 1 },
556
+ wnotice: { afterOpen: 'all', beforeClose: 'all', beforeOpen: 'none', afterClose: 1 },
557
+ code: { afterOpen: 'all', beforeClose: 'all', beforeOpen: 'none', afterClose: 1 },
558
+ // `\s*` — not just newlines — and TWO newlines after the close.
559
+ quote: { afterOpen: 'whitespace', beforeClose: 'whitespace', beforeOpen: 'none', afterClose: 2 },
560
+ // `[list]` itself eats nothing after its opening tag: the pass that eats
561
+ // is `\s*\[\*\]`, which needs an item to follow. `[list]\n\nloose text`
562
+ // keeps both newlines; `[list]\n[*]a` loses one to the item, not the list.
563
+ list: { afterOpen: 'none', beforeClose: 'whitespace', beforeOpen: 'none', afterClose: 2 },
564
+ // `\s*\[\*\]`. The matching `[/*]` of the table exists only in legacy
565
+ // phpBB rows — `BBCodeForDB` never emits one — so the item's close is
566
+ // width-less here and its two-newline budget is unreachable by design;
567
+ // `[*]a\n\n[*]b` loses both newlines to the NEXT item's `\s*`, which is
568
+ // the same output by a different route.
569
+ list_item: { afterOpen: 'none', beforeClose: 'none', beforeOpen: 'whitespace', afterClose: 0 },
570
+ // strtr with `[centre]\n` / `[/centre]\n`: exactly one on each outer edge,
571
+ // and nothing before the close — `x\n[/centre]` really does keep its `<br>`.
572
+ center: { afterOpen: 'one', beforeClose: 'none', beforeOpen: 'none', afterClose: 1 },
573
+ left: { afterOpen: 'one', beforeClose: 'none', beforeOpen: 'none', afterClose: 1 },
574
+ right: { afterOpen: 'one', beforeClose: 'none', beforeOpen: 'none', afterClose: 1 },
575
+ align: { afterOpen: 'one', beforeClose: 'none', beforeOpen: 'none', afterClose: 1 },
576
+ heading: { afterOpen: 'none', beforeClose: 'none', beforeOpen: 'none', afterClose: 1 },
577
+ imagemap: { afterOpen: 'none', beforeClose: 'none', beforeOpen: 'none', afterClose: 1 },
578
+ // `[img]` is inline in osu and swallows nothing at all.
579
+ image: { afterOpen: 'none', beforeClose: 'none', beforeOpen: 'none', afterClose: 0 },
580
+ document: { afterOpen: 'none', beforeClose: 'none', beforeOpen: 'none', afterClose: 0 },
581
+ }
485
582
 
486
- /** osu! quirk: newlines immediately preceding a [code] block are completely ignored */
487
- private isNextCodeBlock(node: RedNode): boolean {
488
- let next = node.nextSibling
489
- while (next && (next.kind === 'spacing' || next.kind === 'empty_line')) {
490
- next = next.nextSibling
491
- }
492
- return next?.kind === 'code'
583
+ /**
584
+ * What a Miliastry-only block does. This is what the old
585
+ * `isPrevBlockBoundary` / `isTrailingBlockBoundary` pair did for every block:
586
+ * eat the first newline after the open, every newline before the close, and
587
+ * the first newline after the close.
588
+ */
589
+ private static readonly LEGACY_BLOCK_RULE = {
590
+ afterOpen: 'one', beforeClose: 'all', beforeOpen: 'none', afterClose: 1,
591
+ } as const
592
+
593
+ /**
594
+ * Containers whose opening tag occupies no source text, so a backwards scan
595
+ * has to walk straight through them.
596
+ */
597
+ private static readonly WIDTHLESS_OPEN = new Set(['paragraph', 'group'])
598
+
599
+ /**
600
+ * Same for the closing side. `list_item` is here because `[/*]` is never
601
+ * written: an item ends where the next `[*]` or the `[/list]` begins, so
602
+ * `\s*\[/list\]` sees the newline that Quasar stores inside the item.
603
+ */
604
+ private static readonly WIDTHLESS_CLOSE = new Set(['paragraph', 'group', 'list_item'])
605
+
606
+ private newlineRule(kind: string) {
607
+ const rule = HTMLRenderer.NEWLINE_RULES[kind]
608
+ if (rule) return rule
609
+ return this.BLOCK_TAGS.has(kind) ? HTMLRenderer.LEGACY_BLOCK_RULE : null
493
610
  }
494
611
 
495
- /** Checks if this is the LAST empty_line right before a code block (skipping only spacing) */
496
- private isImmediateEmptyLineBeforeCode(node: RedNode): boolean {
497
- let next = node.nextSibling
498
- while (next && next.kind === 'spacing') {
499
- next = next.nextSibling
500
- }
501
- return next?.kind === 'code'
612
+ private static isNewlineNode(node: RedNode): boolean {
613
+ return node.kind === 'spacing' || node.kind === 'empty_line'
502
614
  }
503
615
 
504
- private isPrevBlockBoundary(node: RedNode): boolean {
505
- let prev = node.previousSibling
506
- while (prev) {
507
- if (prev.kind === 'spacing' || prev.kind === 'empty_line') {
508
- prev = prev.previousSibling
509
- continue
616
+ private static isBlankText(node: RedNode): boolean {
617
+ return node.kind === 'text' && node.children.length === 0 && node.text.trim() === ''
618
+ }
619
+
620
+ /**
621
+ * Whether this `spacing` / `empty_line` leaf is eaten by a neighbouring tag
622
+ * and therefore renders nothing.
623
+ *
624
+ * Each leaf is exactly ONE source newline (the parser splits a run into one
625
+ * node per `\n`), so the four scans below can be read straight off the
626
+ * regexes they mirror. A newline eaten by any of them is eaten: osu's passes
627
+ * run in a fixed order, but since a consumed newline is consumed whichever
628
+ * pass claimed it, the union is enough — the per-pass order only matters for
629
+ * a budget that could be spent elsewhere, and budgets here are counted from
630
+ * the tag outwards, exactly as `\n?\n?` counts.
631
+ */
632
+ private isNewlineSwallowed(node: RedNode): boolean {
633
+ return this.eatenByOpeningTag(node)
634
+ || this.eatenByClosingTag(node)
635
+ || this.eatenAfterClosingTag(node)
636
+ || this.eatenBeforeOpeningTag(node)
637
+ }
638
+
639
+ /** `\[box\]\n*`, `\[quote\]\s*`, `[centre]\n`. */
640
+ private eatenByOpeningTag(node: RedNode): boolean {
641
+ let cur: RedNode = node
642
+ let newlinesBetween = 0
643
+ let blankBetween = false
644
+ for (;;) {
645
+ const prev = cur.previousSibling
646
+ if (prev) {
647
+ if (HTMLRenderer.isNewlineNode(prev)) { newlinesBetween++; cur = prev; continue }
648
+ if (HTMLRenderer.isBlankText(prev)) { blankBetween = true; cur = prev; continue }
649
+ return false
510
650
  }
511
- if (prev.kind === 'text' && prev.text.trim() === '') {
512
- prev = prev.previousSibling
513
- continue
651
+ const parent = cur.parent
652
+ if (!parent) return false
653
+ if (HTMLRenderer.WIDTHLESS_OPEN.has(parent.kind)) { cur = parent; continue }
654
+ const rule = this.newlineRule(parent.kind)
655
+ if (!rule) return false
656
+ switch (rule.afterOpen) {
657
+ case 'whitespace': return true
658
+ // `\n*` matches newlines only: a stray space breaks the run.
659
+ case 'all': return !blankBetween
660
+ case 'one': return !blankBetween && newlinesBetween === 0
661
+ default: return false
514
662
  }
515
- break
516
663
  }
517
-
518
- if (prev && this.BLOCK_TAGS.has(prev.kind) && prev.kind !== 'image' && prev.kind !== 'imagemap') return true
519
- if (!prev && node.parent && this.BLOCK_TAGS.has(node.parent.kind) && node.parent.kind !== 'image' && node.parent.kind !== 'imagemap') return true
664
+ }
520
665
 
521
- return false
666
+ /** `\n*\[/box\]`, `\s*\[/quote\]`, `\s*\[/list\]`. */
667
+ private eatenByClosingTag(node: RedNode): boolean {
668
+ let cur: RedNode = node
669
+ let blankBetween = false
670
+ for (;;) {
671
+ const next = cur.nextSibling
672
+ if (next) {
673
+ if (HTMLRenderer.isNewlineNode(next)) { cur = next; continue }
674
+ if (HTMLRenderer.isBlankText(next)) { blankBetween = true; cur = next; continue }
675
+ return false
676
+ }
677
+ const parent = cur.parent
678
+ if (!parent) return false
679
+ if (HTMLRenderer.WIDTHLESS_CLOSE.has(parent.kind)) { cur = parent; continue }
680
+ const rule = this.newlineRule(parent.kind)
681
+ if (!rule) return false
682
+ switch (rule.beforeClose) {
683
+ case 'whitespace': return true
684
+ case 'all': return !blankBetween
685
+ default: return false
686
+ }
687
+ }
522
688
  }
523
689
 
524
- private isTrailingBlockBoundary(node: RedNode): boolean {
525
- let next = node.nextSibling
526
- while (next) {
527
- if (next.kind === 'spacing' || next.kind === 'empty_line') {
528
- next = next.nextSibling
529
- continue
690
+ /** `\[/box\]\n?`, `\[/list\]\n?\n?`. */
691
+ private eatenAfterClosingTag(node: RedNode): boolean {
692
+ let cur: RedNode = node
693
+ let newlinesBetween = 0
694
+ for (;;) {
695
+ const prev = cur.previousSibling
696
+ if (!prev) {
697
+ const parent = cur.parent
698
+ if (parent && HTMLRenderer.WIDTHLESS_OPEN.has(parent.kind)) { cur = parent; continue }
699
+ return false
530
700
  }
531
- if (next.kind === 'text' && next.text.trim() === '') {
532
- next = next.nextSibling
533
- continue
701
+ if (HTMLRenderer.isNewlineNode(prev)) { newlinesBetween++; cur = prev; continue }
702
+ // Descend to whatever real closing tag sits immediately to our left.
703
+ let closer: RedNode = prev
704
+ while (HTMLRenderer.WIDTHLESS_CLOSE.has(closer.kind) && closer.children.length > 0) {
705
+ closer = closer.children[closer.children.length - 1]
534
706
  }
535
- break
707
+ const rule = this.newlineRule(closer.kind)
708
+ return rule !== null && newlinesBetween < rule.afterClose
536
709
  }
537
- if (!next && node.parent && this.BLOCK_TAGS.has(node.parent.kind) && node.parent.kind !== 'document') {
538
- return true
710
+ }
711
+
712
+ /** `\s*\[\*\]` — the only pass that eats whitespace BEFORE an opening tag. */
713
+ private eatenBeforeOpeningTag(node: RedNode): boolean {
714
+ let cur: RedNode = node
715
+ for (;;) {
716
+ const next = cur.nextSibling
717
+ if (next) {
718
+ if (HTMLRenderer.isNewlineNode(next) || HTMLRenderer.isBlankText(next)) { cur = next; continue }
719
+ return this.newlineRule(next.kind)?.beforeOpen === 'whitespace'
720
+ }
721
+ const parent = cur.parent
722
+ if (!parent) return false
723
+ if (HTMLRenderer.WIDTHLESS_CLOSE.has(parent.kind)) { cur = parent; continue }
724
+ return false
539
725
  }
540
- return false
541
726
  }
542
727
 
728
+ // ─── Render Helpers ─────────────────────────────────────
729
+
543
730
  private renderError(node: RedNode): string {
544
731
  const errorMsg = this.escapeHtml((node.metadata?.message as string) || node.text || 'Syntax Error')
545
732
  // The content is the raw offending tag, mapped as child text.
@@ -600,6 +787,45 @@ export class HTMLRenderer extends Visitor<string> {
600
787
 
601
788
  /** Read a metadata field, falling back to the raw tag attribute. */
602
789
 
790
+ /**
791
+ * Lo único que osu! acepta en `[color=…]`.
792
+ *
793
+ * Su `BBCodeForDB::parseColour` sella el tag con un uid sólo si el valor
794
+ * matchea `#[[:xdigit:]]{6}` o `[[:alpha:]]+` — nada más. No valida que el
795
+ * nombre sea un color CSS de verdad (`banana` pasa), pero `#fff`, `#ffffffff`,
796
+ * `rgb(...)`, `$token` o un hex sin `#` no pasan. Sin uid, la segunda pasada
797
+ * no ve el tag y el opener *y* el closer quedan como texto en la página.
798
+ */
799
+ private static readonly OSU_COLOR_RE = /^(?:#[0-9a-fA-F]{6}|[a-zA-Z]+)$/
800
+
801
+ /**
802
+ * `[color]` con el vocabulario de cada dialecto.
803
+ *
804
+ * Miliastry (y Lyne) aceptan a propósito más que osu: `#RGB`, `#RGBA`, un
805
+ * `$token` de diseño, nombres propios. Bajo `dialect: 'osu'` eso es una
806
+ * mentira: el editor pintaría color donde la página publicada muestra el
807
+ * BBCode crudo. Así que replicamos lo que hace osu — literal el opener,
808
+ * literal el closer, y los hijos renderizados normalmente en el medio.
809
+ *
810
+ * El chequeo mira el texto crudo del atributo, no el valor saneado: osu
811
+ * matchea sobre la fuente, así que `[color="#ffffff"]` (con comillas) también
812
+ * se le escapa.
813
+ */
814
+ private renderColor(node: RedNode): string {
815
+ if (this.options.dialect === 'osu') {
816
+ const text = node.text || ''
817
+ const eq = text.indexOf('=')
818
+ // Sin `=` en el texto el nodo no vino del parser de BBCode (import de
819
+ // HTML, por ejemplo): ahí el único valor disponible es el de metadata.
820
+ const raw = eq >= 0 ? text.slice(eq + 1) : (text ? '' : nodeAttrValue(node, 'color'))
821
+ if (!HTMLRenderer.OSU_COLOR_RE.test(raw)) {
822
+ const opener = eq >= 0 ? `[color${text}]` : `[${text || 'color'}]`
823
+ return this.escapeHtml(opener) + this.renderChildren(node) + this.escapeHtml('[/color]')
824
+ }
825
+ }
826
+ return this.wrapInline('span', node, this.colorStyle(node))
827
+ }
828
+
603
829
  private colorStyle(node: RedNode): string {
604
830
  const color = sanitizeColor(nodeAttrValue(node, 'color'), this.tokenResolver)
605
831
  return color ? `style="color:${color};"` : ''
@@ -654,6 +880,15 @@ export class HTMLRenderer extends Visitor<string> {
654
880
  return `<strong${entity}><a${this.idAttr(node)} href="${this.escapeHtml(link.href)}"${ext}>${content}</a></strong>`
655
881
  }
656
882
  }
883
+ if (type === 'profile' && this.isOsu()) {
884
+ // Sacado de los fixtures `basic_profile*` de osu-web, no adivinado: el
885
+ // enlace es un `<a>` pelado (nada de `<strong>`), y `data-user-id` lleva
886
+ // el id numérico cuando `[profile=N]` lo trae y `@` + el texto crudo
887
+ // cuando no. El href es ese mismo valor, URL-encoded.
888
+ const key = val || `@${this.collectNodeText(node)}`
889
+ const href = `https://osu.ppy.sh/users/${encodeURIComponent(key)}`
890
+ return `<a${this.idAttr(node)}${entity} class="user-name js-usercard" data-user-id="${this.escapeHtml(key)}" href="${href}">${content}</a>`
891
+ }
657
892
  if (type === 'profile') {
658
893
  const url = this.options.theme === 'lyne' || this.options.dialect === 'lyne'
659
894
  ? `/u/${encodeURIComponent(val || content)}`
@@ -717,7 +952,11 @@ export class HTMLRenderer extends Visitor<string> {
717
952
  if (ytMatch) id = ytMatch[1]
718
953
  // `data-youtube` es lo que deja al converter HTML→BBCode reconocer este
719
954
  // iframe. Sin él el vídeo volvía como un `group` vacío: se perdía entero.
720
- return `<iframe${this.idAttr(node)} class="bb-youtube" data-youtube="${this.escapeHtml(id)}" src="https://www.youtube.com/embed/${this.escapeHtml(id)}" frameborder="0" allowfullscreen></iframe>`
955
+ // Las clases de osu van en el iframe mismo, no en un div contenedor: son
956
+ // las que le dan la caja 16:9. Y su `src` lleva siempre `?rel=0`.
957
+ const cls = this.isOsu() ? 'u-embed-wide u-embed-wide--bbcode' : 'bb-youtube'
958
+ const rel = this.isOsu() ? '?rel=0' : ''
959
+ return `<iframe${this.idAttr(node)} class="${cls}" data-youtube="${this.escapeHtml(id)}" src="https://www.youtube.com/embed/${this.escapeHtml(id)}${rel}" frameborder="0" allowfullscreen></iframe>`
721
960
  }
722
961
 
723
962
  private renderAudio(node: RedNode): string {
@@ -725,6 +964,31 @@ export class HTMLRenderer extends Visitor<string> {
725
964
  if (this.options.mediaProxy && src) {
726
965
  src = this.options.mediaProxy(src)
727
966
  }
967
+ const isLyne = this.options.theme === 'lyne' || this.options.dialect === 'lyne'
968
+ if (isLyne) {
969
+ const rawName = src.split('?')[0].split('#')[0].split('/').filter(Boolean).pop() || 'audio_track.mp3'
970
+ let fileName = rawName
971
+ try {
972
+ fileName = decodeURIComponent(rawName)
973
+ } catch {}
974
+ return `<div${this.idAttr(node)} class="lx-audio bb-audio" data-src="${this.escapeHtml(src)}">`
975
+ + `<audio preload="metadata" src="${this.escapeHtml(src)}"></audio>`
976
+ + `<div class="lx-track" role="progressbar" aria-label="Audio progress"><div class="fill"></div></div>`
977
+ + `<div class="lx-row">`
978
+ + `<button type="button" class="lx-btn" aria-label="Play"><svg viewBox="0 0 16 16"><path d="M3 1.5 14 8 3 14.5z"/></svg></button>`
979
+ + `<div class="lx-title">`
980
+ + `<div class="name">${this.escapeHtml(fileName)}</div>`
981
+ + `<div class="label"><span class="dot"></span> <span class="status-text">audio</span></div>`
982
+ + `</div>`
983
+ + `<div class="lx-vol">`
984
+ + `<button type="button" class="lx-vol-btn" aria-label="Mute"><svg viewBox="0 0 16 16"><path d="M10.707 11.182A4.5 4.5 0 0 0 12.025 8a4.5 4.5 0 0 0-1.318-3.182L10 5.525A3.5 3.5 0 0 1 11.025 8 3.5 3.5 0 0 1 10 10.475zM6.717 3.55A.5.5 0 0 1 7 4v8a.5.5 0 0 1-.812.39L3.825 10.5H1.5A.5.5 0 0 1 1 10V6a.5.5 0 0 1 .5-.5h2.325l2.363-1.89a.5.5 0 0 1 .529-.06"/></svg></button>`
985
+ + `<input type="range" class="lx-vol-slider" min="0" max="1" step="0.01" value="0.2" aria-label="Volume" style="background:linear-gradient(to right, var(--color-accent, #2EE6E2) 20%, var(--color-inset-well, #080D20) 20%);" />`
986
+ + `</div>`
987
+ + `<button type="button" class="lx-speed" aria-label="Playback speed">1.0×</button>`
988
+ + `<div class="lx-time"><span class="cur">0:00</span> / <span class="total">–:––</span></div>`
989
+ + `</div>`
990
+ + `</div>`
991
+ }
728
992
  return `<audio${this.idAttr(node)} controls src="${this.escapeHtml(src)}" class="bb-audio"></audio>`
729
993
  }
730
994
 
@@ -755,6 +1019,13 @@ export class HTMLRenderer extends Visitor<string> {
755
1019
  return `<div${this.idAttr(node)} class="notice bb-cut-panel bb-notice${warning ? ' bb-wnotice' : ''}" role="note"${styleAttr}>${warningIcon}<div class="bb-notice-body">${content}</div></div>`
756
1020
  }
757
1021
 
1022
+ // osu pinta el aviso con `.well` a secas — no hay ninguna clase `notice`
1023
+ // en su hoja de estilos, que es por lo que el bloque salía desnudo.
1024
+ if (this.isOsu()) {
1025
+ const content = HTMLRenderer.trimOsuEdges(this.renderChildren(node))
1026
+ return `<div${this.idAttr(node)} class="well">${content}</div>`
1027
+ }
1028
+
758
1029
  return this.wrapBlock('div', node, 'class="notice"')
759
1030
  }
760
1031
 
@@ -882,7 +1153,22 @@ export class HTMLRenderer extends Visitor<string> {
882
1153
  private renderAlign(node: RedNode): string {
883
1154
  const alignVal = (String(node.metadata?.align ?? '') || nodeAttrValue(node) || 'center').trim().toLowerCase()
884
1155
  const validAlign = alignVal === 'left' || alignVal === 'right' ? alignVal : 'center'
885
- return this.wrapBlock('div', node, `style="text-align:${validAlign};"`)
1156
+ return this.renderAlignAs(node, validAlign)
1157
+ }
1158
+
1159
+ /**
1160
+ * `[centre]` / `[left]` / `[right]` (y `[align=…]`).
1161
+ *
1162
+ * osu! no usa `text-align` inline: estiliza el bloque por nombre de clase,
1163
+ * con la grafía británica `centre`. Fuera del dialecto osu el estilo inline
1164
+ * se mantiene, porque ni Miliastry ni Lyne traen esas reglas.
1165
+ */
1166
+ private renderAlignAs(node: RedNode, align: 'center' | 'left' | 'right'): string {
1167
+ if (this.isOsu()) {
1168
+ const name = align === 'center' ? 'centre' : align
1169
+ return this.wrapBlock('div', node, `class="bbcode__align-${name}"`)
1170
+ }
1171
+ return this.wrapBlock('div', node, `style="text-align:${align};"`)
886
1172
  }
887
1173
 
888
1174
  private renderEffect(node: RedNode): string {
@@ -1052,7 +1338,32 @@ export class HTMLRenderer extends Visitor<string> {
1052
1338
  return `<blockquote${this.idAttr(node)}>${content}</blockquote>`
1053
1339
  }
1054
1340
 
1341
+ /**
1342
+ * La estructura exacta que `bbcode-spoilerbox` de osu-web espera.
1343
+ *
1344
+ * El toggle de osu es JS: `js-spoilerbox__link` es el gancho del click y
1345
+ * `js-spoilerbox__body` el panel que abre. Si falta cualquiera de las dos
1346
+ * clases el box queda mudo, así que la estructura no es decorativa.
1347
+ */
1348
+ private renderOsuSpoilerbox(node: RedNode, title: string, extra: string): string {
1349
+ const content = HTMLRenderer.trimOsuEdges(this.renderChildren(node))
1350
+ return `<div${this.idAttr(node)} class="js-spoilerbox bbcode-spoilerbox"${extra}>` +
1351
+ `<a class="js-spoilerbox__link bbcode-spoilerbox__link" href="#">` +
1352
+ `<span class="bbcode-spoilerbox__link-icon"></span>` +
1353
+ `<span class="bbcode-spoilerbox__link-text">${title}</span></a>` +
1354
+ `<div class="js-spoilerbox__body bbcode-spoilerbox__body">${content}</div></div>`
1355
+ }
1356
+
1357
+ /** El rótulo de un box bajo osu: el del autor, o `SPOILER` en mayúsculas. */
1358
+ private osuBoxTitle(node: RedNode): string {
1359
+ return this.hasOwnTitle(node) ? this.renderTitle(node, 'SPOILER') : 'SPOILER'
1360
+ }
1361
+
1055
1362
  private renderSpoilerbox(node: RedNode): string {
1363
+ if (this.isOsu()) {
1364
+ // Un [spoilerbox] sin título propio se rotula SPOILER, en mayúsculas.
1365
+ return this.renderOsuSpoilerbox(node, this.osuBoxTitle(node), this.bareTitleAttr(node))
1366
+ }
1056
1367
  const title = this.renderTitle(node, 'Spoiler')
1057
1368
  const bare = this.bareTitleAttr(node)
1058
1369
  const content = this.renderChildren(node)
@@ -1068,6 +1379,11 @@ export class HTMLRenderer extends Visitor<string> {
1068
1379
  }
1069
1380
 
1070
1381
  private renderBox(node: RedNode): string {
1382
+ if (this.isOsu()) {
1383
+ // osu no distingue box de spoilerbox: es la misma construcción, y sin
1384
+ // título propio rotula SPOILER igual que `[spoilerbox]`.
1385
+ return this.renderOsuSpoilerbox(node, this.osuBoxTitle(node), this.bareTitleAttr(node))
1386
+ }
1071
1387
  const title = this.renderTitle(node, 'Box')
1072
1388
  const bare = this.bareTitleAttr(node)
1073
1389
  const content = this.renderChildren(node)
@@ -1092,9 +1408,19 @@ export class HTMLRenderer extends Visitor<string> {
1092
1408
  * escrito y devolvía `[box=Box]`.
1093
1409
  */
1094
1410
  private bareTitleAttr(node: RedNode): string {
1411
+ return this.hasOwnTitle(node) ? '' : ' data-bare-title="1"'
1412
+ }
1413
+
1414
+ /**
1415
+ * ¿El título del box lo escribió el autor, o es el relleno del parser?
1416
+ *
1417
+ * `BBCodeToGreenNode` ya deja `metadata.title = 'Box'`/`'Spoiler'` para un
1418
+ * tag pelado, así que el `fallback` de `renderTitle` nunca llega a usarse:
1419
+ * quien quiera otro rótulo por defecto tiene que preguntar por aquí.
1420
+ */
1421
+ private hasOwnTitle(node: RedNode): boolean {
1095
1422
  const raw = node.metadata?.rawTitle
1096
- const hasOwnTitle = raw !== undefined ? String(raw) !== '' : node.metadata?.title !== undefined
1097
- return hasOwnTitle ? '' : ' data-bare-title="1"'
1423
+ return raw !== undefined ? String(raw) !== '' : node.metadata?.title !== undefined
1098
1424
  }
1099
1425
 
1100
1426
  private renderTitle(node: RedNode, fallback: string): string {
@@ -1218,9 +1544,26 @@ export class HTMLRenderer extends Visitor<string> {
1218
1544
  areaUrl = 'https://' + areaUrl
1219
1545
  }
1220
1546
 
1547
+ if (this.isOsu()) {
1548
+ // osu posiciona por CSS (`.imagemap__link` ya es absolute), así que el
1549
+ // style inline lleva sólo las cuatro coordenadas. Un destino `#` no es
1550
+ // un enlace: emite un `span` con la misma clase, para que la zona siga
1551
+ // mostrando su `title` sin navegar a ninguna parte.
1552
+ const pos = `left:${x}%;top:${y}%;width:${w}%;height:${h}%;`
1553
+ const title = ` title="${this.escapeHtml(label)}"`
1554
+ areas += url === '#'
1555
+ ? `<span class="imagemap__link" style="${pos}"${title}></span>`
1556
+ : `<a class="imagemap__link" href="${this.escapeHtml(areaUrl)}" style="${pos}"${title}></a>`
1557
+ continue
1558
+ }
1221
1559
  areas += `<a${this.idAttr(node)} href="${this.escapeHtml(areaUrl)}" target="_blank" rel="noopener" class="imagemap-area bbcode-imap-area" style="position:absolute;left:${x}%;top:${y}%;width:${w}%;height:${h}%;" title="${this.escapeHtml(label || 'Link')}"></a>`
1222
1560
  }
1223
1561
 
1562
+ if (this.isOsu()) {
1563
+ return `<div${this.idAttr(node)} class="imagemap">` +
1564
+ `<img class="imagemap__image" loading="lazy" src="${this.escapeHtml(imageUrl)}" alt="">${areas}</div>`
1565
+ }
1566
+
1224
1567
  return `<div${this.idAttr(node)} class="imagemap-container bbcode-imagemap" style="position:relative;display:inline-block;"><img src="${this.escapeHtml(imageUrl)}" alt="imagemap" style="max-width:100%;height:auto;display:block;">${areas}</div>`
1225
1568
  }
1226
1569
 
@@ -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'