@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.
- package/dist/Visuals/lyne.css +7 -0
- package/dist/index.d.mts +34 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +60 -5
- package/dist/index.mjs +60 -5
- package/package.json +1 -1
- package/src/Tests/LyneMode.test.ts +73 -0
- package/src/Visitors/HTMLRenderer.ts +77 -5
- package/src/Visuals/lyne.css +7 -0
package/dist/Visuals/lyne.css
CHANGED
|
@@ -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
|
@@ -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('"')
|
|
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.
|
|
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
|
|
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
|
|
package/src/Visuals/lyne.css
CHANGED
|
@@ -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 {
|