@celestia-island/hikari 0.55.158 → 0.55.160

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.158",
3
+ "version": "0.55.160",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -25,11 +25,14 @@ import "./HkInfiniteTail.scss";
25
25
  * - `"idle"` — more exists, nothing in flight. With `auto` (default)
26
26
  * the invisible sentinel watches the viewport and emits `loadMore`
27
27
  * as it approaches; with `auto={false}` the explicit load-more
28
- * button (HkLoadMore) renders instead. NOTE: with `auto`, a tail
29
- * page failure retries in place every time the sentinel re-enters
30
- * view (the machine's error guard paces it to one attempt per
31
- * emission, no backoff) — hosts that want backoff map the error
32
- * state away from `"idle"` themselves.
28
+ * button (HkLoadMore) renders instead. NOTE: a failed page
29
+ * terminalizes the machine's walk — the tail keeps emitting
30
+ * `loadMore` as the sentinel re-enters view, but each emission is a
31
+ * no-op until the host calls `reload()` (the retry verb; the
32
+ * delivered rows stay on screen under the host's stale banner).
33
+ * Auto-retrying from the tail is deliberately not supported: the
34
+ * sentinel would re-fire on a fixed geometry with no backoff, one
35
+ * RPC per round trip, which locks a shared rate budget.
33
36
  *
34
37
  * The sentinel observes the BROWSER viewport (IntersectionObserver,
35
38
  * `rootMargin` lookahead), so it works inside any nested scroll
@@ -0,0 +1,86 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { languageForPath } from "./useHighlight";
4
+ import { LANGUAGE_LOADERS } from "./highlightLanguages";
5
+
6
+ /**
7
+ * `languageForPath` is the ONE mapping from a file path to a highlight.js
8
+ * language — shared so a diff pane, a file browser and a markdown fence
9
+ * all derive the same language from the same path. Its two contracts:
10
+ * every non-empty answer must be a language the lazy loader can actually
11
+ * load (otherwise the promise is a lie), and unknown paths must answer ""
12
+ * (callers treat "" as plaintext/auto-detect, never as an error).
13
+ */
14
+ describe("languageForPath", () => {
15
+ it("maps the common extensions onto canonical ids", () => {
16
+ expect(languageForPath("crates/scheduler/src/main.rs")).toBe("rust");
17
+ expect(languageForPath("src/index.ts")).toBe("typescript");
18
+ expect(languageForPath("App.tsx")).toBe("typescript");
19
+ expect(languageForPath("script.js")).toBe("javascript");
20
+ expect(languageForPath("world.toml")).toBe("toml");
21
+ expect(languageForPath("Cargo.toml")).toBe("toml");
22
+ expect(languageForPath("config/world.yml")).toBe("yaml");
23
+ expect(languageForPath("run.sh")).toBe("bash");
24
+ expect(languageForPath("notes.md")).toBe("markdown");
25
+ expect(languageForPath("query.sql")).toBe("sql");
26
+ expect(languageForPath("theme.scss")).toBe("scss");
27
+ expect(languageForPath("index.html")).toBe("xml");
28
+ });
29
+
30
+ it("is case-insensitive on name and extension", () => {
31
+ expect(languageForPath("src/Main.RS")).toBe("rust");
32
+ expect(languageForPath("Dockerfile")).toBe("dockerfile");
33
+ expect(languageForPath("Makefile")).toBe("makefile");
34
+ expect(languageForPath("CMakeLists.txt")).toBe("cmake");
35
+ expect(languageForPath("Jenkinsfile")).toBe("groovy");
36
+ });
37
+
38
+ it("keeps dotted stems like Dockerfile.dev on their dialect", () => {
39
+ expect(languageForPath("deploy/Dockerfile.dev")).toBe("dockerfile");
40
+ expect(languageForPath("dockerfile.prod")).toBe("dockerfile");
41
+ expect(languageForPath("Makefile.am")).toBe("makefile");
42
+ });
43
+
44
+ it("answers \"\" for unknown or extensionless paths, not a guess", () => {
45
+ expect(languageForPath("bin/run")).toBe("");
46
+ expect(languageForPath("README")).toBe("");
47
+ expect(languageForPath("data.xyzzy")).toBe("");
48
+ expect(languageForPath("")).toBe("");
49
+ });
50
+
51
+ it("every mapped id is a language the loader actually has", () => {
52
+ const loaders = new Set(Object.keys(LANGUAGE_LOADERS));
53
+ expect(loaders.size).toBeGreaterThan(50); // positive control
54
+ const probes = [
55
+ "a.rs", "a.ts", "a.tsx", "a.js", "a.json", "a.py", "a.rb", "a.go",
56
+ "a.java", "a.kt", "a.scala", "a.groovy", "a.c", "a.h", "a.cpp",
57
+ "a.hpp", "a.cs", "a.fs", "a.php", "a.swift", "a.m", "a.mm",
58
+ "a.dart", "a.ex", "a.erl", "a.hs", "a.elm", "a.lua", "a.pl",
59
+ "a.r", "a.jl", "a.nim", "a.cr", "a.ml", "a.vb", "a.pas", "a.bas",
60
+ "a.f90", "a.coffee", "a.ls", "a.clj", "a.scm", "a.el", "a.xml",
61
+ "a.html", "a.vue", "a.hbs", "a.twig", "a.haml", "a.erb", "a.dust",
62
+ "a.css", "a.scss", "a.less", "a.yml", "a.yaml", "a.toml", "a.ini",
63
+ "a.cfg", "a.properties", "a.env", "a.md", "a.graphql", "a.gql",
64
+ "a.http", "a.sh", "a.zsh", "a.ps1", "a.bat", "a.cmd", "a.mk",
65
+ "a.cmake", "a.awk", "a.tcl", "a.vim", "a.sql", "a.psql", "a.proto",
66
+ "a.thrift", "a.glsl", "a.vert", "a.frag", "a.wasm", "a.asm",
67
+ "a.v", "a.sv", "a.vhd", "a.vhdl", "a.tex", "a.sty", "a.diff",
68
+ "a.patch", "a.txt", "a.log", "Dockerfile", "Makefile", "Jenkinsfile",
69
+ "CMakeLists.txt", "Cargo.lock", ".gitignore", ".env", ".bashrc",
70
+ ];
71
+ const mapped = probes
72
+ .map((p) => languageForPath(p))
73
+ .filter((id): id is string => id !== "");
74
+ // Each probe resolves to SOMETHING (the table is broad)…
75
+ expect(mapped.length).toBe(probes.length);
76
+ // …and every answer is loadable — no lying promises.
77
+ for (const id of mapped) {
78
+ expect(loaders, `languageForPath answered "${id}", which no loader provides`).toContain(id);
79
+ }
80
+ });
81
+
82
+ it("does not fall off the path on dots in directories", () => {
83
+ expect(languageForPath("my.dir/stuff/main.rs")).toBe("rust");
84
+ expect(languageForPath("v1.2/config.yaml")).toBe("yaml");
85
+ });
86
+ });
@@ -84,6 +84,125 @@ function ensureAutoLangs(): void {
84
84
  for (const l of AUTO_LANGS) loadLanguage(l);
85
85
  }
86
86
 
87
+ /** Whole filenames (lowercased) that carry no useful extension, or whose
88
+ * dialect is decided by the name: `Dockerfile.dev`, `CMakeLists.txt`. */
89
+ const PATH_FILENAME_LANG: Record<string, string> = {
90
+ dockerfile: "dockerfile",
91
+ makefile: "makefile",
92
+ gnumakefile: "makefile",
93
+ "cmakelists.txt": "cmake",
94
+ jenkinsfile: "groovy",
95
+ nginx: "nginx",
96
+ "cargo.lock": "toml",
97
+ ".env": "ini",
98
+ ".gitignore": "ini",
99
+ ".gitattributes": "ini",
100
+ ".gitmodules": "ini",
101
+ ".editorconfig": "ini",
102
+ ".bashrc": "bash",
103
+ ".zshrc": "bash",
104
+ ".profile": "bash",
105
+ };
106
+
107
+ /** Extension (lowercased) → canonical highlight.js language id. Only ids
108
+ * that exist in LANGUAGE_LOADERS: anything else would render plaintext
109
+ * anyway, just with a misleading promise attached. */
110
+ const EXT_TABLE: Record<string, string> = {
111
+ rs: "rust",
112
+ ts: "typescript", tsx: "typescript", mts: "typescript", cts: "typescript",
113
+ js: "javascript", jsx: "javascript", mjs: "javascript", cjs: "javascript",
114
+ json: "json", jsonc: "json",
115
+ py: "python", pyi: "python", pyw: "python",
116
+ rb: "ruby",
117
+ go: "go",
118
+ java: "java",
119
+ kt: "kotlin", kts: "kotlin",
120
+ scala: "scala", sc: "scala",
121
+ groovy: "groovy", gradle: "groovy",
122
+ c: "c", h: "c",
123
+ cpp: "cpp", cc: "cpp", cxx: "cpp", hpp: "cpp", hh: "cpp", hxx: "cpp",
124
+ cs: "csharp",
125
+ fs: "fsharp", fsx: "fsharp",
126
+ php: "php",
127
+ swift: "swift",
128
+ m: "objectivec", mm: "objectivec",
129
+ dart: "dart",
130
+ ex: "elixir", exs: "elixir",
131
+ erl: "erlang", hrl: "erlang",
132
+ hs: "haskell", lhs: "haskell",
133
+ elm: "elm",
134
+ lua: "lua",
135
+ pl: "perl", pm: "perl",
136
+ r: "r",
137
+ jl: "julia",
138
+ nim: "nim",
139
+ cr: "crystal",
140
+ ml: "ocaml", mli: "ocaml", mll: "ocaml", mly: "ocaml",
141
+ vb: "vbnet",
142
+ pas: "delphi", dpr: "delphi",
143
+ bas: "basic",
144
+ f: "fortran", f90: "fortran", f95: "fortran", f03: "fortran", for: "fortran",
145
+ coffee: "coffeescript",
146
+ ls: "livescript",
147
+ clj: "clojure", cljs: "clojure", cljc: "clojure", edn: "clojure",
148
+ scm: "scheme", ss: "scheme", el: "lisp", lisp: "lisp",
149
+ xml: "xml", html: "xml", htm: "xml", svg: "xml", vue: "xml", xhtml: "xml",
150
+ cshtml: "xml", jsp: "xml", erb: "erb", ejs: "xml",
151
+ hbs: "handlebars", handlebars: "handlebars",
152
+ twig: "twig",
153
+ haml: "haml",
154
+ dust: "dust",
155
+ css: "css", scss: "scss", less: "less",
156
+ yml: "yaml", yaml: "yaml",
157
+ toml: "toml",
158
+ ini: "ini", cfg: "ini", conf: "ini", properties: "properties", env: "ini",
159
+ md: "markdown", markdown: "markdown",
160
+ graphql: "graphql", gql: "graphql",
161
+ http: "http",
162
+ sh: "bash", bash: "bash", zsh: "bash", shell: "bash", fish: "bash",
163
+ ps1: "powershell", psm1: "powershell",
164
+ bat: "dos", cmd: "dos",
165
+ mk: "makefile",
166
+ cmake: "cmake",
167
+ dockerfile: "dockerfile",
168
+ nginx: "nginx",
169
+ awk: "awk",
170
+ tcl: "tcl",
171
+ vim: "vim",
172
+ sql: "sql", psql: "pgsql",
173
+ proto: "protobuf",
174
+ thrift: "thrift",
175
+ glsl: "glsl", vert: "glsl", frag: "glsl", geom: "glsl",
176
+ wasm: "wasm",
177
+ asm: "x86asm",
178
+ v: "verilog", sv: "verilog",
179
+ vhd: "vhdl", vhdl: "vhdl",
180
+ tex: "latex", sty: "latex",
181
+ diff: "diff", patch: "diff",
182
+ txt: "plaintext", text: "plaintext", log: "plaintext",
183
+ };
184
+
185
+ /**
186
+ * A file path → the canonical highlight.js language id for it, or "" when
187
+ * nothing sensible is known (callers pass "" as "no language" and the
188
+ * block renders plaintext or auto-detects). One mapping for every code
189
+ * surface — diff panes, file browsers, markdown fences — so the same
190
+ * path always highlights the same way; aliases are resolved by
191
+ * `normalize()` at highlight time, so the table lists canonical ids.
192
+ */
193
+ export function languageForPath(path: string): string {
194
+ const name = path.split("/").pop()?.trim().toLowerCase() ?? "";
195
+ if (!name) return "";
196
+ // Extensionless build scripts: `Dockerfile`, `Jenkinsfile`, `Makefile`.
197
+ const bare = PATH_FILENAME_LANG[name];
198
+ if (bare) return bare;
199
+ // `Dockerfile.dev` / `dockerfile.prod` — the dialect IS the stem.
200
+ if (name.startsWith("dockerfile")) return "dockerfile";
201
+ if (name.startsWith("makefile")) return "makefile";
202
+ const ext = name.includes(".") ? name.split(".").pop()! : "";
203
+ return EXT_TABLE[ext] ?? "";
204
+ }
205
+
87
206
  /**
88
207
  * Highlight `code` for the given language (or auto-detect when omitted).
89
208
  * Returns highlight.js HTML when the grammar is ready, otherwise an
@@ -114,7 +114,7 @@ describe("useInfinitePages", () => {
114
114
  expect(handle.exhausted.value).toBe(true);
115
115
  });
116
116
 
117
- it("a failed later page keeps the delivered rows and retries in place", async () => {
117
+ it("a failed later page keeps the delivered rows and terminalizes the walk", async () => {
118
118
  const fetcher = vi
119
119
  .fn(async (): Promise<HkPageResultLike<Row>> => page([], 0, false))
120
120
  .mockImplementationOnce(async () => page([{ id: "a" }], 3, true))
@@ -127,10 +127,36 @@ describe("useInfinitePages", () => {
127
127
  await handle.loadMore();
128
128
  expect(handle.error.value).toBe("boom");
129
129
  expect(handle.items.value.map((r) => r.id)).toEqual(["a"]);
130
- // The tail failure is retriable without a full reload.
130
+ // A tail failure does NOT auto-retry: the sentinel's idle re-check
131
+ // would re-fire loadMore on a fixed geometry with no backoff — one
132
+ // RPC per round trip forever, locking any shared rate budget. The
133
+ // failure is terminal until `reload`.
131
134
  await handle.loadMore();
135
+ expect(fetcher).toHaveBeenCalledTimes(2);
136
+ expect(handle.items.value.map((r) => r.id)).toEqual(["a"]);
137
+ // `reload` is the retry verb: it clears the failure and refetches
138
+ // from page one.
139
+ await handle.reload();
132
140
  expect(handle.error.value).toBeNull();
133
- expect(handle.items.value.map((r) => r.id)).toEqual(["a", "b", "c"]);
141
+ expect(handle.items.value.map((r) => r.id)).toEqual(["b", "c"]);
142
+ });
143
+
144
+ it("a tail failure cannot storm: repeated loadMore stays a no-op", async () => {
145
+ const fetcher = vi
146
+ .fn(async (): Promise<HkPageResultLike<Row>> => page([], 0, false))
147
+ .mockImplementationOnce(async () => page([{ id: "a" }], 5, true))
148
+ .mockImplementation(async () => {
149
+ throw new Error("tail down");
150
+ });
151
+ const { handle } = withHandle(() => useInfinitePages<Row>({ fetcher, pageSize: 1 }));
152
+ await vi.waitFor(() => expect(handle.loading.value).toBe(false));
153
+ await handle.loadMore();
154
+ expect(handle.error.value).toBe("tail down");
155
+ // Ten idle-entry re-checks (the tail sentinel's emission pattern)
156
+ // spend ZERO further RPCs.
157
+ for (let i = 0; i < 10; i++) await handle.loadMore();
158
+ expect(fetcher).toHaveBeenCalledTimes(2);
159
+ expect(handle.items.value.map((r) => r.id)).toEqual(["a"]);
134
160
  });
135
161
 
136
162
  it("a param change triggers a debounced reload from page one", async () => {
@@ -83,9 +83,9 @@ export interface InfinitePagesHandle<T> {
83
83
  error: Ref<string | null>;
84
84
  /** One-based index of the next page to fetch. */
85
85
  nextPage: Ref<number>;
86
- /** Fetch the next page (guarded: no-op while busy or exhausted).
87
- * A TAIL-page failure stays retriable in place — the next `loadMore`
88
- * refetches the same page; only a page-one failure needs `reload`. */
86
+ /** Fetch the next page (guarded: no-op while busy, exhausted, or
87
+ * after ANY failure — `reload` is the retry verb for both the
88
+ * first-page and the tail-page error dialects). */
89
89
  loadMore: () => Promise<void>;
90
90
  /** Reset to page one and fetch (also the error-clearing retry). */
91
91
  reload: () => Promise<void>;
@@ -101,7 +101,11 @@ export interface InfinitePagesHandle<T> {
101
101
  *
102
102
  * Error dialect: a failed page keeps everything already delivered (the
103
103
  * consumer renders it as a stale banner — one failed fetch must never
104
- * hide rows the user was reading); `reload` is the retry verb.
104
+ * hide rows the user was reading) and terminalizes the walk; `reload`
105
+ * is the retry verb for every failure. Auto-retrying a tail failure is
106
+ * deliberately NOT done: the tail's sentinel would re-fire on a fixed
107
+ * geometry with no backoff, turning one flaky page into an RPC storm
108
+ * that locks a shared rate budget.
105
109
  */
106
110
  export function useInfinitePages<T, P extends Record<string, unknown> = Record<string, unknown>>(
107
111
  options: InfinitePagesOptions<T, P>,
@@ -123,9 +127,6 @@ export function useInfinitePages<T, P extends Record<string, unknown> = Record<s
123
127
  let seq = 0;
124
128
  let reloadTimer: ReturnType<typeof setTimeout> | null = null;
125
129
  let disposed = false;
126
- /** Which phase the current `error` came from — a tail-page failure
127
- * retried by `loadMore` in place, a first-page failure by `reload`. */
128
- let errorPhase: "load" | "more" = "load";
129
130
 
130
131
  function normalize(result: HkPageResultLike<T>): HkPageResult<T> {
131
132
  const loose = result as {
@@ -196,14 +197,20 @@ export function useInfinitePages<T, P extends Record<string, unknown> = Record<s
196
197
  }
197
198
 
198
199
  async function loadMore(): Promise<void> {
199
- // A failure from a LATER page is retriable in place (the delivered
200
- // rows stay, the cursor stays); a failure from page one needs a
201
- // full reload (the retry verb for the hard-error state).
200
+ // ANY failure terminalizes the walk until `reload()` — including a
201
+ // tail-page failure. The in-place retry this used to allow read
202
+ // fine in isolation but stormed in the field: a failed tail leaves
203
+ // the state machine "idle", the tail's sentinel stays in view, and
204
+ // its idle-entry re-check fires loadMore again with no geometry
205
+ // change — one RPC per round trip, no backoff, forever. On a shared
206
+ // per-user rate budget that loop locks the bucket and denies every
207
+ // OTHER paged page, so the retry verb is `reload()` (the delivered
208
+ // rows stay on screen under the stale banner; the header refresh is
209
+ // the user's affordance).
202
210
  if (loading.value || loadingMore.value || exhausted.value) return;
203
- if (error.value && errorPhase !== "more") return;
211
+ if (error.value) return;
204
212
  if (nextPage.value > 1 && items.value.length === 0) return;
205
213
  loadingMore.value = true;
206
- errorPhase = "more";
207
214
  const mine = ++seq;
208
215
  try {
209
216
  await fetchPage(nextPage.value, mine);
@@ -225,7 +232,6 @@ export function useInfinitePages<T, P extends Record<string, unknown> = Record<s
225
232
  reloadTimer = null;
226
233
  }
227
234
  loading.value = true;
228
- errorPhase = "load";
229
235
  error.value = null;
230
236
  if (!options.keepDuringReload) {
231
237
  // Invalidate the cursor BEFORE the fetch so a straggler from the
package/src/index.ts CHANGED
@@ -460,7 +460,7 @@ export type { OriginProbe } from "./utils/connectivity";
460
460
  export { probeHealthEndpoint } from "./utils/healthProbe";
461
461
  export type { HealthProbeBody, HealthProbeResult } from "./utils/healthProbe";
462
462
 
463
- export { highlight, useHighlight } from "./composables/useHighlight";
463
+ export { highlight, languageForPath, useHighlight } from "./composables/useHighlight";
464
464
  export { LANGUAGE_LOADERS } from "./composables/highlightLanguages";
465
465
  export {
466
466
  iconByName,
@@ -0,0 +1,91 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { describe, expect, it } from "vitest";
6
+
7
+ /**
8
+ * The syntax-palette contract (theme/highlight.scss).
9
+ *
10
+ * `useHighlight()` emits `.hljs-*` spans; this sheet is the ONLY thing
11
+ * that colours them, so the pairs documented in channels.scss must stay
12
+ * mapped to their `--code-*` tokens — if a restyle ever swaps a var() for
13
+ * a bare hex, themed hosts stop flipping light/dark for that class and
14
+ * the sheet silently forks the palette the host owns.
15
+ *
16
+ * Assertions run on the BODY (comment lines stripped): the header and
17
+ * inline comments deliberately NAME the classes they explain, so matching
18
+ * against the raw sheet would hit prose instead of rules.
19
+ */
20
+ const body = readFileSync(
21
+ resolve(dirname(fileURLToPath(import.meta.url)), "theme/highlight.scss"),
22
+ "utf-8",
23
+ )
24
+ .split("\n")
25
+ .filter((line) => !line.trim().startsWith("//"))
26
+ .join("\n");
27
+
28
+ /** Pull one declaration block by a selector fragment (regex-escaped). */
29
+ function blockFor(selector: string): string {
30
+ const esc = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
31
+ const re = new RegExp(
32
+ String.raw`\.hljs-${esc}\b[\s\S]*?\{([^}]*)\}`,
33
+ );
34
+ const m = re.exec(body);
35
+ expect(m, `positive control: the .hljs-${selector} rule exists`).not.toBeNull();
36
+ return m![1];
37
+ }
38
+
39
+ describe("highlight palette (theme/highlight.scss)", () => {
40
+ it("rides the --code-* family for every colour, One Dark as fallback", () => {
41
+ const colorRules = body.match(/color:\s*[^;]+;/g) ?? [];
42
+ expect(colorRules.length).toBeGreaterThan(5); // positive control
43
+ for (const rule of colorRules) {
44
+ expect(
45
+ rule,
46
+ `every colour must ride the token family: "${rule}"`,
47
+ ).toMatch(/color:\s*var\(--code-[a-z]+,\s*#[0-9a-fA-F]{6}\);/);
48
+ }
49
+ });
50
+
51
+ it("keeps the pairs channels.scss documents canonical", () => {
52
+ expect(blockFor("keyword")).toContain("var(--code-keyword, #c678dd)");
53
+ expect(blockFor("selector-tag")).toContain("var(--code-keyword, #c678dd)");
54
+ expect(blockFor("built_in")).toContain("var(--code-builtin, #c678dd)");
55
+ expect(blockFor("string")).toContain("var(--code-string, #98c379)");
56
+ expect(blockFor("number")).toContain("var(--code-number, #d19a66)");
57
+ expect(blockFor("literal")).toContain("var(--code-number, #d19a66)");
58
+ expect(blockFor("attr")).toContain("var(--code-number, #d19a66)");
59
+ expect(blockFor("title.function_")).toContain("var(--code-function, #61afef)");
60
+ expect(blockFor("type")).toContain("var(--code-type, #e5c07b)");
61
+ expect(blockFor("title.class_")).toContain("var(--code-type, #e5c07b)");
62
+ expect(blockFor("variable")).toContain("var(--code-variable, #e06c75)");
63
+ expect(blockFor("template-variable")).toContain("var(--code-variable, #e06c75)");
64
+ });
65
+
66
+ it("renders comments grey AND italic, as documented", () => {
67
+ const b = blockFor("comment");
68
+ expect(b).toContain("var(--code-comment, #5c6370)");
69
+ expect(b).toMatch(/font-style:\s*italic/);
70
+ });
71
+
72
+ it("does not paint backgrounds or the .hljs base — surfaces own those", () => {
73
+ expect(body).not.toMatch(/background[^;{]*:/);
74
+ expect(body).not.toMatch(/^\.hljs\s*\{/m);
75
+ // The diff classes belong to the diff surfaces, not the palette.
76
+ expect(body).not.toContain(".hljs-addition");
77
+ expect(body).not.toContain(".hljs-deletion");
78
+ });
79
+
80
+ it("is actually composed into the styles entry — a sheet nobody @uses is dead", () => {
81
+ const entry = readFileSync(
82
+ resolve(dirname(fileURLToPath(import.meta.url)), "index.scss"),
83
+ "utf-8",
84
+ );
85
+ expect(
86
+ entry.match(/@use\s+"\.\/theme\/[^"]+\.scss";/g) ?? [],
87
+ "positive control: the entry composes theme sheets",
88
+ ).toContain("@use \"./theme/channels.scss\";");
89
+ expect(entry).toContain('@use "./theme/highlight.scss";');
90
+ });
91
+ });
@@ -12,7 +12,10 @@
12
12
  // 3. theme/scrollbar.scss — the shared overlay-scrollbar chrome
13
13
  // (.hk-scrollbar-track/thumb) that attachOverlayScrollbars() attaches
14
14
  // to every scroll region (vendored from packages/theme/styles).
15
- // 4. admin-tokens.scss — the plana-ui shared surface: layout tokens,
15
+ // 4. theme/highlight.scss — the syntax palette: colours the .hljs-*
16
+ // spans useHighlight() emits, riding the --code-* family the host
17
+ // theme owns (vendored from packages/theme/styles).
18
+ // 5. admin-tokens.scss — the plana-ui shared surface: layout tokens,
16
19
  // brand-color aliases and the shared admin component classes.
17
20
  //
18
21
  // initTheme() (runtime) writes only deltas against this base into a
@@ -28,4 +31,5 @@
28
31
  @use "./theme/channels.scss";
29
32
  @use "./theme/scale.scss";
30
33
  @use "./theme/scrollbar.scss";
34
+ @use "./theme/highlight.scss";
31
35
  @use "./admin-tokens.scss";
@@ -0,0 +1,117 @@
1
+ // Vendored from packages/theme/styles/_highlight.scss so npm registry
2
+ // installs can resolve it — the exports map only exposes ./styles/*.
3
+ // This sheet is what COLOURS the `.hljs-*` spans that useHighlight() and
4
+ // any other highlight.js driver emit; without it the grammars load and
5
+ // the code still renders monochrome (infra without a palette).
6
+ // Edit the upstream file, then re-copy; do not hand-edit here.
7
+
8
+ // _highlight.scss — the syntax palette for highlight.js output.
9
+ //
10
+ // `useHighlight()` (and anything else driving highlight.js) emits
11
+ // `.hljs-*` spans; this sheet is what actually COLOURS them. Without it
12
+ // the grammars load, the spans land in the DOM, and the code stays
13
+ // monochrome — infra without a palette.
14
+ //
15
+ // Every colour rides the `--code-*` family documented in channels.scss
16
+ // (the family the host theme owns; extend it only by adding a token with
17
+ // a fallback equal to the current visual). The fallbacks here are the
18
+ // canonical One Dark values, so a standalone/themeless consumer renders
19
+ // like One Dark and a themed host (which defines `--code-*` in both
20
+ // modes) flips light/dark automatically — no `.hljs-*` rule anywhere in
21
+ // this sheet hardcodes a palette colour outside a var() fallback.
22
+ //
23
+ // The explicit pairs from the channels.scss doc block are canonical:
24
+ // keyword ← .hljs-keyword / .hljs-selector-tag
25
+ // builtin ← .hljs-built_in
26
+ // string ← .hljs-string
27
+ // number ← .hljs-number / .hljs-literal / .hljs-attr
28
+ // comment ← .hljs-comment / .hljs-doctag (grey, italic)
29
+ // function ← .hljs-title.function_
30
+ // type ← .hljs-type / class titles
31
+ // variable ← .hljs-variable / .hljs-template-variable
32
+ // The remaining highlight.js classes are bucketed to the nearest of the
33
+ // same eight by judgement (the canonical pairs above are exact); the
34
+ // goal is that every span lands in the family, not that each bucket
35
+ // matches One Dark's own file class-for-class.
36
+ //
37
+ // Deliberately NOT set: `.hljs` base colour/background and the
38
+ // `.hljs-addition` / `.hljs-deletion` diff classes — containers (diff
39
+ // panes, markdown fences) own their surfaces, and a palette layer that
40
+ // painted backgrounds would fight them.
41
+
42
+ // keyword — purple
43
+ .hljs-keyword,
44
+ .hljs-selector-tag,
45
+ .hljs-formula {
46
+ color: var(--code-keyword, #c678dd);
47
+ }
48
+
49
+ // builtin — purple in dark, blue in light
50
+ .hljs-built_in,
51
+ .hljs-selector-id,
52
+ .hljs-symbol,
53
+ .hljs-bullet,
54
+ .hljs-link,
55
+ .hljs-meta {
56
+ color: var(--code-builtin, #c678dd);
57
+ }
58
+
59
+ // string — green
60
+ .hljs-string,
61
+ .hljs-regexp,
62
+ .hljs-attribute,
63
+ .hljs-meta .hljs-string {
64
+ color: var(--code-string, #98c379);
65
+ }
66
+
67
+ // number — orange in dark, blue in light
68
+ .hljs-number,
69
+ .hljs-literal,
70
+ .hljs-attr,
71
+ .hljs-property,
72
+ .hljs-selector-class,
73
+ .hljs-selector-attr,
74
+ .hljs-selector-pseudo {
75
+ color: var(--code-number, #d19a66);
76
+ }
77
+
78
+ // comment — grey, italic
79
+ .hljs-comment,
80
+ .hljs-quote,
81
+ .hljs-doctag {
82
+ color: var(--code-comment, #5c6370);
83
+ font-style: italic;
84
+ }
85
+
86
+ // function — blue
87
+ .hljs-title,
88
+ .hljs-title.function_,
89
+ .hljs-title.function_.invoke__,
90
+ .hljs-section {
91
+ color: var(--code-function, #61afef);
92
+ }
93
+
94
+ // type — yellow in dark, green in light
95
+ .hljs-type,
96
+ .hljs-title.class_,
97
+ .hljs-class .hljs-title {
98
+ color: var(--code-type, #e5c07b);
99
+ }
100
+
101
+ // variable — red in dark, orange in light
102
+ .hljs-variable,
103
+ .hljs-template-variable,
104
+ .hljs-subst,
105
+ .hljs-name,
106
+ .hljs-tag {
107
+ color: var(--code-variable, #e06c75);
108
+ }
109
+
110
+ // Non-colour emphasis classes: keep the shapes highlight.js intends.
111
+ .hljs-emphasis {
112
+ font-style: italic;
113
+ }
114
+
115
+ .hljs-strong {
116
+ font-weight: bold;
117
+ }
@@ -52,6 +52,8 @@ const VENDORED_FROM_VUE: Array<[string, string]> = [
52
52
  // (packages/theme/styles) and is vendored here so npm consumers can
53
53
  // resolve it — the exports map only exposes ./styles/*.
54
54
  ["theme/scrollbar.scss", "../../theme/styles/_scrollbar.scss"],
55
+ // The syntax palette for .hljs-* spans lives there for the same reason.
56
+ ["theme/highlight.scss", "../../theme/styles/_highlight.scss"],
55
57
  ];
56
58
 
57
59