@cbcruk/highlight-kit 0.1.0 → 0.2.0

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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@cbcruk/highlight-kit",
3
3
  "description": "CSS Custom Highlight API 기반 텍스트 하이라이트 core와 React 어댑터",
4
- "version": "0.1.0",
4
+ "version": "0.2.0",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -1,168 +0,0 @@
1
- //#region src/core.d.ts
2
- /** Options controlling how a string pattern is matched against text. */
3
- interface MatchOptions {
4
- /**
5
- * Case sensitive match. Ignored for `RegExp` patterns, which keep their own flags.
6
- * @default false
7
- */
8
- caseSensitive?: boolean;
9
- /**
10
- * Match whole words only (wraps the pattern in `\b`). Ignored for `RegExp` patterns.
11
- * @default false
12
- */
13
- wholeWord?: boolean;
14
- }
15
- /** Reactive state of one highlight name, as exposed to subscribers. */
16
- interface HighlightSnapshot {
17
- /** Whether this name currently has any registered ranges */
18
- readonly active: boolean;
19
- /** Number of ranges registered under this name */
20
- readonly count: number;
21
- }
22
- /** A source contributing ranges, keyed by a unique id (e.g. React's useId) */
23
- type SourceId = string | symbol;
24
- /**
25
- * Where reconciled ranges are written. The controller's bookkeeping always
26
- * runs; only this side effect is swappable (CSS registry, no-op for tests/SSR).
27
- */
28
- interface HighlightSink {
29
- /** Replace the registered highlight for `name` with `ranges`. */
30
- commit(name: string, ranges: Range[], priority: number): void;
31
- /** Drop the registered highlight for `name`. */
32
- remove(name: string): void;
33
- /**
34
- * When this returns false the controller skips bookkeeping entirely, so
35
- * snapshots stay empty. Omit it to always track ranges.
36
- */
37
- isSupported?(): boolean;
38
- }
39
- /** Options for {@link createHighlightController}. */
40
- interface HighlightControllerOptions {
41
- /** Where reconciled ranges are written. Defaults to {@link createCssHighlightSink}. */
42
- sink?: HighlightSink;
43
- }
44
- /** Whether the CSS Custom Highlight API (`CSS.highlights` and `Highlight`) is available. */
45
- declare function isHighlightSupported(): boolean;
46
- /**
47
- * Collect the text nodes under an element that contain non-whitespace text.
48
- *
49
- * Whitespace-only nodes (newlines, indentation between elements) are skipped,
50
- * so this is suited to pattern matching, not to offset math against `textContent`.
51
- */
52
- declare function getTextNodes(root: Node): Text[];
53
- /**
54
- * Compute Range objects for every match of `pattern` within `root`.
55
- * Pure: returns ranges, does not touch the registry.
56
- */
57
- declare function computeRanges(root: Element, pattern: string | RegExp, options?: MatchOptions): Range[];
58
- /**
59
- * Map flat character offsets onto Range objects. Useful when you already know positions.
60
- *
61
- * Offsets count over `root.textContent`, whitespace-only text nodes (newlines,
62
- * indentation between elements) included, so positions computed from
63
- * `textContent` or on a server against the same text line up. Unlike
64
- * {@link computeRanges}, a span may cross text node boundaries; it yields one
65
- * range per text node it touches. Out-of-bounds parts are ignored.
66
- *
67
- * @example
68
- * ```ts
69
- * import { rangesFromOffsets } from '@cbcruk/highlight-kit'
70
- *
71
- * const el = document.querySelector('#article')!
72
- * const start = el.textContent!.indexOf('wisdom')
73
- * rangesFromOffsets(el, [{ start, end: start + 'wisdom'.length }])
74
- * ```
75
- */
76
- declare function rangesFromOffsets(root: Element, spans: ReadonlyArray<{
77
- start: number;
78
- end: number;
79
- }>): Range[];
80
- /** Sink that writes to the document-global `CSS.highlights` registry. */
81
- declare function createCssHighlightSink(): HighlightSink;
82
- /** Side-effect-free sink: bookkeeping still runs, nothing is painted. */
83
- declare function createNoopSink(): HighlightSink;
84
- /**
85
- * Tracks ranges per highlight name and source, and writes their union to a sink.
86
- *
87
- * Multiple sources may contribute to one name; each change reconciles the name
88
- * into a single highlight and notifies `subscribe` listeners. Create instances
89
- * with {@link createHighlightController} or use the shared {@link highlights}.
90
- *
91
- * @example
92
- * ```ts
93
- * import { computeRanges, highlights } from '@cbcruk/highlight-kit'
94
- *
95
- * const el = document.querySelector('#article')!
96
- * highlights.set('search', 'my-source', computeRanges(el, 'wisdom'))
97
- * highlights.getSnapshot('search') // { active: true, count: ... }
98
- * highlights.remove('search', 'my-source')
99
- * ```
100
- */
101
- declare class HighlightController {
102
- #private;
103
- /** Create a controller that writes to `sink` (the CSS registry sink by default). */
104
- constructor({ sink }?: HighlightControllerOptions);
105
- /**
106
- * Whether the sink reports support. `true` when the sink has no `isSupported`.
107
- * When `false`, {@link HighlightController.set} is a no-op.
108
- */
109
- get supported(): boolean;
110
- /** useSyncExternalStore: stable identity (arrow field on the instance). */
111
- subscribe: (listener: () => void) => (() => void);
112
- /** Snapshot for a given highlight name (referentially stable). */
113
- getSnapshot: (name: string) => HighlightSnapshot;
114
- /** Snapshots of every active name (referentially stable between changes). */
115
- getSnapshots: () => Readonly<Record<string, HighlightSnapshot>>;
116
- /** SSR / unsupported: constant empty snapshot. */
117
- getServerSnapshot: () => HighlightSnapshot;
118
- /** SSR / unsupported: constant empty record. */
119
- getServerSnapshots: () => Readonly<Record<string, HighlightSnapshot>>;
120
- /** Union of every source's ranges currently registered under `name`. */
121
- getRanges(name: string): Range[];
122
- /**
123
- * Register/replace a source's ranges under a name, then reconcile.
124
- * `priority` applies to the whole name; the most recent call wins.
125
- */
126
- set(name: string, sourceId: SourceId, ranges: Range[], priority?: number): void;
127
- /** Remove a single source's contribution to a name. */
128
- remove(name: string, sourceId: SourceId): void;
129
- /** Drop a name entirely, regardless of sources. */
130
- clear(name: string): void;
131
- /** Drop everything this controller manages. */
132
- clearAll(): void;
133
- }
134
- /**
135
- * Create an isolated controller. Useful for tests (with {@link createNoopSink})
136
- * or for scoping subscriptions via the React `HighlightProvider`. Highlight
137
- * *names* are still document-global once painted by a CSS sink.
138
- */
139
- declare function createHighlightController(options?: HighlightControllerOptions): HighlightController;
140
- /** The shared singleton. */
141
- declare const highlights: HighlightController;
142
- /**
143
- * Build `::highlight(name)` CSS rules from a style map without injecting them.
144
- *
145
- * camelCase property names are converted to kebab-case; values are written as is.
146
- *
147
- * @example
148
- * ```ts
149
- * import { generateHighlightCSS } from '@cbcruk/highlight-kit'
150
- *
151
- * generateHighlightCSS({ search: { backgroundColor: 'yellow' } })
152
- * // '::highlight(search) {\n background-color: yellow;\n}'
153
- * ```
154
- */
155
- declare function generateHighlightCSS(styles: Record<string, Partial<CSSStyleDeclaration>>): string;
156
- /**
157
- * Append a `<style>` element with `::highlight()` rules to `document.head`.
158
- *
159
- * An existing element with the same `id` is removed first, so calling it again
160
- * replaces the previous rules.
161
- *
162
- * @param styles - Style declarations keyed by highlight name
163
- * @param id - `id` of the `<style>` element
164
- * @returns The inserted `<style>` element
165
- */
166
- declare function injectHighlightStyles(styles: Record<string, Partial<CSSStyleDeclaration>>, id?: string): HTMLStyleElement;
167
- //#endregion
168
- export { MatchOptions as a, createCssHighlightSink as c, generateHighlightCSS as d, getTextNodes as f, rangesFromOffsets as g, isHighlightSupported as h, HighlightSnapshot as i, createHighlightController as l, injectHighlightStyles as m, HighlightControllerOptions as n, SourceId as o, highlights as p, HighlightSink as r, computeRanges as s, HighlightController as t, createNoopSink as u };
@@ -1,306 +0,0 @@
1
- //#region src/core.ts
2
- /** Stable empty snapshot — referentially constant for useSyncExternalStore */
3
- const EMPTY_SNAPSHOT = Object.freeze({
4
- active: false,
5
- count: 0
6
- });
7
- /** Whether the CSS Custom Highlight API (`CSS.highlights` and `Highlight`) is available. */
8
- function isHighlightSupported() {
9
- return typeof CSS !== "undefined" && "highlights" in CSS && typeof Highlight !== "undefined";
10
- }
11
- /**
12
- * Collect the text nodes under an element that contain non-whitespace text.
13
- *
14
- * Whitespace-only nodes (newlines, indentation between elements) are skipped,
15
- * so this is suited to pattern matching, not to offset math against `textContent`.
16
- */
17
- function getTextNodes(root) {
18
- const nodes = [];
19
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
20
- let node;
21
- while (node = walker.nextNode()) if (node.textContent && node.textContent.trim()) nodes.push(node);
22
- return nodes;
23
- }
24
- function toRegExp(pattern, options) {
25
- if (pattern instanceof RegExp) return pattern.flags.includes("g") ? pattern : new RegExp(pattern.source, pattern.flags + "g");
26
- const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
27
- const body = options.wholeWord ? `\\b${escaped}\\b` : escaped;
28
- return new RegExp(body, options.caseSensitive ? "g" : "gi");
29
- }
30
- /**
31
- * Compute Range objects for every match of `pattern` within `root`.
32
- * Pure: returns ranges, does not touch the registry.
33
- */
34
- function computeRanges(root, pattern, options = {}) {
35
- if (!pattern) return [];
36
- const regex = toRegExp(pattern, options);
37
- const ranges = [];
38
- for (const textNode of getTextNodes(root)) {
39
- const text = textNode.textContent ?? "";
40
- regex.lastIndex = 0;
41
- let m;
42
- while ((m = regex.exec(text)) !== null) {
43
- if (m[0].length === 0) {
44
- regex.lastIndex++;
45
- continue;
46
- }
47
- try {
48
- const range = new Range();
49
- range.setStart(textNode, m.index);
50
- range.setEnd(textNode, m.index + m[0].length);
51
- ranges.push(range);
52
- } catch {}
53
- }
54
- }
55
- return ranges;
56
- }
57
- /** Collect every text node under `root`, whitespace-only ones included. */
58
- function getAllTextNodes(root) {
59
- const nodes = [];
60
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
61
- let node;
62
- while (node = walker.nextNode()) nodes.push(node);
63
- return nodes;
64
- }
65
- /**
66
- * Map flat character offsets onto Range objects. Useful when you already know positions.
67
- *
68
- * Offsets count over `root.textContent`, whitespace-only text nodes (newlines,
69
- * indentation between elements) included, so positions computed from
70
- * `textContent` or on a server against the same text line up. Unlike
71
- * {@link computeRanges}, a span may cross text node boundaries; it yields one
72
- * range per text node it touches. Out-of-bounds parts are ignored.
73
- *
74
- * @example
75
- * ```ts
76
- * import { rangesFromOffsets } from '@cbcruk/highlight-kit'
77
- *
78
- * const el = document.querySelector('#article')!
79
- * const start = el.textContent!.indexOf('wisdom')
80
- * rangesFromOffsets(el, [{ start, end: start + 'wisdom'.length }])
81
- * ```
82
- */
83
- function rangesFromOffsets(root, spans) {
84
- const nodes = getAllTextNodes(root);
85
- const layout = [];
86
- let offset = 0;
87
- for (const node of nodes) {
88
- const len = node.textContent?.length ?? 0;
89
- if (len === 0) continue;
90
- layout.push({
91
- node,
92
- start: offset,
93
- end: offset + len
94
- });
95
- offset += len;
96
- }
97
- const ranges = [];
98
- for (const { start, end } of spans) for (const { node, start: ns, end: ne } of layout) if (ns < end && ne > start) try {
99
- const range = new Range();
100
- range.setStart(node, Math.max(0, start - ns));
101
- range.setEnd(node, Math.min(node.textContent?.length ?? 0, end - ns));
102
- ranges.push(range);
103
- } catch {}
104
- return ranges;
105
- }
106
- /** Sink that writes to the document-global `CSS.highlights` registry. */
107
- function createCssHighlightSink() {
108
- return {
109
- commit(name, ranges, priority) {
110
- if (!isHighlightSupported()) return;
111
- const highlight = new Highlight(...ranges);
112
- highlight.priority = priority;
113
- CSS.highlights.set(name, highlight);
114
- },
115
- remove(name) {
116
- if (!isHighlightSupported()) return;
117
- CSS.highlights.delete(name);
118
- },
119
- isSupported: isHighlightSupported
120
- };
121
- }
122
- /** Side-effect-free sink: bookkeeping still runs, nothing is painted. */
123
- function createNoopSink() {
124
- return {
125
- commit() {},
126
- remove() {}
127
- };
128
- }
129
- const EMPTY_SNAPSHOTS = Object.freeze({});
130
- /**
131
- * Tracks ranges per highlight name and source, and writes their union to a sink.
132
- *
133
- * Multiple sources may contribute to one name; each change reconciles the name
134
- * into a single highlight and notifies `subscribe` listeners. Create instances
135
- * with {@link createHighlightController} or use the shared {@link highlights}.
136
- *
137
- * @example
138
- * ```ts
139
- * import { computeRanges, highlights } from '@cbcruk/highlight-kit'
140
- *
141
- * const el = document.querySelector('#article')!
142
- * highlights.set('search', 'my-source', computeRanges(el, 'wisdom'))
143
- * highlights.getSnapshot('search') // { active: true, count: ... }
144
- * highlights.remove('search', 'my-source')
145
- * ```
146
- */
147
- var HighlightController = class {
148
- #sink;
149
- /** name -> (sourceId -> ranges). Multiple sources may share a name. */
150
- #entries = /* @__PURE__ */ new Map();
151
- /** Cached per-name snapshots; refs are stable until that name changes. */
152
- #snapshots = /* @__PURE__ */ new Map();
153
- /** Cached name -> snapshot record; rebuilt on every emit. */
154
- #allSnapshots = EMPTY_SNAPSHOTS;
155
- /** External-store listeners. */
156
- #listeners = /* @__PURE__ */ new Set();
157
- /** Create a controller that writes to `sink` (the CSS registry sink by default). */
158
- constructor({ sink = createCssHighlightSink() } = {}) {
159
- this.#sink = sink;
160
- }
161
- /**
162
- * Whether the sink reports support. `true` when the sink has no `isSupported`.
163
- * When `false`, {@link HighlightController.set} is a no-op.
164
- */
165
- get supported() {
166
- return this.#sink.isSupported?.() ?? true;
167
- }
168
- /** useSyncExternalStore: stable identity (arrow field on the instance). */
169
- subscribe = (listener) => {
170
- this.#listeners.add(listener);
171
- return () => this.#listeners.delete(listener);
172
- };
173
- /** Snapshot for a given highlight name (referentially stable). */
174
- getSnapshot = (name) => {
175
- return this.#snapshots.get(name) ?? EMPTY_SNAPSHOT;
176
- };
177
- /** Snapshots of every active name (referentially stable between changes). */
178
- getSnapshots = () => {
179
- return this.#allSnapshots;
180
- };
181
- /** SSR / unsupported: constant empty snapshot. */
182
- getServerSnapshot = () => EMPTY_SNAPSHOT;
183
- /** SSR / unsupported: constant empty record. */
184
- getServerSnapshots = () => EMPTY_SNAPSHOTS;
185
- /** Union of every source's ranges currently registered under `name`. */
186
- getRanges(name) {
187
- const entry = this.#entries.get(name);
188
- return entry ? this.#merge(entry) : [];
189
- }
190
- /**
191
- * Register/replace a source's ranges under a name, then reconcile.
192
- * `priority` applies to the whole name; the most recent call wins.
193
- */
194
- set(name, sourceId, ranges, priority = 0) {
195
- if (!this.supported) return;
196
- let entry = this.#entries.get(name);
197
- if (!entry) {
198
- entry = {
199
- priority,
200
- sources: /* @__PURE__ */ new Map()
201
- };
202
- this.#entries.set(name, entry);
203
- } else entry.priority = priority;
204
- entry.sources.set(sourceId, ranges);
205
- this.#reconcile(name);
206
- this.#emit();
207
- }
208
- /** Remove a single source's contribution to a name. */
209
- remove(name, sourceId) {
210
- const entry = this.#entries.get(name);
211
- if (!entry || !entry.sources.delete(sourceId)) return;
212
- if (entry.sources.size === 0) this.#entries.delete(name);
213
- this.#reconcile(name);
214
- this.#emit();
215
- }
216
- /** Drop a name entirely, regardless of sources. */
217
- clear(name) {
218
- if (!this.#entries.delete(name)) return;
219
- this.#reconcile(name);
220
- this.#emit();
221
- }
222
- /** Drop everything this controller manages. */
223
- clearAll() {
224
- const names = [...this.#entries.keys()];
225
- this.#entries.clear();
226
- for (const name of names) this.#sink.remove(name);
227
- this.#snapshots.clear();
228
- this.#emit();
229
- }
230
- #merge(entry) {
231
- const all = [];
232
- for (const ranges of entry.sources.values()) all.push(...ranges);
233
- return all;
234
- }
235
- /** Union all sources for `name` into one highlight and update the snapshot. */
236
- #reconcile(name) {
237
- const entry = this.#entries.get(name);
238
- const all = entry ? this.#merge(entry) : [];
239
- if (!entry || all.length === 0) {
240
- this.#sink.remove(name);
241
- this.#snapshots.set(name, EMPTY_SNAPSHOT);
242
- return;
243
- }
244
- this.#sink.commit(name, all, entry.priority);
245
- this.#snapshots.set(name, {
246
- active: true,
247
- count: all.length
248
- });
249
- }
250
- #emit() {
251
- const next = {};
252
- for (const [name, snapshot] of this.#snapshots) if (snapshot.active) next[name] = snapshot;
253
- this.#allSnapshots = next;
254
- for (const listener of this.#listeners) listener();
255
- }
256
- };
257
- /**
258
- * Create an isolated controller. Useful for tests (with {@link createNoopSink})
259
- * or for scoping subscriptions via the React `HighlightProvider`. Highlight
260
- * *names* are still document-global once painted by a CSS sink.
261
- */
262
- function createHighlightController(options) {
263
- return new HighlightController(options);
264
- }
265
- /** The shared singleton. */
266
- const highlights = createHighlightController();
267
- /**
268
- * Build `::highlight(name)` CSS rules from a style map without injecting them.
269
- *
270
- * camelCase property names are converted to kebab-case; values are written as is.
271
- *
272
- * @example
273
- * ```ts
274
- * import { generateHighlightCSS } from '@cbcruk/highlight-kit'
275
- *
276
- * generateHighlightCSS({ search: { backgroundColor: 'yellow' } })
277
- * // '::highlight(search) {\n background-color: yellow;\n}'
278
- * ```
279
- */
280
- function generateHighlightCSS(styles) {
281
- return Object.entries(styles).map(([name, style]) => {
282
- return `::highlight(${name}) {\n${Object.entries(style).map(([prop, value]) => {
283
- return ` ${prop.replace(/([A-Z])/g, "-$1").toLowerCase()}: ${value};`;
284
- }).join("\n")}\n}`;
285
- }).join("\n\n");
286
- }
287
- /**
288
- * Append a `<style>` element with `::highlight()` rules to `document.head`.
289
- *
290
- * An existing element with the same `id` is removed first, so calling it again
291
- * replaces the previous rules.
292
- *
293
- * @param styles - Style declarations keyed by highlight name
294
- * @param id - `id` of the `<style>` element
295
- * @returns The inserted `<style>` element
296
- */
297
- function injectHighlightStyles(styles, id = "highlight-kit-styles") {
298
- document.getElementById(id)?.remove();
299
- const el = document.createElement("style");
300
- el.id = id;
301
- el.textContent = generateHighlightCSS(styles);
302
- document.head.appendChild(el);
303
- return el;
304
- }
305
- //#endregion
306
- export { generateHighlightCSS as a, injectHighlightStyles as c, createNoopSink as i, isHighlightSupported as l, createCssHighlightSink as n, getTextNodes as o, createHighlightController as r, highlights as s, computeRanges as t, rangesFromOffsets as u };