@celestia-island/hikari 0.55.284 → 0.55.285
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/package.json
CHANGED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, h, nextTick, ref } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkMarkdownRenderer from "./HkMarkdownRenderer";
|
|
5
|
+
import { applyInlineChipsToBody, inlineChipDisplay, parseInlineChips } from "./inlineChips";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Inline chip pass contract tests (the opt-in `inlineChips` mode):
|
|
9
|
+
* - the parser splits `{{ token }}` / `@ref` forms and preserves the
|
|
10
|
+
* accessible text (tokens canonicalize their spacing, refs stay raw,
|
|
11
|
+
* plain runs round-trip byte-exactly)
|
|
12
|
+
* - the boundary guards hold: emails, doubled `@`, code spans, code
|
|
13
|
+
* fences and link text never chip
|
|
14
|
+
* - the component applies the pass only when the prop is on
|
|
15
|
+
*
|
|
16
|
+
* House style: raw createApp mounts torn down after each case; the
|
|
17
|
+
* async marked/DOMPurify settle uses a bounded poll.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const mounts: { app: ReturnType<typeof createApp>; container: HTMLElement }[] = [];
|
|
21
|
+
|
|
22
|
+
function mount(props: Record<string, unknown>) {
|
|
23
|
+
const container = document.createElement("div");
|
|
24
|
+
document.body.appendChild(container);
|
|
25
|
+
const app = createApp({ render: () => h(HkMarkdownRenderer, props as never) });
|
|
26
|
+
app.mount(container);
|
|
27
|
+
mounts.push({ app, container });
|
|
28
|
+
return container;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function settleFor(root: HTMLElement, selector: string, tries = 100): Promise<void> {
|
|
32
|
+
for (let i = 0; i < tries; i += 1) {
|
|
33
|
+
if (root.querySelector(selector)) return;
|
|
34
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
35
|
+
await nextTick();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
for (const { app, container } of mounts) {
|
|
41
|
+
app.unmount();
|
|
42
|
+
container.remove();
|
|
43
|
+
}
|
|
44
|
+
mounts.length = 0;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("parseInlineChips", () => {
|
|
48
|
+
it("splits tokens, refs and plain runs in source order", () => {
|
|
49
|
+
expect(parseInlineChips("Badge: {{container_badge}} then @system/permission-mode")).toEqual([
|
|
50
|
+
{ kind: "text", text: "Badge: " },
|
|
51
|
+
{ kind: "token", token: "container_badge" },
|
|
52
|
+
{ kind: "text", text: " then " },
|
|
53
|
+
{ kind: "ref", raw: "@system/permission-mode" },
|
|
54
|
+
]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("tokenizes with the shared grammar rules (bare ids only)", () => {
|
|
58
|
+
// Richer engine grammars stay literal — same rule as HkTemplateText.
|
|
59
|
+
expect(parseInlineChips("{{ a.b }} {{ x | filter }} {{ ok }}")).toEqual([
|
|
60
|
+
{ kind: "text", text: "{{ a.b }} {{ x | filter }} " },
|
|
61
|
+
{ kind: "token", token: "ok" },
|
|
62
|
+
]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("guards the @ boundary: emails and doubled @ stay text", () => {
|
|
66
|
+
expect(parseInlineChips("mail foo@bar.com now")).toEqual([
|
|
67
|
+
{ kind: "text", text: "mail foo@bar.com now" },
|
|
68
|
+
]);
|
|
69
|
+
expect(parseInlineChips("user@@host")).toEqual([{ kind: "text", text: "user@@host" }]);
|
|
70
|
+
expect(parseInlineChips("word@notachip")).toEqual([{ kind: "text", text: "word@notachip" }]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("refs stop at punctuation and match bare handles", () => {
|
|
74
|
+
expect(parseInlineChips("see @system/x, then @demiurge.")).toEqual([
|
|
75
|
+
{ kind: "text", text: "see " },
|
|
76
|
+
{ kind: "ref", raw: "@system/x" },
|
|
77
|
+
{ kind: "text", text: ", then " },
|
|
78
|
+
{ kind: "ref", raw: "@demiurge" },
|
|
79
|
+
{ kind: "text", text: "." },
|
|
80
|
+
]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("chip display canonicalizes tokens and keeps refs raw", () => {
|
|
84
|
+
expect(inlineChipDisplay({ kind: "token", token: "current_datetime" })).toBe("{{ current_datetime }}");
|
|
85
|
+
expect(inlineChipDisplay({ kind: "ref", raw: "@system/related-todos" })).toBe("@system/related-todos");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("applyInlineChipsToBody", () => {
|
|
90
|
+
it("chips prose but never enters code, pre or links", () => {
|
|
91
|
+
const root = document.createElement("div");
|
|
92
|
+
root.innerHTML =
|
|
93
|
+
"<p>Badge {{container_badge}} ref @system/permission-mode mail a@b.com</p>" +
|
|
94
|
+
"<pre><code>{{ keep_literal }}</code></pre>" +
|
|
95
|
+
"<p><a href=\"https://x/@at/in-url\">link @text</a></p>";
|
|
96
|
+
const chips = applyInlineChipsToBody(root);
|
|
97
|
+
const chipEls = [...root.querySelectorAll("span.hk-tpl-chip")];
|
|
98
|
+
// Two prose chips only: the token and the ref (email, code, link excluded).
|
|
99
|
+
expect(chips).toBe(2);
|
|
100
|
+
expect(chipEls.map((el) => el.textContent)).toEqual(["{{ container_badge }}", "@system/permission-mode"]);
|
|
101
|
+
expect(chipEls[0]!.hasAttribute("data-ref")).toBe(false);
|
|
102
|
+
expect(chipEls[1]!.hasAttribute("data-ref")).toBe(true);
|
|
103
|
+
// Literal surfaces untouched.
|
|
104
|
+
expect(root.querySelector("code")!.textContent).toBe("{{ keep_literal }}");
|
|
105
|
+
expect(root.querySelector("a")!.textContent).toBe("link @text");
|
|
106
|
+
// Idempotent: a second pass finds nothing new to chip.
|
|
107
|
+
expect(applyInlineChipsToBody(root)).toBe(0);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("HkMarkdownRenderer inlineChips", () => {
|
|
112
|
+
it("chips tokens and refs when the prop is on", async () => {
|
|
113
|
+
const container = mount({
|
|
114
|
+
content: "Badge: {{container_badge}}\n\nRead @system/related-todos first.",
|
|
115
|
+
inlineChips: true,
|
|
116
|
+
});
|
|
117
|
+
await settleFor(container, ".hk-tpl-chip");
|
|
118
|
+
const chips = [...container.querySelectorAll("span.hk-tpl-chip")];
|
|
119
|
+
expect(chips.map((el) => el.textContent)).toEqual(["{{ container_badge }}", "@system/related-todos"]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("renders no chips by default (existing consumers unchanged)", async () => {
|
|
123
|
+
const container = mount({ content: "Badge: {{container_badge}} ref @system/related-todos" });
|
|
124
|
+
await settleFor(container, ".hk-markdown-body p");
|
|
125
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
126
|
+
await nextTick();
|
|
127
|
+
expect(container.querySelectorAll(".hk-tpl-chip").length).toBe(0);
|
|
128
|
+
expect(container.textContent).toContain("{{container_badge}}");
|
|
129
|
+
expect(container.textContent).toContain("@system/related-todos");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("guards the grammar boundaries the two implementations must share", () => {
|
|
133
|
+
// Multi-level refs, `@` at either end, a leading `_`/`-` segment.
|
|
134
|
+
expect(parseInlineChips("@a/b/c")).toEqual([{ kind: "ref", raw: "@a/b/c" }]);
|
|
135
|
+
expect(parseInlineChips("@sole")).toEqual([{ kind: "ref", raw: "@sole" }]);
|
|
136
|
+
expect(parseInlineChips("mail @")).toEqual([{ kind: "text", text: "mail @" }]);
|
|
137
|
+
expect(parseInlineChips("@_private @-x @--flag")).toEqual([
|
|
138
|
+
{ kind: "text", text: "@_private @-x @--flag" },
|
|
139
|
+
]);
|
|
140
|
+
// Never chips inside a bare URL (npm-scoped packages are common in
|
|
141
|
+
// prompt prose) — the Rust mirror guards this too.
|
|
142
|
+
expect(parseInlineChips("see https://www.npmjs.com/package/@celestia-island/hikari")).toEqual([
|
|
143
|
+
{ kind: "text", text: "see https://www.npmjs.com/package/@celestia-island/hikari" },
|
|
144
|
+
]);
|
|
145
|
+
// Carriage returns inside the braces are still the same token.
|
|
146
|
+
expect(parseInlineChips("{{\rname\r}}")).toEqual([{ kind: "token", token: "name" }]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("un-chips when the prop flips back off (same mount)", async () => {
|
|
150
|
+
// The chip pass mutates the DOM in place, so a naive watcher leaves the
|
|
151
|
+
// chips behind when the prop goes back to false — this drives the real
|
|
152
|
+
// toggle on ONE mounted instance.
|
|
153
|
+
const flag = ref(true);
|
|
154
|
+
const content = "Badge {{x}} and @a/b";
|
|
155
|
+
const container = document.createElement("div");
|
|
156
|
+
document.body.appendChild(container);
|
|
157
|
+
const app = createApp({ render: () => h(HkMarkdownRenderer, { content, inlineChips: flag.value }) });
|
|
158
|
+
app.mount(container);
|
|
159
|
+
mounts.push({ app, container });
|
|
160
|
+
|
|
161
|
+
await settleFor(container, ".hk-tpl-chip");
|
|
162
|
+
expect(container.querySelectorAll(".hk-tpl-chip").length).toBe(2);
|
|
163
|
+
|
|
164
|
+
flag.value = false;
|
|
165
|
+
await nextTick();
|
|
166
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
167
|
+
await nextTick();
|
|
168
|
+
expect(container.querySelectorAll(".hk-tpl-chip").length).toBe(0);
|
|
169
|
+
expect(container.textContent).toContain("{{x}}");
|
|
170
|
+
expect(container.textContent).toContain("@a/b");
|
|
171
|
+
|
|
172
|
+
// ...and back on, still one mount.
|
|
173
|
+
flag.value = true;
|
|
174
|
+
await nextTick();
|
|
175
|
+
await settleFor(container, ".hk-tpl-chip");
|
|
176
|
+
expect(container.querySelectorAll(".hk-tpl-chip").length).toBe(2);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -207,3 +207,41 @@
|
|
|
207
207
|
@include backdrop-filter(blur(4px));
|
|
208
208
|
z-index: 10;
|
|
209
209
|
}
|
|
210
|
+
|
|
211
|
+
/* ── Inline chips (opt-in `inlineChips` prop) ────────────────────────
|
|
212
|
+
Fill-template tokens (`{{ name }}`) and asset references (`@level/name`,
|
|
213
|
+
bare `@name`) chipped inside rendered prose. The look is the family's
|
|
214
|
+
`.hk-tpl-chip` (HkTemplateField.scss is canonical); duplicated here
|
|
215
|
+
SCOPED to the markdown body so a markdown-only consumer does not pull
|
|
216
|
+
the template editor's sheet. Refs take the muted variant: a reference
|
|
217
|
+
points elsewhere, it is not a fillable value, so it must not read as
|
|
218
|
+
a template token. */
|
|
219
|
+
.hk-markdown-body .hk-tpl-chip {
|
|
220
|
+
display: inline-flex;
|
|
221
|
+
align-items: center;
|
|
222
|
+
padding: 0 var(--space-6, 6px);
|
|
223
|
+
border-radius: var(--radius-sm, 4px);
|
|
224
|
+
border: 1px solid color-mix(in srgb, var(--hi-color-primary) 32%, transparent);
|
|
225
|
+
background: color-mix(in srgb, var(--hi-color-primary) 12%, transparent);
|
|
226
|
+
color: color-mix(in srgb, var(--hi-color-primary) 70%, rgb(0 0 0));
|
|
227
|
+
font-family: var(--font-mono, monospace);
|
|
228
|
+
font-size: 0.8em;
|
|
229
|
+
line-height: 1.5;
|
|
230
|
+
white-space: nowrap;
|
|
231
|
+
/* NO user-select here: rendered prose IS content (see the sheet's own
|
|
232
|
+
* `user-select(text)` policy at the top and styles/selection.scss's
|
|
233
|
+
* "markdown body" content container). The editor chip in
|
|
234
|
+
* HkTemplateField.scss disables selection for caret/click handling;
|
|
235
|
+
* copying a PROMPT BODY must keep every `{{token}}` / `@ref` in the
|
|
236
|
+
* clipboard, so this scoped copy must not carry that rule. */
|
|
237
|
+
|
|
238
|
+
[data-theme="dark"] & {
|
|
239
|
+
color: color-mix(in srgb, var(--hi-color-primary) 60%, rgb(255 255 255));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
&[data-ref] {
|
|
243
|
+
border-color: color-mix(in srgb, var(--hi-color-muted, #6c6c6c) 40%, transparent);
|
|
244
|
+
background: color-mix(in srgb, var(--hi-color-muted, #6c6c6c) 10%, transparent);
|
|
245
|
+
color: var(--hi-color-muted, #6c6c6c);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { computed, defineComponent, nextTick, onBeforeUnmount, ref, watch } from "vue";
|
|
2
2
|
import type { Renderer, Tokens } from "marked";
|
|
3
3
|
import HSpinner from "./HkSpinner";
|
|
4
|
+
import { applyInlineChipsToBody } from "./inlineChips";
|
|
4
5
|
import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
|
|
5
6
|
import { asIndexable } from "../utils/typing";
|
|
6
7
|
import "./HkMarkdownRenderer.scss";
|
|
@@ -118,10 +119,26 @@ export default defineComponent({
|
|
|
118
119
|
content: { type: String, required: true },
|
|
119
120
|
loading: { type: Boolean, default: false },
|
|
120
121
|
plain: { type: Boolean, default: false },
|
|
122
|
+
/**
|
|
123
|
+
* Opt-in inline chip pass: fill-template tokens (`{{ name }}`) and
|
|
124
|
+
* asset references (`@level/name`, bare `@name`) inside the
|
|
125
|
+
* rendered prose chip up as `.hk-tpl-chip` spans (refs carry
|
|
126
|
+
* `data-ref`, the muted variant). Applied AFTER sanitize, as pure
|
|
127
|
+
* DOM surgery with `textContent` only — the pass cannot introduce
|
|
128
|
+
* markup, and `code`/`pre`/`a` subtrees are skipped so literal
|
|
129
|
+
* code and link text stay verbatim. Off by default: every existing
|
|
130
|
+
* consumer renders exactly as before.
|
|
131
|
+
*/
|
|
132
|
+
inlineChips: { type: Boolean, default: false },
|
|
121
133
|
},
|
|
122
134
|
setup(props) {
|
|
123
135
|
const renderedHtml = ref("");
|
|
124
136
|
const parseError = ref(false);
|
|
137
|
+
// Monotonic parse counter: `renderedHtml` does not change when the
|
|
138
|
+
// SAME body is re-parsed for a prop flip, so a watch on it would never
|
|
139
|
+
// fire — and the chip pass mutates the DOM in place, leaving chips
|
|
140
|
+
// behind after `inlineChips` goes back to false.
|
|
141
|
+
const parseSeq = ref(0);
|
|
125
142
|
|
|
126
143
|
async function parse() {
|
|
127
144
|
if (!props.content) {
|
|
@@ -138,11 +155,13 @@ export default defineComponent({
|
|
|
138
155
|
} catch {
|
|
139
156
|
parseError.value = true;
|
|
140
157
|
renderedHtml.value = renderPlain(props.content);
|
|
158
|
+
} finally {
|
|
159
|
+
parseSeq.value += 1;
|
|
141
160
|
}
|
|
142
161
|
}
|
|
143
162
|
|
|
144
163
|
watch(
|
|
145
|
-
() => [props.content, props.plain],
|
|
164
|
+
() => [props.content, props.plain, props.inlineChips],
|
|
146
165
|
() => { void parse(); },
|
|
147
166
|
{ immediate: true },
|
|
148
167
|
);
|
|
@@ -168,8 +187,20 @@ export default defineComponent({
|
|
|
168
187
|
}
|
|
169
188
|
}
|
|
170
189
|
|
|
171
|
-
watch(
|
|
172
|
-
void nextTick(
|
|
190
|
+
watch(parseSeq, () => {
|
|
191
|
+
void nextTick(() => {
|
|
192
|
+
const body = bodyRef.value;
|
|
193
|
+
if (!body) return;
|
|
194
|
+
// Re-seat the DOM on the pristine rendered HTML first: the chip
|
|
195
|
+
// pass mutates it in place, so a prop flip must start from the
|
|
196
|
+
// un-chipped markup (assigning the string Vue already patched is
|
|
197
|
+
// idempotent; when the body did not change it is the only write).
|
|
198
|
+
body.innerHTML = renderedHtml.value;
|
|
199
|
+
// The chip pass runs BEFORE the overlay scan: it splits text
|
|
200
|
+
// nodes, so the scan must reconcile against the final DOM.
|
|
201
|
+
if (props.inlineChips) applyInlineChipsToBody(body);
|
|
202
|
+
syncOverlayScrollbars();
|
|
203
|
+
});
|
|
173
204
|
});
|
|
174
205
|
|
|
175
206
|
onBeforeUnmount(() => {
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* inlineChips — the read-only chip pass for markdown-embedded prompt
|
|
3
|
+
* text (`HkMarkdownRenderer`'s opt-in `inlineChips` mode).
|
|
4
|
+
*
|
|
5
|
+
* Prompt assets (entelecheia's five prompt levels) embed two reference
|
|
6
|
+
* forms in their prose:
|
|
7
|
+
* - fill-template tokens `{{ name }}` — the shared fill-template
|
|
8
|
+
* grammar (`templateGrammar.ts` parses them; only bare
|
|
9
|
+
* `[A-Za-z0-9_]+` identifiers are tokens, everything richer stays
|
|
10
|
+
* literal);
|
|
11
|
+
* - asset references `@level/name` (`@system/permission-mode`) and
|
|
12
|
+
* bare `@name` handles — the engine's cross-asset mention form.
|
|
13
|
+
*
|
|
14
|
+
* This module splits a plain-text run into literal text and chip
|
|
15
|
+
* segments; the caller renders chips as `.hk-tpl-chip` spans (the
|
|
16
|
+
* family's chip look). Chips preserve the REFERENCE, not the exact
|
|
17
|
+
* bytes: tokens render the canonical `{{ name }}` spelling (so `{{name}}`
|
|
18
|
+
* displays as `{{ name }}` — the same rule HkTemplateText applies, and
|
|
19
|
+
* `parseTemplate`/`serializeTemplate` remain byte-exact for the value
|
|
20
|
+
* itself) while refs render their raw matched text.
|
|
21
|
+
*
|
|
22
|
+
* Boundary guards (an `@` must NOT chip):
|
|
23
|
+
* - preceded by a word character (`foo@bar.com` — emails stay text);
|
|
24
|
+
* - preceded by another `@` (`user@@host` stays text);
|
|
25
|
+
* refs never absorb trailing punctuation (`.`/`'`/`,` are not in the
|
|
26
|
+
* charset) and never match inside `code`/`pre`/links — the renderer's
|
|
27
|
+
* DOM walk skips those subtrees.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { parseTemplate, formatTokenDisplay } from "./templateGrammar";
|
|
31
|
+
|
|
32
|
+
export interface HkInlineChipTextSegment {
|
|
33
|
+
kind: "text";
|
|
34
|
+
text: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface HkInlineChipTokenSegment {
|
|
38
|
+
kind: "token";
|
|
39
|
+
/** The bare identifier (`container_badge`). */
|
|
40
|
+
token: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface HkInlineChipRefSegment {
|
|
44
|
+
kind: "ref";
|
|
45
|
+
/** The exact raw matched text (`@system/permission-mode`). */
|
|
46
|
+
raw: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type HkInlineChipSegment =
|
|
50
|
+
| HkInlineChipTextSegment
|
|
51
|
+
| HkInlineChipTokenSegment
|
|
52
|
+
| HkInlineChipRefSegment;
|
|
53
|
+
|
|
54
|
+
/** `@ref` grammar: `@` (not preceded by a word char or another `@`),
|
|
55
|
+
* one path segment, then `/segment` repeats. Punctuation ends the
|
|
56
|
+
* match naturally (dot and quote are outside the charset). */
|
|
57
|
+
const REF_RE = /(?<![A-Za-z0-9_@])@[A-Za-z0-9][A-Za-z0-9_-]*(?:\/[A-Za-z0-9][A-Za-z0-9_-]*)*/g;
|
|
58
|
+
|
|
59
|
+
/** Split literal text runs into text + `@ref` segments (tokens are
|
|
60
|
+
* already extracted by the template pass before this runs). */
|
|
61
|
+
function splitRefs(text: string, push: (seg: HkInlineChipSegment) => void): void {
|
|
62
|
+
let cursor = 0;
|
|
63
|
+
REF_RE.lastIndex = 0;
|
|
64
|
+
for (let m = REF_RE.exec(text); m; m = REF_RE.exec(text)) {
|
|
65
|
+
if (m.index > cursor) {
|
|
66
|
+
push({ kind: "text", text: text.slice(cursor, m.index) });
|
|
67
|
+
}
|
|
68
|
+
// URL guard: an `@` inside a bare URL is part of the URL. marked
|
|
69
|
+
// autolinks most of these (and `<a>` is a chip skip surface), but the
|
|
70
|
+
// rule must not depend on the engine's autolink heuristic — npm-scoped
|
|
71
|
+
// package URLs are common in prompt prose.
|
|
72
|
+
let tokenStart = m.index;
|
|
73
|
+
while (tokenStart > 0 && !/\s/.test(text[tokenStart - 1]!)) tokenStart -= 1;
|
|
74
|
+
if (text.slice(tokenStart, m.index).includes("://")) {
|
|
75
|
+
push({ kind: "text", text: m[0] });
|
|
76
|
+
cursor = m.index + m[0].length;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
push({ kind: "ref", raw: m[0] });
|
|
80
|
+
cursor = m.index + m[0].length;
|
|
81
|
+
}
|
|
82
|
+
if (cursor < text.length) {
|
|
83
|
+
push({ kind: "text", text: text.slice(cursor) });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Split a plain-text run into literal text, `{{ token }}` and `@ref`
|
|
89
|
+
* segments, in source order. The empty string parses to an empty list;
|
|
90
|
+
* a run with no chip forms round-trips as one text segment.
|
|
91
|
+
*/
|
|
92
|
+
export function parseInlineChips(text: string): HkInlineChipSegment[] {
|
|
93
|
+
const out: HkInlineChipSegment[] = [];
|
|
94
|
+
const push = (seg: HkInlineChipSegment) => {
|
|
95
|
+
// Merge adjacent text runs so the caller can cheaply detect the
|
|
96
|
+
// "nothing to chip" case (one text segment).
|
|
97
|
+
const last = out[out.length - 1];
|
|
98
|
+
if (seg.kind === "text" && last?.kind === "text") {
|
|
99
|
+
last.text += seg.text;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
out.push(seg);
|
|
103
|
+
};
|
|
104
|
+
for (const seg of parseTemplate(text)) {
|
|
105
|
+
if (seg.kind === "token") {
|
|
106
|
+
push({ kind: "token", token: seg.token });
|
|
107
|
+
} else {
|
|
108
|
+
splitRefs(seg.text, push);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The canonical chip text for a segment (tokens spell `{{ name }}`,
|
|
115
|
+
* refs keep their raw form — both copy back to the source). */
|
|
116
|
+
export function inlineChipDisplay(seg: HkInlineChipTokenSegment | HkInlineChipRefSegment): string {
|
|
117
|
+
return seg.kind === "token" ? formatTokenDisplay(seg.token) : seg.raw;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Subtrees the chip pass must never enter: literal code (the token is
|
|
121
|
+
* documentation there, not a live form), links (the `@` may be part of
|
|
122
|
+
* a URL or handle path the anchor already contextualizes) and chips
|
|
123
|
+
* themselves (idempotency — a second pass must not re-chip). */
|
|
124
|
+
const CHIP_SKIP_SELECTOR = "code, pre, a, script, style, textarea, .hk-tpl-chip";
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Walk `root`'s text nodes and wrap chip forms in `.hk-tpl-chip`
|
|
128
|
+
* spans. Pure DOM surgery on already-sanitized markup: the only nodes
|
|
129
|
+
* created are spans + text, built with `textContent` (no HTML
|
|
130
|
+
* parsing), so the pass cannot reintroduce markup. Tokens get the
|
|
131
|
+
* canonical `{{ name }}` spelling; refs carry `data-ref` (the muted
|
|
132
|
+
* chip variant) and their exact raw text.
|
|
133
|
+
*
|
|
134
|
+
* Returns the number of chips created (0 when the mode is off or the
|
|
135
|
+
* body carries no chip forms) — a cheap assertion hook for tests.
|
|
136
|
+
*/
|
|
137
|
+
export function applyInlineChipsToBody(root: HTMLElement | null | undefined): number {
|
|
138
|
+
if (!root) return 0;
|
|
139
|
+
const walker = document.createTreeWalker(root, 4 /* NodeFilter.SHOW_TEXT */);
|
|
140
|
+
const targets: Text[] = [];
|
|
141
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
142
|
+
const value = node.nodeValue ?? "";
|
|
143
|
+
// Cheap prefilter: chips only ever introduce `{` or `@`.
|
|
144
|
+
if (!/[{@]/.test(value)) continue;
|
|
145
|
+
const parent = (node as Text).parentElement;
|
|
146
|
+
if (parent?.closest?.(CHIP_SKIP_SELECTOR)) continue;
|
|
147
|
+
targets.push(node as Text);
|
|
148
|
+
}
|
|
149
|
+
let chips = 0;
|
|
150
|
+
for (const node of targets) {
|
|
151
|
+
const segments = parseInlineChips(node.nodeValue ?? "");
|
|
152
|
+
if (segments.length === 0 || segments.every((seg) => seg.kind === "text")) continue;
|
|
153
|
+
const frag = document.createDocumentFragment();
|
|
154
|
+
for (const seg of segments) {
|
|
155
|
+
if (seg.kind === "text") {
|
|
156
|
+
frag.append(seg.text);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
chips += 1;
|
|
160
|
+
const chip = document.createElement("span");
|
|
161
|
+
chip.className = "hk-tpl-chip";
|
|
162
|
+
chip.setAttribute("data-static", "");
|
|
163
|
+
chip.textContent = inlineChipDisplay(seg);
|
|
164
|
+
if (seg.kind === "ref") chip.setAttribute("data-ref", "");
|
|
165
|
+
frag.append(chip);
|
|
166
|
+
}
|
|
167
|
+
node.replaceWith(frag);
|
|
168
|
+
}
|
|
169
|
+
return chips;
|
|
170
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -913,6 +913,17 @@ export {
|
|
|
913
913
|
type HkTemplateSegment,
|
|
914
914
|
type HkTemplateTokenDef,
|
|
915
915
|
} from "./components/templateGrammar";
|
|
916
|
+
// The chip grammar lives beside the fill-template grammar it reuses; both
|
|
917
|
+
// are exported so hosts can reason about (and test) reference forms
|
|
918
|
+
// without mounting a renderer.
|
|
919
|
+
export {
|
|
920
|
+
parseInlineChips,
|
|
921
|
+
inlineChipDisplay,
|
|
922
|
+
applyInlineChipsToBody,
|
|
923
|
+
type HkInlineChipSegment,
|
|
924
|
+
type HkInlineChipTokenSegment,
|
|
925
|
+
type HkInlineChipRefSegment,
|
|
926
|
+
} from "./components/inlineChips";
|
|
916
927
|
export { HkErrorLanding as HkErrorLanding } from "./components/HkErrorLanding";
|
|
917
928
|
export { HkErrorReportingOverlay as HkErrorReportingOverlay } from "./errorReporting";
|
|
918
929
|
export { HkAdminShell as HkAdminShell } from "./components/HkAdminShell";
|