@miliastry/quasar 1.0.2 → 1.0.3

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.
@@ -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 {
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 `@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).
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 linkifyMentions;
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 isSafeMentionHref;
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 `@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).
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 linkifyMentions;
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 isSafeMentionHref;
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.linkifyMentions(node.text) : this.escapeHtml(node.text);
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 `@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).
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
- linkifyMentions(text) {
5865
- const resolver = this.options.mentionResolver;
5866
- if (!resolver) return this.escapeHtml(text);
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) out += this.escapeHtml(text.slice(last, m.index));
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 = resolver(name);
5875
- if (link && this.isSafeMentionHref(link.href)) {
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
- isSafeMentionHref(href) {
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.linkifyMentions(node.text) : this.escapeHtml(node.text);
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 `@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).
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
- linkifyMentions(text) {
5863
- const resolver = this.options.mentionResolver;
5864
- if (!resolver) return this.escapeHtml(text);
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) out += this.escapeHtml(text.slice(last, m.index));
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 = resolver(name);
5873
- if (link && this.isSafeMentionHref(link.href)) {
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
- isSafeMentionHref(href) {
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.2",
3
+ "version": "1.0.3",
4
4
  "description": "Quasar Document Engine - Advanced BBCode/Markdown/HTML AST Engine",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -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&amp;t=83000">1:23</a>')
254
+ expect(html).toContain('<a class="bb-timeref" href="line://edit?map=42&amp;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.linkifyMentions(node.text)
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 `@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).
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 linkifyMentions(text: string): string {
1099
- const resolver = this.options.mentionResolver
1100
- if (!resolver) return this.escapeHtml(text)
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) out += this.escapeHtml(text.slice(last, m.index))
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 = resolver(name)
1110
- if (link && this.isSafeMentionHref(link.href)) {
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 isSafeMentionHref(href: string): boolean {
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)
@@ -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 {