@miliastry/quasar 1.0.0 → 1.0.2

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.
@@ -52,6 +52,13 @@
52
52
  border-bottom-color: var(--color-accent, #2EE6E2) !important;
53
53
  }
54
54
 
55
+ /* Forum @mentions — inherit the accent link look, slightly bolder so they
56
+ read as handles rather than plain links. */
57
+ .bbcode-preview-lyne a.bb-mention,
58
+ .bbcode-preview.theme-lyne a.bb-mention {
59
+ font-weight: 600 !important;
60
+ }
61
+
55
62
  /* ─── 45° Cut Panels ────────────────────────────────────────────────────── */
56
63
 
57
64
  .bb-cut-panel {
package/dist/index.d.mts CHANGED
@@ -2298,6 +2298,15 @@ interface HTMLRendererOptions {
2298
2298
  href: string;
2299
2299
  external?: boolean;
2300
2300
  } | null;
2301
+ /**
2302
+ * Forum-style `@mention` linkifier. Called with the bare name (no `@`);
2303
+ * return null to leave the mention as plain text. Used by forum hosts that
2304
+ * linkify `@username` to a profile route.
2305
+ */
2306
+ mentionResolver?: (name: string) => {
2307
+ href: string;
2308
+ external?: boolean;
2309
+ } | null;
2301
2310
  }
2302
2311
  declare class HTMLRenderer extends Visitor<string> {
2303
2312
  private options;
@@ -2389,6 +2398,13 @@ declare class HTMLRenderer extends Visitor<string> {
2389
2398
  * mantenga vivos los nodos del árbol.
2390
2399
  */
2391
2400
  static idMode: 'blocks' | 'all' | 'none';
2401
+ /**
2402
+ * Nesting depth of `[tables]` while rendering. `[col]`/`[row]`/`[th]` are
2403
+ * table cells only INSIDE a `[tables]`; orphaned ones (e.g. `[col]` inside
2404
+ * `[columns]`, or stray cells) render as plain inline spans — the same rule
2405
+ * LYNE's forum renderer applies.
2406
+ */
2407
+ private tableDepth;
2392
2408
  private idAttr;
2393
2409
  /**
2394
2410
  * Kinds that carry `data-node-id`.
@@ -2523,6 +2539,24 @@ declare class HTMLRenderer extends Visitor<string> {
2523
2539
  * returns the input untouched in that common case, then does a single pass
2524
2540
  * when there is actually something to escape.
2525
2541
  */
2542
+ /** Resolver configured at construction (read through a getter for brevity). */
2543
+ private get mentionResolver();
2544
+ /**
2545
+ * `@mention` shape used by forum hosts: 3-15 word chars, boundaries that
2546
+ * exclude longer usernames (same rule LYNE's forum uses). Non-global so
2547
+ * `lastIndex` never leaks between calls.
2548
+ */
2549
+ private static readonly MENTION_RE;
2550
+ /**
2551
+ * Linkify `@username` runs in a plain-text leaf. Runs through the resolver;
2552
+ * null leaves the run as text. Every interpolated string is HTML-escaped,
2553
+ * and hrefs with dangerous protocols (javascript:, data:, vbscript:) are
2554
+ * rejected — the mention stays as plain text even if the resolver returns
2555
+ * one (defence in depth; the resolver itself should never produce it).
2556
+ */
2557
+ private linkifyMentions;
2558
+ /** Root-relative, http(s), mailto and line:// hrefs are the only safe shapes. */
2559
+ private isSafeMentionHref;
2526
2560
  private escapeHtml;
2527
2561
  }
2528
2562
 
package/dist/index.d.ts CHANGED
@@ -2298,6 +2298,15 @@ interface HTMLRendererOptions {
2298
2298
  href: string;
2299
2299
  external?: boolean;
2300
2300
  } | null;
2301
+ /**
2302
+ * Forum-style `@mention` linkifier. Called with the bare name (no `@`);
2303
+ * return null to leave the mention as plain text. Used by forum hosts that
2304
+ * linkify `@username` to a profile route.
2305
+ */
2306
+ mentionResolver?: (name: string) => {
2307
+ href: string;
2308
+ external?: boolean;
2309
+ } | null;
2301
2310
  }
2302
2311
  declare class HTMLRenderer extends Visitor<string> {
2303
2312
  private options;
@@ -2389,6 +2398,13 @@ declare class HTMLRenderer extends Visitor<string> {
2389
2398
  * mantenga vivos los nodos del árbol.
2390
2399
  */
2391
2400
  static idMode: 'blocks' | 'all' | 'none';
2401
+ /**
2402
+ * Nesting depth of `[tables]` while rendering. `[col]`/`[row]`/`[th]` are
2403
+ * table cells only INSIDE a `[tables]`; orphaned ones (e.g. `[col]` inside
2404
+ * `[columns]`, or stray cells) render as plain inline spans — the same rule
2405
+ * LYNE's forum renderer applies.
2406
+ */
2407
+ private tableDepth;
2392
2408
  private idAttr;
2393
2409
  /**
2394
2410
  * Kinds that carry `data-node-id`.
@@ -2523,6 +2539,24 @@ declare class HTMLRenderer extends Visitor<string> {
2523
2539
  * returns the input untouched in that common case, then does a single pass
2524
2540
  * when there is actually something to escape.
2525
2541
  */
2542
+ /** Resolver configured at construction (read through a getter for brevity). */
2543
+ private get mentionResolver();
2544
+ /**
2545
+ * `@mention` shape used by forum hosts: 3-15 word chars, boundaries that
2546
+ * exclude longer usernames (same rule LYNE's forum uses). Non-global so
2547
+ * `lastIndex` never leaks between calls.
2548
+ */
2549
+ private static readonly MENTION_RE;
2550
+ /**
2551
+ * Linkify `@username` runs in a plain-text leaf. Runs through the resolver;
2552
+ * null leaves the run as text. Every interpolated string is HTML-escaped,
2553
+ * and hrefs with dangerous protocols (javascript:, data:, vbscript:) are
2554
+ * rejected — the mention stays as plain text even if the resolver returns
2555
+ * one (defence in depth; the resolver itself should never produce it).
2556
+ */
2557
+ private linkifyMentions;
2558
+ /** Root-relative, http(s), mailto and line:// hrefs are the only safe shapes. */
2559
+ private isSafeMentionHref;
2526
2560
  private escapeHtml;
2527
2561
  }
2528
2562
 
package/dist/index.js CHANGED
@@ -4816,7 +4816,8 @@ var HTMLRenderer = class _HTMLRenderer extends Visitor {
4816
4816
  dialect: options.dialect ?? (options.theme === "lyne" ? "lyne" : "miliastry"),
4817
4817
  theme: options.theme ?? (options.dialect === "lyne" ? "lyne" : "osu"),
4818
4818
  mediaProxy: options.mediaProxy,
4819
- entityLinkResolver: options.entityLinkResolver
4819
+ entityLinkResolver: options.entityLinkResolver,
4820
+ mentionResolver: options.mentionResolver
4820
4821
  };
4821
4822
  }
4822
4823
  // ─── Tag → HTML Element Map ─────────────────────────────
@@ -4966,6 +4967,13 @@ var HTMLRenderer = class _HTMLRenderer extends Visitor {
4966
4967
  * mantenga vivos los nodos del árbol.
4967
4968
  */
4968
4969
  static idMode = "all";
4970
+ /**
4971
+ * Nesting depth of `[tables]` while rendering. `[col]`/`[row]`/`[th]` are
4972
+ * table cells only INSIDE a `[tables]`; orphaned ones (e.g. `[col]` inside
4973
+ * `[columns]`, or stray cells) render as plain inline spans — the same rule
4974
+ * LYNE's forum renderer applies.
4975
+ */
4976
+ tableDepth = 0;
4969
4977
  idAttr(node) {
4970
4978
  if (_HTMLRenderer.idMode === "none") return "";
4971
4979
  if (_HTMLRenderer.idMode === "all") return ` data-node-id="${node.id}"`;
@@ -5038,7 +5046,7 @@ var HTMLRenderer = class _HTMLRenderer extends Visitor {
5038
5046
  // ─── Node Rendering ─────────────────────────────────────
5039
5047
  renderNode(node) {
5040
5048
  if (node.children.length === 0 && node.kind === "text") {
5041
- let out = this.escapeHtml(node.text);
5049
+ let out = this.mentionResolver ? this.linkifyMentions(node.text) : this.escapeHtml(node.text);
5042
5050
  const style = node.metadata?.style;
5043
5051
  if (style) {
5044
5052
  const inlineStyles = [];
@@ -5123,12 +5131,12 @@ var HTMLRenderer = class _HTMLRenderer extends Visitor {
5123
5131
  case "tables":
5124
5132
  return this.renderTables(node);
5125
5133
  case "table_row":
5126
- return this.wrapBlock("tr", node);
5134
+ return this.tableDepth > 0 ? this.wrapBlock("tr", node) : this.wrapInline("span", node);
5127
5135
  case "table_col":
5128
- return this.wrapInline("td", node);
5136
+ return this.tableDepth > 0 ? this.wrapInline("td", node) : this.wrapInline("span", node);
5129
5137
  case "table_th": {
5130
5138
  const content = this.renderChildren(node);
5131
- return `<th${this.idAttr(node)} class="bb-th"><span class="bb-table-badge">${content}</span></th>`;
5139
+ return this.tableDepth > 0 ? `<th${this.idAttr(node)} class="bb-th"><span class="bb-table-badge">${content}</span></th>` : this.wrapInline("span", node);
5132
5140
  }
5133
5141
  case "gallery":
5134
5142
  return this.renderGallery(node);
@@ -5467,7 +5475,9 @@ var HTMLRenderer = class _HTMLRenderer extends Visitor {
5467
5475
  variants.has("striped") ? "bb-table-striped" : "",
5468
5476
  variants.has("borders") ? "bb-table-borders" : ""
5469
5477
  ].filter(Boolean).join(" ");
5478
+ this.tableDepth++;
5470
5479
  const content = this.renderChildren(node);
5480
+ this.tableDepth--;
5471
5481
  const accent = this.boxAccentStyle(node, "table");
5472
5482
  return `<div${this.idAttr(node)} class="bb-table-frame"${accent}><table class="${tableClasses}"><tbody>${content}</tbody></table></div>`;
5473
5483
  }
@@ -5834,6 +5844,51 @@ var HTMLRenderer = class _HTMLRenderer extends Visitor {
5834
5844
  * returns the input untouched in that common case, then does a single pass
5835
5845
  * when there is actually something to escape.
5836
5846
  */
5847
+ /** Resolver configured at construction (read through a getter for brevity). */
5848
+ get mentionResolver() {
5849
+ return this.options.mentionResolver;
5850
+ }
5851
+ /**
5852
+ * `@mention` shape used by forum hosts: 3-15 word chars, boundaries that
5853
+ * exclude longer usernames (same rule LYNE's forum uses). Non-global so
5854
+ * `lastIndex` never leaks between calls.
5855
+ */
5856
+ static MENTION_RE = /(?<![A-Za-z0-9_-])@([A-Za-z0-9_-]{3,15})(?![A-Za-z0-9_-])/g;
5857
+ /**
5858
+ * Linkify `@username` runs in a plain-text leaf. Runs through the resolver;
5859
+ * null leaves the run as text. Every interpolated string is HTML-escaped,
5860
+ * and hrefs with dangerous protocols (javascript:, data:, vbscript:) are
5861
+ * rejected — the mention stays as plain text even if the resolver returns
5862
+ * one (defence in depth; the resolver itself should never produce it).
5863
+ */
5864
+ linkifyMentions(text) {
5865
+ const resolver = this.options.mentionResolver;
5866
+ if (!resolver) return this.escapeHtml(text);
5867
+ let out = "";
5868
+ let last = 0;
5869
+ _HTMLRenderer.MENTION_RE.lastIndex = 0;
5870
+ let m;
5871
+ while ((m = _HTMLRenderer.MENTION_RE.exec(text)) !== null) {
5872
+ if (m.index > last) out += this.escapeHtml(text.slice(last, m.index));
5873
+ const name = m[1];
5874
+ const link = resolver(name);
5875
+ if (link && this.isSafeMentionHref(link.href)) {
5876
+ const ext = link.external ? ' target="_blank" rel="noopener"' : "";
5877
+ out += `<a class="bb-mention" href="${this.escapeHtml(link.href)}"${ext}>@${this.escapeHtml(name)}</a>`;
5878
+ } else {
5879
+ out += this.escapeHtml(m[0]);
5880
+ }
5881
+ last = m.index + m[0].length;
5882
+ }
5883
+ if (last < text.length) out += this.escapeHtml(text.slice(last));
5884
+ return out;
5885
+ }
5886
+ /** Root-relative, http(s), mailto and line:// hrefs are the only safe shapes. */
5887
+ isSafeMentionHref(href) {
5888
+ const h = href.trim();
5889
+ if (h.startsWith("/") && !h.startsWith("//")) return true;
5890
+ return /^(https?:|mailto:|line:)/i.test(h);
5891
+ }
5837
5892
  escapeHtml(text) {
5838
5893
  if (!_HTMLRenderer.HTML_ESCAPE_RE.test(text)) return text;
5839
5894
  let out = "";
package/dist/index.mjs CHANGED
@@ -4814,7 +4814,8 @@ var HTMLRenderer = class _HTMLRenderer extends Visitor {
4814
4814
  dialect: options.dialect ?? (options.theme === "lyne" ? "lyne" : "miliastry"),
4815
4815
  theme: options.theme ?? (options.dialect === "lyne" ? "lyne" : "osu"),
4816
4816
  mediaProxy: options.mediaProxy,
4817
- entityLinkResolver: options.entityLinkResolver
4817
+ entityLinkResolver: options.entityLinkResolver,
4818
+ mentionResolver: options.mentionResolver
4818
4819
  };
4819
4820
  }
4820
4821
  // ─── Tag → HTML Element Map ─────────────────────────────
@@ -4964,6 +4965,13 @@ var HTMLRenderer = class _HTMLRenderer extends Visitor {
4964
4965
  * mantenga vivos los nodos del árbol.
4965
4966
  */
4966
4967
  static idMode = "all";
4968
+ /**
4969
+ * Nesting depth of `[tables]` while rendering. `[col]`/`[row]`/`[th]` are
4970
+ * table cells only INSIDE a `[tables]`; orphaned ones (e.g. `[col]` inside
4971
+ * `[columns]`, or stray cells) render as plain inline spans — the same rule
4972
+ * LYNE's forum renderer applies.
4973
+ */
4974
+ tableDepth = 0;
4967
4975
  idAttr(node) {
4968
4976
  if (_HTMLRenderer.idMode === "none") return "";
4969
4977
  if (_HTMLRenderer.idMode === "all") return ` data-node-id="${node.id}"`;
@@ -5036,7 +5044,7 @@ var HTMLRenderer = class _HTMLRenderer extends Visitor {
5036
5044
  // ─── Node Rendering ─────────────────────────────────────
5037
5045
  renderNode(node) {
5038
5046
  if (node.children.length === 0 && node.kind === "text") {
5039
- let out = this.escapeHtml(node.text);
5047
+ let out = this.mentionResolver ? this.linkifyMentions(node.text) : this.escapeHtml(node.text);
5040
5048
  const style = node.metadata?.style;
5041
5049
  if (style) {
5042
5050
  const inlineStyles = [];
@@ -5121,12 +5129,12 @@ var HTMLRenderer = class _HTMLRenderer extends Visitor {
5121
5129
  case "tables":
5122
5130
  return this.renderTables(node);
5123
5131
  case "table_row":
5124
- return this.wrapBlock("tr", node);
5132
+ return this.tableDepth > 0 ? this.wrapBlock("tr", node) : this.wrapInline("span", node);
5125
5133
  case "table_col":
5126
- return this.wrapInline("td", node);
5134
+ return this.tableDepth > 0 ? this.wrapInline("td", node) : this.wrapInline("span", node);
5127
5135
  case "table_th": {
5128
5136
  const content = this.renderChildren(node);
5129
- return `<th${this.idAttr(node)} class="bb-th"><span class="bb-table-badge">${content}</span></th>`;
5137
+ return this.tableDepth > 0 ? `<th${this.idAttr(node)} class="bb-th"><span class="bb-table-badge">${content}</span></th>` : this.wrapInline("span", node);
5130
5138
  }
5131
5139
  case "gallery":
5132
5140
  return this.renderGallery(node);
@@ -5465,7 +5473,9 @@ var HTMLRenderer = class _HTMLRenderer extends Visitor {
5465
5473
  variants.has("striped") ? "bb-table-striped" : "",
5466
5474
  variants.has("borders") ? "bb-table-borders" : ""
5467
5475
  ].filter(Boolean).join(" ");
5476
+ this.tableDepth++;
5468
5477
  const content = this.renderChildren(node);
5478
+ this.tableDepth--;
5469
5479
  const accent = this.boxAccentStyle(node, "table");
5470
5480
  return `<div${this.idAttr(node)} class="bb-table-frame"${accent}><table class="${tableClasses}"><tbody>${content}</tbody></table></div>`;
5471
5481
  }
@@ -5832,6 +5842,51 @@ var HTMLRenderer = class _HTMLRenderer extends Visitor {
5832
5842
  * returns the input untouched in that common case, then does a single pass
5833
5843
  * when there is actually something to escape.
5834
5844
  */
5845
+ /** Resolver configured at construction (read through a getter for brevity). */
5846
+ get mentionResolver() {
5847
+ return this.options.mentionResolver;
5848
+ }
5849
+ /**
5850
+ * `@mention` shape used by forum hosts: 3-15 word chars, boundaries that
5851
+ * exclude longer usernames (same rule LYNE's forum uses). Non-global so
5852
+ * `lastIndex` never leaks between calls.
5853
+ */
5854
+ static MENTION_RE = /(?<![A-Za-z0-9_-])@([A-Za-z0-9_-]{3,15})(?![A-Za-z0-9_-])/g;
5855
+ /**
5856
+ * Linkify `@username` runs in a plain-text leaf. Runs through the resolver;
5857
+ * null leaves the run as text. Every interpolated string is HTML-escaped,
5858
+ * and hrefs with dangerous protocols (javascript:, data:, vbscript:) are
5859
+ * rejected — the mention stays as plain text even if the resolver returns
5860
+ * one (defence in depth; the resolver itself should never produce it).
5861
+ */
5862
+ linkifyMentions(text) {
5863
+ const resolver = this.options.mentionResolver;
5864
+ if (!resolver) return this.escapeHtml(text);
5865
+ let out = "";
5866
+ let last = 0;
5867
+ _HTMLRenderer.MENTION_RE.lastIndex = 0;
5868
+ let m;
5869
+ while ((m = _HTMLRenderer.MENTION_RE.exec(text)) !== null) {
5870
+ if (m.index > last) out += this.escapeHtml(text.slice(last, m.index));
5871
+ const name = m[1];
5872
+ const link = resolver(name);
5873
+ if (link && this.isSafeMentionHref(link.href)) {
5874
+ const ext = link.external ? ' target="_blank" rel="noopener"' : "";
5875
+ out += `<a class="bb-mention" href="${this.escapeHtml(link.href)}"${ext}>@${this.escapeHtml(name)}</a>`;
5876
+ } else {
5877
+ out += this.escapeHtml(m[0]);
5878
+ }
5879
+ last = m.index + m[0].length;
5880
+ }
5881
+ if (last < text.length) out += this.escapeHtml(text.slice(last));
5882
+ return out;
5883
+ }
5884
+ /** Root-relative, http(s), mailto and line:// hrefs are the only safe shapes. */
5885
+ isSafeMentionHref(href) {
5886
+ const h = href.trim();
5887
+ if (h.startsWith("/") && !h.startsWith("//")) return true;
5888
+ return /^(https?:|mailto:|line:)/i.test(h);
5889
+ }
5835
5890
  escapeHtml(text) {
5836
5891
  if (!_HTMLRenderer.HTML_ESCAPE_RE.test(text)) return text;
5837
5892
  let out = "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miliastry/quasar",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Quasar Document Engine - Advanced BBCode/Markdown/HTML AST Engine",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -183,5 +183,78 @@ describe('Lyne Mode & Dialect Isolation', () => {
183
183
  const exported = exporter.export(doc.root!)
184
184
  expect(exported).toBe('[wnotice=#ff0000]Be careful[/wnotice]')
185
185
  })
186
+
187
+ it('linkifies @mentions via mentionResolver and keeps unresolved ones as text', () => {
188
+ const doc = new BBCodeDocumentModel({
189
+ source: '[b]Hey @airi and @ghost[/b] tail',
190
+ mode: 'lyne',
191
+ })
192
+ const renderer = new HTMLRenderer({
193
+ registry: doc.tagRegistry,
194
+ dialect: 'lyne',
195
+ theme: 'lyne',
196
+ mentionResolver: (name) => (name === 'airi' ? { href: `/u/${name}`, external: false } : null),
197
+ })
198
+ const html = renderer.render(doc.root!)
199
+ expect(html).toContain('<a class="bb-mention" href="/u/airi">@airi</a>')
200
+ expect(html).toContain('@ghost')
201
+ expect(html).toContain('tail')
202
+ })
203
+
204
+ it('escapes mention names and rejects dangerous mention hrefs', () => {
205
+ const doc = new BBCodeDocumentModel({
206
+ source: 'Hi @evil"onclick="alert(1)',
207
+ mode: 'lyne',
208
+ })
209
+ const renderer = new HTMLRenderer({
210
+ registry: doc.tagRegistry,
211
+ dialect: 'lyne',
212
+ theme: 'lyne',
213
+ mentionResolver: () => ({ href: 'javascript:alert(1)', external: false }),
214
+ })
215
+ const html = renderer.render(doc.root!)
216
+ // Dangerous protocol → mention stays plain text, escaped
217
+ expect(html).not.toContain('javascript:')
218
+ expect(html).not.toContain('<a class="bb-mention"')
219
+ expect(html).toContain('&quot;')
220
+ })
221
+
222
+ it('allows safe mention hrefs (root-relative and http)', () => {
223
+ const doc = new BBCodeDocumentModel({
224
+ source: '@airi @jane',
225
+ mode: 'lyne',
226
+ })
227
+ const renderer = new HTMLRenderer({
228
+ registry: doc.tagRegistry,
229
+ dialect: 'lyne',
230
+ theme: 'lyne',
231
+ mentionResolver: (name) => ({
232
+ href: name === 'airi' ? `/u/${name}` : `https://example.com/u/${name}`,
233
+ external: name !== 'airi',
234
+ }),
235
+ })
236
+ const html = renderer.render(doc.root!)
237
+ expect(html).toContain('<a class="bb-mention" href="/u/airi">@airi</a>')
238
+ expect(html).toContain('<a class="bb-mention" href="https://example.com/u/jane" target="_blank" rel="noopener">@jane</a>')
239
+ })
240
+
241
+ it('renders [col] outside [tables] as a plain span, inside as td', () => {
242
+ const doc = new BBCodeDocumentModel({
243
+ source: `[columns=2][col]Left[/col][col]Right[/col][/columns]
244
+ [tables][row][col]Cell[/col][/row][/tables]`,
245
+ mode: 'lyne',
246
+ })
247
+ const prev = HTMLRenderer.idMode
248
+ HTMLRenderer.idMode = 'none'
249
+ try {
250
+ const html = doc.toHTML()
251
+ // Orphaned [col] inside [columns] → span, not td
252
+ expect(html).toContain('<div class="bb-columns" style="column-count:2;"><span>Left</span><span>Right</span></div>')
253
+ // [col] inside [tables] stays a real cell
254
+ expect(html).toContain('<td>Cell</td>')
255
+ } finally {
256
+ HTMLRenderer.idMode = prev
257
+ }
258
+ })
186
259
  })
187
260
  })
@@ -32,13 +32,20 @@ export interface HTMLRendererOptions {
32
32
  mediaProxy?: (url: string) => string
33
33
  /** Resolver for entity links like profile, guild, map */
34
34
  entityLinkResolver?: (kind: string, value: string) => { href: string; external?: boolean } | null
35
+ /**
36
+ * Forum-style `@mention` linkifier. Called with the bare name (no `@`);
37
+ * return null to leave the mention as plain text. Used by forum hosts that
38
+ * linkify `@username` to a profile route.
39
+ */
40
+ mentionResolver?: (name: string) => { href: string; external?: boolean } | null
35
41
  }
36
42
 
37
43
  export class HTMLRenderer extends Visitor<string> {
38
- private options: Required<Omit<HTMLRendererOptions, 'registry' | 'mediaProxy' | 'entityLinkResolver'>> & {
44
+ private options: Required<Omit<HTMLRendererOptions, 'registry' | 'mediaProxy' | 'entityLinkResolver' | 'mentionResolver'>> & {
39
45
  registry?: TagRegistry
40
46
  mediaProxy?: (url: string) => string
41
47
  entityLinkResolver?: (kind: string, value: string) => { href: string; external?: boolean } | null
48
+ mentionResolver?: (name: string) => { href: string; external?: boolean } | null
42
49
  }
43
50
 
44
51
  constructor(options: HTMLRendererOptions = {}) {
@@ -50,6 +57,7 @@ export class HTMLRenderer extends Visitor<string> {
50
57
  theme: options.theme ?? (options.dialect === 'lyne' ? 'lyne' : 'osu'),
51
58
  mediaProxy: options.mediaProxy,
52
59
  entityLinkResolver: options.entityLinkResolver,
60
+ mentionResolver: options.mentionResolver,
53
61
  }
54
62
  }
55
63
 
@@ -160,6 +168,14 @@ export class HTMLRenderer extends Visitor<string> {
160
168
  */
161
169
  static idMode: 'blocks' | 'all' | 'none' = 'all'
162
170
 
171
+ /**
172
+ * Nesting depth of `[tables]` while rendering. `[col]`/`[row]`/`[th]` are
173
+ * table cells only INSIDE a `[tables]`; orphaned ones (e.g. `[col]` inside
174
+ * `[columns]`, or stray cells) render as plain inline spans — the same rule
175
+ * LYNE's forum renderer applies.
176
+ */
177
+ private tableDepth = 0
178
+
163
179
  private idAttr(node: RedNode): string {
164
180
  if (HTMLRenderer.idMode === 'none') return ''
165
181
  if (HTMLRenderer.idMode === 'all') return ` data-node-id="${node.id}"`
@@ -219,7 +235,9 @@ export class HTMLRenderer extends Visitor<string> {
219
235
  private renderNode(node: RedNode): string {
220
236
  // Leaf nodes
221
237
  if (node.children.length === 0 && node.kind === 'text') {
222
- let out = this.escapeHtml(node.text)
238
+ let out = this.mentionResolver
239
+ ? this.linkifyMentions(node.text)
240
+ : this.escapeHtml(node.text)
223
241
  const style = node.metadata?.style as Record<string, string> | undefined
224
242
  if (style) {
225
243
  const inlineStyles = []
@@ -277,11 +295,13 @@ export class HTMLRenderer extends Visitor<string> {
277
295
  case 'imagemap': return this.renderImagemap(node)
278
296
  case 'align': return this.renderAlign(node)
279
297
  case 'tables': return this.renderTables(node)
280
- case 'table_row': return this.wrapBlock('tr', node)
281
- case 'table_col': return this.wrapInline('td', node)
298
+ case 'table_row': return this.tableDepth > 0 ? this.wrapBlock('tr', node) : this.wrapInline('span', node)
299
+ case 'table_col': return this.tableDepth > 0 ? this.wrapInline('td', node) : this.wrapInline('span', node)
282
300
  case 'table_th': {
283
301
  const content = this.renderChildren(node)
284
- return `<th${this.idAttr(node)} class="bb-th"><span class="bb-table-badge">${content}</span></th>`
302
+ return this.tableDepth > 0
303
+ ? `<th${this.idAttr(node)} class="bb-th"><span class="bb-table-badge">${content}</span></th>`
304
+ : this.wrapInline('span', node)
285
305
  }
286
306
  case 'gallery': return this.renderGallery(node)
287
307
  case 'columns': return this.renderColumns(node)
@@ -642,7 +662,9 @@ export class HTMLRenderer extends Visitor<string> {
642
662
  variants.has('striped') ? 'bb-table-striped' : '',
643
663
  variants.has('borders') ? 'bb-table-borders' : '',
644
664
  ].filter(Boolean).join(' ')
665
+ this.tableDepth++
645
666
  const content = this.renderChildren(node)
667
+ this.tableDepth--
646
668
  // `[tables=striped:#hex]`: de `--table-accent` se deriva toda la paleta
647
669
  // de la tabla (gradiente del frame, hover de filas, encabezado, bordes).
648
670
  const accent = this.boxAccentStyle(node, 'table')
@@ -1054,6 +1076,56 @@ export class HTMLRenderer extends Visitor<string> {
1054
1076
  * returns the input untouched in that common case, then does a single pass
1055
1077
  * when there is actually something to escape.
1056
1078
  */
1079
+ /** Resolver configured at construction (read through a getter for brevity). */
1080
+ private get mentionResolver(): ((name: string) => { href: string; external?: boolean } | null) | undefined {
1081
+ return this.options.mentionResolver
1082
+ }
1083
+
1084
+ /**
1085
+ * `@mention` shape used by forum hosts: 3-15 word chars, boundaries that
1086
+ * exclude longer usernames (same rule LYNE's forum uses). Non-global so
1087
+ * `lastIndex` never leaks between calls.
1088
+ */
1089
+ private static readonly MENTION_RE = /(?<![A-Za-z0-9_-])@([A-Za-z0-9_-]{3,15})(?![A-Za-z0-9_-])/g
1090
+
1091
+ /**
1092
+ * Linkify `@username` runs in a plain-text leaf. Runs through the resolver;
1093
+ * null leaves the run as text. Every interpolated string is HTML-escaped,
1094
+ * and hrefs with dangerous protocols (javascript:, data:, vbscript:) are
1095
+ * rejected — the mention stays as plain text even if the resolver returns
1096
+ * one (defence in depth; the resolver itself should never produce it).
1097
+ */
1098
+ private linkifyMentions(text: string): string {
1099
+ const resolver = this.options.mentionResolver
1100
+ if (!resolver) return this.escapeHtml(text)
1101
+
1102
+ let out = ''
1103
+ let last = 0
1104
+ HTMLRenderer.MENTION_RE.lastIndex = 0
1105
+ let m: RegExpExecArray | null
1106
+ while ((m = HTMLRenderer.MENTION_RE.exec(text)) !== null) {
1107
+ if (m.index > last) out += this.escapeHtml(text.slice(last, m.index))
1108
+ const name = m[1]
1109
+ const link = resolver(name)
1110
+ if (link && this.isSafeMentionHref(link.href)) {
1111
+ const ext = link.external ? ' target="_blank" rel="noopener"' : ''
1112
+ out += `<a class="bb-mention" href="${this.escapeHtml(link.href)}"${ext}>@${this.escapeHtml(name)}</a>`
1113
+ } else {
1114
+ out += this.escapeHtml(m[0])
1115
+ }
1116
+ last = m.index + m[0].length
1117
+ }
1118
+ if (last < text.length) out += this.escapeHtml(text.slice(last))
1119
+ return out
1120
+ }
1121
+
1122
+ /** Root-relative, http(s), mailto and line:// hrefs are the only safe shapes. */
1123
+ private isSafeMentionHref(href: string): boolean {
1124
+ const h = href.trim()
1125
+ if (h.startsWith('/') && !h.startsWith('//')) return true
1126
+ return /^(https?:|mailto:|line:)/i.test(h)
1127
+ }
1128
+
1057
1129
  private escapeHtml(text: string): string {
1058
1130
  if (!HTMLRenderer.HTML_ESCAPE_RE.test(text)) return text
1059
1131
 
@@ -52,6 +52,13 @@
52
52
  border-bottom-color: var(--color-accent, #2EE6E2) !important;
53
53
  }
54
54
 
55
+ /* Forum @mentions — inherit the accent link look, slightly bolder so they
56
+ read as handles rather than plain links. */
57
+ .bbcode-preview-lyne a.bb-mention,
58
+ .bbcode-preview.theme-lyne a.bb-mention {
59
+ font-weight: 600 !important;
60
+ }
61
+
55
62
  /* ─── 45° Cut Panels ────────────────────────────────────────────────────── */
56
63
 
57
64
  .bb-cut-panel {