@miliastry/quasar 1.0.2 → 1.0.4
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 +32 -9
- package/dist/index.d.mts +19 -7
- package/dist/index.d.ts +19 -7
- package/dist/index.js +47 -14
- package/dist/index.mjs +47 -14
- package/package.json +2 -4
- package/src/Tests/LyneMode.test.ts +31 -0
- package/src/Visitors/HTMLRenderer.ts +62 -15
- package/src/Visuals/lyne.css +32 -9
package/dist/Visuals/lyne.css
CHANGED
|
@@ -59,6 +59,29 @@
|
|
|
59
59
|
font-weight: 600 !important;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/* Editor timestamp chips (map surfaces) — the 45°-cut accent chip, same
|
|
63
|
+
geometry as the forum's refChip. */
|
|
64
|
+
.bbcode-preview-lyne a.bb-timeref,
|
|
65
|
+
.bbcode-preview.theme-lyne a.bb-timeref {
|
|
66
|
+
display: inline-flex;
|
|
67
|
+
align-items: center;
|
|
68
|
+
gap: 7px;
|
|
69
|
+
font-family: var(--font-numeric, "IBM Plex Mono", monospace) !important;
|
|
70
|
+
font-size: 0.5625rem !important;
|
|
71
|
+
font-weight: 500 !important;
|
|
72
|
+
letter-spacing: 0.5px;
|
|
73
|
+
text-transform: uppercase;
|
|
74
|
+
color: var(--color-accent, #2EE6E2) !important;
|
|
75
|
+
background: rgb(46 230 226 / 0.08) !important;
|
|
76
|
+
border: 1px solid rgb(46 230 226 / 0.4) !important;
|
|
77
|
+
padding: 4px 10px;
|
|
78
|
+
clip-path: polygon(0 0, 100% 0, calc(100% - 6px) 100%, 0 100%);
|
|
79
|
+
text-decoration: none !important;
|
|
80
|
+
white-space: nowrap;
|
|
81
|
+
vertical-align: middle;
|
|
82
|
+
margin: 0 2px;
|
|
83
|
+
}
|
|
84
|
+
|
|
62
85
|
/* ─── 45° Cut Panels ────────────────────────────────────────────────────── */
|
|
63
86
|
|
|
64
87
|
.bb-cut-panel {
|
|
@@ -100,35 +123,35 @@
|
|
|
100
123
|
.bbcode-preview.theme-lyne .notice,
|
|
101
124
|
.bb-notice {
|
|
102
125
|
display: block !important;
|
|
103
|
-
background: rgba(
|
|
104
|
-
border: 1px solid rgba(
|
|
126
|
+
background: rgba(46, 230, 226, 0.08);
|
|
127
|
+
border: 1px solid rgba(46, 230, 226, 0.4);
|
|
128
|
+
border-left: 3px solid #2EE6E2;
|
|
105
129
|
border-radius: 0 !important;
|
|
106
130
|
padding: 13px 16px !important;
|
|
107
131
|
margin: 0 !important;
|
|
108
132
|
font-size: 0.8125rem !important;
|
|
109
133
|
line-height: 1.55 !important;
|
|
110
|
-
color:
|
|
134
|
+
color: rgba(46, 230, 226, 0.95);
|
|
111
135
|
white-space: normal !important;
|
|
112
136
|
clip-path: polygon(8px 0, 100% 0, 100% calc(100% - 8px), calc(100% - 8px) 100%, 0 100%, 0 8px) !important;
|
|
113
137
|
width: 100% !important;
|
|
114
138
|
box-sizing: border-box !important;
|
|
115
139
|
}
|
|
116
140
|
|
|
117
|
-
/* Especificidad 0,2,0 (igual a `.bbcode-preview-lyne .notice`) para que el
|
|
118
|
-
`display:flex` gane sobre el `display:block` de la regla base del notice;
|
|
119
|
-
con un selector de clase simple (0,1,0) el símbolo quedaba ARRIBA del texto. */
|
|
120
141
|
.bbcode-preview-lyne .bb-wnotice,
|
|
121
142
|
.bbcode-preview.theme-lyne .bb-wnotice,
|
|
122
143
|
.bb-notice.bb-wnotice {
|
|
123
144
|
display: flex !important;
|
|
124
145
|
gap: 12px !important;
|
|
125
146
|
align-items: flex-start !important;
|
|
126
|
-
|
|
127
|
-
|
|
147
|
+
background: rgba(255, 195, 77, 0.08);
|
|
148
|
+
border: 1px solid rgba(255, 195, 77, 0.4);
|
|
149
|
+
border-left: 3px solid #FFC34D;
|
|
150
|
+
color: rgb(255, 224, 163);
|
|
128
151
|
}
|
|
129
152
|
|
|
130
153
|
.bb-notice-mark {
|
|
131
|
-
color: var(--color-warning, #FFC34D)
|
|
154
|
+
color: var(--color-warning, #FFC34D);
|
|
132
155
|
font-size: 0.9375rem !important;
|
|
133
156
|
flex: 0 0 auto !important;
|
|
134
157
|
margin-top: 1px !important;
|
package/dist/index.d.mts
CHANGED
|
@@ -2307,6 +2307,15 @@ interface HTMLRendererOptions {
|
|
|
2307
2307
|
href: string;
|
|
2308
2308
|
external?: boolean;
|
|
2309
2309
|
} | null;
|
|
2310
|
+
/**
|
|
2311
|
+
* Timestamp chip linkifier (`1:23`, `01:23.456`, `01:23:456`). Called with
|
|
2312
|
+
* the millisecond offset and the display label; return null to leave the
|
|
2313
|
+
* timestamp as plain text. Used by map hosts that deep-link into an editor.
|
|
2314
|
+
*/
|
|
2315
|
+
timestampResolver?: (ms: number, label: string) => {
|
|
2316
|
+
href: string;
|
|
2317
|
+
external?: boolean;
|
|
2318
|
+
} | null;
|
|
2310
2319
|
}
|
|
2311
2320
|
declare class HTMLRenderer extends Visitor<string> {
|
|
2312
2321
|
private options;
|
|
@@ -2548,15 +2557,18 @@ declare class HTMLRenderer extends Visitor<string> {
|
|
|
2548
2557
|
*/
|
|
2549
2558
|
private static readonly MENTION_RE;
|
|
2550
2559
|
/**
|
|
2551
|
-
* Linkify
|
|
2552
|
-
* null leaves the run as text.
|
|
2553
|
-
*
|
|
2554
|
-
*
|
|
2555
|
-
*
|
|
2560
|
+
* Linkify a plain-text leaf: `@mention` runs and (optionally) timestamp
|
|
2561
|
+
* chips. Both run through their resolver; null leaves the run as text.
|
|
2562
|
+
* Every interpolated string is HTML-escaped, and hrefs with dangerous
|
|
2563
|
+
* protocols (javascript:, data:, vbscript:) are rejected (defence in depth;
|
|
2564
|
+
* the resolvers themselves should never produce one).
|
|
2556
2565
|
*/
|
|
2557
|
-
private
|
|
2566
|
+
private linkifyText;
|
|
2567
|
+
/** `1:23`, `01:23.456`, `01:23:456` — the editor deep-link shape. */
|
|
2568
|
+
private static readonly TS_RE;
|
|
2569
|
+
private linkifyTimestamps;
|
|
2558
2570
|
/** Root-relative, http(s), mailto and line:// hrefs are the only safe shapes. */
|
|
2559
|
-
private
|
|
2571
|
+
private isSafeHref;
|
|
2560
2572
|
private escapeHtml;
|
|
2561
2573
|
}
|
|
2562
2574
|
|
package/dist/index.d.ts
CHANGED
|
@@ -2307,6 +2307,15 @@ interface HTMLRendererOptions {
|
|
|
2307
2307
|
href: string;
|
|
2308
2308
|
external?: boolean;
|
|
2309
2309
|
} | null;
|
|
2310
|
+
/**
|
|
2311
|
+
* Timestamp chip linkifier (`1:23`, `01:23.456`, `01:23:456`). Called with
|
|
2312
|
+
* the millisecond offset and the display label; return null to leave the
|
|
2313
|
+
* timestamp as plain text. Used by map hosts that deep-link into an editor.
|
|
2314
|
+
*/
|
|
2315
|
+
timestampResolver?: (ms: number, label: string) => {
|
|
2316
|
+
href: string;
|
|
2317
|
+
external?: boolean;
|
|
2318
|
+
} | null;
|
|
2310
2319
|
}
|
|
2311
2320
|
declare class HTMLRenderer extends Visitor<string> {
|
|
2312
2321
|
private options;
|
|
@@ -2548,15 +2557,18 @@ declare class HTMLRenderer extends Visitor<string> {
|
|
|
2548
2557
|
*/
|
|
2549
2558
|
private static readonly MENTION_RE;
|
|
2550
2559
|
/**
|
|
2551
|
-
* Linkify
|
|
2552
|
-
* null leaves the run as text.
|
|
2553
|
-
*
|
|
2554
|
-
*
|
|
2555
|
-
*
|
|
2560
|
+
* Linkify a plain-text leaf: `@mention` runs and (optionally) timestamp
|
|
2561
|
+
* chips. Both run through their resolver; null leaves the run as text.
|
|
2562
|
+
* Every interpolated string is HTML-escaped, and hrefs with dangerous
|
|
2563
|
+
* protocols (javascript:, data:, vbscript:) are rejected (defence in depth;
|
|
2564
|
+
* the resolvers themselves should never produce one).
|
|
2556
2565
|
*/
|
|
2557
|
-
private
|
|
2566
|
+
private linkifyText;
|
|
2567
|
+
/** `1:23`, `01:23.456`, `01:23:456` — the editor deep-link shape. */
|
|
2568
|
+
private static readonly TS_RE;
|
|
2569
|
+
private linkifyTimestamps;
|
|
2558
2570
|
/** Root-relative, http(s), mailto and line:// hrefs are the only safe shapes. */
|
|
2559
|
-
private
|
|
2571
|
+
private isSafeHref;
|
|
2560
2572
|
private escapeHtml;
|
|
2561
2573
|
}
|
|
2562
2574
|
|
package/dist/index.js
CHANGED
|
@@ -4817,7 +4817,8 @@ var HTMLRenderer = class _HTMLRenderer extends Visitor {
|
|
|
4817
4817
|
theme: options.theme ?? (options.dialect === "lyne" ? "lyne" : "osu"),
|
|
4818
4818
|
mediaProxy: options.mediaProxy,
|
|
4819
4819
|
entityLinkResolver: options.entityLinkResolver,
|
|
4820
|
-
mentionResolver: options.mentionResolver
|
|
4820
|
+
mentionResolver: options.mentionResolver,
|
|
4821
|
+
timestampResolver: options.timestampResolver
|
|
4821
4822
|
};
|
|
4822
4823
|
}
|
|
4823
4824
|
// ─── Tag → HTML Element Map ─────────────────────────────
|
|
@@ -5046,7 +5047,7 @@ var HTMLRenderer = class _HTMLRenderer extends Visitor {
|
|
|
5046
5047
|
// ─── Node Rendering ─────────────────────────────────────
|
|
5047
5048
|
renderNode(node) {
|
|
5048
5049
|
if (node.children.length === 0 && node.kind === "text") {
|
|
5049
|
-
let out = this.mentionResolver ? this.
|
|
5050
|
+
let out = this.mentionResolver || this.options.timestampResolver ? this.linkifyText(node.text) : this.escapeHtml(node.text);
|
|
5050
5051
|
const style = node.metadata?.style;
|
|
5051
5052
|
if (style) {
|
|
5052
5053
|
const inlineStyles = [];
|
|
@@ -5855,24 +5856,27 @@ var HTMLRenderer = class _HTMLRenderer extends Visitor {
|
|
|
5855
5856
|
*/
|
|
5856
5857
|
static MENTION_RE = /(?<![A-Za-z0-9_-])@([A-Za-z0-9_-]{3,15})(?![A-Za-z0-9_-])/g;
|
|
5857
5858
|
/**
|
|
5858
|
-
* Linkify
|
|
5859
|
-
* null leaves the run as text.
|
|
5860
|
-
*
|
|
5861
|
-
*
|
|
5862
|
-
*
|
|
5859
|
+
* Linkify a plain-text leaf: `@mention` runs and (optionally) timestamp
|
|
5860
|
+
* chips. Both run through their resolver; null leaves the run as text.
|
|
5861
|
+
* Every interpolated string is HTML-escaped, and hrefs with dangerous
|
|
5862
|
+
* protocols (javascript:, data:, vbscript:) are rejected (defence in depth;
|
|
5863
|
+
* the resolvers themselves should never produce one).
|
|
5863
5864
|
*/
|
|
5864
|
-
|
|
5865
|
-
const
|
|
5866
|
-
|
|
5865
|
+
linkifyText(text) {
|
|
5866
|
+
const mentionResolver = this.options.mentionResolver;
|
|
5867
|
+
const timestampResolver = this.options.timestampResolver;
|
|
5868
|
+
if (!mentionResolver && !timestampResolver) return this.escapeHtml(text);
|
|
5867
5869
|
let out = "";
|
|
5868
5870
|
let last = 0;
|
|
5869
5871
|
_HTMLRenderer.MENTION_RE.lastIndex = 0;
|
|
5870
5872
|
let m;
|
|
5871
5873
|
while ((m = _HTMLRenderer.MENTION_RE.exec(text)) !== null) {
|
|
5872
|
-
if (m.index > last)
|
|
5874
|
+
if (m.index > last) {
|
|
5875
|
+
out += this.linkifyTimestamps(text.slice(last, m.index), timestampResolver);
|
|
5876
|
+
}
|
|
5873
5877
|
const name = m[1];
|
|
5874
|
-
const link =
|
|
5875
|
-
if (link && this.
|
|
5878
|
+
const link = mentionResolver?.(name);
|
|
5879
|
+
if (link && this.isSafeHref(link.href)) {
|
|
5876
5880
|
const ext = link.external ? ' target="_blank" rel="noopener"' : "";
|
|
5877
5881
|
out += `<a class="bb-mention" href="${this.escapeHtml(link.href)}"${ext}>@${this.escapeHtml(name)}</a>`;
|
|
5878
5882
|
} else {
|
|
@@ -5880,11 +5884,40 @@ var HTMLRenderer = class _HTMLRenderer extends Visitor {
|
|
|
5880
5884
|
}
|
|
5881
5885
|
last = m.index + m[0].length;
|
|
5882
5886
|
}
|
|
5887
|
+
if (last < text.length) {
|
|
5888
|
+
out += this.linkifyTimestamps(text.slice(last), timestampResolver);
|
|
5889
|
+
}
|
|
5890
|
+
return out;
|
|
5891
|
+
}
|
|
5892
|
+
/** `1:23`, `01:23.456`, `01:23:456` — the editor deep-link shape. */
|
|
5893
|
+
static TS_RE = /\b(\d{1,2}):([0-5]\d)(?:\.(\d{1,3})\b|:(\d{1,3})\b)?/g;
|
|
5894
|
+
linkifyTimestamps(text, resolver) {
|
|
5895
|
+
if (!resolver) return this.escapeHtml(text);
|
|
5896
|
+
let out = "";
|
|
5897
|
+
let last = 0;
|
|
5898
|
+
_HTMLRenderer.TS_RE.lastIndex = 0;
|
|
5899
|
+
let m;
|
|
5900
|
+
while ((m = _HTMLRenderer.TS_RE.exec(text)) !== null) {
|
|
5901
|
+
if (m.index > last) out += this.escapeHtml(text.slice(last, m.index));
|
|
5902
|
+
const minutes = parseInt(m[1], 10);
|
|
5903
|
+
const seconds = parseInt(m[2], 10);
|
|
5904
|
+
const frac = m[3] ? parseInt(m[3].padEnd(3, "0"), 10) : m[4] ? parseInt(m[4], 10) : 0;
|
|
5905
|
+
const ms = (minutes * 60 + seconds) * 1e3 + frac;
|
|
5906
|
+
const label = m[4] ? `${m[1].padStart(2, "0")}:${m[2]}:${m[4].padStart(3, "0")}` : m[3] ? `${m[1]}:${m[2]}.${m[3]}` : `${m[1]}:${m[2]}`;
|
|
5907
|
+
const link = resolver(ms, label);
|
|
5908
|
+
if (link && this.isSafeHref(link.href)) {
|
|
5909
|
+
const ext = link.external ? ' target="_blank" rel="noopener"' : "";
|
|
5910
|
+
out += `<a class="bb-timeref" href="${this.escapeHtml(link.href)}"${ext}>${this.escapeHtml(label)}</a>`;
|
|
5911
|
+
} else {
|
|
5912
|
+
out += this.escapeHtml(m[0]);
|
|
5913
|
+
}
|
|
5914
|
+
last = m.index + m[0].length;
|
|
5915
|
+
}
|
|
5883
5916
|
if (last < text.length) out += this.escapeHtml(text.slice(last));
|
|
5884
5917
|
return out;
|
|
5885
5918
|
}
|
|
5886
5919
|
/** Root-relative, http(s), mailto and line:// hrefs are the only safe shapes. */
|
|
5887
|
-
|
|
5920
|
+
isSafeHref(href) {
|
|
5888
5921
|
const h = href.trim();
|
|
5889
5922
|
if (h.startsWith("/") && !h.startsWith("//")) return true;
|
|
5890
5923
|
return /^(https?:|mailto:|line:)/i.test(h);
|
package/dist/index.mjs
CHANGED
|
@@ -4815,7 +4815,8 @@ var HTMLRenderer = class _HTMLRenderer extends Visitor {
|
|
|
4815
4815
|
theme: options.theme ?? (options.dialect === "lyne" ? "lyne" : "osu"),
|
|
4816
4816
|
mediaProxy: options.mediaProxy,
|
|
4817
4817
|
entityLinkResolver: options.entityLinkResolver,
|
|
4818
|
-
mentionResolver: options.mentionResolver
|
|
4818
|
+
mentionResolver: options.mentionResolver,
|
|
4819
|
+
timestampResolver: options.timestampResolver
|
|
4819
4820
|
};
|
|
4820
4821
|
}
|
|
4821
4822
|
// ─── Tag → HTML Element Map ─────────────────────────────
|
|
@@ -5044,7 +5045,7 @@ var HTMLRenderer = class _HTMLRenderer extends Visitor {
|
|
|
5044
5045
|
// ─── Node Rendering ─────────────────────────────────────
|
|
5045
5046
|
renderNode(node) {
|
|
5046
5047
|
if (node.children.length === 0 && node.kind === "text") {
|
|
5047
|
-
let out = this.mentionResolver ? this.
|
|
5048
|
+
let out = this.mentionResolver || this.options.timestampResolver ? this.linkifyText(node.text) : this.escapeHtml(node.text);
|
|
5048
5049
|
const style = node.metadata?.style;
|
|
5049
5050
|
if (style) {
|
|
5050
5051
|
const inlineStyles = [];
|
|
@@ -5853,24 +5854,27 @@ var HTMLRenderer = class _HTMLRenderer extends Visitor {
|
|
|
5853
5854
|
*/
|
|
5854
5855
|
static MENTION_RE = /(?<![A-Za-z0-9_-])@([A-Za-z0-9_-]{3,15})(?![A-Za-z0-9_-])/g;
|
|
5855
5856
|
/**
|
|
5856
|
-
* Linkify
|
|
5857
|
-
* null leaves the run as text.
|
|
5858
|
-
*
|
|
5859
|
-
*
|
|
5860
|
-
*
|
|
5857
|
+
* Linkify a plain-text leaf: `@mention` runs and (optionally) timestamp
|
|
5858
|
+
* chips. Both run through their resolver; null leaves the run as text.
|
|
5859
|
+
* Every interpolated string is HTML-escaped, and hrefs with dangerous
|
|
5860
|
+
* protocols (javascript:, data:, vbscript:) are rejected (defence in depth;
|
|
5861
|
+
* the resolvers themselves should never produce one).
|
|
5861
5862
|
*/
|
|
5862
|
-
|
|
5863
|
-
const
|
|
5864
|
-
|
|
5863
|
+
linkifyText(text) {
|
|
5864
|
+
const mentionResolver = this.options.mentionResolver;
|
|
5865
|
+
const timestampResolver = this.options.timestampResolver;
|
|
5866
|
+
if (!mentionResolver && !timestampResolver) return this.escapeHtml(text);
|
|
5865
5867
|
let out = "";
|
|
5866
5868
|
let last = 0;
|
|
5867
5869
|
_HTMLRenderer.MENTION_RE.lastIndex = 0;
|
|
5868
5870
|
let m;
|
|
5869
5871
|
while ((m = _HTMLRenderer.MENTION_RE.exec(text)) !== null) {
|
|
5870
|
-
if (m.index > last)
|
|
5872
|
+
if (m.index > last) {
|
|
5873
|
+
out += this.linkifyTimestamps(text.slice(last, m.index), timestampResolver);
|
|
5874
|
+
}
|
|
5871
5875
|
const name = m[1];
|
|
5872
|
-
const link =
|
|
5873
|
-
if (link && this.
|
|
5876
|
+
const link = mentionResolver?.(name);
|
|
5877
|
+
if (link && this.isSafeHref(link.href)) {
|
|
5874
5878
|
const ext = link.external ? ' target="_blank" rel="noopener"' : "";
|
|
5875
5879
|
out += `<a class="bb-mention" href="${this.escapeHtml(link.href)}"${ext}>@${this.escapeHtml(name)}</a>`;
|
|
5876
5880
|
} else {
|
|
@@ -5878,11 +5882,40 @@ var HTMLRenderer = class _HTMLRenderer extends Visitor {
|
|
|
5878
5882
|
}
|
|
5879
5883
|
last = m.index + m[0].length;
|
|
5880
5884
|
}
|
|
5885
|
+
if (last < text.length) {
|
|
5886
|
+
out += this.linkifyTimestamps(text.slice(last), timestampResolver);
|
|
5887
|
+
}
|
|
5888
|
+
return out;
|
|
5889
|
+
}
|
|
5890
|
+
/** `1:23`, `01:23.456`, `01:23:456` — the editor deep-link shape. */
|
|
5891
|
+
static TS_RE = /\b(\d{1,2}):([0-5]\d)(?:\.(\d{1,3})\b|:(\d{1,3})\b)?/g;
|
|
5892
|
+
linkifyTimestamps(text, resolver) {
|
|
5893
|
+
if (!resolver) return this.escapeHtml(text);
|
|
5894
|
+
let out = "";
|
|
5895
|
+
let last = 0;
|
|
5896
|
+
_HTMLRenderer.TS_RE.lastIndex = 0;
|
|
5897
|
+
let m;
|
|
5898
|
+
while ((m = _HTMLRenderer.TS_RE.exec(text)) !== null) {
|
|
5899
|
+
if (m.index > last) out += this.escapeHtml(text.slice(last, m.index));
|
|
5900
|
+
const minutes = parseInt(m[1], 10);
|
|
5901
|
+
const seconds = parseInt(m[2], 10);
|
|
5902
|
+
const frac = m[3] ? parseInt(m[3].padEnd(3, "0"), 10) : m[4] ? parseInt(m[4], 10) : 0;
|
|
5903
|
+
const ms = (minutes * 60 + seconds) * 1e3 + frac;
|
|
5904
|
+
const label = m[4] ? `${m[1].padStart(2, "0")}:${m[2]}:${m[4].padStart(3, "0")}` : m[3] ? `${m[1]}:${m[2]}.${m[3]}` : `${m[1]}:${m[2]}`;
|
|
5905
|
+
const link = resolver(ms, label);
|
|
5906
|
+
if (link && this.isSafeHref(link.href)) {
|
|
5907
|
+
const ext = link.external ? ' target="_blank" rel="noopener"' : "";
|
|
5908
|
+
out += `<a class="bb-timeref" href="${this.escapeHtml(link.href)}"${ext}>${this.escapeHtml(label)}</a>`;
|
|
5909
|
+
} else {
|
|
5910
|
+
out += this.escapeHtml(m[0]);
|
|
5911
|
+
}
|
|
5912
|
+
last = m.index + m[0].length;
|
|
5913
|
+
}
|
|
5881
5914
|
if (last < text.length) out += this.escapeHtml(text.slice(last));
|
|
5882
5915
|
return out;
|
|
5883
5916
|
}
|
|
5884
5917
|
/** Root-relative, http(s), mailto and line:// hrefs are the only safe shapes. */
|
|
5885
|
-
|
|
5918
|
+
isSafeHref(href) {
|
|
5886
5919
|
const h = href.trim();
|
|
5887
5920
|
if (h.startsWith("/") && !h.startsWith("//")) return true;
|
|
5888
5921
|
return /^(https?:|mailto:|line:)/i.test(h);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miliastry/quasar",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "Quasar Document Engine - Advanced BBCode/Markdown/HTML AST Engine",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -34,9 +34,7 @@
|
|
|
34
34
|
"verify": "npm run typecheck && npm run test"
|
|
35
35
|
},
|
|
36
36
|
"publishConfig": {
|
|
37
|
-
"access": "public"
|
|
38
|
-
"main": "dist/index.js",
|
|
39
|
-
"types": "dist/index.d.ts"
|
|
37
|
+
"access": "public"
|
|
40
38
|
},
|
|
41
39
|
"files": [
|
|
42
40
|
"dist",
|
|
@@ -238,6 +238,37 @@ describe('Lyne Mode & Dialect Isolation', () => {
|
|
|
238
238
|
expect(html).toContain('<a class="bb-mention" href="https://example.com/u/jane" target="_blank" rel="noopener">@jane</a>')
|
|
239
239
|
})
|
|
240
240
|
|
|
241
|
+
it('turns timestamps into editor deep-link chips via timestampResolver', () => {
|
|
242
|
+
const doc = new BBCodeDocumentModel({
|
|
243
|
+
source: 'At 1:23 the beat drops, see 04:05.5 for the outro',
|
|
244
|
+
mode: 'lyne',
|
|
245
|
+
})
|
|
246
|
+
const renderer = new HTMLRenderer({
|
|
247
|
+
registry: doc.tagRegistry,
|
|
248
|
+
dialect: 'lyne',
|
|
249
|
+
theme: 'lyne',
|
|
250
|
+
timestampResolver: (ms, label) => ({ href: `line://edit?map=42&t=${ms}`, external: false }),
|
|
251
|
+
})
|
|
252
|
+
const html = renderer.render(doc.root!)
|
|
253
|
+
expect(html).toContain('<a class="bb-timeref" href="line://edit?map=42&t=83000">1:23</a>')
|
|
254
|
+
expect(html).toContain('<a class="bb-timeref" href="line://edit?map=42&t=245500">04:05.5</a>')
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('keeps timestamps as plain text without timestampResolver', () => {
|
|
258
|
+
const doc = new BBCodeDocumentModel({
|
|
259
|
+
source: 'At 1:23 nothing happens',
|
|
260
|
+
mode: 'lyne',
|
|
261
|
+
})
|
|
262
|
+
const renderer = new HTMLRenderer({
|
|
263
|
+
registry: doc.tagRegistry,
|
|
264
|
+
dialect: 'lyne',
|
|
265
|
+
theme: 'lyne',
|
|
266
|
+
})
|
|
267
|
+
const html = renderer.render(doc.root!)
|
|
268
|
+
expect(html).toContain('At 1:23 nothing happens')
|
|
269
|
+
expect(html).not.toContain('bb-timeref')
|
|
270
|
+
})
|
|
271
|
+
|
|
241
272
|
it('renders [col] outside [tables] as a plain span, inside as td', () => {
|
|
242
273
|
const doc = new BBCodeDocumentModel({
|
|
243
274
|
source: `[columns=2][col]Left[/col][col]Right[/col][/columns]
|
|
@@ -38,14 +38,21 @@ export interface HTMLRendererOptions {
|
|
|
38
38
|
* linkify `@username` to a profile route.
|
|
39
39
|
*/
|
|
40
40
|
mentionResolver?: (name: string) => { href: string; external?: boolean } | null
|
|
41
|
+
/**
|
|
42
|
+
* Timestamp chip linkifier (`1:23`, `01:23.456`, `01:23:456`). Called with
|
|
43
|
+
* the millisecond offset and the display label; return null to leave the
|
|
44
|
+
* timestamp as plain text. Used by map hosts that deep-link into an editor.
|
|
45
|
+
*/
|
|
46
|
+
timestampResolver?: (ms: number, label: string) => { href: string; external?: boolean } | null
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
export class HTMLRenderer extends Visitor<string> {
|
|
44
|
-
private options: Required<Omit<HTMLRendererOptions, 'registry' | 'mediaProxy' | 'entityLinkResolver' | 'mentionResolver'>> & {
|
|
50
|
+
private options: Required<Omit<HTMLRendererOptions, 'registry' | 'mediaProxy' | 'entityLinkResolver' | 'mentionResolver' | 'timestampResolver'>> & {
|
|
45
51
|
registry?: TagRegistry
|
|
46
52
|
mediaProxy?: (url: string) => string
|
|
47
53
|
entityLinkResolver?: (kind: string, value: string) => { href: string; external?: boolean } | null
|
|
48
54
|
mentionResolver?: (name: string) => { href: string; external?: boolean } | null
|
|
55
|
+
timestampResolver?: (ms: number, label: string) => { href: string; external?: boolean } | null
|
|
49
56
|
}
|
|
50
57
|
|
|
51
58
|
constructor(options: HTMLRendererOptions = {}) {
|
|
@@ -58,6 +65,7 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
58
65
|
mediaProxy: options.mediaProxy,
|
|
59
66
|
entityLinkResolver: options.entityLinkResolver,
|
|
60
67
|
mentionResolver: options.mentionResolver,
|
|
68
|
+
timestampResolver: options.timestampResolver,
|
|
61
69
|
}
|
|
62
70
|
}
|
|
63
71
|
|
|
@@ -235,8 +243,8 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
235
243
|
private renderNode(node: RedNode): string {
|
|
236
244
|
// Leaf nodes
|
|
237
245
|
if (node.children.length === 0 && node.kind === 'text') {
|
|
238
|
-
let out = this.mentionResolver
|
|
239
|
-
? this.
|
|
246
|
+
let out = this.mentionResolver || this.options.timestampResolver
|
|
247
|
+
? this.linkifyText(node.text)
|
|
240
248
|
: this.escapeHtml(node.text)
|
|
241
249
|
const style = node.metadata?.style as Record<string, string> | undefined
|
|
242
250
|
if (style) {
|
|
@@ -1089,25 +1097,28 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
1089
1097
|
private static readonly MENTION_RE = /(?<![A-Za-z0-9_-])@([A-Za-z0-9_-]{3,15})(?![A-Za-z0-9_-])/g
|
|
1090
1098
|
|
|
1091
1099
|
/**
|
|
1092
|
-
* Linkify
|
|
1093
|
-
* null leaves the run as text.
|
|
1094
|
-
*
|
|
1095
|
-
*
|
|
1096
|
-
*
|
|
1100
|
+
* Linkify a plain-text leaf: `@mention` runs and (optionally) timestamp
|
|
1101
|
+
* chips. Both run through their resolver; null leaves the run as text.
|
|
1102
|
+
* Every interpolated string is HTML-escaped, and hrefs with dangerous
|
|
1103
|
+
* protocols (javascript:, data:, vbscript:) are rejected (defence in depth;
|
|
1104
|
+
* the resolvers themselves should never produce one).
|
|
1097
1105
|
*/
|
|
1098
|
-
private
|
|
1099
|
-
const
|
|
1100
|
-
|
|
1106
|
+
private linkifyText(text: string): string {
|
|
1107
|
+
const mentionResolver = this.options.mentionResolver
|
|
1108
|
+
const timestampResolver = this.options.timestampResolver
|
|
1109
|
+
if (!mentionResolver && !timestampResolver) return this.escapeHtml(text)
|
|
1101
1110
|
|
|
1102
1111
|
let out = ''
|
|
1103
1112
|
let last = 0
|
|
1104
1113
|
HTMLRenderer.MENTION_RE.lastIndex = 0
|
|
1105
1114
|
let m: RegExpExecArray | null
|
|
1106
1115
|
while ((m = HTMLRenderer.MENTION_RE.exec(text)) !== null) {
|
|
1107
|
-
if (m.index > last)
|
|
1116
|
+
if (m.index > last) {
|
|
1117
|
+
out += this.linkifyTimestamps(text.slice(last, m.index), timestampResolver)
|
|
1118
|
+
}
|
|
1108
1119
|
const name = m[1]
|
|
1109
|
-
const link =
|
|
1110
|
-
if (link && this.
|
|
1120
|
+
const link = mentionResolver?.(name)
|
|
1121
|
+
if (link && this.isSafeHref(link.href)) {
|
|
1111
1122
|
const ext = link.external ? ' target="_blank" rel="noopener"' : ''
|
|
1112
1123
|
out += `<a class="bb-mention" href="${this.escapeHtml(link.href)}"${ext}>@${this.escapeHtml(name)}</a>`
|
|
1113
1124
|
} else {
|
|
@@ -1115,12 +1126,48 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
1115
1126
|
}
|
|
1116
1127
|
last = m.index + m[0].length
|
|
1117
1128
|
}
|
|
1129
|
+
if (last < text.length) {
|
|
1130
|
+
out += this.linkifyTimestamps(text.slice(last), timestampResolver)
|
|
1131
|
+
}
|
|
1132
|
+
return out
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/** `1:23`, `01:23.456`, `01:23:456` — the editor deep-link shape. */
|
|
1136
|
+
private static readonly TS_RE = /\b(\d{1,2}):([0-5]\d)(?:\.(\d{1,3})\b|:(\d{1,3})\b)?/g
|
|
1137
|
+
|
|
1138
|
+
private linkifyTimestamps(text: string, resolver: ((ms: number, label: string) => { href: string; external?: boolean } | null) | undefined): string {
|
|
1139
|
+
if (!resolver) return this.escapeHtml(text)
|
|
1140
|
+
|
|
1141
|
+
let out = ''
|
|
1142
|
+
let last = 0
|
|
1143
|
+
HTMLRenderer.TS_RE.lastIndex = 0
|
|
1144
|
+
let m: RegExpExecArray | null
|
|
1145
|
+
while ((m = HTMLRenderer.TS_RE.exec(text)) !== null) {
|
|
1146
|
+
if (m.index > last) out += this.escapeHtml(text.slice(last, m.index))
|
|
1147
|
+
const minutes = parseInt(m[1], 10)
|
|
1148
|
+
const seconds = parseInt(m[2], 10)
|
|
1149
|
+
const frac = m[3] ? parseInt(m[3].padEnd(3, '0'), 10) : m[4] ? parseInt(m[4], 10) : 0
|
|
1150
|
+
const ms = (minutes * 60 + seconds) * 1000 + frac
|
|
1151
|
+
const label = m[4]
|
|
1152
|
+
? `${m[1].padStart(2, '0')}:${m[2]}:${m[4].padStart(3, '0')}`
|
|
1153
|
+
: m[3]
|
|
1154
|
+
? `${m[1]}:${m[2]}.${m[3]}`
|
|
1155
|
+
: `${m[1]}:${m[2]}`
|
|
1156
|
+
const link = resolver(ms, label)
|
|
1157
|
+
if (link && this.isSafeHref(link.href)) {
|
|
1158
|
+
const ext = link.external ? ' target="_blank" rel="noopener"' : ''
|
|
1159
|
+
out += `<a class="bb-timeref" href="${this.escapeHtml(link.href)}"${ext}>${this.escapeHtml(label)}</a>`
|
|
1160
|
+
} else {
|
|
1161
|
+
out += this.escapeHtml(m[0])
|
|
1162
|
+
}
|
|
1163
|
+
last = m.index + m[0].length
|
|
1164
|
+
}
|
|
1118
1165
|
if (last < text.length) out += this.escapeHtml(text.slice(last))
|
|
1119
1166
|
return out
|
|
1120
1167
|
}
|
|
1121
1168
|
|
|
1122
1169
|
/** Root-relative, http(s), mailto and line:// hrefs are the only safe shapes. */
|
|
1123
|
-
private
|
|
1170
|
+
private isSafeHref(href: string): boolean {
|
|
1124
1171
|
const h = href.trim()
|
|
1125
1172
|
if (h.startsWith('/') && !h.startsWith('//')) return true
|
|
1126
1173
|
return /^(https?:|mailto:|line:)/i.test(h)
|
package/src/Visuals/lyne.css
CHANGED
|
@@ -59,6 +59,29 @@
|
|
|
59
59
|
font-weight: 600 !important;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/* Editor timestamp chips (map surfaces) — the 45°-cut accent chip, same
|
|
63
|
+
geometry as the forum's refChip. */
|
|
64
|
+
.bbcode-preview-lyne a.bb-timeref,
|
|
65
|
+
.bbcode-preview.theme-lyne a.bb-timeref {
|
|
66
|
+
display: inline-flex;
|
|
67
|
+
align-items: center;
|
|
68
|
+
gap: 7px;
|
|
69
|
+
font-family: var(--font-numeric, "IBM Plex Mono", monospace) !important;
|
|
70
|
+
font-size: 0.5625rem !important;
|
|
71
|
+
font-weight: 500 !important;
|
|
72
|
+
letter-spacing: 0.5px;
|
|
73
|
+
text-transform: uppercase;
|
|
74
|
+
color: var(--color-accent, #2EE6E2) !important;
|
|
75
|
+
background: rgb(46 230 226 / 0.08) !important;
|
|
76
|
+
border: 1px solid rgb(46 230 226 / 0.4) !important;
|
|
77
|
+
padding: 4px 10px;
|
|
78
|
+
clip-path: polygon(0 0, 100% 0, calc(100% - 6px) 100%, 0 100%);
|
|
79
|
+
text-decoration: none !important;
|
|
80
|
+
white-space: nowrap;
|
|
81
|
+
vertical-align: middle;
|
|
82
|
+
margin: 0 2px;
|
|
83
|
+
}
|
|
84
|
+
|
|
62
85
|
/* ─── 45° Cut Panels ────────────────────────────────────────────────────── */
|
|
63
86
|
|
|
64
87
|
.bb-cut-panel {
|
|
@@ -100,35 +123,35 @@
|
|
|
100
123
|
.bbcode-preview.theme-lyne .notice,
|
|
101
124
|
.bb-notice {
|
|
102
125
|
display: block !important;
|
|
103
|
-
background: rgba(
|
|
104
|
-
border: 1px solid rgba(
|
|
126
|
+
background: rgba(46, 230, 226, 0.08);
|
|
127
|
+
border: 1px solid rgba(46, 230, 226, 0.4);
|
|
128
|
+
border-left: 3px solid #2EE6E2;
|
|
105
129
|
border-radius: 0 !important;
|
|
106
130
|
padding: 13px 16px !important;
|
|
107
131
|
margin: 0 !important;
|
|
108
132
|
font-size: 0.8125rem !important;
|
|
109
133
|
line-height: 1.55 !important;
|
|
110
|
-
color:
|
|
134
|
+
color: rgba(46, 230, 226, 0.95);
|
|
111
135
|
white-space: normal !important;
|
|
112
136
|
clip-path: polygon(8px 0, 100% 0, 100% calc(100% - 8px), calc(100% - 8px) 100%, 0 100%, 0 8px) !important;
|
|
113
137
|
width: 100% !important;
|
|
114
138
|
box-sizing: border-box !important;
|
|
115
139
|
}
|
|
116
140
|
|
|
117
|
-
/* Especificidad 0,2,0 (igual a `.bbcode-preview-lyne .notice`) para que el
|
|
118
|
-
`display:flex` gane sobre el `display:block` de la regla base del notice;
|
|
119
|
-
con un selector de clase simple (0,1,0) el símbolo quedaba ARRIBA del texto. */
|
|
120
141
|
.bbcode-preview-lyne .bb-wnotice,
|
|
121
142
|
.bbcode-preview.theme-lyne .bb-wnotice,
|
|
122
143
|
.bb-notice.bb-wnotice {
|
|
123
144
|
display: flex !important;
|
|
124
145
|
gap: 12px !important;
|
|
125
146
|
align-items: flex-start !important;
|
|
126
|
-
|
|
127
|
-
|
|
147
|
+
background: rgba(255, 195, 77, 0.08);
|
|
148
|
+
border: 1px solid rgba(255, 195, 77, 0.4);
|
|
149
|
+
border-left: 3px solid #FFC34D;
|
|
150
|
+
color: rgb(255, 224, 163);
|
|
128
151
|
}
|
|
129
152
|
|
|
130
153
|
.bb-notice-mark {
|
|
131
|
-
color: var(--color-warning, #FFC34D)
|
|
154
|
+
color: var(--color-warning, #FFC34D);
|
|
132
155
|
font-size: 0.9375rem !important;
|
|
133
156
|
flex: 0 0 auto !important;
|
|
134
157
|
margin-top: 1px !important;
|