Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,8 @@ jobs:
path: /tmp
- name: Restore build outputs
run: tar -xzf /tmp/build-outputs.tgz
- name: "node-ui production build (896 MB heap budget)"
run: pnpm --filter @origintrail-official/dkg-node-ui run build:ui
- name: "node-ui tests"
run: pnpm --filter @origintrail-official/dkg-node-ui test

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"scripts": {
"build": "node scripts/build.mjs",
"build:packages": "turbo build",
"build:runtime:packages": "pnpm -r --filter @origintrail-official/dkg-core... --filter @origintrail-official/dkg-storage... --filter @origintrail-official/dkg-query... --filter @origintrail-official/dkg-publisher... --filter @origintrail-official/dkg-chain... --filter @origintrail-official/dkg-epcis... --filter @origintrail-official/dkg-okf... --filter @origintrail-official/dkg-random-sampling... --filter @origintrail-official/dkg-agent... --filter @origintrail-official/dkg-graph-viz... --filter @origintrail-official/dkg-node-ui... --filter @origintrail-official/dkg-adapter-openclaw... --filter @origintrail-official/dkg-adapter-hermes... --filter @origintrail-official/kafka-plugin... --filter @origintrail-official/dkg... --filter \"!@origintrail-official/dkg-evm-module\" run build",
"build:runtime:packages": "node scripts/build-runtime-packages.mjs",
Comment thread
branarakic marked this conversation as resolved.
"build:runtime": "pnpm run build:runtime:packages && pnpm --filter @origintrail-official/dkg-node-ui run build:ui",
"test": "turbo test && pnpm run test:scripts",
"test:scripts": "node --test scripts/lib/__tests__/*.test.mjs",
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/test/status-route-store-quads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ import {
} from '../src/daemon/routes/status.js';
import type { RequestContext } from '../src/daemon/routes/context.js';

const DISABLED_PUBLISHER_STATE: RequestContext['publisherState'] = {
runtime: null,
availability: {
available: false,
reason: 'publisher_disabled',
retryable: false,
operatorActionRequired: true,
},
};

interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
Expand All @@ -32,6 +42,7 @@ async function startStatusServer(query: () => Promise<unknown>): Promise<{
await handleStatusRoutes({
req,
res,
publisherState: DISABLED_PUBLISHER_STATE,
path: url.pathname,
url,
network: null,
Expand Down
2 changes: 1 addition & 1 deletion packages/node-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"types": "dist/index.d.ts",
"scripts": {
"build": "node -e \"const fs=require('fs'); ['dist','tsconfig.tsbuildinfo'].forEach((p)=>fs.rmSync(p,{recursive:true,force:true}))\" && tsc",
"build:ui": "cross-env NODE_OPTIONS=--max-old-space-size=4096 vite build",
"build:ui": "cross-env NODE_OPTIONS=--max-old-space-size=896 vite build",
"build:full": "pnpm build && pnpm build:ui",
"dev:ui": "vite",
"test": "vitest run",
Expand Down
41 changes: 4 additions & 37 deletions packages/node-ui/src/ui/components/chat/CodeBlock.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,18 @@
import React, { useEffect, useRef, useState } from 'react';
import { Check, Copy } from 'lucide-react';
import { useLayoutStore } from '../../stores/layout.js';
import { normalizeShikiLanguage } from './shikiLanguages.js';

type Highlighter = {
codeToHtml: (code: string, opts: { lang: string; theme: string }) => string;
};

// Curated allow-list — kept narrow so the lazy-loaded shiki bundle stays
// small. Includes languages this monorepo actually uses in fenced blocks:
// Solidity contracts, Rust/Go adapters, SPARQL, TOML configs, diffs in
// review threads, and Dockerfiles for deployment notes.
const SUPPORTED_LANGS = [
'ts', 'tsx', 'js', 'jsx', 'py', 'sh', 'bash', 'json', 'yaml',
'sql', 'sparql', 'md', 'html', 'css',
'solidity', 'rust', 'go', 'toml', 'diff', 'dockerfile', 'xml',
] as const;

type SupportedLang = typeof SUPPORTED_LANGS[number];

let highlighterPromise: Promise<Highlighter> | null = null;

function loadHighlighter(): Promise<Highlighter> {
if (highlighterPromise) return highlighterPromise;
highlighterPromise = import('shiki').then((shiki) =>
shiki.createHighlighter({
themes: ['github-dark', 'github-light'],
langs: [...SUPPORTED_LANGS],
}) as Promise<Highlighter>,
highlighterPromise = import('./shikiHighlighter.js').then((shiki) =>
shiki.createHighlighter() as Promise<Highlighter>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: Finish the Shiki facade instead of keeping half the boundary in CodeBlock

What's wrong
This PR introduces a Shiki facade, but the component still knows too much about the facade internals. The local Highlighter interface plus cast is a loose contract that can drift from the real facade return type, and it makes the abstraction feel unfinished rather than simplifying the component.

Example
Current shape: CodeBlock imports ./shikiHighlighter.js, calls createHighlighter(), casts it to a local Promise<Highlighter>, and owns the singleton promise. That leaves the Shiki boundary split across the React component and the facade.

Suggested direction
Make shikiHighlighter.ts the canonical owner of the highlighter type, singleton promise, and reset-on-error logic. Then CodeBlock can just ask for highlighted HTML and no longer needs a local structural type or cast.

For Agents
Look at CodeBlock.tsx and shikiHighlighter.ts. Preserve lazy loading, the singleton highlighter, and retry-after-failure behavior, but move the highlighter contract/cache into the facade, or export a highlightCode(code, lang, theme)/loadHighlighter() API that is typed at the boundary. Existing CodeBlock and markdown lazy-load tests should keep passing.

).catch((err) => {
// Reset so the next code block retries instead of holding a dead promise.
highlighterPromise = null;
Expand All @@ -35,32 +21,13 @@ function loadHighlighter(): Promise<Highlighter> {
return highlighterPromise;
}

function normalizeLang(raw: string | undefined): SupportedLang | null {
if (!raw) return null;
const lang = raw.toLowerCase().trim();
if ((SUPPORTED_LANGS as readonly string[]).includes(lang)) return lang as SupportedLang;
// Aliases for languages users commonly tag with shorthand or alternate names.
if (lang === 'typescript') return 'ts';
if (lang === 'javascript') return 'js';
if (lang === 'python') return 'py';
if (lang === 'shell' || lang === 'zsh') return 'sh';
if (lang === 'yml') return 'yaml';
if (lang === 'markdown') return 'md';
if (lang === 'sol') return 'solidity';
if (lang === 'rs') return 'rust';
if (lang === 'golang') return 'go';
if (lang === 'patch') return 'diff';
if (lang === 'docker') return 'dockerfile';
return null;
}

interface CodeBlockProps {
code: string;
lang?: string;
}

export function CodeBlock({ code, lang }: CodeBlockProps) {
const normalizedLang = normalizeLang(lang);
const normalizedLang = normalizeShikiLanguage(lang);
const [html, setHtml] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
// Read theme from the layout store (the same source that drives `body.light`
Expand Down
16 changes: 16 additions & 0 deletions packages/node-ui/src/ui/components/chat/shikiHighlighter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { createHighlighterCore } from 'shiki/core';
import { createOnigurumaEngine } from 'shiki/engine/oniguruma';
import githubDark from 'shiki/themes/github-dark.mjs';
import githubLight from 'shiki/themes/github-light.mjs';
import { loadShikiLanguageRegistrations } from './shikiLanguages.js';

// The root `shiki` entry constructs its full bundle and therefore exposes a
// dynamic import edge for every bundled language and theme to Vite. Core plus
// the explicit registry keeps this lazy chunk limited to CodeBlock languages.
export async function createHighlighter() {
return createHighlighterCore({
engine: createOnigurumaEngine(import('shiki/wasm')),
themes: [githubDark, githubLight],
langs: await loadShikiLanguageRegistrations(),
});
}
88 changes: 88 additions & 0 deletions packages/node-ui/src/ui/components/chat/shikiLanguages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import type { LanguageRegistration } from 'shiki/core';

type ShikiLanguageModule = { default: LanguageRegistration[] };

type ShikiLanguageDefinition = {
aliases: Readonly<Record<string, string>>;
load: () => Promise<ShikiLanguageModule>;
};

function defineShikiLanguageRegistry<
const Registry extends readonly ShikiLanguageDefinition[],
>(registry: Registry): Registry {
return registry;
}

/**
* The single source of truth for every fenced-code label accepted by
* CodeBlock, its normalized Shiki language, and the grammar loaded for it.
* Explicit dynamic imports retain the lazy highlighter boundary without
* pulling Shiki's full language registry into the Vite build graph.
*/
export const SHIKI_LANGUAGE_REGISTRY = defineShikiLanguageRegistry([
{
aliases: { sh: 'sh', shell: 'sh', zsh: 'sh', bash: 'bash' },
load: () => import('shiki/langs/bash.mjs'),
},
{ aliases: { css: 'css' }, load: () => import('shiki/langs/css.mjs') },
{ aliases: { diff: 'diff', patch: 'diff' }, load: () => import('shiki/langs/diff.mjs') },
{
aliases: { dockerfile: 'dockerfile', docker: 'dockerfile' },
load: () => import('shiki/langs/dockerfile.mjs'),
},
{ aliases: { go: 'go', golang: 'go' }, load: () => import('shiki/langs/go.mjs') },
{ aliases: { html: 'html' }, load: () => import('shiki/langs/html.mjs') },
{ aliases: { js: 'js', javascript: 'js' }, load: () => import('shiki/langs/javascript.mjs') },
{ aliases: { json: 'json' }, load: () => import('shiki/langs/json.mjs') },
{ aliases: { jsx: 'jsx' }, load: () => import('shiki/langs/jsx.mjs') },
{ aliases: { md: 'md', markdown: 'md' }, load: () => import('shiki/langs/markdown.mjs') },
{ aliases: { py: 'py', python: 'py' }, load: () => import('shiki/langs/python.mjs') },
{ aliases: { rust: 'rust', rs: 'rust' }, load: () => import('shiki/langs/rust.mjs') },
{
aliases: { solidity: 'solidity', sol: 'solidity' },
load: () => import('shiki/langs/solidity.mjs'),
},
{ aliases: { sparql: 'sparql' }, load: () => import('shiki/langs/sparql.mjs') },
{ aliases: { sql: 'sql' }, load: () => import('shiki/langs/sql.mjs') },
{ aliases: { toml: 'toml' }, load: () => import('shiki/langs/toml.mjs') },
{ aliases: { tsx: 'tsx' }, load: () => import('shiki/langs/tsx.mjs') },
{ aliases: { ts: 'ts', typescript: 'ts' }, load: () => import('shiki/langs/typescript.mjs') },
{ aliases: { xml: 'xml' }, load: () => import('shiki/langs/xml.mjs') },
{ aliases: { yaml: 'yaml', yml: 'yaml' }, load: () => import('shiki/langs/yaml.mjs') },
] as const);

type UnionToIntersection<Union> = (
Union extends unknown ? (value: Union) => void : never
) extends (value: infer Intersection) => void ? Intersection : never;

type ShikiLanguageAliases = UnionToIntersection<
typeof SHIKI_LANGUAGE_REGISTRY[number]['aliases']
>;

export const SHIKI_LANGUAGE_ALIASES = Object.freeze(Object.assign(
{},
...SHIKI_LANGUAGE_REGISTRY.map(({ aliases }) => aliases),
)) as Readonly<ShikiLanguageAliases>;

export type ShikiLanguageAlias = keyof ShikiLanguageAliases;
export type SupportedShikiLanguage = ShikiLanguageAliases[ShikiLanguageAlias];

export const SUPPORTED_SHIKI_LANGUAGE_ALIASES = Object.freeze(
Object.keys(SHIKI_LANGUAGE_ALIASES) as ShikiLanguageAlias[],
);

export function normalizeShikiLanguage(raw: string | undefined): SupportedShikiLanguage | null {
if (!raw) return null;
const label = raw.toLowerCase().trim() as ShikiLanguageAlias;
return SHIKI_LANGUAGE_ALIASES[label] ?? null;
}

export async function loadShikiLanguageRegistrations(): Promise<LanguageRegistration[]> {
const registrations = (await Promise.all(
SHIKI_LANGUAGE_REGISTRY.map(({ load }) => load()),
)).flatMap(({ default: grammar }) => grammar);

return registrations.filter((grammar, index, all) =>
all.findIndex((candidate) => candidate.name === grammar.name) === index,
);
}
8 changes: 4 additions & 4 deletions packages/node-ui/test/code-block.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,14 +94,14 @@ describe('CodeBlock', () => {
// immediate plain-text fallback. A broken shiki bundle, a botched
// `normalizeLang` map, or a wrong theme key would still pass them
// all — the rendered branch never executes in the assertions. Mock
// `shiki` per-test, re-import CodeBlock through a fresh module
// graph (the test file imports the real component at the top), then
// the fine-grained shiki facade per-test, re-import CodeBlock through a
// fresh module graph (the test file imports the real component at the top), then
// wait for the async `loadHighlighter().then(setHtml)` chain to
// settle and assert that `.v10-md-pre-rendered` actually replaced
// the fallback with the highlighter's output.
vi.useRealTimers(); // shiki path uses promises/microtasks, not setTimeout
vi.resetModules();
vi.doMock('shiki', () => ({
vi.doMock('../src/ui/components/chat/shikiHighlighter.js', () => ({
createHighlighter: async () => ({
codeToHtml: (code: string, opts: { lang: string; theme: string }) =>
`<pre class="shiki shiki-${opts.theme}" data-lang="${opts.lang}"><code>SHIKI:${code}</code></pre>`,
Expand Down Expand Up @@ -135,7 +135,7 @@ describe('CodeBlock', () => {

await act(async () => { root.unmount(); });
container.remove();
vi.doUnmock('shiki');
vi.doUnmock('../src/ui/components/chat/shikiHighlighter.js');
vi.resetModules();
});

Expand Down
16 changes: 8 additions & 8 deletions packages/node-ui/test/markdown-message.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,19 @@
// react-markdown sanitization default (no raw HTML), remark-breaks soft-break
// → <br>, fenced code blocks → <CodeBlock> with plain-text fallback for
// unsupported languages, and the lazy-shiki gate (no fenced block → no
// dynamic import of `shiki`).
// dynamic import of the fine-grained shiki facade).

import React, { act } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createRoot } from 'react-dom/client';

// IMPORTANT: this mock asserts the "shiki must not load when there are no
// IMPORTANT: this mock asserts the highlighter must not load when there are no
// fenced blocks" contract. Any markdown without a fenced code block that
// triggers `import('shiki')` will throw and fail the test. The mock returns
// triggers the lazy facade import will throw and fail the test. The mock returns
// a benign `createHighlighter` stub for the cases that DO load shiki on
// purpose (covered in code-block.test.ts).
let shikiImportCount = 0;
vi.mock('shiki', () => {
vi.mock('../src/ui/components/chat/shikiHighlighter.js', () => {
shikiImportCount += 1;
return {
createHighlighter: async () => ({
Expand Down Expand Up @@ -66,9 +66,9 @@ describe('MarkdownMessage rendering', () => {
document.body.innerHTML = '';
// Clear Vitest's module cache so every test re-imports MarkdownMessage
// (and therefore CodeBlock) from a fresh state. Without this, the
// `vi.mock('shiki', ...)` factory above only runs once per test file:
// after the first fenced-block test imports shiki, the module is
// cached and any subsequent `import('shiki')` resolves without
// highlighter mock factory above only runs once per test file:
// after the first fenced-block test imports it, the module is
// cached and any subsequent facade import resolves without
// re-invoking the factory, so the `lazy-shiki gate` assertion can
// false-pass even if a real load happens. Codex CHMS6.
vi.resetModules();
Expand Down Expand Up @@ -283,7 +283,7 @@ describe('MarkdownMessage rendering', () => {
await unmount();
});

it('lazy-shiki gate: rendering markdown with NO fenced blocks does not trigger import("shiki")', async () => {
it('lazy-shiki gate: rendering markdown with NO fenced blocks does not load the highlighter', async () => {
const md = '# Heading\n\nSome **bold** text with `inline code` and a [link](https://x.test).\n\n- a\n- b\n';
const { container, unmount } = await render(md);
// Sanity: rich markdown rendered.
Expand Down
29 changes: 29 additions & 0 deletions packages/node-ui/test/shiki-highlighter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { createHighlighter } from '../src/ui/components/chat/shikiHighlighter.js';
import {
normalizeShikiLanguage,
SUPPORTED_SHIKI_LANGUAGE_ALIASES,
} from '../src/ui/components/chat/shikiLanguages.js';

describe('fine-grained Shiki highlighter', () => {
it('loads every CodeBlock language alias and both UI themes', async () => {
const highlighter = await createHighlighter();
try {
for (const alias of SUPPORTED_SHIKI_LANGUAGE_ALIASES) {
const lang = normalizeShikiLanguage(alias);
expect(lang, alias).not.toBeNull();
expect(() => highlighter.codeToHtml('const value = 1;', {
lang: lang!,
theme: 'github-dark',
}), alias).not.toThrow();
}

expect(highlighter.codeToHtml('const value = 1;', {
lang: 'ts',
theme: 'github-light',
})).toContain('github-light');
} finally {
highlighter.dispose();
}
});
});
34 changes: 34 additions & 0 deletions scripts/build-runtime-packages.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import { pathToFileURL } from 'node:url';
import { runtimeBuildPnpmArgs } from './lib/runtime-build-plan.mjs';

export function runRuntimePackageBuild({
extraArgs = process.argv.slice(2),
spawn = spawnSync,
platform = process.platform,
reportError = (message) => console.error(message),
} = {}) {
const args = runtimeBuildPnpmArgs(['run', 'build', ...extraArgs]);
const result = spawn('pnpm', args, {
stdio: 'inherit',
shell: platform === 'win32',
});

if (result.error) {
reportError(result.error.message);
return 1;
}
if (typeof result.status === 'number') return result.status;
if (result.signal) {
reportError(`pnpm ${args.join(' ')} exited via ${result.signal}`);
return 1;
}
reportError(`pnpm ${args.join(' ')} exited without a status`);
return 1;
}

const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isMain) {
process.exitCode = runRuntimePackageBuild();
}
Loading
Loading