@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/dist/index.js CHANGED
@@ -6234,6 +6234,42 @@ var SemanticAnalyzer = class {
6234
6234
  return diagnostics.length > 0 ? diagnostics : null;
6235
6235
  }
6236
6236
  });
6237
+ this.register({
6238
+ code: "box-missing-equals",
6239
+ severity: "warning",
6240
+ kinds: ["box"],
6241
+ validate: (node, ctx) => {
6242
+ if (node.text.startsWith("=")) return null;
6243
+ const openEnd = node.innerStart > node.range.start ? node.innerStart : node.range.start + 5;
6244
+ if (node.range.start < 0 || openEnd > ctx.source.length) return null;
6245
+ const openTag = ctx.source.slice(node.range.start, openEnd);
6246
+ if (openTag.includes("=")) return null;
6247
+ const fixes = [
6248
+ {
6249
+ description: "Add '=' to [box]",
6250
+ isAutomatic: true,
6251
+ operations: [
6252
+ {
6253
+ kind: "replace_text",
6254
+ range: { start: node.range.start, end: openEnd },
6255
+ newText: "[box=]"
6256
+ }
6257
+ ]
6258
+ }
6259
+ ];
6260
+ return createDiagnostic(
6261
+ "box-missing-equals",
6262
+ "[box] without '=' can be previewed in Miliastry, but osu! requires [box=] to parse it correctly",
6263
+ "warning",
6264
+ {
6265
+ nodeId: node.id,
6266
+ nodeKind: node.kind,
6267
+ range: { start: node.range.start, end: openEnd },
6268
+ fixes
6269
+ }
6270
+ );
6271
+ }
6272
+ });
6237
6273
  }
6238
6274
  /**
6239
6275
  * Create a validator for a specific tag/kind.
@@ -8331,10 +8367,29 @@ var KIND_TO_TAG_NAME = {
8331
8367
  function hasAttrValue(value) {
8332
8368
  return value !== void 0 && value !== null && String(value) !== "";
8333
8369
  }
8334
- function normalizeColorToHex(color) {
8370
+ function expandHexForOsu(body) {
8371
+ if (!/^[0-9a-fA-F]+$/.test(body)) return null;
8372
+ switch (body.length) {
8373
+ case 3:
8374
+ return body[0] + body[0] + body[1] + body[1] + body[2] + body[2];
8375
+ case 4:
8376
+ return body[0] + body[0] + body[1] + body[1] + body[2] + body[2];
8377
+ case 6:
8378
+ return body;
8379
+ case 8:
8380
+ return body.slice(0, 6);
8381
+ default:
8382
+ return null;
8383
+ }
8384
+ }
8385
+ function normalizeColorToHex(color, target = "miliastry") {
8335
8386
  if (!color) return color;
8336
8387
  const trimmed = color.trim();
8337
- if (trimmed.startsWith("#")) return trimmed;
8388
+ if (trimmed.startsWith("#")) {
8389
+ if (target !== "osu") return trimmed;
8390
+ const expanded = expandHexForOsu(trimmed.slice(1));
8391
+ return expanded === null ? trimmed : `#${expanded}`;
8392
+ }
8338
8393
  const rgbMatch = trimmed.match(/^rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)$/i);
8339
8394
  if (rgbMatch) {
8340
8395
  const r = parseInt(rgbMatch[1], 10).toString(16).padStart(2, "0");
@@ -8342,6 +8397,10 @@ function normalizeColorToHex(color) {
8342
8397
  const b = parseInt(rgbMatch[3], 10).toString(16).padStart(2, "0");
8343
8398
  return `#${r}${g}${b}`.toLowerCase();
8344
8399
  }
8400
+ if (target === "osu" && /\d/.test(trimmed)) {
8401
+ const expanded = expandHexForOsu(trimmed);
8402
+ if (expanded !== null) return `#${expanded}`;
8403
+ }
8345
8404
  return trimmed;
8346
8405
  }
8347
8406
  var BBCodeExporter = class extends Visitor {
@@ -8466,7 +8525,7 @@ var BBCodeExporter = class extends Visitor {
8466
8525
  if (this.shouldResolveTokens() && col.startsWith("$")) {
8467
8526
  col = resolveTokenValue(col, this.tokenResolver);
8468
8527
  }
8469
- out = `[color=${normalizeColorToHex(col)}]${out}[/color]`;
8528
+ out = `[color=${normalizeColorToHex(col, this.target)}]${out}[/color]`;
8470
8529
  }
8471
8530
  if (style.fontSize) {
8472
8531
  let size = style.fontSize;
@@ -8549,7 +8608,7 @@ var BBCodeExporter = class extends Visitor {
8549
8608
  if (this.shouldResolveTokens() && color.startsWith("$")) {
8550
8609
  color = resolveTokenValue(color, this.tokenResolver);
8551
8610
  }
8552
- return `=${normalizeColorToHex(color)}`;
8611
+ return `=${normalizeColorToHex(color, this.target)}`;
8553
8612
  } else if (node.kind === "font" && hasAttrValue(node.metadata.font)) {
8554
8613
  let font = String(node.metadata.font);
8555
8614
  if (this.shouldResolveTokens() && font.startsWith("$")) {
@@ -8652,7 +8711,7 @@ var BBCodeExporter = class extends Visitor {
8652
8711
  if (this.shouldResolveTokens() && col.startsWith("$")) {
8653
8712
  col = resolveTokenValue(col, this.tokenResolver);
8654
8713
  }
8655
- return `=${normalizeColorToHex(col)}`;
8714
+ return `=${normalizeColorToHex(col, this.target)}`;
8656
8715
  }
8657
8716
  if (this.shouldResolveTokens() && text.startsWith("=")) {
8658
8717
  let val = text.slice(1);
@@ -8977,6 +9036,23 @@ var HTMLRenderer = class _HTMLRenderer extends Visitor {
8977
9036
  * LYNE's forum renderer applies.
8978
9037
  */
8979
9038
  tableDepth = 0;
9039
+ /**
9040
+ * ¿Estamos emitiendo el vocabulario de clases de osu!?
9041
+ *
9042
+ * osu estiliza box, spoilerbox, notice, imagemap, youtube, los alineados y
9043
+ * los perfiles POR NOMBRE DE CLASE, no por estilo inline. Sobre una userpage
9044
+ * real el HTML de Quasar salía sin estilo porque emitía su propio
9045
+ * vocabulario (`<details>`, `.notice`, `.imagemap-container`…). Bajo
9046
+ * `dialect: 'osu'` se emiten las clases y la estructura de osu; el resto de
9047
+ * dialectos conserva la suya, que es la que sus hojas de estilo esperan.
9048
+ */
9049
+ isOsu() {
9050
+ return this.options.dialect === "osu";
9051
+ }
9052
+ /** osu recorta los saltos pegados a la apertura y al cierre de box/notice. */
9053
+ static trimOsuEdges(html) {
9054
+ return html.replace(/^[\t ]*\r?\n/, "").replace(/\r?\n[\t ]*$/, "");
9055
+ }
8980
9056
  idAttr(node) {
8981
9057
  if (_HTMLRenderer.idMode === "none") return "";
8982
9058
  if (_HTMLRenderer.idMode === "all") return ` data-node-id="${node.id}"`;
@@ -9107,7 +9183,7 @@ var HTMLRenderer = class _HTMLRenderer extends Visitor {
9107
9183
  case "spoiler":
9108
9184
  return this.wrapInline("span", node, 'class="spoiler"');
9109
9185
  case "color":
9110
- return this.wrapInline("span", node, this.colorStyle(node));
9186
+ return this.renderColor(node);
9111
9187
  case "font_size":
9112
9188
  return this.wrapInline("span", node, this.fontSizeStyle(node));
9113
9189
  case "font":
@@ -9125,11 +9201,11 @@ var HTMLRenderer = class _HTMLRenderer extends Visitor {
9125
9201
  case "audio":
9126
9202
  return this.renderAudio(node);
9127
9203
  case "center":
9128
- return this.wrapBlock("div", node, 'style="text-align:center;"');
9204
+ return this.renderAlignAs(node, "center");
9129
9205
  case "right":
9130
- return this.wrapBlock("div", node, 'style="text-align:right;"');
9206
+ return this.renderAlignAs(node, "right");
9131
9207
  case "left":
9132
- return this.wrapBlock("div", node, 'style="text-align:left;"');
9208
+ return this.renderAlignAs(node, "left");
9133
9209
  // Sigue siendo siempre `h2`, como antes: el nivel de BBCode no mapea al
9134
9210
  // de HTML y un `[heading=9]` daría un `<h9>` inválido. `data-bare-level`
9135
9211
  // sólo anota que el 2 lo puso el renderer, para que el camino de vuelta
@@ -9262,12 +9338,10 @@ var HTMLRenderer = class _HTMLRenderer extends Visitor {
9262
9338
  case "paint":
9263
9339
  return this.renderEffectSegments(node, "paint");
9264
9340
  case "spacing":
9265
- if (this.options.osuBehaviour && this.isNextCodeBlock(node)) return "\n";
9266
- if (this.isTrailingBlockBoundary(node)) return "\n";
9267
- return this.isPrevBlockBoundary(node) ? "\n" : `<br${this.idAttr(node)}>`;
9341
+ if (this.isNewlineSwallowed(node)) return "\n";
9342
+ return `<br${this.idAttr(node)}>`;
9268
9343
  case "empty_line":
9269
- if (this.options.osuBehaviour && this.isImmediateEmptyLineBeforeCode(node)) return "\n";
9270
- if (this.isTrailingBlockBoundary(node)) return "\n";
9344
+ if (this.isNewlineSwallowed(node)) return "\n";
9271
9345
  return `<div class="bb-empty-line"${this.idAttr(node)}><br></div>`;
9272
9346
  case "group":
9273
9347
  return this.wrapInline("span", node, 'class="group"');
@@ -9303,58 +9377,241 @@ var HTMLRenderer = class _HTMLRenderer extends Visitor {
9303
9377
  return this.escapeHtml(node.text || "");
9304
9378
  }
9305
9379
  }
9306
- // ─── Render Helpers ─────────────────────────────────────
9307
- /** osu! quirk: newlines immediately preceding a [code] block are completely ignored */
9308
- isNextCodeBlock(node) {
9309
- let next = node.nextSibling;
9310
- while (next && (next.kind === "spacing" || next.kind === "empty_line")) {
9311
- next = next.nextSibling;
9312
- }
9313
- return next?.kind === "code";
9314
- }
9315
- /** Checks if this is the LAST empty_line right before a code block (skipping only spacing) */
9316
- isImmediateEmptyLineBeforeCode(node) {
9317
- let next = node.nextSibling;
9318
- while (next && next.kind === "spacing") {
9319
- next = next.nextSibling;
9320
- }
9321
- return next?.kind === "code";
9322
- }
9323
- isPrevBlockBoundary(node) {
9324
- let prev = node.previousSibling;
9325
- while (prev) {
9326
- if (prev.kind === "spacing" || prev.kind === "empty_line") {
9327
- prev = prev.previousSibling;
9328
- continue;
9380
+ // ─── Newline swallowing ─────────────────────────────────
9381
+ //
9382
+ // osu! turns newlines into `<br />` with one flat rule at the very end of
9383
+ // `BBCodeFromDB::toHTML` — `str_replace("\n", '<br />')`. Every subtlety
9384
+ // lives BEFORE that line: each block pass is a regex that eats the newlines
9385
+ // touching its own tags, so those newlines are simply gone by the time the
9386
+ // flat rule runs. The amount eaten differs per tag, and the asymmetries are
9387
+ // not decorative:
9388
+ //
9389
+ // parseBox `\[box=…\]\n*` `\n*\[/box\]\n?`
9390
+ // parseCode `\[code\]\n*` `\n*\[/code\]\n?`
9391
+ // parseNotice `\[notice\]\n*` `\n*\[/notice\]\n?`
9392
+ // parseList `\s*\[\*\]` `\s*\[/list\]\n?\n?`
9393
+ // parseQuote `\[quote…\]\s*` `\s*\[/quote\]\n?\n?`
9394
+ // parseHeading — `\[/heading\]\n?`
9395
+ // parseImagemap — `\[/imagemap\]\n?`
9396
+ // parseAlignment strtr of `[centre]\n` and `[/centre]\n` — exactly one
9397
+ //
9398
+ // Quasar used to approximate all of that with two neighbourhood heuristics
9399
+ // (`isPrevBlockBoundary` / `isTrailingBlockBoundary`) that treated every
9400
+ // block alike, so they over-ate at `[centre]`/`[/imagemap]` and under-ate at
9401
+ // `[/list]`/`[/quote]`. This models the real rules instead.
9402
+ //
9403
+ // Deliberately NOT gated on the dialect: Miliastry is "osu with steroids"
9404
+ // and has to break lines the same way. Blocks that only exist in Miliastry
9405
+ // (tables, gallery, columns, scroll, …) have no osu counterpart to copy, so
9406
+ // they keep the legacy behaviour via {@link HTMLRenderer.LEGACY_BLOCK_RULE}.
9407
+ /** How many newlines a construct swallows around its own tags. */
9408
+ static NEWLINE_RULES = {
9409
+ // `\n*` inside both edges, one newline after the close.
9410
+ box: { afterOpen: "all", beforeClose: "all", beforeOpen: "none", afterClose: 1 },
9411
+ boxw: { afterOpen: "all", beforeClose: "all", beforeOpen: "none", afterClose: 1 },
9412
+ spoilerbox: { afterOpen: "all", beforeClose: "all", beforeOpen: "none", afterClose: 1 },
9413
+ notice: { afterOpen: "all", beforeClose: "all", beforeOpen: "none", afterClose: 1 },
9414
+ wnotice: { afterOpen: "all", beforeClose: "all", beforeOpen: "none", afterClose: 1 },
9415
+ code: { afterOpen: "all", beforeClose: "all", beforeOpen: "none", afterClose: 1 },
9416
+ // `\s*` — not just newlines — and TWO newlines after the close.
9417
+ quote: { afterOpen: "whitespace", beforeClose: "whitespace", beforeOpen: "none", afterClose: 2 },
9418
+ // `[list]` itself eats nothing after its opening tag: the pass that eats
9419
+ // is `\s*\[\*\]`, which needs an item to follow. `[list]\n\nloose text`
9420
+ // keeps both newlines; `[list]\n[*]a` loses one to the item, not the list.
9421
+ list: { afterOpen: "none", beforeClose: "whitespace", beforeOpen: "none", afterClose: 2 },
9422
+ // `\s*\[\*\]`. The matching `[/*]` of the table exists only in legacy
9423
+ // phpBB rows — `BBCodeForDB` never emits one — so the item's close is
9424
+ // width-less here and its two-newline budget is unreachable by design;
9425
+ // `[*]a\n\n[*]b` loses both newlines to the NEXT item's `\s*`, which is
9426
+ // the same output by a different route.
9427
+ list_item: { afterOpen: "none", beforeClose: "none", beforeOpen: "whitespace", afterClose: 0 },
9428
+ // strtr with `[centre]\n` / `[/centre]\n`: exactly one on each outer edge,
9429
+ // and nothing before the close — `x\n[/centre]` really does keep its `<br>`.
9430
+ center: { afterOpen: "one", beforeClose: "none", beforeOpen: "none", afterClose: 1 },
9431
+ left: { afterOpen: "one", beforeClose: "none", beforeOpen: "none", afterClose: 1 },
9432
+ right: { afterOpen: "one", beforeClose: "none", beforeOpen: "none", afterClose: 1 },
9433
+ align: { afterOpen: "one", beforeClose: "none", beforeOpen: "none", afterClose: 1 },
9434
+ heading: { afterOpen: "none", beforeClose: "none", beforeOpen: "none", afterClose: 1 },
9435
+ imagemap: { afterOpen: "none", beforeClose: "none", beforeOpen: "none", afterClose: 1 },
9436
+ // `[img]` is inline in osu and swallows nothing at all.
9437
+ image: { afterOpen: "none", beforeClose: "none", beforeOpen: "none", afterClose: 0 },
9438
+ document: { afterOpen: "none", beforeClose: "none", beforeOpen: "none", afterClose: 0 }
9439
+ };
9440
+ /**
9441
+ * What a Miliastry-only block does. This is what the old
9442
+ * `isPrevBlockBoundary` / `isTrailingBlockBoundary` pair did for every block:
9443
+ * eat the first newline after the open, every newline before the close, and
9444
+ * the first newline after the close.
9445
+ */
9446
+ static LEGACY_BLOCK_RULE = {
9447
+ afterOpen: "one",
9448
+ beforeClose: "all",
9449
+ beforeOpen: "none",
9450
+ afterClose: 1
9451
+ };
9452
+ /**
9453
+ * Containers whose opening tag occupies no source text, so a backwards scan
9454
+ * has to walk straight through them.
9455
+ */
9456
+ static WIDTHLESS_OPEN = /* @__PURE__ */ new Set(["paragraph", "group"]);
9457
+ /**
9458
+ * Same for the closing side. `list_item` is here because `[/*]` is never
9459
+ * written: an item ends where the next `[*]` or the `[/list]` begins, so
9460
+ * `\s*\[/list\]` sees the newline that Quasar stores inside the item.
9461
+ */
9462
+ static WIDTHLESS_CLOSE = /* @__PURE__ */ new Set(["paragraph", "group", "list_item"]);
9463
+ newlineRule(kind) {
9464
+ const rule = _HTMLRenderer.NEWLINE_RULES[kind];
9465
+ if (rule) return rule;
9466
+ return this.BLOCK_TAGS.has(kind) ? _HTMLRenderer.LEGACY_BLOCK_RULE : null;
9467
+ }
9468
+ static isNewlineNode(node) {
9469
+ return node.kind === "spacing" || node.kind === "empty_line";
9470
+ }
9471
+ static isBlankText(node) {
9472
+ return node.kind === "text" && node.children.length === 0 && node.text.trim() === "";
9473
+ }
9474
+ /**
9475
+ * Whether this `spacing` / `empty_line` leaf is eaten by a neighbouring tag
9476
+ * and therefore renders nothing.
9477
+ *
9478
+ * Each leaf is exactly ONE source newline (the parser splits a run into one
9479
+ * node per `\n`), so the four scans below can be read straight off the
9480
+ * regexes they mirror. A newline eaten by any of them is eaten: osu's passes
9481
+ * run in a fixed order, but since a consumed newline is consumed whichever
9482
+ * pass claimed it, the union is enough — the per-pass order only matters for
9483
+ * a budget that could be spent elsewhere, and budgets here are counted from
9484
+ * the tag outwards, exactly as `\n?\n?` counts.
9485
+ */
9486
+ isNewlineSwallowed(node) {
9487
+ return this.eatenByOpeningTag(node) || this.eatenByClosingTag(node) || this.eatenAfterClosingTag(node) || this.eatenBeforeOpeningTag(node);
9488
+ }
9489
+ /** `\[box\]\n*`, `\[quote\]\s*`, `[centre]\n`. */
9490
+ eatenByOpeningTag(node) {
9491
+ let cur = node;
9492
+ let newlinesBetween = 0;
9493
+ let blankBetween = false;
9494
+ for (; ; ) {
9495
+ const prev = cur.previousSibling;
9496
+ if (prev) {
9497
+ if (_HTMLRenderer.isNewlineNode(prev)) {
9498
+ newlinesBetween++;
9499
+ cur = prev;
9500
+ continue;
9501
+ }
9502
+ if (_HTMLRenderer.isBlankText(prev)) {
9503
+ blankBetween = true;
9504
+ cur = prev;
9505
+ continue;
9506
+ }
9507
+ return false;
9329
9508
  }
9330
- if (prev.kind === "text" && prev.text.trim() === "") {
9331
- prev = prev.previousSibling;
9509
+ const parent = cur.parent;
9510
+ if (!parent) return false;
9511
+ if (_HTMLRenderer.WIDTHLESS_OPEN.has(parent.kind)) {
9512
+ cur = parent;
9332
9513
  continue;
9333
9514
  }
9334
- break;
9515
+ const rule = this.newlineRule(parent.kind);
9516
+ if (!rule) return false;
9517
+ switch (rule.afterOpen) {
9518
+ case "whitespace":
9519
+ return true;
9520
+ // `\n*` matches newlines only: a stray space breaks the run.
9521
+ case "all":
9522
+ return !blankBetween;
9523
+ case "one":
9524
+ return !blankBetween && newlinesBetween === 0;
9525
+ default:
9526
+ return false;
9527
+ }
9335
9528
  }
9336
- if (prev && this.BLOCK_TAGS.has(prev.kind) && prev.kind !== "image" && prev.kind !== "imagemap") return true;
9337
- if (!prev && node.parent && this.BLOCK_TAGS.has(node.parent.kind) && node.parent.kind !== "image" && node.parent.kind !== "imagemap") return true;
9338
- return false;
9339
9529
  }
9340
- isTrailingBlockBoundary(node) {
9341
- let next = node.nextSibling;
9342
- while (next) {
9343
- if (next.kind === "spacing" || next.kind === "empty_line") {
9344
- next = next.nextSibling;
9530
+ /** `\n*\[/box\]`, `\s*\[/quote\]`, `\s*\[/list\]`. */
9531
+ eatenByClosingTag(node) {
9532
+ let cur = node;
9533
+ let blankBetween = false;
9534
+ for (; ; ) {
9535
+ const next = cur.nextSibling;
9536
+ if (next) {
9537
+ if (_HTMLRenderer.isNewlineNode(next)) {
9538
+ cur = next;
9539
+ continue;
9540
+ }
9541
+ if (_HTMLRenderer.isBlankText(next)) {
9542
+ blankBetween = true;
9543
+ cur = next;
9544
+ continue;
9545
+ }
9546
+ return false;
9547
+ }
9548
+ const parent = cur.parent;
9549
+ if (!parent) return false;
9550
+ if (_HTMLRenderer.WIDTHLESS_CLOSE.has(parent.kind)) {
9551
+ cur = parent;
9345
9552
  continue;
9346
9553
  }
9347
- if (next.kind === "text" && next.text.trim() === "") {
9348
- next = next.nextSibling;
9554
+ const rule = this.newlineRule(parent.kind);
9555
+ if (!rule) return false;
9556
+ switch (rule.beforeClose) {
9557
+ case "whitespace":
9558
+ return true;
9559
+ case "all":
9560
+ return !blankBetween;
9561
+ default:
9562
+ return false;
9563
+ }
9564
+ }
9565
+ }
9566
+ /** `\[/box\]\n?`, `\[/list\]\n?\n?`. */
9567
+ eatenAfterClosingTag(node) {
9568
+ let cur = node;
9569
+ let newlinesBetween = 0;
9570
+ for (; ; ) {
9571
+ const prev = cur.previousSibling;
9572
+ if (!prev) {
9573
+ const parent = cur.parent;
9574
+ if (parent && _HTMLRenderer.WIDTHLESS_OPEN.has(parent.kind)) {
9575
+ cur = parent;
9576
+ continue;
9577
+ }
9578
+ return false;
9579
+ }
9580
+ if (_HTMLRenderer.isNewlineNode(prev)) {
9581
+ newlinesBetween++;
9582
+ cur = prev;
9349
9583
  continue;
9350
9584
  }
9351
- break;
9585
+ let closer = prev;
9586
+ while (_HTMLRenderer.WIDTHLESS_CLOSE.has(closer.kind) && closer.children.length > 0) {
9587
+ closer = closer.children[closer.children.length - 1];
9588
+ }
9589
+ const rule = this.newlineRule(closer.kind);
9590
+ return rule !== null && newlinesBetween < rule.afterClose;
9352
9591
  }
9353
- if (!next && node.parent && this.BLOCK_TAGS.has(node.parent.kind) && node.parent.kind !== "document") {
9354
- return true;
9592
+ }
9593
+ /** `\s*\[\*\]` — the only pass that eats whitespace BEFORE an opening tag. */
9594
+ eatenBeforeOpeningTag(node) {
9595
+ let cur = node;
9596
+ for (; ; ) {
9597
+ const next = cur.nextSibling;
9598
+ if (next) {
9599
+ if (_HTMLRenderer.isNewlineNode(next) || _HTMLRenderer.isBlankText(next)) {
9600
+ cur = next;
9601
+ continue;
9602
+ }
9603
+ return this.newlineRule(next.kind)?.beforeOpen === "whitespace";
9604
+ }
9605
+ const parent = cur.parent;
9606
+ if (!parent) return false;
9607
+ if (_HTMLRenderer.WIDTHLESS_CLOSE.has(parent.kind)) {
9608
+ cur = parent;
9609
+ continue;
9610
+ }
9611
+ return false;
9355
9612
  }
9356
- return false;
9357
9613
  }
9614
+ // ─── Render Helpers ─────────────────────────────────────
9358
9615
  renderError(node) {
9359
9616
  const errorMsg = this.escapeHtml(node.metadata?.message || node.text || "Syntax Error");
9360
9617
  const content = this.renderChildren(node) || this.escapeHtml(node.text || "");
@@ -9406,6 +9663,41 @@ var HTMLRenderer = class _HTMLRenderer extends Visitor {
9406
9663
  return !!raw && /^[a-z]{2,20}(?: [a-z]{2,20})?$|^[1-9]00$/i.test(raw.trim());
9407
9664
  }
9408
9665
  /** Read a metadata field, falling back to the raw tag attribute. */
9666
+ /**
9667
+ * Lo único que osu! acepta en `[color=…]`.
9668
+ *
9669
+ * Su `BBCodeForDB::parseColour` sella el tag con un uid sólo si el valor
9670
+ * matchea `#[[:xdigit:]]{6}` o `[[:alpha:]]+` — nada más. No valida que el
9671
+ * nombre sea un color CSS de verdad (`banana` pasa), pero `#fff`, `#ffffffff`,
9672
+ * `rgb(...)`, `$token` o un hex sin `#` no pasan. Sin uid, la segunda pasada
9673
+ * no ve el tag y el opener *y* el closer quedan como texto en la página.
9674
+ */
9675
+ static OSU_COLOR_RE = /^(?:#[0-9a-fA-F]{6}|[a-zA-Z]+)$/;
9676
+ /**
9677
+ * `[color]` con el vocabulario de cada dialecto.
9678
+ *
9679
+ * Miliastry (y Lyne) aceptan a propósito más que osu: `#RGB`, `#RGBA`, un
9680
+ * `$token` de diseño, nombres propios. Bajo `dialect: 'osu'` eso es una
9681
+ * mentira: el editor pintaría color donde la página publicada muestra el
9682
+ * BBCode crudo. Así que replicamos lo que hace osu — literal el opener,
9683
+ * literal el closer, y los hijos renderizados normalmente en el medio.
9684
+ *
9685
+ * El chequeo mira el texto crudo del atributo, no el valor saneado: osu
9686
+ * matchea sobre la fuente, así que `[color="#ffffff"]` (con comillas) también
9687
+ * se le escapa.
9688
+ */
9689
+ renderColor(node) {
9690
+ if (this.options.dialect === "osu") {
9691
+ const text = node.text || "";
9692
+ const eq = text.indexOf("=");
9693
+ const raw = eq >= 0 ? text.slice(eq + 1) : text ? "" : nodeAttrValue(node, "color");
9694
+ if (!_HTMLRenderer.OSU_COLOR_RE.test(raw)) {
9695
+ const opener = eq >= 0 ? `[color${text}]` : `[${text || "color"}]`;
9696
+ return this.escapeHtml(opener) + this.renderChildren(node) + this.escapeHtml("[/color]");
9697
+ }
9698
+ }
9699
+ return this.wrapInline("span", node, this.colorStyle(node));
9700
+ }
9409
9701
  colorStyle(node) {
9410
9702
  const color = sanitizeColor(nodeAttrValue(node, "color"), this.tokenResolver);
9411
9703
  return color ? `style="color:${color};"` : "";
@@ -9447,6 +9739,11 @@ var HTMLRenderer = class _HTMLRenderer extends Visitor {
9447
9739
  return `<strong${entity}><a${this.idAttr(node)} href="${this.escapeHtml(link.href)}"${ext}>${content}</a></strong>`;
9448
9740
  }
9449
9741
  }
9742
+ if (type === "profile" && this.isOsu()) {
9743
+ const key2 = val || `@${this.collectNodeText(node)}`;
9744
+ const href = `https://osu.ppy.sh/users/${encodeURIComponent(key2)}`;
9745
+ return `<a${this.idAttr(node)}${entity} class="user-name js-usercard" data-user-id="${this.escapeHtml(key2)}" href="${href}">${content}</a>`;
9746
+ }
9450
9747
  if (type === "profile") {
9451
9748
  const url = this.options.theme === "lyne" || this.options.dialect === "lyne" ? `/u/${encodeURIComponent(val || content)}` : `https://osu.ppy.sh/users/${this.escapeHtml(val || content)}`;
9452
9749
  return `<strong${entity}><a${this.idAttr(node)} href="${url}" target="_blank" rel="noopener">${content}</a></strong>`;
@@ -9501,13 +9798,25 @@ var HTMLRenderer = class _HTMLRenderer extends Visitor {
9501
9798
  if (!id) return this.mediaError("youtube", "[youtube] missing video ID");
9502
9799
  const ytMatch = /(?:youtu\.be\/|v=|\/embed\/|\/shorts\/)([\w-]{11})/.exec(id);
9503
9800
  if (ytMatch) id = ytMatch[1];
9504
- 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>`;
9801
+ const cls = this.isOsu() ? "u-embed-wide u-embed-wide--bbcode" : "bb-youtube";
9802
+ const rel = this.isOsu() ? "?rel=0" : "";
9803
+ 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>`;
9505
9804
  }
9506
9805
  renderAudio(node) {
9507
9806
  let src = String(node.metadata?.src ?? "") || node.text || "";
9508
9807
  if (this.options.mediaProxy && src) {
9509
9808
  src = this.options.mediaProxy(src);
9510
9809
  }
9810
+ const isLyne = this.options.theme === "lyne" || this.options.dialect === "lyne";
9811
+ if (isLyne) {
9812
+ const rawName = src.split("?")[0].split("#")[0].split("/").filter(Boolean).pop() || "audio_track.mp3";
9813
+ let fileName = rawName;
9814
+ try {
9815
+ fileName = decodeURIComponent(rawName);
9816
+ } catch {
9817
+ }
9818
+ return `<div${this.idAttr(node)} class="lx-audio bb-audio" data-src="${this.escapeHtml(src)}"><audio preload="metadata" src="${this.escapeHtml(src)}"></audio><div class="lx-track" role="progressbar" aria-label="Audio progress"><div class="fill"></div></div><div class="lx-row"><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><div class="lx-title"><div class="name">${this.escapeHtml(fileName)}</div><div class="label"><span class="dot"></span> <span class="status-text">audio</span></div></div><div class="lx-vol"><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><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%);" /></div><button type="button" class="lx-speed" aria-label="Playback speed">1.0\xD7</button><div class="lx-time"><span class="cur">0:00</span> / <span class="total">\u2013:\u2013\u2013</span></div></div></div>`;
9819
+ }
9511
9820
  return `<audio${this.idAttr(node)} controls src="${this.escapeHtml(src)}" class="bb-audio"></audio>`;
9512
9821
  }
9513
9822
  hexToRgba(hex, alpha) {
@@ -9533,6 +9842,10 @@ var HTMLRenderer = class _HTMLRenderer extends Visitor {
9533
9842
  const warningIcon = warning ? `<span aria-hidden class="bb-notice-mark"${markStyle}>\u26A0</span>` : "";
9534
9843
  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>`;
9535
9844
  }
9845
+ if (this.isOsu()) {
9846
+ const content = _HTMLRenderer.trimOsuEdges(this.renderChildren(node));
9847
+ return `<div${this.idAttr(node)} class="well">${content}</div>`;
9848
+ }
9536
9849
  return this.wrapBlock("div", node, 'class="notice"');
9537
9850
  }
9538
9851
  renderTables(node) {
@@ -9638,7 +9951,21 @@ var HTMLRenderer = class _HTMLRenderer extends Visitor {
9638
9951
  renderAlign(node) {
9639
9952
  const alignVal = (String(node.metadata?.align ?? "") || nodeAttrValue(node) || "center").trim().toLowerCase();
9640
9953
  const validAlign = alignVal === "left" || alignVal === "right" ? alignVal : "center";
9641
- return this.wrapBlock("div", node, `style="text-align:${validAlign};"`);
9954
+ return this.renderAlignAs(node, validAlign);
9955
+ }
9956
+ /**
9957
+ * `[centre]` / `[left]` / `[right]` (y `[align=…]`).
9958
+ *
9959
+ * osu! no usa `text-align` inline: estiliza el bloque por nombre de clase,
9960
+ * con la grafía británica `centre`. Fuera del dialecto osu el estilo inline
9961
+ * se mantiene, porque ni Miliastry ni Lyne traen esas reglas.
9962
+ */
9963
+ renderAlignAs(node, align) {
9964
+ if (this.isOsu()) {
9965
+ const name = align === "center" ? "centre" : align;
9966
+ return this.wrapBlock("div", node, `class="bbcode__align-${name}"`);
9967
+ }
9968
+ return this.wrapBlock("div", node, `style="text-align:${align};"`);
9642
9969
  }
9643
9970
  renderEffect(node) {
9644
9971
  const raw = (String(node.metadata?.effectType ?? "") || nodeAttrValue(node) || "glow").toLowerCase().trim();
@@ -9827,7 +10154,25 @@ var HTMLRenderer = class _HTMLRenderer extends Visitor {
9827
10154
  }
9828
10155
  return `<blockquote${this.idAttr(node)}>${content}</blockquote>`;
9829
10156
  }
10157
+ /**
10158
+ * La estructura exacta que `bbcode-spoilerbox` de osu-web espera.
10159
+ *
10160
+ * El toggle de osu es JS: `js-spoilerbox__link` es el gancho del click y
10161
+ * `js-spoilerbox__body` el panel que abre. Si falta cualquiera de las dos
10162
+ * clases el box queda mudo, así que la estructura no es decorativa.
10163
+ */
10164
+ renderOsuSpoilerbox(node, title, extra) {
10165
+ const content = _HTMLRenderer.trimOsuEdges(this.renderChildren(node));
10166
+ return `<div${this.idAttr(node)} class="js-spoilerbox bbcode-spoilerbox"${extra}><a class="js-spoilerbox__link bbcode-spoilerbox__link" href="#"><span class="bbcode-spoilerbox__link-icon"></span><span class="bbcode-spoilerbox__link-text">${title}</span></a><div class="js-spoilerbox__body bbcode-spoilerbox__body">${content}</div></div>`;
10167
+ }
10168
+ /** El rótulo de un box bajo osu: el del autor, o `SPOILER` en mayúsculas. */
10169
+ osuBoxTitle(node) {
10170
+ return this.hasOwnTitle(node) ? this.renderTitle(node, "SPOILER") : "SPOILER";
10171
+ }
9830
10172
  renderSpoilerbox(node) {
10173
+ if (this.isOsu()) {
10174
+ return this.renderOsuSpoilerbox(node, this.osuBoxTitle(node), this.bareTitleAttr(node));
10175
+ }
9831
10176
  const title = this.renderTitle(node, "Spoiler");
9832
10177
  const bare = this.bareTitleAttr(node);
9833
10178
  const content = this.renderChildren(node);
@@ -9837,6 +10182,9 @@ var HTMLRenderer = class _HTMLRenderer extends Visitor {
9837
10182
  return `<details${this.idAttr(node)}${bare}${accent}><summary><span class="bb-box-heading">${title}</span></summary><div class="${bodyCls}">${content}</div></details>`;
9838
10183
  }
9839
10184
  renderBox(node) {
10185
+ if (this.isOsu()) {
10186
+ return this.renderOsuSpoilerbox(node, this.osuBoxTitle(node), this.bareTitleAttr(node));
10187
+ }
9840
10188
  const title = this.renderTitle(node, "Box");
9841
10189
  const bare = this.bareTitleAttr(node);
9842
10190
  const content = this.renderChildren(node);
@@ -9854,9 +10202,18 @@ var HTMLRenderer = class _HTMLRenderer extends Visitor {
9854
10202
  * escrito y devolvía `[box=Box]`.
9855
10203
  */
9856
10204
  bareTitleAttr(node) {
10205
+ return this.hasOwnTitle(node) ? "" : ' data-bare-title="1"';
10206
+ }
10207
+ /**
10208
+ * ¿El título del box lo escribió el autor, o es el relleno del parser?
10209
+ *
10210
+ * `BBCodeToGreenNode` ya deja `metadata.title = 'Box'`/`'Spoiler'` para un
10211
+ * tag pelado, así que el `fallback` de `renderTitle` nunca llega a usarse:
10212
+ * quien quiera otro rótulo por defecto tiene que preguntar por aquí.
10213
+ */
10214
+ hasOwnTitle(node) {
9857
10215
  const raw = node.metadata?.rawTitle;
9858
- const hasOwnTitle = raw !== void 0 ? String(raw) !== "" : node.metadata?.title !== void 0;
9859
- return hasOwnTitle ? "" : ' data-bare-title="1"';
10216
+ return raw !== void 0 ? String(raw) !== "" : node.metadata?.title !== void 0;
9860
10217
  }
9861
10218
  renderTitle(node, fallback) {
9862
10219
  const titleNodes = node.metadata?.titleNodes;
@@ -9955,8 +10312,17 @@ var HTMLRenderer = class _HTMLRenderer extends Visitor {
9955
10312
  if (areaUrl && !areaUrl.startsWith("http://") && !areaUrl.startsWith("https://") && !areaUrl.startsWith("mailto:")) {
9956
10313
  areaUrl = "https://" + areaUrl;
9957
10314
  }
10315
+ if (this.isOsu()) {
10316
+ const pos = `left:${x}%;top:${y}%;width:${w}%;height:${h}%;`;
10317
+ const title = ` title="${this.escapeHtml(label)}"`;
10318
+ areas += url === "#" ? `<span class="imagemap__link" style="${pos}"${title}></span>` : `<a class="imagemap__link" href="${this.escapeHtml(areaUrl)}" style="${pos}"${title}></a>`;
10319
+ continue;
10320
+ }
9958
10321
  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>`;
9959
10322
  }
10323
+ if (this.isOsu()) {
10324
+ return `<div${this.idAttr(node)} class="imagemap"><img class="imagemap__image" loading="lazy" src="${this.escapeHtml(imageUrl)}" alt="">${areas}</div>`;
10325
+ }
9960
10326
  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>`;
9961
10327
  }
9962
10328
  collectNodeText(node) {
@@ -10241,38 +10607,39 @@ function morphNodes(parent, newParent) {
10241
10607
  oldNode.nodeValue = newNode.nodeValue;
10242
10608
  }
10243
10609
  } else if (oldNode.nodeType === 1 && newNode.nodeType === 1 && oldNode.tagName === newNode.tagName) {
10244
- const oldEl = oldNode;
10245
- const newEl = newNode;
10246
- if (oldEl.tagName === "DETAILS" && oldEl.hasAttribute("open")) {
10247
- newEl.setAttribute("open", "");
10248
- }
10249
- if (oldEl.classList.contains("open")) {
10250
- newEl.classList.add("open");
10251
- }
10252
- if (oldEl.classList.contains("is-open")) {
10253
- newEl.classList.add("is-open");
10254
- }
10255
- const newAttrs = newEl.attributes;
10256
- for (let i2 = 0; i2 < newAttrs.length; i2++) {
10257
- const attr = newAttrs[i2];
10258
- if (oldEl.getAttribute(attr.name) !== attr.value) {
10259
- oldEl.setAttribute(attr.name, attr.value);
10260
- }
10261
- }
10262
- const oldAttrs = oldEl.attributes;
10263
- for (let i2 = oldAttrs.length - 1; i2 >= 0; i2--) {
10264
- const name = oldAttrs[i2].name;
10265
- if (!newEl.hasAttribute(name)) {
10266
- oldEl.removeAttribute(name);
10267
- }
10268
- }
10269
- morphNodes(oldEl, newEl);
10610
+ morphElement(oldNode, newNode);
10270
10611
  } else {
10271
10612
  oldNode.parentNode.replaceChild(newNode.cloneNode(true), oldNode);
10272
10613
  }
10273
10614
  }
10274
10615
  }
10275
10616
  }
10617
+ function morphElement(oldEl, newEl) {
10618
+ if (oldEl.tagName === "DETAILS" && oldEl.hasAttribute("open")) {
10619
+ newEl.setAttribute("open", "");
10620
+ }
10621
+ if (oldEl.classList.contains("open")) {
10622
+ newEl.classList.add("open");
10623
+ }
10624
+ if (oldEl.classList.contains("is-open")) {
10625
+ newEl.classList.add("is-open");
10626
+ }
10627
+ const newAttrs = newEl.attributes;
10628
+ for (let i = 0; i < newAttrs.length; i++) {
10629
+ const attr = newAttrs[i];
10630
+ if (oldEl.getAttribute(attr.name) !== attr.value) {
10631
+ oldEl.setAttribute(attr.name, attr.value);
10632
+ }
10633
+ }
10634
+ const oldAttrs = oldEl.attributes;
10635
+ for (let i = oldAttrs.length - 1; i >= 0; i--) {
10636
+ const name = oldAttrs[i].name;
10637
+ if (!newEl.hasAttribute(name)) {
10638
+ oldEl.removeAttribute(name);
10639
+ }
10640
+ }
10641
+ morphNodes(oldEl, newEl);
10642
+ }
10276
10643
 
10277
10644
  // src/Visitors/BlockPatcher.ts
10278
10645
  var caches = /* @__PURE__ */ new WeakMap();
@@ -10310,6 +10677,9 @@ function getCache(container) {
10310
10677
  function blockKey(node, index) {
10311
10678
  return node.id ?? `__block_${index}`;
10312
10679
  }
10680
+ function isContextSensitive(node) {
10681
+ return node.kind === "spacing" || node.kind === "empty_line";
10682
+ }
10313
10683
  function nodeFromHtml(html) {
10314
10684
  const t = document.createElement("template");
10315
10685
  t.innerHTML = html;
@@ -10328,7 +10698,7 @@ function renderedTag(html) {
10328
10698
  return m ? m[1].toUpperCase() : null;
10329
10699
  }
10330
10700
  function shouldMorphInPlace(element, run) {
10331
- return run.kind === "element" && !!run.node.id && run.node.children.length > 0 && canMorphInPlace(run.node) && renderedTag(run.html) === element.tagName;
10701
+ return run.kind === "element" && !!run.node.id && canMorphInPlace(run.node) && renderedTag(run.html) === element.tagName;
10332
10702
  }
10333
10703
  function buildRuns(blocks, keys, getHtml, baseStart) {
10334
10704
  const runs = [];
@@ -10394,7 +10764,7 @@ function reconcileKeyed(container, rootNode, keys, renderer, cache, options) {
10394
10764
  let patched = 0;
10395
10765
  try {
10396
10766
  runs = buildRuns(blocks, keys, (node, key) => {
10397
- if (cache.lastNode.get(key) === node) {
10767
+ if (cache.lastNode.get(key) === node && !isContextSensitive(node)) {
10398
10768
  return { html: cache.lastHtml.get(key) ?? "", kind: cache.lastClass.get(key) ?? "none" };
10399
10769
  }
10400
10770
  const html = renderer.render(node);
@@ -10435,7 +10805,12 @@ function reconcileKeyed(container, rootNode, keys, renderer, cache, options) {
10435
10805
  continue;
10436
10806
  }
10437
10807
  if (element.nodeType === 1 && shouldMorphInPlace(element, run)) {
10438
- morphHTML(element, renderer.renderChildren(run.node));
10808
+ const newEl = nodeFromHtml(run.html);
10809
+ if (newEl && newEl.nodeType === 1 && newEl.tagName === element.tagName) {
10810
+ morphElement(element, newEl);
10811
+ } else {
10812
+ container.replaceChild(newEl, element);
10813
+ }
10439
10814
  } else {
10440
10815
  container.replaceChild(nodeFromHtml(run.html), element);
10441
10816
  }
@@ -10502,7 +10877,7 @@ function reconcileWindowed(container, rootNode, change, renderer, cache, options
10502
10877
  let runs;
10503
10878
  try {
10504
10879
  runs = buildRuns(blocks, keys, (node2, key) => {
10505
- if (cache.lastNode.get(key) === node2) {
10880
+ if (cache.lastNode.get(key) === node2 && !isContextSensitive(node2)) {
10506
10881
  return { html: cache.lastHtml.get(key) ?? "", kind: cache.lastClass.get(key) ?? "none" };
10507
10882
  }
10508
10883
  const html = renderer.render(node2);
@@ -10661,7 +11036,12 @@ function reconcileWindowed(container, rootNode, change, renderer, cache, options
10661
11036
  continue;
10662
11037
  }
10663
11038
  if (element.nodeType === 1 && shouldMorphInPlace(element, run)) {
10664
- morphHTML(element, renderer.renderChildren(run.node));
11039
+ const newEl = nodeFromHtml(run.html);
11040
+ if (newEl && newEl.nodeType === 1 && newEl.tagName === element.tagName) {
11041
+ morphElement(element, newEl);
11042
+ } else {
11043
+ container.replaceChild(newEl, element);
11044
+ }
10665
11045
  } else {
10666
11046
  container.replaceChild(nodeFromHtml(run.html), element);
10667
11047
  }
@@ -11883,6 +12263,13 @@ function domToGreenTree(root) {
11883
12263
  children.push(greenLeaf("spacing", ""));
11884
12264
  }
11885
12265
  });
12266
+ } else if (el.classList.contains("lx-audio")) {
12267
+ kind = "audio";
12268
+ const audioSrc = el.getAttribute("data-src") || el.querySelector("audio")?.getAttribute("src") || "";
12269
+ if (audioSrc) {
12270
+ children.push(greenLeaf("text", audioSrc));
12271
+ currentOffset += audioSrc.length;
12272
+ }
11886
12273
  } else if (el.style.textAlign === "center") kind = "center";
11887
12274
  else if (el.style.textAlign === "right") kind = "right";
11888
12275
  else if (el.style.textAlign === "left") kind = "left";
@@ -12616,7 +13003,7 @@ var GreenNodePool = class _GreenNodePool {
12616
13003
  };
12617
13004
 
12618
13005
  // src/BBCode/BBCodeDocumentModel.ts
12619
- var BBCodeDocumentModel = class extends DocumentModel {
13006
+ var BBCodeDocumentModel = class _BBCodeDocumentModel extends DocumentModel {
12620
13007
  _strictMode;
12621
13008
  _dialect;
12622
13009
  /** Per-document interner, or null when interning is off. */
@@ -12652,6 +13039,35 @@ var BBCodeDocumentModel = class extends DocumentModel {
12652
13039
  const r = renderer ?? new HTMLRenderer({ dialect: this._dialect, theme: this._dialect === "lyne" ? "lyne" : "osu" });
12653
13040
  return r.render(this.redRoot);
12654
13041
  }
13042
+ /**
13043
+ * Fast-path read-only rendering for forum posts, comments, and static views.
13044
+ *
13045
+ * Completely bypasses semantic analysis (linter), undo stack allocations, and
13046
+ * editor node ids (idMode: 'none') to maximize throughput and minimize memory.
13047
+ */
13048
+ static renderForum(source, options = {}) {
13049
+ const previousIdMode = HTMLRenderer.idMode;
13050
+ HTMLRenderer.idMode = "none";
13051
+ try {
13052
+ const dialect = options.dialect ?? (options.theme === "lyne" ? "lyne" : "miliastry");
13053
+ const doc = new _BBCodeDocumentModel({
13054
+ source: source || " ",
13055
+ dialect,
13056
+ autoAnalyze: false,
13057
+ maxUndo: 0,
13058
+ incremental: false
13059
+ });
13060
+ if (!doc.redRoot) return "";
13061
+ const renderer = new HTMLRenderer({
13062
+ ...options,
13063
+ dialect,
13064
+ registry: options.registry ?? doc.tagRegistry
13065
+ });
13066
+ return renderer.render(doc.redRoot);
13067
+ } finally {
13068
+ HTMLRenderer.idMode = previousIdMode;
13069
+ }
13070
+ }
12655
13071
  /**
12656
13072
  * Parse BBCode text directly to a GreenNode tree using the
12657
13073
  * DocumentEngine's built-in BBCode Lexer + Parser.
@@ -12702,6 +13118,7 @@ var BBCodeDocumentModel = class extends DocumentModel {
12702
13118
  return new BBCodeExporter(this.tagRegistry, exportTarget).export(root);
12703
13119
  }
12704
13120
  };
13121
+ var renderForumBBCode = BBCodeDocumentModel.renderForum;
12705
13122
 
12706
13123
  // src/Edits/Rules/Rule.ts
12707
13124
  function endOf(p) {
@@ -13264,31 +13681,32 @@ function transformRange(range3, changes) {
13264
13681
  // src/Visuals/BoxDrawer.ts
13265
13682
  var DEFAULT_DURATION_MS = 400;
13266
13683
  var MIN_DURATION_MS = 120;
13684
+ var OSU_OPEN_CLASS = "js-spoilerbox--open";
13267
13685
  var DEFAULT_EASING = "cubic-bezier(0.33, 1, 0.68, 1)";
13268
13686
  var running = /* @__PURE__ */ new WeakMap();
13269
13687
  function prefersReducedMotion() {
13270
13688
  return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
13271
13689
  }
13272
- function toggleBoxWithDrawer(details, options = {}) {
13690
+ function animateDrawer(details, isOpen, setOpen, options = {}) {
13273
13691
  if (typeof details.animate !== "function" || prefersReducedMotion()) {
13274
- details.open = !details.open;
13692
+ setOpen(!isOpen());
13275
13693
  return;
13276
13694
  }
13277
13695
  const from = details.getBoundingClientRect().height;
13278
13696
  const previous = running.get(details);
13279
13697
  const restoreOverflow = previous ? previous.restoreOverflow : details.style.overflow;
13280
- const opening = previous ? !previous.opening : !details.open;
13698
+ const opening = previous ? !previous.opening : !isOpen();
13281
13699
  previous?.animation.cancel();
13282
13700
  let collapsed;
13283
13701
  let expanded;
13284
- if (details.open) {
13702
+ if (isOpen()) {
13285
13703
  expanded = details.getBoundingClientRect().height;
13286
- details.open = false;
13704
+ setOpen(false);
13287
13705
  collapsed = details.getBoundingClientRect().height;
13288
- details.open = true;
13706
+ setOpen(true);
13289
13707
  } else {
13290
13708
  collapsed = details.getBoundingClientRect().height;
13291
- details.open = true;
13709
+ setOpen(true);
13292
13710
  expanded = details.getBoundingClientRect().height;
13293
13711
  }
13294
13712
  const to = opening ? expanded : collapsed;
@@ -13307,15 +13725,36 @@ function toggleBoxWithDrawer(details, options = {}) {
13307
13725
  const settle = (finished) => {
13308
13726
  if (running.get(details)?.animation !== animation) return;
13309
13727
  running.delete(details);
13310
- if (finished) details.open = opening;
13728
+ if (finished) setOpen(opening);
13311
13729
  details.style.overflow = restoreOverflow;
13312
13730
  };
13313
13731
  animation.addEventListener("finish", () => settle(true));
13314
13732
  animation.addEventListener("cancel", () => settle(false));
13315
13733
  }
13734
+ function toggleBoxWithDrawer(details, options = {}) {
13735
+ animateDrawer(details, () => details.open, (v) => {
13736
+ details.open = v;
13737
+ }, options);
13738
+ }
13739
+ function toggleSpoilerboxWithDrawer(box, options = {}) {
13740
+ animateDrawer(
13741
+ box,
13742
+ () => box.classList.contains(OSU_OPEN_CLASS),
13743
+ (v) => box.classList.toggle(OSU_OPEN_CLASS, v),
13744
+ options
13745
+ );
13746
+ }
13316
13747
  function bindBoxDrawer(root, options = {}) {
13317
13748
  const handleClick = (e) => {
13318
13749
  const target = e.target;
13750
+ const osuLink = target?.closest(".js-spoilerbox__link");
13751
+ if (osuLink) {
13752
+ const box = osuLink.closest(".js-spoilerbox");
13753
+ if (!(box instanceof HTMLElement) || !root.contains(box)) return;
13754
+ e.preventDefault();
13755
+ toggleSpoilerboxWithDrawer(box, options);
13756
+ return;
13757
+ }
13319
13758
  const summary = target?.closest("summary");
13320
13759
  if (!summary) return;
13321
13760
  const details = summary.parentElement;
@@ -13328,6 +13767,314 @@ function bindBoxDrawer(root, options = {}) {
13328
13767
  return () => root.removeEventListener("click", handleClick);
13329
13768
  }
13330
13769
 
13770
+ // src/Visuals/LyneAudio.ts
13771
+ function bindLyneAudio(root = typeof document !== "undefined" ? document.documentElement : void 0) {
13772
+ if (!root || typeof root.addEventListener !== "function") {
13773
+ return () => {
13774
+ };
13775
+ }
13776
+ const formatTime = (seconds) => {
13777
+ if (!isFinite(seconds) || isNaN(seconds) || seconds < 0) return "\u2013:\u2013\u2013";
13778
+ const m = Math.floor(seconds / 60);
13779
+ const s = Math.floor(seconds % 60);
13780
+ return `${m}:${s < 10 ? "0" : ""}${s}`;
13781
+ };
13782
+ const PLAY_SVG = '<svg viewBox="0 0 16 16"><path d="M3 1.5 14 8 3 14.5z"/></svg>';
13783
+ const PAUSE_SVG = '<svg viewBox="0 0 16 16"><path d="M3 2h3.6v12H3zM9.4 2H13v12H9.4z"/></svg>';
13784
+ const RETRY_SVG = '<svg viewBox="0 0 16 16"><path d="M8 2a6 6 0 1 0 6 6h-2a4 4 0 1 1-4-4v3l5-4-5-4z"/></svg>';
13785
+ const VOL_LOW_SVG = '<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>';
13786
+ const VOL_HIGH_SVG = '<svg viewBox="0 0 16 16"><path d="M11.536 14.01A8.47 8.47 0 0 0 14.026 8a8.47 8.47 0 0 0-2.49-6.01l-.708.707A7.48 7.48 0 0 1 13.025 8c0 2.071-.84 3.946-2.197 5.303z"/><path d="M10.121 12.596A6.48 6.48 0 0 0 12.025 8a6.48 6.48 0 0 0-1.904-4.596l-.707.707A5.48 5.48 0 0 1 11.025 8a5.48 5.48 0 0 1-1.61 3.89z"/><path d="M8.707 11.182A4.5 4.5 0 0 0 10.025 8a4.5 4.5 0 0 0-1.318-3.182L8 5.525A3.5 3.5 0 0 1 9.025 8 3.5 3.5 0 0 1 8 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>';
13787
+ const VOL_MUTE_SVG = '<svg viewBox="0 0 16 16"><path d="M6.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-.06m7.137 2.096a.5.5 0 0 1 0 .708L12.207 8l1.647 1.646a.5.5 0 0 1-.708.708L11.5 8.707l-1.646 1.647a.5.5 0 0 1-.708-.708L10.793 8 9.146 6.354a.5.5 0 1 1 .708-.708L11.5 7.293l1.646-1.647a.5.5 0 0 1 .708 0"/></svg>';
13788
+ const DEFAULT_VOLUME = 0.2;
13789
+ const applyDefaultVolume = (audio) => {
13790
+ if (audio && audio.dataset.volSet !== "true") {
13791
+ audio.volume = DEFAULT_VOLUME;
13792
+ audio.dataset.volSet = "true";
13793
+ }
13794
+ };
13795
+ const updateVolUI = (player, vol) => {
13796
+ const btn = player.querySelector(".lx-vol-btn");
13797
+ const slider = player.querySelector(".lx-vol-slider");
13798
+ if (slider) {
13799
+ if (Math.abs(parseFloat(slider.value) - vol) > 5e-3) {
13800
+ slider.value = String(vol);
13801
+ }
13802
+ const pct = (vol * 100).toFixed(1);
13803
+ slider.style.background = `linear-gradient(to right, var(--color-accent, #2EE6E2) ${pct}%, var(--color-inset-well, #080D20) ${pct}%)`;
13804
+ }
13805
+ if (btn) {
13806
+ if (vol <= 1e-3) {
13807
+ btn.innerHTML = VOL_MUTE_SVG;
13808
+ btn.setAttribute("aria-label", "Unmute");
13809
+ } else if (vol > 0.5) {
13810
+ btn.innerHTML = VOL_HIGH_SVG;
13811
+ btn.setAttribute("aria-label", "Mute");
13812
+ } else {
13813
+ btn.innerHTML = VOL_LOW_SVG;
13814
+ btn.setAttribute("aria-label", "Mute");
13815
+ }
13816
+ }
13817
+ };
13818
+ const onClick = (e) => {
13819
+ const target = e.target;
13820
+ if (!target) return;
13821
+ const btn = target.closest(".lx-btn");
13822
+ if (btn) {
13823
+ const player = btn.closest(".lx-audio");
13824
+ const audio = player?.querySelector("audio");
13825
+ if (audio && player) {
13826
+ e.preventDefault();
13827
+ e.stopPropagation();
13828
+ if (player.classList.contains("is-error")) {
13829
+ player.classList.remove("is-error");
13830
+ player.classList.add("is-loading");
13831
+ audio.load();
13832
+ const p = audio.play();
13833
+ if (p !== void 0) {
13834
+ p.catch((err) => {
13835
+ console.warn("[LyneAudio] retry playback failed:", err);
13836
+ player.classList.remove("is-loading");
13837
+ player.classList.add("is-error");
13838
+ });
13839
+ }
13840
+ } else if (audio.paused) {
13841
+ const doc = root.ownerDocument || document;
13842
+ doc.querySelectorAll(".lx-audio.is-playing").forEach((other) => {
13843
+ if (other !== player) {
13844
+ const otherAudio = other.querySelector("audio");
13845
+ if (otherAudio && !otherAudio.paused) {
13846
+ otherAudio.pause();
13847
+ }
13848
+ }
13849
+ });
13850
+ player.classList.add("is-loading");
13851
+ const p = audio.play();
13852
+ if (p !== void 0) {
13853
+ p.then(() => {
13854
+ player.classList.remove("is-loading");
13855
+ }).catch((err) => {
13856
+ console.warn("[LyneAudio] play failed:", err);
13857
+ player.classList.remove("is-loading");
13858
+ player.classList.add("is-error");
13859
+ });
13860
+ }
13861
+ } else {
13862
+ audio.pause();
13863
+ }
13864
+ }
13865
+ return;
13866
+ }
13867
+ const track = target.closest(".lx-track");
13868
+ if (track) {
13869
+ const player = track.closest(".lx-audio");
13870
+ const audio = player?.querySelector("audio");
13871
+ if (audio) {
13872
+ e.preventDefault();
13873
+ e.stopPropagation();
13874
+ const rect = track.getBoundingClientRect();
13875
+ const pct = Math.min(Math.max((e.clientX - rect.left) / rect.width, 0), 1);
13876
+ if (audio.duration && isFinite(audio.duration)) {
13877
+ audio.currentTime = pct * audio.duration;
13878
+ }
13879
+ const fill = track.querySelector(".fill");
13880
+ if (fill) fill.style.width = `${pct * 100}%`;
13881
+ }
13882
+ return;
13883
+ }
13884
+ const speed = target.closest(".lx-speed");
13885
+ if (speed) {
13886
+ const player = speed.closest(".lx-audio");
13887
+ const audio = player?.querySelector("audio");
13888
+ if (audio) {
13889
+ e.preventDefault();
13890
+ e.stopPropagation();
13891
+ const rates = [1, 1.25, 1.5, 0.75];
13892
+ const curRate = audio.playbackRate || 1;
13893
+ const curIdx = rates.indexOf(curRate);
13894
+ const nextRate = rates[(curIdx + 1) % rates.length];
13895
+ audio.playbackRate = nextRate;
13896
+ speed.textContent = `${nextRate}\xD7`;
13897
+ speed.classList.toggle("active", nextRate !== 1);
13898
+ }
13899
+ return;
13900
+ }
13901
+ const volBtn = target.closest(".lx-vol-btn");
13902
+ if (volBtn) {
13903
+ const player = volBtn.closest(".lx-audio");
13904
+ const audio = player?.querySelector("audio");
13905
+ if (audio && player) {
13906
+ e.preventDefault();
13907
+ e.stopPropagation();
13908
+ audio.dataset.volSet = "true";
13909
+ if (audio.muted || audio.volume <= 1e-3) {
13910
+ const prev = parseFloat(audio.dataset.prevVol || String(DEFAULT_VOLUME)) || DEFAULT_VOLUME;
13911
+ audio.muted = false;
13912
+ audio.volume = prev;
13913
+ updateVolUI(player, prev);
13914
+ } else {
13915
+ audio.dataset.prevVol = String(audio.volume);
13916
+ audio.muted = true;
13917
+ updateVolUI(player, 0);
13918
+ }
13919
+ }
13920
+ return;
13921
+ }
13922
+ };
13923
+ const onInput = (e) => {
13924
+ const target = e.target;
13925
+ if (!target) return;
13926
+ const slider = target.closest(".lx-vol-slider");
13927
+ if (slider) {
13928
+ const player = slider.closest(".lx-audio");
13929
+ const audio = player?.querySelector("audio");
13930
+ if (audio) {
13931
+ const val = parseFloat(slider.value);
13932
+ const clamped = isNaN(val) ? DEFAULT_VOLUME : Math.max(0, Math.min(1, val));
13933
+ audio.dataset.volSet = "true";
13934
+ audio.muted = clamped === 0;
13935
+ audio.volume = clamped;
13936
+ if (player) {
13937
+ updateVolUI(player, clamped);
13938
+ }
13939
+ }
13940
+ }
13941
+ };
13942
+ const onPlay = (e) => {
13943
+ const audio = e.target;
13944
+ if (audio?.tagName !== "AUDIO") return;
13945
+ applyDefaultVolume(audio);
13946
+ const player = audio.closest(".lx-audio");
13947
+ if (!player) return;
13948
+ player.classList.remove("is-loading");
13949
+ player.classList.add("is-playing");
13950
+ const btn = player.querySelector(".lx-btn");
13951
+ if (btn) {
13952
+ btn.innerHTML = PAUSE_SVG;
13953
+ btn.setAttribute("aria-label", "Pause");
13954
+ }
13955
+ const label = player.querySelector(".lx-title .label .status-text");
13956
+ if (label) label.textContent = "playing";
13957
+ updateVolUI(player, audio.muted ? 0 : audio.volume);
13958
+ };
13959
+ const onPause = (e) => {
13960
+ const audio = e.target;
13961
+ if (audio?.tagName !== "AUDIO") return;
13962
+ const player = audio.closest(".lx-audio");
13963
+ if (!player) return;
13964
+ player.classList.remove("is-playing", "is-loading");
13965
+ const btn = player.querySelector(".lx-btn");
13966
+ if (btn) {
13967
+ btn.innerHTML = PLAY_SVG;
13968
+ btn.setAttribute("aria-label", "Play");
13969
+ }
13970
+ const label = player.querySelector(".lx-title .label .status-text");
13971
+ if (label) label.textContent = "audio";
13972
+ };
13973
+ const onTimeUpdate = (e) => {
13974
+ const audio = e.target;
13975
+ if (audio?.tagName !== "AUDIO") return;
13976
+ const player = audio.closest(".lx-audio");
13977
+ if (!player) return;
13978
+ const cur = audio.currentTime || 0;
13979
+ const dur = audio.duration || 0;
13980
+ const fill = player.querySelector(".lx-track .fill");
13981
+ const curTime = player.querySelector(".lx-time .cur");
13982
+ if (fill && dur > 0 && isFinite(dur)) {
13983
+ fill.style.width = `${cur / dur * 100}%`;
13984
+ }
13985
+ if (curTime) {
13986
+ curTime.textContent = formatTime(cur);
13987
+ }
13988
+ };
13989
+ const onLoadedMetadata = (e) => {
13990
+ const audio = e.target;
13991
+ if (audio?.tagName !== "AUDIO") return;
13992
+ applyDefaultVolume(audio);
13993
+ const player = audio.closest(".lx-audio");
13994
+ if (!player) return;
13995
+ const dur = audio.duration || 0;
13996
+ const totalTime = player.querySelector(".lx-time .total");
13997
+ if (totalTime && dur > 0 && isFinite(dur)) {
13998
+ totalTime.textContent = formatTime(dur);
13999
+ }
14000
+ updateVolUI(player, audio.muted ? 0 : audio.volume);
14001
+ };
14002
+ const onWaiting = (e) => {
14003
+ const audio = e.target;
14004
+ if (audio?.tagName !== "AUDIO") return;
14005
+ const player = audio.closest(".lx-audio");
14006
+ if (!player) return;
14007
+ player.classList.add("is-loading");
14008
+ const label = player.querySelector(".lx-title .label .status-text");
14009
+ if (label) label.textContent = "buffering\u2026";
14010
+ };
14011
+ const onError = (e) => {
14012
+ const audio = e.target;
14013
+ if (audio?.tagName !== "AUDIO") return;
14014
+ const player = audio.closest(".lx-audio");
14015
+ if (!player) return;
14016
+ player.classList.remove("is-playing", "is-loading");
14017
+ player.classList.add("is-error");
14018
+ const label = player.querySelector(".lx-title .label .status-text");
14019
+ if (label) label.textContent = "unavailable \u2014 file error";
14020
+ const btn = player.querySelector(".lx-btn");
14021
+ if (btn) {
14022
+ btn.innerHTML = RETRY_SVG;
14023
+ btn.setAttribute("aria-label", "Retry");
14024
+ btn.style.background = "var(--color-danger, #FF3B5C)";
14025
+ }
14026
+ const curTime = player.querySelector(".lx-time .cur");
14027
+ const totalTime = player.querySelector(".lx-time .total");
14028
+ if (curTime) curTime.textContent = "\u2013:\u2013\u2013";
14029
+ if (totalTime) totalTime.textContent = "\u2013:\u2013\u2013";
14030
+ };
14031
+ const onVolumeChange = (e) => {
14032
+ const audio = e.target;
14033
+ if (audio?.tagName !== "AUDIO") return;
14034
+ const player = audio.closest(".lx-audio");
14035
+ if (!player) return;
14036
+ updateVolUI(player, audio.muted ? 0 : audio.volume);
14037
+ };
14038
+ try {
14039
+ root.querySelectorAll("audio").forEach((audio) => {
14040
+ applyDefaultVolume(audio);
14041
+ const player = audio.closest(".lx-audio");
14042
+ if (player) {
14043
+ updateVolUI(player, audio.muted ? 0 : audio.volume);
14044
+ }
14045
+ });
14046
+ } catch {
14047
+ }
14048
+ root.addEventListener("click", onClick, true);
14049
+ root.addEventListener("input", onInput, true);
14050
+ root.addEventListener("change", onInput, true);
14051
+ root.addEventListener("play", onPlay, true);
14052
+ root.addEventListener("pause", onPause, true);
14053
+ root.addEventListener("timeupdate", onTimeUpdate, true);
14054
+ root.addEventListener("loadedmetadata", onLoadedMetadata, true);
14055
+ root.addEventListener("durationchange", onLoadedMetadata, true);
14056
+ root.addEventListener("waiting", onWaiting, true);
14057
+ root.addEventListener("error", onError, true);
14058
+ root.addEventListener("volumechange", onVolumeChange, true);
14059
+ return () => {
14060
+ root.removeEventListener("click", onClick, true);
14061
+ root.removeEventListener("input", onInput, true);
14062
+ root.removeEventListener("change", onInput, true);
14063
+ root.removeEventListener("play", onPlay, true);
14064
+ root.removeEventListener("pause", onPause, true);
14065
+ root.removeEventListener("timeupdate", onTimeUpdate, true);
14066
+ root.removeEventListener("loadedmetadata", onLoadedMetadata, true);
14067
+ root.removeEventListener("durationchange", onLoadedMetadata, true);
14068
+ root.removeEventListener("waiting", onWaiting, true);
14069
+ root.removeEventListener("error", onError, true);
14070
+ root.removeEventListener("volumechange", onVolumeChange, true);
14071
+ };
14072
+ }
14073
+ var setupLyneAudioRuntime = bindLyneAudio;
14074
+ if (typeof window !== "undefined" && typeof document !== "undefined") {
14075
+ bindLyneAudio(document.documentElement);
14076
+ }
14077
+
13331
14078
  // src/Visuals/index.ts
13332
14079
  var visualThemes = [
13333
14080
  {
@@ -17764,6 +18511,7 @@ exports.bbBlockToGreenNode = bbBlockToGreenNode;
17764
18511
  exports.bbBlocksToGreenTree = bbBlocksToGreenTree;
17765
18512
  exports.bbBlocksToRedTree = bbBlocksToRedTree;
17766
18513
  exports.bindBoxDrawer = bindBoxDrawer;
18514
+ exports.bindLyneAudio = bindLyneAudio;
17767
18515
  exports.blendHex = blendHex;
17768
18516
  exports.buildRangeScope = buildRangeScope;
17769
18517
  exports.buildSampleTable = buildSampleTable;
@@ -17818,6 +18566,7 @@ exports.mixHex = mixHex;
17818
18566
  exports.mixHexOklab = mixHexOklab;
17819
18567
  exports.mixMultiple = mixMultiple;
17820
18568
  exports.mixMultipleStops = mixMultipleStops;
18569
+ exports.morphElement = morphElement;
17821
18570
  exports.morphHTML = morphHTML;
17822
18571
  exports.nodeAttrValue = nodeAttrValue;
17823
18572
  exports.nodeKindToTag = nodeKindToTag;
@@ -17836,6 +18585,7 @@ exports.positionedChildren = positionedChildren;
17836
18585
  exports.posterizeHex = posterizeHex;
17837
18586
  exports.randAt = randAt;
17838
18587
  exports.reconcileVisualDOMToBBCode = reconcileVisualDOMToBBCode;
18588
+ exports.renderForumBBCode = renderForumBBCode;
17839
18589
  exports.repairNesting = repairNesting;
17840
18590
  exports.resolveEditConflicts = resolveEditConflicts;
17841
18591
  exports.resolveTokenValue = resolveTokenValue;
@@ -17844,6 +18594,7 @@ exports.samplePaintGrid = samplePaintGrid;
17844
18594
  exports.sanitizeColor = sanitizeColor;
17845
18595
  exports.sanitizeFontFamily = sanitizeFontFamily;
17846
18596
  exports.sanitizeFontSize = sanitizeFontSize;
18597
+ exports.setupLyneAudioRuntime = setupLyneAudioRuntime;
17847
18598
  exports.shortenableHex = shortenableHex;
17848
18599
  exports.solveCubicBezierY = solveCubicBezierY;
17849
18600
  exports.spatialFromParams = spatialFromParams;
@@ -17855,6 +18606,7 @@ exports.stringifyPaintPalette = stringifyPaintPalette;
17855
18606
  exports.tagToNodeKind = tagToNodeKind;
17856
18607
  exports.toTokenResolver = toTokenResolver;
17857
18608
  exports.toggleBoxWithDrawer = toggleBoxWithDrawer;
18609
+ exports.toggleSpoilerboxWithDrawer = toggleSpoilerboxWithDrawer;
17858
18610
  exports.transformOffset = transformOffset;
17859
18611
  exports.transformRange = transformRange;
17860
18612
  exports.validateExpression = validateExpression;