@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.
- package/README.md +1 -1
- package/dist/Visuals/lyne.css +257 -3
- package/dist/Visuals/osu.css +357 -6
- package/dist/index.js +850 -98
- package/dist/index.mjs +846 -99
- package/dist/types/BBCode/BBCodeDocumentModel.d.ts +11 -1
- package/dist/types/Visitors/DOMMorpher.d.ts +6 -0
- package/dist/types/Visitors/HTMLRenderer.d.ts +120 -8
- package/dist/types/Visitors/index.d.ts +1 -1
- package/dist/types/Visuals/BoxDrawer.d.ts +10 -5
- package/dist/types/Visuals/LyneAudio.d.ts +8 -0
- package/dist/types/Visuals/index.d.ts +1 -0
- package/dist/types/index.d.ts +4 -3
- package/package.json +1 -1
- package/src/BBCode/BBCodeDocumentModel.ts +36 -1
- package/src/HTML/HTMLToGreenNode.ts +8 -0
- package/src/Semantic/SemanticAnalyzer.ts +43 -0
- package/src/Visitors/BBCodeExporter.ts +51 -5
- package/src/Visitors/BlockPatcher.ts +36 -11
- package/src/Visitors/DOMMorpher.ts +44 -38
- package/src/Visitors/HTMLRenderer.ts +402 -59
- package/src/Visitors/index.ts +1 -1
- package/src/Visuals/BoxDrawer.ts +56 -10
- package/src/Visuals/LyneAudio.ts +346 -0
- package/src/Visuals/index.ts +1 -0
- package/src/Visuals/lyne.css +257 -3
- package/src/Visuals/osu.css +357 -6
- package/src/index.ts +6 -3
|
@@ -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
|
-
*
|
|
36
|
-
*
|
|
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.
|
|
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.
|
|
353
|
-
case 'right': return this.
|
|
354
|
-
case 'left': return this.
|
|
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.
|
|
445
|
-
|
|
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.
|
|
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
|
-
// ───
|
|
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
|
-
/**
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
496
|
-
|
|
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
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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 (
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
707
|
+
const rule = this.newlineRule(closer.kind)
|
|
708
|
+
return rule !== null && newlinesBetween < rule.afterClose
|
|
536
709
|
}
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
package/src/Visitors/index.ts
CHANGED
|
@@ -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'
|