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: 1 addition & 1 deletion src/web-ui/src/app/scenes/session/fileDropPreviewKind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const extensions: Record<string, FilePreviewKind> = Object.fromEntries(
spreadsheet: 'xls xlsx xlsm csv tsv ods numbers',
presentation: 'ppt pptx odp key',
document: 'doc docx odt rtf txt md markdown pages',
code: 'js jsx ts tsx py rs go java c h cpp hpp cs rb php swift kt vue svelte html css scss json yaml yml toml xml sql sh ps1 ipynb',
code: 'js jsx ts tsx py rs go java c h cpp hpp cs rb php swift kt vue svelte html css scss json yaml yml toml xml sql sh ps1 ipynb cj',
archive: 'zip rar 7z tar gz bz2 xz tgz',
audio: 'mp3 wav flac aac m4a ogg opus',
video: 'mp4 mov mkv avi webm m4v wmv',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export const APPEARANCE_LANGUAGE_TOKENS = {
javascriptReact: languageToken('cyan'),
python: languageToken('blue'),
rust: languageToken('orange'),
cangjie: languageToken('red'),
go: languageToken('cyan'),
java: languageToken('orange'),
kotlin: languageToken('purple'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,7 @@ describe('builtin appearance preset output', () => {
"type": "dark",
},
{
"hash": "44d681ba137355f1db61559e01e190cb805b25f1e62fc396e7ebe834a429cf3a",
"hash": "c1fe8c49368e8ff96debaeba117ce40b22b2f9c48e2e5d86f372b8fd032380fe",
"id": "openbitfun-dark",
"type": "dark",
},
Expand Down
7 changes: 6 additions & 1 deletion src/web-ui/src/infrastructure/appearance/builtins/dark.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,12 @@ export const openBitFunDarkPalette: AppearancePalette = {
monaco: {
base: 'vs-dark',
inherit: true,
rules: [],
// `vs-dark` has no rule for the `function` token, so function names fall back
// to the default foreground. Declared function names and call sites use that
// token (Cangjie, and any other language grammar that emits it).
rules: [
{ token: 'function', foreground: 'DCDCAA' },
],
colors: {
background: DARK_BACKGROUND_PRIMARY,
foreground: DARK_TEXT_PRIMARY,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,21 @@ const BUILTIN_LANGUAGES: Language[] = [
lineCommentPrefix: '//',
blockComment: { start: '/*', end: '*/' },
},
{
id: 'cangjie',
name: 'Cangjie',
category: 'programming',
extensions: ['cj'],
monacoId: 'cangjie',
prismId: 'cangjie',
textmateScope: 'source.cj',
iconType: 'code',
color: APPEARANCE_LANGUAGE_TOKENS.cangjie,
aliases: ['cj'],
supportsComments: true,
lineCommentPrefix: '//',
blockComment: { start: '/*', end: '*/' },
},
{
id: 'go',
name: 'Go',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';

import { getEditorType } from './helpers';
import { detectLanguage, getEditorType, getMonacoLanguage } from './helpers';

describe('getEditorType', () => {
it('routes PDF files to the PDF viewer case-insensitively', () => {
Expand All @@ -12,3 +12,11 @@ describe('getEditorType', () => {
expect(getEditorType('legacy.HTM')).toBe('html-preview');
});
});

describe('Cangjie language detection', () => {
it('maps .cj sources to the Cangjie editor language', () => {
expect(getMonacoLanguage('main.cj')).toBe('cangjie');
expect(detectLanguage('src/app.cj').language.id).toBe('cangjie');
expect(getEditorType('main.cj')).toBe('code-editor');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,10 @@
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getLoadedPrismSyntaxHighlighter, loadPrismSyntaxHighlighter } from '@/shared/utils/syntaxHighlighterLoader';

import { AsyncPrismSyntaxHighlighter } from './AsyncPrismSyntaxHighlighter';

vi.mock('@/shared/utils/syntaxHighlighterLoader', () => ({
getLoadedPrismSyntaxHighlighter: () => null,
loadPrismSyntaxHighlighter: () => new Promise(() => {}),
}));

vi.mock('@/shared/utils/startupTaskScheduling', () => ({
scheduleAfterStartupPaint: () => () => {},
}));
Expand Down Expand Up @@ -49,4 +45,22 @@ describe('AsyncPrismSyntaxHighlighter appearance contract', () => {
expect(container.querySelector('pre[data-openbitfun-part="codePre"]')).not.toBeNull();
expect(container.querySelector('code[data-openbitfun-part="codeContent"]')).not.toBeNull();
});

it('highlights Cangjie when another consumer has already loaded Prism', async () => {
const highlighter = await loadPrismSyntaxHighlighter();
expect(getLoadedPrismSyntaxHighlighter()).toBe(highlighter);

await act(async () => {
root.render(
<AsyncPrismSyntaxHighlighter language="cangjie" style={{}}>
{'func add(a: Int64): Int64 { return a + 1 }'}
</AsyncPrismSyntaxHighlighter>,
);
});

expect(container.querySelector('.code-block-fallback')).toBeNull();
expect(container.querySelector('code .token.keyword')?.textContent).toBe('func');
expect(container.querySelector('code .token.function')?.textContent).toBe('add');
expect(container.querySelector('code .token.builtin')?.textContent).toBe('Int64');
});
});
3 changes: 3 additions & 0 deletions src/web-ui/src/infrastructure/markdown/MarkdownRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ const EDITOR_OPENABLE_EXTENSIONS = new Set([
'gitignore', 'gitattributes', 'editorconfig',
'log', 'dockerfile', 'makefile', 'mk', 'gradle',
'properties', 'plist', 'tex', 'mermaid', 'svg',
'cj',
]);
const EDITOR_OPENABLE_BASENAMES = new Set([
'dockerfile',
Expand Down Expand Up @@ -792,6 +793,8 @@ function formatCodeLanguageLabel(lang: string): string {
makefile: 'Makefile',
toml: 'TOML',
xml: 'XML',
cangjie: 'Cangjie',
cj: 'Cangjie',
rust: 'Rust',
typescript: 'TypeScript',
javascript: 'JavaScript',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, it, vi } from 'vitest';
import Prism from 'prismjs';
import SyntaxHighlighter from 'react-syntax-highlighter/dist/esm/prism-async-light';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { cangjiePrismGrammar } from '@/tools/editor/languages/cangjie.language';
import { ensureCangjiePrismLanguage } from './cangjiePrismLanguage';

const grammar = cangjiePrismGrammar as unknown as Prism.Grammar;

describe('cangjie Prism grammar', () => {
it('tokenizes declarations for chat code fences', () => {
const html = Prism.highlight('func add(a: Int64): Int64 {\n return a + 1\n}', grammar, 'cangjie');

expect(html).toContain('token keyword');
expect(html).toContain('token function');
expect(html).toContain('token builtin');
expect(html).toContain('token punctuation');
});

it('keeps comments, strings and annotations whole', () => {
const html = Prism.highlight('// note\nlet s = "a ${b} c"\n@Derive(ToString) struct P {}', grammar, 'cangjie');

expect(html).toContain('token comment');
expect(html).toContain('token string');
expect(html).toContain('token annotation function');
expect(html).toContain('token class-name');
});
});

describe('ensureCangjiePrismLanguage', () => {
it('registers once and highlights with the real async highlighter', async () => {
const highlighter = SyntaxHighlighter as typeof SyntaxHighlighter & {
preload: () => Promise<void>;
};
await highlighter.preload();
const registerLanguage = vi.spyOn(highlighter, 'registerLanguage');

try {
ensureCangjiePrismLanguage(highlighter);
ensureCangjiePrismLanguage(highlighter);

expect(registerLanguage).toHaveBeenCalledTimes(1);
expect(registerLanguage).toHaveBeenCalledWith('cangjie', expect.any(Function));
const html = renderToStaticMarkup(React.createElement(highlighter, {
language: 'cangjie',
useInlineStyles: false,
children: 'func add(a: Int64): Int64 { return a + 1 }',
}));
expect(html).toContain('class="token keyword"');
expect(html).toContain('class="token function"');
expect(html).toContain('class="token builtin"');
} finally {
registerLanguage.mockRestore();
}
});
});
48 changes: 48 additions & 0 deletions src/web-ui/src/infrastructure/markdown/cangjiePrismLanguage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Registers the Cangjie grammar on the lazily loaded Prism highlighter.
*
* Markdown code fences are rendered by `react-syntax-highlighter`'s
* `prism-async-light`, which only knows the languages listed in its own
* refractor loader map. Cangjie is not one of them, and unknown languages
* degrade to plain text, so the grammar built for the editor is registered on
* the loaded instance instead of adding a dependency on refractor.
*/

import type * as Prism from 'prismjs';
import { createLogger } from '@/shared/utils/logger';
import { cangjiePrismGrammar } from '@/tools/editor/languages/cangjie.language';

const log = createLogger('cangjiePrismLanguage');

interface AsyncHighlighterRegistrar {
registerLanguage?: (name: string, grammar: typeof registerCangjiePrismGrammar) => void;
}

const PRISM_LANGUAGE_ID = 'cangjie';
let registered = false;

// Refractor accepts language modules, rather than Prism grammar objects.
function registerCangjiePrismGrammar(prism: typeof Prism): void {
prism.languages[PRISM_LANGUAGE_ID] = cangjiePrismGrammar;
}
registerCangjiePrismGrammar.displayName = PRISM_LANGUAGE_ID;

/** Idempotently register the Cangjie Prism grammar on a loaded highlighter. */
export function ensureCangjiePrismLanguage(highlighter: unknown): void {
if (registered) {
return;
}

const registerLanguage = (highlighter as AsyncHighlighterRegistrar | null | undefined)?.registerLanguage;
if (typeof registerLanguage !== 'function') {
log.warn('Prism highlighter does not expose registerLanguage; Cangjie fences stay plain');
return;
}

try {
registerLanguage.call(highlighter, PRISM_LANGUAGE_ID, registerCangjiePrismGrammar);
registered = true;
} catch (error) {
log.warn('Failed to register Cangjie Prism grammar', error);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ export function getLanguageDisplayName(language?: string): string {
'cpp': 'C++',
'c': 'C',
'csharp': 'C#',
'cangjie': 'Cangjie',
'html': 'HTML',
'css': 'CSS',
'scss': 'SCSS',
Expand Down
5 changes: 4 additions & 1 deletion src/web-ui/src/shared/utils/syntaxHighlighterLoader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ it('keeps the fallback available until the AST engine is ready and shares concur
let finishEngine!: () => void;
const engineReady = new Promise<void>(resolve => { finishEngine = resolve; });
const preload = vi.fn(() => engineReady);
const component = Object.assign(() => null, { preload });
const registerLanguage = vi.fn();
const component = Object.assign(() => null, { preload, registerLanguage });
vi.doMock('react-syntax-highlighter/dist/esm/prism-async-light', () => ({ default: component }));
try {
const loader = await import('./syntaxHighlighterLoader');
Expand All @@ -14,9 +15,11 @@ it('keeps the fallback available until the AST engine is ready and shares concur
expect(second).toBe(first);
await vi.waitFor(() => expect(preload).toHaveBeenCalledTimes(1));
expect(loader.getLoadedPrismSyntaxHighlighter()).toBeNull();
expect(registerLanguage).not.toHaveBeenCalled();
finishEngine();
expect(await first).toBe(component);
expect(loader.getLoadedPrismSyntaxHighlighter()).toBe(component);
expect(registerLanguage).toHaveBeenCalledExactlyOnceWith('cangjie', expect.any(Function));
} finally {
vi.doUnmock('react-syntax-highlighter/dist/esm/prism-async-light');
vi.resetModules();
Expand Down
4 changes: 4 additions & 0 deletions src/web-ui/src/shared/utils/syntaxHighlighterLoader.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type React from 'react';
import { ensureCangjiePrismLanguage } from '@/infrastructure/markdown/cangjiePrismLanguage';
import { createModuleLoader } from './moduleLoader';

type SyntaxHighlighterComponent = React.ComponentType<any>;
Expand All @@ -21,6 +22,9 @@ export const loadPrismSyntaxHighlighter = createModuleLoader(() =>
preload: () => Promise<void>;
};
await component.preload();
// All consumers, including CodePreview and cached Markdown renders, must
// receive the custom grammar before the component becomes available.
ensureCangjiePrismLanguage(component);
prismSyntaxHighlighterComponent = component;
return prismSyntaxHighlighterComponent;
},
Expand Down
1 change: 1 addition & 0 deletions src/web-ui/src/tools/editor/components/EditorStatusBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ const getLanguageDisplayName = (language: string): string => {
'dockerfile': 'Dockerfile',
'plaintext': 'Plain Text',
'toml': 'TOML',
'cangjie': 'Cangjie',
'ini': 'INI',
'vue': 'Vue',
'svelte': 'Svelte',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ const getLanguageDisplayName = (id: string, aliases?: string[]): string => {
bash: 'Bash',
plaintext: 'Plain Text',
toml: 'TOML',
cangjie: 'Cangjie',
vue: 'Vue',
svelte: 'Svelte',
graphql: 'GraphQL',
Expand Down
Loading
Loading