@ugoku/core 0.0.0-stage → 0.1.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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +29 -3
  3. package/scripts/build-edit-chunk.mjs +12 -0
  4. package/scripts/build-ts-chunk.mjs +12 -0
  5. package/src/css.d.ts +5 -0
  6. package/src/edit/editor.browser.test.ts +52 -0
  7. package/src/edit/editor.ts +29 -0
  8. package/src/edit/highlighter-cache.test.ts +31 -0
  9. package/src/edit/highlighter-cache.ts +39 -0
  10. package/src/edit/overlay.browser.test.ts +78 -0
  11. package/src/edit/overlay.ts +34 -0
  12. package/src/edit/persistence.test.ts +43 -0
  13. package/src/edit/persistence.ts +26 -0
  14. package/src/edit/splice.browser.test.ts +63 -0
  15. package/src/edit/splice.ts +27 -0
  16. package/src/edit/standalone-entry.browser.test.ts +35 -0
  17. package/src/edit/standalone-entry.ts +53 -0
  18. package/src/index.ts +19 -0
  19. package/src/lang.test.ts +23 -0
  20. package/src/lang.ts +9 -0
  21. package/src/mount/index.ts +1 -0
  22. package/src/mount/mount.browser.test.ts +350 -0
  23. package/src/mount/mount.ts +288 -0
  24. package/src/output-panel/index.ts +2 -0
  25. package/src/output-panel/output-panel.browser.test.ts +69 -0
  26. package/src/output-panel/output-panel.ts +55 -0
  27. package/src/output-panel/render-value.browser.test.ts +107 -0
  28. package/src/output-panel/render-value.ts +197 -0
  29. package/src/output-panel/styles.css +93 -0
  30. package/src/runner/index.ts +12 -0
  31. package/src/runner/protocol.ts +37 -0
  32. package/src/runner/run-code.browser.test.ts +90 -0
  33. package/src/runner/run-code.ts +68 -0
  34. package/src/runner/worker-source.browser.test.ts +147 -0
  35. package/src/runner/worker-source.ts +106 -0
  36. package/src/serializer/serialize-value.test.ts +148 -0
  37. package/src/serializer/serialize-value.ts +177 -0
  38. package/src/serializer/types.ts +76 -0
  39. package/src/smoke.browser.test.ts +6 -0
  40. package/src/ts/strip.test.ts +48 -0
  41. package/src/ts/strip.ts +5 -0
  42. package/README.md +0 -3
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 David Skewis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json CHANGED
@@ -1,6 +1,32 @@
1
1
  {
2
2
  "name": "@ugoku/core",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/ugokujs/ugoku.git",
8
+ "directory": "packages/core"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "type": "module",
14
+ "main": "src/index.ts",
15
+ "exports": {
16
+ ".": "./src/index.ts",
17
+ "./edit": "./src/edit/editor.ts",
18
+ "./ts": "./src/ts/strip.ts",
19
+ "./styles.css": "./src/output-panel/styles.css"
20
+ },
21
+ "dependencies": {
22
+ "shiki": "^4.5.0",
23
+ "sucrase": "^3.35.1"
24
+ },
25
+ "devDependencies": {
26
+ "esbuild": "^0.24.0"
27
+ },
28
+ "scripts": {
29
+ "build:edit-chunk": "node scripts/build-edit-chunk.mjs",
30
+ "build:ts-chunk": "node scripts/build-ts-chunk.mjs"
31
+ }
6
32
  }
@@ -0,0 +1,12 @@
1
+ import { build } from 'esbuild';
2
+
3
+ await build({
4
+ entryPoints: ['src/edit/standalone-entry.ts'],
5
+ bundle: true,
6
+ format: 'esm',
7
+ platform: 'browser',
8
+ minify: true,
9
+ outfile: 'dist/edit-standalone.js',
10
+ });
11
+
12
+ console.log('built dist/edit-standalone.js');
@@ -0,0 +1,12 @@
1
+ import { build } from 'esbuild';
2
+
3
+ await build({
4
+ entryPoints: ['src/ts/strip.ts'],
5
+ bundle: true,
6
+ format: 'esm',
7
+ platform: 'browser',
8
+ minify: true,
9
+ outfile: 'dist/ts-standalone.js',
10
+ });
11
+
12
+ console.log('built dist/ts-standalone.js');
package/src/css.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ declare module '*.css';
2
+ declare module '*.css?url' {
3
+ const url: string;
4
+ export default url;
5
+ }
@@ -0,0 +1,52 @@
1
+ import { expect, test } from 'vitest';
2
+ import { highlightInto } from './editor';
3
+
4
+ function createPre(opts: { lang: string; theme?: string; themeLight?: string; themeDark?: string }): HTMLElement {
5
+ const pre = document.createElement('pre');
6
+ pre.dataset.ugokuLang = opts.lang;
7
+ if (opts.theme) pre.dataset.ugokuTheme = opts.theme;
8
+ if (opts.themeLight) pre.dataset.ugokuThemeLight = opts.themeLight;
9
+ if (opts.themeDark) pre.dataset.ugokuThemeDark = opts.themeDark;
10
+ const code = document.createElement('code');
11
+ code.textContent = 'placeholder';
12
+ pre.appendChild(code);
13
+ document.body.appendChild(pre);
14
+ return pre;
15
+ }
16
+
17
+ test('highlightInto re-highlights a single-theme block', async () => {
18
+ const pre = createPre({ lang: 'javascript', theme: 'github-light' });
19
+
20
+ await highlightInto(pre, 'let x = 1;');
21
+
22
+ expect(pre.className).toContain('shiki');
23
+ expect(pre.querySelector('code')?.innerHTML).toContain('span');
24
+
25
+ pre.remove();
26
+ });
27
+
28
+ test('highlightInto re-highlights a dual-theme block', async () => {
29
+ const pre = createPre({ lang: 'typescript', themeLight: 'github-light', themeDark: 'github-dark' });
30
+
31
+ await highlightInto(pre, 'let x: number = 1;');
32
+
33
+ expect(pre.querySelector('code')?.innerHTML).toContain('--shiki-dark');
34
+
35
+ pre.remove();
36
+ });
37
+
38
+ test('highlightInto rejects when the lang is unsupported, leaving the pre untouched', async () => {
39
+ const pre = createPre({ lang: 'not-a-real-lang', theme: 'github-light' });
40
+ const before = pre.querySelector('code')!.innerHTML;
41
+
42
+ await expect(highlightInto(pre, 'x')).rejects.toThrow();
43
+ expect(pre.querySelector('code')?.innerHTML).toBe(before);
44
+
45
+ pre.remove();
46
+ });
47
+
48
+ test('highlightInto is a no-op when no theme attributes are present', async () => {
49
+ const pre = createPre({ lang: 'javascript' });
50
+ await expect(highlightInto(pre, 'let x = 1;')).resolves.toBeUndefined();
51
+ pre.remove();
52
+ });
@@ -0,0 +1,29 @@
1
+ import { ensureLoaded } from './highlighter-cache.ts';
2
+ import { spliceHighlightedHtml } from './splice.ts';
3
+
4
+ export async function highlightInto(pre: HTMLElement, code: string): Promise<void> {
5
+ const lang = pre.dataset.ugokuLang ?? '';
6
+ const singleTheme = pre.dataset.ugokuTheme;
7
+ const lightTheme = pre.dataset.ugokuThemeLight;
8
+ const darkTheme = pre.dataset.ugokuThemeDark;
9
+
10
+ const themeNames = singleTheme ? [singleTheme] : [lightTheme, darkTheme].filter((t): t is string => !!t);
11
+ if (themeNames.length === 0) return;
12
+
13
+ const highlighter = await ensureLoaded(lang, themeNames);
14
+
15
+ // defaultColor: false matches VitePress's own shiki output: spans carry only
16
+ // --shiki-light/--shiki-dark custom properties, no baseline inline `color`.
17
+ // VitePress's CSS (.dark .vp-code span { color: var(--shiki-dark) }) has no
18
+ // !important, so a plain inline `color` (shiki's default) would always win
19
+ // and the block would stay stuck on the light theme after any rehighlight.
20
+ const html = singleTheme
21
+ ? highlighter.codeToHtml(code, { lang, theme: singleTheme })
22
+ : highlighter.codeToHtml(code, {
23
+ lang,
24
+ themes: { light: lightTheme!, dark: darkTheme! },
25
+ defaultColor: false,
26
+ });
27
+
28
+ spliceHighlightedHtml(pre, html);
29
+ }
@@ -0,0 +1,31 @@
1
+ import { expect, test } from 'vitest';
2
+ import { ensureLoaded } from './highlighter-cache';
3
+
4
+ test('ensureLoaded loads a supported language and theme and returns a usable highlighter', async () => {
5
+ const highlighter = await ensureLoaded('javascript', ['github-light']);
6
+ const html = highlighter.codeToHtml('let x = 1;', { lang: 'javascript', theme: 'github-light' });
7
+ expect(html).toContain('<pre');
8
+ expect(html).toContain('<code>');
9
+ });
10
+
11
+ test('ensureLoaded is idempotent for a language/theme already loaded', async () => {
12
+ await ensureLoaded('javascript', ['github-light']);
13
+ await expect(ensureLoaded('javascript', ['github-light'])).resolves.toBeDefined();
14
+ });
15
+
16
+ test('ensureLoaded rejects an unsupported language', async () => {
17
+ await expect(ensureLoaded('not-a-real-lang', ['github-light'])).rejects.toThrow();
18
+ });
19
+
20
+ test('ensureLoaded rejects an unsupported theme', async () => {
21
+ await expect(ensureLoaded('javascript', ['not-a-real-theme'])).rejects.toThrow();
22
+ });
23
+
24
+ test('ensureLoaded loads multiple themes for a dual-theme block', async () => {
25
+ const highlighter = await ensureLoaded('typescript', ['github-light', 'github-dark']);
26
+ const html = highlighter.codeToHtml('let x: number = 1;', {
27
+ lang: 'typescript',
28
+ themes: { light: 'github-light', dark: 'github-dark' },
29
+ });
30
+ expect(html).toContain('--shiki-dark');
31
+ });
@@ -0,0 +1,39 @@
1
+ import { createHighlighterCore } from 'shiki/core';
2
+ import { createJavaScriptRegexEngine } from 'shiki/engine/javascript';
3
+ import { bundledLanguages } from 'shiki/langs';
4
+ import { bundledThemes } from 'shiki/themes';
5
+ import type { HighlighterCore } from 'shiki/core';
6
+
7
+ let highlighterPromise: Promise<HighlighterCore> | null = null;
8
+
9
+ function getHighlighter(): Promise<HighlighterCore> {
10
+ if (!highlighterPromise) {
11
+ highlighterPromise = createHighlighterCore({
12
+ engine: createJavaScriptRegexEngine(),
13
+ });
14
+ }
15
+ return highlighterPromise;
16
+ }
17
+
18
+ export async function ensureLoaded(lang: string, themeNames: string[]): Promise<HighlighterCore> {
19
+ if (!(lang in bundledLanguages)) {
20
+ throw new Error(`ugoku: unsupported language "${lang}" for live re-highlighting`);
21
+ }
22
+
23
+ const highlighter = await getHighlighter();
24
+
25
+ if (!highlighter.getLoadedLanguages().includes(lang)) {
26
+ await highlighter.loadLanguage(bundledLanguages[lang as keyof typeof bundledLanguages]());
27
+ }
28
+
29
+ for (const theme of themeNames) {
30
+ if (!(theme in bundledThemes)) {
31
+ throw new Error(`ugoku: unsupported theme "${theme}" for live re-highlighting`);
32
+ }
33
+ if (!highlighter.getLoadedThemes().includes(theme)) {
34
+ await highlighter.loadTheme(bundledThemes[theme as keyof typeof bundledThemes]());
35
+ }
36
+ }
37
+
38
+ return highlighter;
39
+ }
@@ -0,0 +1,78 @@
1
+ import { expect, test } from 'vitest';
2
+ import { createOverlay, syncOverlayFallbacks } from './overlay';
3
+
4
+ function createPre(): HTMLElement {
5
+ const pre = document.createElement('pre');
6
+ const code = document.createElement('code');
7
+ code.textContent = 'let x = 1;';
8
+ pre.appendChild(code);
9
+ document.body.appendChild(pre);
10
+ return pre;
11
+ }
12
+
13
+ test('createOverlay appends a textarea with the overlay class as a child of pre', () => {
14
+ const pre = createPre();
15
+ const overlay = createOverlay(pre);
16
+
17
+ expect(overlay.tagName).toBe('TEXTAREA');
18
+ expect(overlay.className).toBe('ugoku-edit-overlay');
19
+ expect(overlay.parentElement).toBe(pre);
20
+
21
+ pre.remove();
22
+ });
23
+
24
+ test('createOverlay disables spellcheck and native autocomplete', () => {
25
+ const pre = createPre();
26
+ const overlay = createOverlay(pre);
27
+
28
+ expect(overlay.spellcheck).toBe(false);
29
+ expect(overlay.getAttribute('autocomplete')).toBe('off');
30
+
31
+ pre.remove();
32
+ });
33
+
34
+ test('syncOverlayFallbacks copies the real rendered font metrics and colors from pre/code onto the overlay as CSS custom properties', () => {
35
+ const pre = document.createElement('pre');
36
+ pre.style.backgroundColor = 'rgb(36, 41, 46)';
37
+ pre.style.color = 'rgb(225, 228, 232)';
38
+
39
+ const code = document.createElement('code');
40
+ code.style.fontFamily = 'Menlo, monospace';
41
+ code.style.fontSize = '13px';
42
+ code.style.lineHeight = '20px';
43
+ code.textContent = 'let x = 1;';
44
+ pre.appendChild(code);
45
+ document.body.appendChild(pre);
46
+
47
+ const overlay = createOverlay(pre);
48
+ syncOverlayFallbacks(pre, overlay);
49
+
50
+ expect(overlay.style.getPropertyValue('--ugoku-color-edit-bg')).toBe('rgb(36, 41, 46)');
51
+ expect(overlay.style.getPropertyValue('--ugoku-color-edit-text')).toBe('rgb(225, 228, 232)');
52
+ expect(overlay.style.getPropertyValue('--ugoku-font-edit-family')).toBe('Menlo, monospace');
53
+ expect(overlay.style.getPropertyValue('--ugoku-font-edit-size')).toBe('13px');
54
+ expect(overlay.style.getPropertyValue('--ugoku-font-edit-line-height')).toBe('20px');
55
+
56
+ pre.remove();
57
+ });
58
+
59
+ test("syncOverlayFallbacks sums pre's and code's real padding onto the overlay, so edit mode doesn't shift text that a host (e.g. VitePress) splits padding for between the two elements", () => {
60
+ const pre = document.createElement('pre');
61
+ pre.style.padding = '20px 0px';
62
+
63
+ const code = document.createElement('code');
64
+ code.style.padding = '0px 24px';
65
+ code.textContent = 'let x = 1;';
66
+ pre.appendChild(code);
67
+ document.body.appendChild(pre);
68
+
69
+ const overlay = createOverlay(pre);
70
+ syncOverlayFallbacks(pre, overlay);
71
+
72
+ expect(overlay.style.getPropertyValue('--ugoku-padding-edit-top')).toBe('20px');
73
+ expect(overlay.style.getPropertyValue('--ugoku-padding-edit-right')).toBe('24px');
74
+ expect(overlay.style.getPropertyValue('--ugoku-padding-edit-bottom')).toBe('20px');
75
+ expect(overlay.style.getPropertyValue('--ugoku-padding-edit-left')).toBe('24px');
76
+
77
+ pre.remove();
78
+ });
@@ -0,0 +1,34 @@
1
+ export function createOverlay(pre: HTMLElement): HTMLTextAreaElement {
2
+ const textarea = document.createElement('textarea');
3
+ textarea.className = 'ugoku-edit-overlay';
4
+ textarea.spellcheck = false;
5
+ textarea.setAttribute('autocomplete', 'off');
6
+ textarea.setAttribute('autocapitalize', 'off');
7
+ pre.appendChild(textarea);
8
+ return textarea;
9
+ }
10
+
11
+ export function syncOverlayFallbacks(pre: HTMLElement, overlay: HTMLTextAreaElement): void {
12
+ const codeEl = pre.querySelector('code') ?? pre;
13
+ const codeStyle = getComputedStyle(codeEl);
14
+ overlay.style.setProperty('--ugoku-font-edit-family', codeStyle.fontFamily);
15
+ overlay.style.setProperty('--ugoku-font-edit-size', codeStyle.fontSize);
16
+ overlay.style.setProperty('--ugoku-font-edit-line-height', codeStyle.lineHeight);
17
+
18
+ const preStyle = getComputedStyle(pre);
19
+ overlay.style.setProperty('--ugoku-color-edit-bg', preStyle.backgroundColor);
20
+ overlay.style.setProperty('--ugoku-color-edit-text', preStyle.color);
21
+
22
+ // Some hosts (e.g. VitePress) split a code block's padding between <pre>
23
+ // and <code> rather than putting it all on one element. Summing both
24
+ // sides here reproduces the real text inset regardless of how a host
25
+ // divides it, instead of guessing a single fixed value.
26
+ const sumPx = (a: string, b: string) => `${parseFloat(a) + parseFloat(b)}px`;
27
+ overlay.style.setProperty('--ugoku-padding-edit-top', sumPx(preStyle.paddingTop, codeStyle.paddingTop));
28
+ overlay.style.setProperty('--ugoku-padding-edit-right', sumPx(preStyle.paddingRight, codeStyle.paddingRight));
29
+ overlay.style.setProperty(
30
+ '--ugoku-padding-edit-bottom',
31
+ sumPx(preStyle.paddingBottom, codeStyle.paddingBottom),
32
+ );
33
+ overlay.style.setProperty('--ugoku-padding-edit-left', sumPx(preStyle.paddingLeft, codeStyle.paddingLeft));
34
+ }
@@ -0,0 +1,43 @@
1
+ import { expect, test, vi } from 'vitest';
2
+ import { loadSavedEdit, saveEdit, clearSavedEdit } from './persistence';
3
+
4
+ test('saveEdit then loadSavedEdit round-trips a value', () => {
5
+ saveEdit('hash-a', 'let x = 1;');
6
+ expect(loadSavedEdit('hash-a')).toBe('let x = 1;');
7
+ clearSavedEdit('hash-a');
8
+ });
9
+
10
+ test('loadSavedEdit returns null when nothing is saved', () => {
11
+ expect(loadSavedEdit('never-saved-hash')).toBeNull();
12
+ });
13
+
14
+ test('clearSavedEdit removes the saved value', () => {
15
+ saveEdit('hash-b', 'console.log(1)');
16
+ clearSavedEdit('hash-b');
17
+ expect(loadSavedEdit('hash-b')).toBeNull();
18
+ });
19
+
20
+ test('different hashes do not collide', () => {
21
+ saveEdit('hash-c', 'one');
22
+ saveEdit('hash-d', 'two');
23
+ expect(loadSavedEdit('hash-c')).toBe('one');
24
+ expect(loadSavedEdit('hash-d')).toBe('two');
25
+ clearSavedEdit('hash-c');
26
+ clearSavedEdit('hash-d');
27
+ });
28
+
29
+ test('saveEdit does not throw when localStorage.setItem throws', () => {
30
+ const spy = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
31
+ throw new Error('quota exceeded');
32
+ });
33
+ expect(() => saveEdit('hash-e', 'x')).not.toThrow();
34
+ spy.mockRestore();
35
+ });
36
+
37
+ test('loadSavedEdit returns null when localStorage.getItem throws', () => {
38
+ const spy = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
39
+ throw new Error('disabled');
40
+ });
41
+ expect(loadSavedEdit('hash-f')).toBeNull();
42
+ spy.mockRestore();
43
+ });
@@ -0,0 +1,26 @@
1
+ const PREFIX = 'ugoku:edit:';
2
+
3
+ export function loadSavedEdit(hash: string): string | null {
4
+ try {
5
+ return localStorage.getItem(PREFIX + hash);
6
+ } catch {
7
+ return null;
8
+ }
9
+ }
10
+
11
+ export function saveEdit(hash: string, value: string): void {
12
+ try {
13
+ localStorage.setItem(PREFIX + hash, value);
14
+ } catch {
15
+ // Private browsing, quota exceeded, or storage disabled — degrade to
16
+ // in-memory-only editing for this page view.
17
+ }
18
+ }
19
+
20
+ export function clearSavedEdit(hash: string): void {
21
+ try {
22
+ localStorage.removeItem(PREFIX + hash);
23
+ } catch {
24
+ // See saveEdit.
25
+ }
26
+ }
@@ -0,0 +1,63 @@
1
+ import { expect, test } from 'vitest';
2
+ import { spliceHighlightedHtml } from './splice';
3
+
4
+ function createPre(): HTMLElement {
5
+ const pre = document.createElement('pre');
6
+ pre.className = 'shiki original-theme';
7
+ pre.setAttribute('style', 'background-color:#fff');
8
+ pre.dataset.ugokuMode = 'run-edit';
9
+ const code = document.createElement('code');
10
+ code.innerHTML = '<span class="line">original</span>';
11
+ pre.appendChild(code);
12
+ document.body.appendChild(pre);
13
+ return pre;
14
+ }
15
+
16
+ test('spliceHighlightedHtml unions classes (preserving the pre\'s own) and replaces style and code innerHTML', () => {
17
+ const pre = createPre();
18
+ const freshHtml =
19
+ '<pre class="shiki new-theme" style="background-color:#000"><code><span class="line">updated</span></code></pre>';
20
+
21
+ spliceHighlightedHtml(pre, freshHtml);
22
+
23
+ // "original-theme" simulates a host-added class (e.g. VitePress's `vp-code`)
24
+ // that shiki's raw rehighlight output never knows about — it must survive.
25
+ expect(pre.className).toBe('shiki original-theme new-theme');
26
+ expect(pre.getAttribute('style')).toBe('background-color:#000');
27
+ expect(pre.querySelector('code')?.innerHTML).toBe('<span class="line">updated</span>');
28
+
29
+ pre.remove();
30
+ });
31
+
32
+ test('spliceHighlightedHtml preserves data-ugoku-* attributes', () => {
33
+ const pre = createPre();
34
+ const freshHtml = '<pre class="shiki new-theme"><code><span class="line">updated</span></code></pre>';
35
+
36
+ spliceHighlightedHtml(pre, freshHtml);
37
+
38
+ expect(pre.dataset.ugokuMode).toBe('run-edit');
39
+
40
+ pre.remove();
41
+ });
42
+
43
+ test('spliceHighlightedHtml removes the style attribute when the fresh markup has none', () => {
44
+ const pre = createPre();
45
+ const freshHtml = '<pre class="shiki new-theme"><code><span class="line">updated</span></code></pre>';
46
+
47
+ spliceHighlightedHtml(pre, freshHtml);
48
+
49
+ expect(pre.hasAttribute('style')).toBe(false);
50
+
51
+ pre.remove();
52
+ });
53
+
54
+ test('spliceHighlightedHtml is a no-op when the fresh markup has no code element', () => {
55
+ const pre = createPre();
56
+ const originalInnerHTML = pre.querySelector('code')!.innerHTML;
57
+
58
+ spliceHighlightedHtml(pre, '<div>not a pre/code structure</div>');
59
+
60
+ expect(pre.querySelector('code')?.innerHTML).toBe(originalInnerHTML);
61
+
62
+ pre.remove();
63
+ });
@@ -0,0 +1,27 @@
1
+ export function spliceHighlightedHtml(pre: HTMLElement, freshHtml: string): void {
2
+ const container = document.createElement('div');
3
+ container.innerHTML = freshHtml;
4
+ const freshPre = container.firstElementChild as HTMLElement | null;
5
+ const freshCode = freshPre?.querySelector('code');
6
+ const codeEl = pre.querySelector('code');
7
+ if (!freshPre || !freshCode || !codeEl) return;
8
+
9
+ // Union rather than replace: hosts (e.g. VitePress's `vp-code`) add classes
10
+ // of their own to the build-time <pre> that shiki's raw output doesn't know
11
+ // about. Dropping them here can break host CSS that depends on them (e.g.
12
+ // VitePress's dark-mode span-color rule is scoped to `.vp-code span`).
13
+ const mergedClasses = new Set(pre.className.split(/\s+/).filter(Boolean));
14
+ freshPre.className
15
+ .split(/\s+/)
16
+ .filter(Boolean)
17
+ .forEach((c) => mergedClasses.add(c));
18
+ pre.className = Array.from(mergedClasses).join(' ');
19
+ const style = freshPre.getAttribute('style');
20
+ if (style) {
21
+ pre.setAttribute('style', style);
22
+ } else {
23
+ pre.removeAttribute('style');
24
+ }
25
+
26
+ codeEl.innerHTML = freshCode.innerHTML;
27
+ }
@@ -0,0 +1,35 @@
1
+ import { expect, test } from 'vitest';
2
+ import { highlightInto } from './standalone-entry';
3
+
4
+ function createPre(lang: string): HTMLElement {
5
+ const pre = document.createElement('pre');
6
+ pre.dataset.ugokuLang = lang;
7
+ pre.dataset.ugokuTheme = 'github-light';
8
+ const code = document.createElement('code');
9
+ code.textContent = 'placeholder';
10
+ pre.appendChild(code);
11
+ document.body.appendChild(pre);
12
+ return pre;
13
+ }
14
+
15
+ test('highlightInto recognizes the "ts" shorthand, not just "typescript"', async () => {
16
+ const pre = createPre('ts');
17
+ await highlightInto(pre, 'let x: number = 1;');
18
+ expect(pre.className).toContain('shiki');
19
+ pre.remove();
20
+ });
21
+
22
+ test('highlightInto recognizes the "js" shorthand, not just "javascript"', async () => {
23
+ const pre = createPre('js');
24
+ await highlightInto(pre, 'let x = 1;');
25
+ expect(pre.className).toContain('shiki');
26
+ pre.remove();
27
+ });
28
+
29
+ test('highlightInto still rejects genuinely unsupported langs', async () => {
30
+ const pre = createPre('python');
31
+ const before = pre.querySelector('code')!.innerHTML;
32
+ await highlightInto(pre, 'x = 1');
33
+ expect(pre.querySelector('code')?.innerHTML).toBe(before);
34
+ pre.remove();
35
+ });
@@ -0,0 +1,53 @@
1
+ import { createHighlighterCore } from 'shiki/core';
2
+ import { createJavaScriptRegexEngine } from 'shiki/engine/javascript';
3
+ import javascript from 'shiki/langs/javascript.mjs';
4
+ import typescript from 'shiki/langs/typescript.mjs';
5
+ import githubLight from 'shiki/themes/github-light.mjs';
6
+ import githubDark from 'shiki/themes/github-dark.mjs';
7
+ import { spliceHighlightedHtml } from './splice.ts';
8
+ import { normalizeLang } from '../lang.ts';
9
+ import type { HighlighterCore } from 'shiki/core';
10
+
11
+ const SUPPORTED_LANGS = new Set(['javascript', 'typescript']);
12
+ const SUPPORTED_THEMES = new Set(['github-light', 'github-dark']);
13
+
14
+ let highlighterPromise: Promise<HighlighterCore> | null = null;
15
+
16
+ function getHighlighter(): Promise<HighlighterCore> {
17
+ if (!highlighterPromise) {
18
+ highlighterPromise = createHighlighterCore({
19
+ engine: createJavaScriptRegexEngine(),
20
+ langs: [javascript, typescript],
21
+ themes: [githubLight, githubDark],
22
+ });
23
+ }
24
+ return highlighterPromise;
25
+ }
26
+
27
+ export async function highlightInto(pre: HTMLElement, code: string): Promise<void> {
28
+ const lang = pre.dataset.ugokuLang ?? '';
29
+ const singleTheme = pre.dataset.ugokuTheme;
30
+ const lightTheme = pre.dataset.ugokuThemeLight;
31
+ const darkTheme = pre.dataset.ugokuThemeDark;
32
+ const themeNames = singleTheme ? [singleTheme] : [lightTheme, darkTheme].filter((t): t is string => !!t);
33
+
34
+ const unsupported =
35
+ !SUPPORTED_LANGS.has(normalizeLang(lang) ?? '') ||
36
+ themeNames.length === 0 ||
37
+ !themeNames.every((t) => SUPPORTED_THEMES.has(t));
38
+ if (unsupported) return;
39
+
40
+ const highlighter = await getHighlighter();
41
+ // See editor.ts: defaultColor: false matches VitePress's own shiki output
42
+ // format (--shiki-light/--shiki-dark only, no baseline inline `color`),
43
+ // which its non-!important dark-mode CSS rule depends on.
44
+ const html = singleTheme
45
+ ? highlighter.codeToHtml(code, { lang, theme: singleTheme })
46
+ : highlighter.codeToHtml(code, {
47
+ lang,
48
+ themes: { light: lightTheme!, dark: darkTheme! },
49
+ defaultColor: false,
50
+ });
51
+
52
+ spliceHighlightedHtml(pre, html);
53
+ }
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ import './output-panel/styles.css';
2
+
3
+ export { runCode } from './runner/index.ts';
4
+ export type {
5
+ RunCodeCallbacks,
6
+ RunHandle,
7
+ SerializedValue,
8
+ SerializedError,
9
+ RunMessage,
10
+ ConsoleMessage,
11
+ DoneMessage,
12
+ ErrorMessage,
13
+ MainToWorkerMessage,
14
+ WorkerToMainMessage,
15
+ } from './runner/index.ts';
16
+
17
+ export { OutputPanel, renderValue } from './output-panel/index.ts';
18
+
19
+ export { mount, unmount } from './mount/index.ts';
@@ -0,0 +1,23 @@
1
+ import { expect, test } from 'vitest';
2
+ import { normalizeLang } from './lang';
3
+
4
+ test('normalizes the "ts" shorthand to "typescript"', () => {
5
+ expect(normalizeLang('ts')).toBe('typescript');
6
+ });
7
+
8
+ test('normalizes the "js" shorthand to "javascript"', () => {
9
+ expect(normalizeLang('js')).toBe('javascript');
10
+ });
11
+
12
+ test('passes already-canonical lang names through unchanged', () => {
13
+ expect(normalizeLang('typescript')).toBe('typescript');
14
+ expect(normalizeLang('javascript')).toBe('javascript');
15
+ });
16
+
17
+ test('passes unrecognized langs through unchanged', () => {
18
+ expect(normalizeLang('python')).toBe('python');
19
+ });
20
+
21
+ test('passes undefined through unchanged', () => {
22
+ expect(normalizeLang(undefined)).toBeUndefined();
23
+ });
package/src/lang.ts ADDED
@@ -0,0 +1,9 @@
1
+ const LANG_ALIASES: Record<string, string> = {
2
+ ts: 'typescript',
3
+ js: 'javascript',
4
+ };
5
+
6
+ export function normalizeLang(lang: string | undefined): string | undefined {
7
+ if (!lang) return lang;
8
+ return LANG_ALIASES[lang] ?? lang;
9
+ }