diff --git a/docs/app/bench/docs-infra/components/code-highlighter/demos/CodeContent.tsx b/docs/app/bench/docs-infra/components/code-highlighter/demos/CodeContent.tsx index dd99934a1..b6af1cfe3 100644 --- a/docs/app/bench/docs-infra/components/code-highlighter/demos/CodeContent.tsx +++ b/docs/app/bench/docs-infra/components/code-highlighter/demos/CodeContent.tsx @@ -7,7 +7,7 @@ import { useCode } from '@mui/internal-docs-infra/useCode'; import styles from './CodeContent.module.css'; -import '@wooorm/starry-night/style/light'; +import '../../../../../docs-infra/components/code-highlighter/demos/syntax.css'; export function CodeContent(props: ContentProps<{}>) { const code = useCode(props, { preClassName: styles.codeBlock }); diff --git a/docs/app/bench/docs-infra/components/code-highlighter/demos/CodeContentLoading.tsx b/docs/app/bench/docs-infra/components/code-highlighter/demos/CodeContentLoading.tsx index 8d15dfde8..1008f6ad1 100644 --- a/docs/app/bench/docs-infra/components/code-highlighter/demos/CodeContentLoading.tsx +++ b/docs/app/bench/docs-infra/components/code-highlighter/demos/CodeContentLoading.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; import type { ContentLoadingProps } from '@mui/internal-docs-infra/CodeHighlighter/types'; import styles from './CodeContent.module.css'; -import '@wooorm/starry-night/style/light'; +import '../../../../../docs-infra/components/code-highlighter/demos/syntax.css'; export function CodeContentLoading(props: ContentLoadingProps<{}>) { return ( diff --git a/docs/app/docs-infra/components/code-controller-context/demos/code-editor/CodeEditorContent.tsx b/docs/app/docs-infra/components/code-controller-context/demos/code-editor/CodeEditorContent.tsx index 2f9972650..d2a6fbf48 100644 --- a/docs/app/docs-infra/components/code-controller-context/demos/code-editor/CodeEditorContent.tsx +++ b/docs/app/docs-infra/components/code-controller-context/demos/code-editor/CodeEditorContent.tsx @@ -7,7 +7,7 @@ import { useCode } from '@mui/internal-docs-infra/useCode'; import { LabeledSwitch } from '@/components/LabeledSwitch'; import styles from './CodeEditorContent.module.css'; -import '@wooorm/starry-night/style/light'; // load the light theme for syntax highlighting +import '../../../code-highlighter/demos/syntax.css'; export function CodeEditorContent(props: ContentProps) { const preRef = React.useRef(null); diff --git a/docs/app/docs-infra/components/code-controller-context/demos/demo-live/DemoLiveContent.tsx b/docs/app/docs-infra/components/code-controller-context/demos/demo-live/DemoLiveContent.tsx index 167c91925..100cbd866 100644 --- a/docs/app/docs-infra/components/code-controller-context/demos/demo-live/DemoLiveContent.tsx +++ b/docs/app/docs-infra/components/code-controller-context/demos/demo-live/DemoLiveContent.tsx @@ -9,7 +9,7 @@ import { Tabs } from '@/components/Tabs'; import { Select } from '@/components/Select'; import styles from './DemoLiveContent.module.css'; -import '@wooorm/starry-night/style/light'; +import '../../../code-highlighter/demos/syntax.css'; const variantNames: Record = { CssModules: 'CSS Modules', diff --git a/docs/app/docs-infra/components/code-controller-context/demos/multi-file/MultiFileContent.tsx b/docs/app/docs-infra/components/code-controller-context/demos/multi-file/MultiFileContent.tsx index a447eebe9..c0cf80e6a 100644 --- a/docs/app/docs-infra/components/code-controller-context/demos/multi-file/MultiFileContent.tsx +++ b/docs/app/docs-infra/components/code-controller-context/demos/multi-file/MultiFileContent.tsx @@ -7,7 +7,7 @@ import { useCode } from '@mui/internal-docs-infra/useCode'; import { Tabs } from '@/components/Tabs'; import styles from '../code-editor/CodeEditorContent.module.css'; -import '@wooorm/starry-night/style/light'; +import '../../../code-highlighter/demos/syntax.css'; export function MultiFileContent(props: ContentProps) { const preRef = React.useRef(null); diff --git a/docs/app/docs-infra/components/code-highlighter/demos/CodeContent.tsx b/docs/app/docs-infra/components/code-highlighter/demos/CodeContent.tsx index ff6339ce5..f15c7008c 100644 --- a/docs/app/docs-infra/components/code-highlighter/demos/CodeContent.tsx +++ b/docs/app/docs-infra/components/code-highlighter/demos/CodeContent.tsx @@ -9,7 +9,7 @@ import { CopyButton } from '@/components/CopyButton'; import { Select } from '@/components/Select'; import styles from './CodeContent.module.css'; -import '@wooorm/starry-night/style/light'; +import './syntax.css'; const variantNames: Record = { CssModules: 'CSS Modules', diff --git a/docs/app/docs-infra/components/code-highlighter/demos/DemoContent.tsx b/docs/app/docs-infra/components/code-highlighter/demos/DemoContent.tsx index 294339889..08d6f08ed 100644 --- a/docs/app/docs-infra/components/code-highlighter/demos/DemoContent.tsx +++ b/docs/app/docs-infra/components/code-highlighter/demos/DemoContent.tsx @@ -9,7 +9,7 @@ import { CopyButton } from '@/components/CopyButton'; import { Select } from '@/components/Select'; import styles from './DemoContent.module.css'; -import '@wooorm/starry-night/style/light'; +import './syntax.css'; const variantNames: Record = { CssModules: 'CSS Modules', diff --git a/docs/app/docs-infra/components/code-highlighter/demos/demo-fallback-all-files/DemoContentLoading.tsx b/docs/app/docs-infra/components/code-highlighter/demos/demo-fallback-all-files/DemoContentLoading.tsx index 936980ce8..34f410482 100644 --- a/docs/app/docs-infra/components/code-highlighter/demos/demo-fallback-all-files/DemoContentLoading.tsx +++ b/docs/app/docs-infra/components/code-highlighter/demos/demo-fallback-all-files/DemoContentLoading.tsx @@ -6,7 +6,7 @@ import { Tabs } from '@/components/Tabs'; import styles from '../DemoContent.module.css'; import loadingStyles from './DemoContentLoading.module.css'; -import '@wooorm/starry-night/style/light'; +import '../syntax.css'; export function DemoContentLoading(props: ContentLoadingProps) { const tabs = React.useMemo( diff --git a/docs/app/docs-infra/components/code-highlighter/demos/demo-fallback-all-variants/DemoContentLoading.tsx b/docs/app/docs-infra/components/code-highlighter/demos/demo-fallback-all-variants/DemoContentLoading.tsx index 069c8d4aa..b01246aa3 100644 --- a/docs/app/docs-infra/components/code-highlighter/demos/demo-fallback-all-variants/DemoContentLoading.tsx +++ b/docs/app/docs-infra/components/code-highlighter/demos/demo-fallback-all-variants/DemoContentLoading.tsx @@ -7,7 +7,7 @@ import { Select } from '@/components/Select'; import styles from '../DemoContent.module.css'; import loadingStyles from './DemoContentLoading.module.css'; -import '@wooorm/starry-night/style/light'; +import '../syntax.css'; const variantNames: Record = { CssModules: 'CSS Modules', diff --git a/docs/app/docs-infra/components/code-highlighter/demos/demo-fallback/DemoContentLoading.tsx b/docs/app/docs-infra/components/code-highlighter/demos/demo-fallback/DemoContentLoading.tsx index c9f806ced..301bb14fb 100644 --- a/docs/app/docs-infra/components/code-highlighter/demos/demo-fallback/DemoContentLoading.tsx +++ b/docs/app/docs-infra/components/code-highlighter/demos/demo-fallback/DemoContentLoading.tsx @@ -5,7 +5,7 @@ import type { ContentLoadingProps } from '@mui/internal-docs-infra/CodeHighlight import { Tabs } from '@/components/Tabs'; import styles from '../DemoContent.module.css'; -import '@wooorm/starry-night/style/light'; +import '../syntax.css'; export function DemoContentLoading(props: ContentLoadingProps) { const tabs = React.useMemo( diff --git a/docs/app/docs-infra/components/code-highlighter/demos/syntax.css b/docs/app/docs-infra/components/code-highlighter/demos/syntax.css new file mode 100644 index 000000000..d97d7867d --- /dev/null +++ b/docs/app/docs-infra/components/code-highlighter/demos/syntax.css @@ -0,0 +1,78 @@ +@import '@wooorm/starry-night/style/light' layer(starry-night); + +:root { + --color-docs-infra-syntax-number: #0550ae; + --color-docs-infra-syntax-boolean: #0550ae; + --color-docs-infra-syntax-nullish: #59636e; + --color-docs-infra-syntax-attr-key: #0550ae; + --color-docs-infra-syntax-attr-value: #0a3069; + --color-docs-infra-syntax-attr-equals: #24292f; + --color-docs-infra-syntax-data-attr: #0550ae; + --color-docs-infra-syntax-css-property: #0550ae; + --color-docs-infra-syntax-css-value: #0550ae; + --color-docs-infra-syntax-this: #cf222e; + --color-docs-infra-syntax-builtin-type: #0550ae; + --color-docs-infra-syntax-jsx: #116329; + --color-docs-infra-syntax-html-tag: #0550ae; + --color-docs-infra-syntax-jsx-tag: #116329; +} + +.di-num { + color: var(--color-docs-infra-syntax-number); +} + +.di-bool { + color: var(--color-docs-infra-syntax-boolean); +} + +.di-n { + color: var(--color-docs-infra-syntax-nullish); +} + +.di-ak { + color: var(--color-docs-infra-syntax-attr-key); +} + +.di-av { + color: var(--color-docs-infra-syntax-attr-value); +} + +.di-ae { + color: var(--color-docs-infra-syntax-attr-equals); +} + +.di-da { + color: var(--color-docs-infra-syntax-data-attr); +} + +.di-cp { + color: var(--color-docs-infra-syntax-css-property); +} + +.di-cv { + color: var(--color-docs-infra-syntax-css-value); +} + +.di-this { + color: var(--color-docs-infra-syntax-this); +} + +.di-bt { + color: var(--color-docs-infra-syntax-builtin-type); +} + +.di-jsx { + color: var(--color-docs-infra-syntax-jsx); +} + +.di-ht { + color: var(--color-docs-infra-syntax-html-tag); +} + +.di-jt { + color: var(--color-docs-infra-syntax-jsx-tag); +} + +code[data-inline] .pl-smi { + color: inherit; +} diff --git a/docs/app/docs-infra/pipeline/enhance-code-emphasis/demos/CollapsibleContent.tsx b/docs/app/docs-infra/pipeline/enhance-code-emphasis/demos/CollapsibleContent.tsx index 79df021aa..675eb68d7 100644 --- a/docs/app/docs-infra/pipeline/enhance-code-emphasis/demos/CollapsibleContent.tsx +++ b/docs/app/docs-infra/pipeline/enhance-code-emphasis/demos/CollapsibleContent.tsx @@ -5,7 +5,7 @@ import type { ContentProps } from '@mui/internal-docs-infra/CodeHighlighter/type import { useCode } from '@mui/internal-docs-infra/useCode'; import styles from './CollapsibleContent.module.css'; -import '@wooorm/starry-night/style/light'; +import '../../../components/code-highlighter/demos/syntax.css'; export function CollapsibleContent(props: ContentProps) { const code = useCode(props, { preClassName: styles.codeBlock }); diff --git a/docs/app/docs-infra/pipeline/enhance-code-emphasis/demos/IndentContent.tsx b/docs/app/docs-infra/pipeline/enhance-code-emphasis/demos/IndentContent.tsx index d26fa2223..c352f96e8 100644 --- a/docs/app/docs-infra/pipeline/enhance-code-emphasis/demos/IndentContent.tsx +++ b/docs/app/docs-infra/pipeline/enhance-code-emphasis/demos/IndentContent.tsx @@ -5,7 +5,7 @@ import type { ContentProps } from '@mui/internal-docs-infra/CodeHighlighter/type import { useCode } from '@mui/internal-docs-infra/useCode'; import styles from './IndentContent.module.css'; -import '@wooorm/starry-night/style/light'; +import '../../../components/code-highlighter/demos/syntax.css'; export function IndentContent(props: ContentProps) { const code = useCode(props, { preClassName: styles.codeBlock }); diff --git a/docs/app/docs-infra/pipeline/enhance-code-inline/page.mdx b/docs/app/docs-infra/pipeline/enhance-code-inline/page.mdx index 3e8b9ba36..bfdd89fa5 100644 --- a/docs/app/docs-infra/pipeline/enhance-code-inline/page.mdx +++ b/docs/app/docs-infra/pipeline/enhance-code-inline/page.mdx @@ -1,6 +1,6 @@ # Enhance Code Inline -A rehype plugin that improves the visual appearance of inline `` elements by consolidating HTML tag brackets into styled spans, reclassifying misidentified tokens, and applying nullish value classes. +A rehype plugin that improves the visual appearance of inline `` elements by wrapping HTML/JSX tag patterns in styled spans and reclassifying misidentified tokens. --- @@ -8,18 +8,26 @@ A rehype plugin that improves the visual appearance of inline `` elements The `enhanceCodeInline` plugin transforms syntax-highlighted inline code in three ways: -1. **Tag bracket wrapping**: Identifies patterns where `<` or ``, and wraps the entire tag (including brackets) into the highlighting span. +1. **Tag bracket wrapping**: Identifies patterns where `<` or ``, and wraps the entire tag (brackets + tag-name span + any attributes) in a new wrapper span. The original `pl-*` spans are preserved intact inside — no semantic information is destroyed. 2. **Token reclassification**: Corrects misidentified token classes from the upstream syntax highlighter. For example, `function` is sometimes classified as `pl-en` (entity name) but should be styled as `pl-k` (keyword). -3. **Nullish value styling**: Detects spans containing `undefined`, `null`, `""`, or `''` and applies the `di-n` class for distinct visual treatment. +3. **Built-in type enhancement** (TypeScript only): Reclassifies standalone type keywords like `string`, `number`, `boolean`, and `void` from `pl-smi`/`pl-k` to `pl-c1 di-bt`, matching the output that `extendSyntaxTokens` produces for these keywords in type-annotation context. + +### Wrapper Classes + +The wrapper class depends on the inner tag-name span: + +| Inner class | Wrapper class | Meaning | +| ----------- | ------------- | ------------------------------------------- | +| `pl-ent` | `di-ht` | HTML tag (e.g. `
`, ``) | +| `pl-c1` | `di-jt` | JSX component tag (e.g. ``, ``) | ### Key Features -- **Tag consolidation**: Groups `<`, tag name, and `>` into a single styled span +- **Wrapper spans**: Groups `<`, tag-name span, attributes, and `>` under a `di-ht` or `di-jt` wrapper without destroying the inner `pl-*` spans - **Token reclassification**: Fixes misidentified token classes (e.g., `function` from `pl-en` → `pl-k`) -- **Nullish value styling**: Applies `di-n` class to `undefined`, `null`, `""`, and `''` values -- **Multiple class support**: Works with both `pl-ent` (HTML entities) and `pl-c1` (React components) +- **Built-in type enhancement**: Reclassifies standalone TypeScript built-in type keywords (`pl-smi` → `pl-c1 di-bt`), gated to TypeScript languages only - **Consecutive tag handling**: Properly processes multiple tags in sequence like `
` -- **Attribute preservation**: Maintains all original classes and attributes on spans +- **Attribute preservation**: Maintains all original classes and attributes on inner spans - **Safe processing**: Only modifies inline code, not code blocks within `
` elements
 
 ---
@@ -50,7 +58,7 @@ const result = await processor.process(
 );
 
 console.log(String(result));
-// Output: <div>
+// Output: <div>
 ```
 
 ### With transformHtmlCodeInline
@@ -72,24 +80,28 @@ const processor = unified()
 
 ## Transformation Examples
 
-### HTML Entity Tags (pl-ent)
+### HTML Entity Tags (pl-ent → di-ht)
 
 ```html
 
 <div>
 
-
-<div>
+
+<div>
 ```
 
-### React Component Tags (pl-c1)
+### JSX Component Tags (pl-c1 → di-jt)
 
 ```html
 
-<Box>
+<Box>
 
-
-<Box>
+
+<Box>
 ```
 
 ### Closing Tags
@@ -99,29 +111,38 @@ const processor = unified()
 </div>
 
 
-</div>
+</div>
 ```
 
 ### Multiple Consecutive Tags
 
 ```html
 
-<div><Box>
+<div><Box>
 
-
-<div><Box>
+
+<div><Box>
 ```
 
 ### Tags with Attributes
 
-Tags with attributes are fully supported - everything from the opening `<` to the closing `>` or `/>` is wrapped:
+Tags with attributes are fully supported — everything from the opening `<` to the closing `>` or `/>` is wrapped:
 
 ```html
 
-<Box flag option={true} />
+<Box flag option={true} />
 
 
-<Box flag option={true} />
+<Box flag option={true} />
 ```
 
 ```html
@@ -129,7 +150,9 @@ Tags with attributes are fully supported - everything from the opening `<` to th
 <div className="test">
 
 
-<div className="test">
+<div className="test">
 ```
 
 ---
@@ -144,14 +167,14 @@ The plugin looks for this specific pattern in inline code elements:
 2. **Span element** with class `pl-ent` or `pl-c1`
 3. **Text node** containing `>`, `/>`, or ` />`
 
-When all three conditions are met, it consolidates everything from the opening bracket through the closing bracket into a single span.
+When all three conditions are met, it wraps the bracket text, the tag-name span, and any trailing text up to the closing bracket into a new wrapper span (`di-ht` or `di-jt`).
 
 ### Processing Flow
 
 ```
 Input:  text("<")  +  span.pl-ent("div")  +  text(" className='x'>")
                           ↓
-Output:           span.pl-ent("
") +Output: span.di-ht( "<" + span.pl-ent("div") + " className='x'>" ) ``` ### Queue-Based Processing @@ -175,7 +198,9 @@ Self-closing tags are fully supported, with or without a space before `/>`: <br /> -<br /> +<br /> ``` ```html @@ -183,7 +208,9 @@ Self-closing tags are fully supported, with or without a space before `/>`: <input/> -<input/> +<input/> ``` ### Code Blocks @@ -226,41 +253,39 @@ The upstream syntax highlighter sometimes assigns the wrong token class. The plu This ensures `function` receives keyword styling rather than entity name styling. -### Nullish Values +### Built-in Type Enhancement (TypeScript only) + +Starry Night tokenizes standalone type keywords inconsistently in inline code. When there is no surrounding type context (like a type annotation), keywords like `string` become `pl-smi` (identifier) and `void` becomes `pl-k` (keyword) — rather than the `pl-c1` (constant) they receive in expressions like `type x = string`. -Values like `undefined`, `null`, and empty strings get the `di-n` class for distinct styling: +This plugin reclassifies those tokens to `pl-c1 di-bt` for TypeScript-family languages (`language-ts`, `language-tsx`, `language-typescript`), matching the behavior of `extendSyntaxTokens` in block code: ```html - -undefined + +string - -undefined + +string ``` ```html - -"" + +void - -"" + +void ``` -This works alongside tag enhancement - both transformations are applied in a single pass: - -```html - -<Box> | undefined +The enhancement is **not applied** in these cases: - -<Box> | undefined -``` +- **Non-TypeScript languages**: JavaScript, CSS, or code without a language class are left unchanged +- **`void` as an operator**: When `void` appears alongside other tokens (e.g., `void fn()`), it is kept as `pl-k` since it is the unary `void` operator, not a type keyword +- **Already `pl-c1`**: Tokens already classified as `pl-c1` (e.g., in `type x = string`) are left for `extendSyntaxTokens` to handle --- ## Why This Enhancement? -Without this plugin, HTML tags in inline code are styled inconsistently: +Without this plugin, HTML tags in inline code have their brackets unstyled: ```css /* Before: only the tag name gets color */ @@ -268,13 +293,11 @@ Without this plugin, HTML tags in inline code are styled inconsistently: < /* default text color */ > /* default text color */ -/* After: the entire tag is styled uniformly */ -
/* all colored together */ +/* After: wrapper span allows styling the entire tag */ +<div> ``` -This makes inline code snippets like `` | +| `di-ak` | Attribute key | `className` in `
` | +| `di-ae` | Attribute equals | `=` in `
` | +| `di-av` | Attribute value | `"x"` in `
` | +| `di-da` | CSS data attribute selector | `data-active` in `[data-active]{:css}` | +| `di-cp` | CSS property name | `color` in `div { color: red }{:css}` | +| `di-cv` | CSS property value | `flex` in `div { display: flex }{:css}` | +| `di-ht` | HTML tag wrapper (inline only) | `
`, `` (wraps brackets + tag name) | +| `di-jt` | JSX component tag wrapper (inline only) | ``, `` (wraps brackets + tag name) | + +`di-this` applies to JS/TS family grammars (`.js`, `.ts`, `.tsx`, `.jsx`, `.mdx`). `di-bt` applies to TS family grammars only (`.ts`, `.tsx`, `.jsx`, `.mdx`) — plain JS is excluded because `string`, `number`, etc. are valid variable names. `di-jsx` only applies to JSX grammars (`.tsx`, `.jsx`, `.mdx`). `di-ak`, `di-ae`, `di-av` only apply to HTML/JSX/MDX grammars. `di-da`, `di-cp`, and `di-cv` only apply to CSS. `di-ht` and `di-jt` are added by `enhanceCodeInline` (not `parseSource`) as wrapper spans around tag brackets and the inner `pl-ent` or `pl-c1` span. + +Existing `pl-*` classes are never removed — `42` becomes `42`. + --- ## Supported Languages @@ -189,8 +217,6 @@ const result = parseSource('', 'empty.js'); **When NOT to use:** -- **Client-side highlighting** - Starry Night is heavy; use pre-computed highlighting when possible -- **Real-time editing** - Too slow for keystroke-by-keystroke highlighting - **Simple text display** - Use plain `
` tags if highlighting isn't needed
 
 ---
diff --git a/docs/app/global.css b/docs/app/global.css
index 19bda93fa..c259be649 100644
--- a/docs/app/global.css
+++ b/docs/app/global.css
@@ -40,7 +40,3 @@ body {
   color: #fefcfe;
   background: #8145b5;
 }
-
-code[data-inline] .pl-smi {
-  color: inherit;
-}
diff --git a/docs/components/DemoPerformanceContent/DemoPerformanceContent.tsx b/docs/components/DemoPerformanceContent/DemoPerformanceContent.tsx
index a9ad4c3f6..23db6f581 100644
--- a/docs/components/DemoPerformanceContent/DemoPerformanceContent.tsx
+++ b/docs/components/DemoPerformanceContent/DemoPerformanceContent.tsx
@@ -9,7 +9,7 @@ import { CopyButton } from '@/components/CopyButton';
 import { Select } from '@/components/Select';
 import styles from '../../app/docs-infra/components/code-highlighter/demos/DemoContent.module.css';
 
-import '@wooorm/starry-night/style/light';
+import '../../app/docs-infra/components/code-highlighter/demos/syntax.css';
 import { BenchViewer } from '../BenchViewer';
 
 const variantNames: Record = {
diff --git a/docs/components/DemoPerformanceContentLoading/DemoPerformanceContentLoading.tsx b/docs/components/DemoPerformanceContentLoading/DemoPerformanceContentLoading.tsx
index 5802412ec..5f948e41a 100644
--- a/docs/components/DemoPerformanceContentLoading/DemoPerformanceContentLoading.tsx
+++ b/docs/components/DemoPerformanceContentLoading/DemoPerformanceContentLoading.tsx
@@ -5,7 +5,7 @@ import type { ContentLoadingProps } from '@mui/internal-docs-infra/CodeHighlight
 import { Tabs } from '@/components/Tabs';
 import styles from '../../app/docs-infra/components/code-highlighter/demos/DemoContent.module.css';
 
-import '@wooorm/starry-night/style/light';
+import '../../app/docs-infra/components/code-highlighter/demos/syntax.css';
 
 export function DemoPerformanceContentLoading(props: ContentLoadingProps) {
   const tabs = React.useMemo(
diff --git a/packages/docs-infra/src/CodeProvider/CodeProvider.tsx b/packages/docs-infra/src/CodeProvider/CodeProvider.tsx
index f35b6ac8c..e6f261fe3 100644
--- a/packages/docs-infra/src/CodeProvider/CodeProvider.tsx
+++ b/packages/docs-infra/src/CodeProvider/CodeProvider.tsx
@@ -12,6 +12,7 @@ import type {
 } from '../CodeHighlighter/types';
 import { extensionMap, grammars } from '../pipeline/parseSource/grammars';
 import { starryNightGutter } from '../pipeline/parseSource/addLineGutters';
+import { extendSyntaxTokens } from '../pipeline/parseSource/extendSyntaxTokens';
 // Import the heavy functions
 import { loadCodeFallback } from '../pipeline/loadCodeVariant/loadCodeFallback';
 import { loadCodeVariant } from '../pipeline/loadCodeVariant/loadCodeVariant';
@@ -73,7 +74,9 @@ export function CodeProvider({
           };
         }
 
-        const highlighted = starryNight.highlight(source, extensionMap[fileType]);
+        const grammarScope = extensionMap[fileType];
+        const highlighted = starryNight.highlight(source, grammarScope);
+        extendSyntaxTokens(highlighted, grammarScope);
         const sourceLines = source.split(/\r?\n|\r/);
         starryNightGutter(highlighted, sourceLines); // mutates the tree to add line gutters
 
diff --git a/packages/docs-infra/src/pipeline/enhanceCodeEmphasis/enhanceCodeEmphasis.test.ts b/packages/docs-infra/src/pipeline/enhanceCodeEmphasis/enhanceCodeEmphasis.test.ts
index 475645e71..7eb89faff 100644
--- a/packages/docs-infra/src/pipeline/enhanceCodeEmphasis/enhanceCodeEmphasis.test.ts
+++ b/packages/docs-infra/src/pipeline/enhanceCodeEmphasis/enhanceCodeEmphasis.test.ts
@@ -78,7 +78,7 @@ describe('enhanceCodeEmphasis', () => {
       expect(result).toMatchInlineSnapshot(`
         "export default function Button() {
           return (
-            <button className="primary">Click me</button>
+            <button className="primary">Click me</button>
           );
         }"
       `);
@@ -96,10 +96,10 @@ const e = 5; // @highlight`,
 
       expect(result).toMatchInlineSnapshot(
         `
-        "const a = 1;
-        const b = 2;
-        const c = 3;
-        const d = 4;
+        "const a = 1;
+        const b = 2;
+        const c = 3;
+        const d = 4;
         "
       `,
       );
@@ -116,7 +116,7 @@ const e = 5; // @highlight`,
 
       expect(result).toMatchInlineSnapshot(`
         "export default function Component() {
-          const [count, setCount] = useState(0);
+          const [count, setCount] = useState(0);
           return <div>{count}</div>;
         }"
       `);
@@ -134,7 +134,7 @@ const e = 5; // @highlight`,
       expect(result).toMatchInlineSnapshot(`
         "export default function Component() {
           const url = getUrl();
-          return <a href={url}>Link</a>;
+          return <a href={url}>Link</a>;
         }"
       `);
     });
@@ -180,7 +180,7 @@ const e = 5; // @highlight`,
 
       expect(result).toMatchInlineSnapshot(`
         "function test() {
-          return null;
+          return null;
         }"
       `);
     });
@@ -199,7 +199,7 @@ const e = 5; // @highlight`,
       expect(result).toMatchInlineSnapshot(
         `
         "function test() {
-          return null;
+          return null;
         }"
       `,
       );
@@ -300,7 +300,7 @@ const e = 5; // @highlight`,
         "export default function Component() {
           return (
             <div>
-              <h1 className="primary">Heading 1</h1>
+              <h1 className="primary">Heading 1</h1>
             </div>
           );
         }"
@@ -328,7 +328,7 @@ const e = 5; // @highlight`,
       );
 
       expect(result).toMatchInlineSnapshot(
-        `"<AlertDialog.Trigger handle={demoAlertDialog}>Open</AlertDialog.Trigger>"`,
+        `"<AlertDialog.Trigger handle={demoAlertDialog}>Open</AlertDialog.Trigger>"`,
       );
     });
 
@@ -340,7 +340,7 @@ const e = 5; // @highlight`,
       );
 
       expect(result).toMatchInlineSnapshot(
-        `"<Button render={<div />} nativeButton={false}>"`,
+        `"<Button render={<div />} nativeButton={false}>"`,
       );
     });
 
@@ -369,7 +369,7 @@ const e = 5; // @highlight`,
       expect(result).toMatch(/data-ln="2"[^>]*data-hl=""/);
       // The text highlight wraps ", 40px" which contains syntax-highlighted children
       expect(result).toContain(
-        ', 40px',
+        ', 40px',
       );
     });
 
@@ -398,8 +398,8 @@ const e = 5; // @highlight`,
       expect(result).toContain('data-hl-part="start"');
       expect(result).toContain('data-hl-part="end"');
       // The pl-e element must remain intact
-      expect(result).toContain('');
-      expect(result).not.toContain('native');
+      expect(result).toContain('');
+      expect(result).not.toContain('native');
     });
 
     it('should use data-hl-part for nested fragmented highlights', () => {
@@ -468,7 +468,7 @@ const e = 5; // @highlight`,
       );
 
       expect(result).toMatchInlineSnapshot(
-        `"<Input value={a} /> <Input value={b} /> // @highlight-text "value""`,
+        `"<Input value={a} /> <Input value={b} /> // @highlight-text "value""`,
       );
     });
 
@@ -495,7 +495,7 @@ const e = 5; // @highlight`,
       );
 
       expect(result).toMatchInlineSnapshot(
-        `"<Input value={value} />"`,
+        `"<Input value={value} />"`,
       );
     });
   });
@@ -597,10 +597,10 @@ const another = 99; // @highlight`,
       );
 
       expect(result).toMatchInlineSnapshot(`
-        "const value = 42;
+        "const value = 42;
         function example() {
-          const x = 1;
-          const y = 2;
+          const x = 1;
+          const y = 2;
           return x + y;
         }
         "
@@ -649,9 +649,9 @@ const c = 3;`,
       );
 
       expect(result).toMatchInlineSnapshot(`
-        "const a = 1;
-        const b = 2;
-        const c = 3;"
+        "const a = 1;
+        const b = 2;
+        const c = 3;"
       `);
     });
 
@@ -665,9 +665,9 @@ const c = 3;`,
       );
 
       expect(result).toMatchInlineSnapshot(`
-        "const a = 1;
-        const b = 2;
-        const c = 3;"
+        "const a = 1;
+        const b = 2;
+        const c = 3;"
       `);
     });
 
@@ -680,9 +680,9 @@ const c = 3;`,
       );
 
       expect(result).toMatchInlineSnapshot(`
-        "const a = 1;
-        const b = 2;
-        const c = 3;"
+        "const a = 1;
+        const b = 2;
+        const c = 3;"
       `);
     });
 
@@ -695,8 +695,8 @@ const b = 2;`,
 
       // Should not add any emphasis since there's no quoted text
       expect(result).toMatchInlineSnapshot(`
-        "const a = 1;
-        const b = 2;"
+        "const a = 1;
+        const b = 2;"
       `);
     });
   });
@@ -750,10 +750,10 @@ const b = 2;`,
           const [data, setData] = useState([]);
           return (
             <div>
-              <Header />
-              <Chart data={data} />
-              <Table data={data} />
-              <Footer />
+              <Header />
+              <Chart data={data} />
+              <Table data={data} />
+              <Footer />
             </div>
           );
         }"
@@ -891,7 +891,7 @@ const z = 3;`,
         "export default function Component() {
           return (
             <div>
-              <h1 className="primary">Heading 1</h1> {/* @highlight-text "primary" "Heading 1" */}
+              <h1 className="primary">Heading 1</h1> {/* @highlight-text "primary" "Heading 1" */}
             </div>
           );
         }"
@@ -928,10 +928,14 @@ const g = 7;`,
       expect(result).toContain('data-frame-type="highlighted"');
       expect(result).toContain('data-frame-type="padding-bottom"');
 
-      // Verify frame boundaries
-      expect(result).toMatch(/data-frame-type="padding-top"/);
-      expect(result).toMatch(/data-frame-type="highlighted"/);
-      expect(result).toMatch(/data-frame-type="padding-bottom"/);
+      // Verify frame boundaries: which lines belong to which frame
+      expect(result).toMatch(/data-frame-type="padding-top">]*>.*data-ln="3"/s);
+      // Padding bottom should include lines 5-6
+      expect(result).toMatch(/data-frame-type="padding-bottom">.*data-ln="6"/s);
     });
 
     it('should limit total focus area with focusFramesMaxSize', async () => {
@@ -962,9 +966,13 @@ const l = 12;`,
       // focusFramesMaxSize = 5, so remaining for padding = 5 - 3 = 2
       // floor(2/2)=1 top, ceil(2/2)=1 bottom
       // Even though paddingFrameMaxSize is 5, focusFramesMaxSize caps it
-      expect(result).toMatch(/data-frame-type="padding-top"/);
-      expect(result).toMatch(/data-frame-type="highlighted"/);
-      expect(result).toMatch(/data-frame-type="padding-bottom"/);
+      // Padding top: only line 5 (capped to 1)
+      expect(result).toMatch(/data-frame-type="padding-top">]*>]*>.*data-ln="8"/s);
+      // Padding bottom: only line 9 (capped to 1)
+      expect(result).toMatch(/data-frame-type="padding-bottom"> {
@@ -1007,13 +1015,17 @@ const e = 5;`,
 
       // Two highlight regions: line 1 and line 4
       // @focus is on line 4, so padding goes around line 4
-      // Padding top: line 3, padding bottom: line 5
-      expect(result).toMatch(/data-frame-type="padding-top"/);
-      expect(result).toMatch(/data-frame-type="highlighted"/);
-      expect(result).toMatch(/data-frame-type="padding-bottom"/);
+      // Padding top: line 3
+      expect(result).toMatch(/data-frame-type="padding-top">]*>]*> {
@@ -1056,13 +1068,18 @@ const d = 4;`,
       );
 
       // @focus on line 3 with description "important line"
-      expect(result).toMatch(/data-frame-type="highlighted"/);
+      // Highlighted (focused): line 3
+      expect(result).toMatch(/data-frame-type="highlighted"[^>]*>]*> {
   }
 
   describe('entity tag enhancement (pl-ent)', () => {
-    it('wraps < and > around pl-ent span into the span', async () => {
+    it('wraps < and > around pl-ent span in a di-ht wrapper', async () => {
       const input =
         '<div>';
 
       const output = await processHtml(input);
 
       expect(output).toBe(
-        '<div>',
+        '<div>',
       );
     });
 
@@ -38,7 +38,7 @@ describe('enhanceCodeInline', () => {
       const output = await processHtml(input);
 
       expect(output).toBe(
-        '<div><span>',
+        '<div><span>',
       );
     });
 
@@ -48,7 +48,7 @@ describe('enhanceCodeInline', () => {
       const output = await processHtml(input);
 
       expect(output).toBe(
-        '<br />',
+        '<br />',
       );
     });
 
@@ -58,18 +58,18 @@ describe('enhanceCodeInline', () => {
       const output = await processHtml(input);
 
       expect(output).toBe(
-        '<input/>',
+        '<input/>',
       );
     });
 
     it('handles tags with attributes', async () => {
       const input =
-        '<Box flag option={true} />';
+        '<Box flag option={true} />';
 
       const output = await processHtml(input);
 
       expect(output).toBe(
-        '<Box flag option={true} />',
+        '<Box flag option={true} />',
       );
     });
 
@@ -80,7 +80,7 @@ describe('enhanceCodeInline', () => {
       const output = await processHtml(input);
 
       expect(output).toBe(
-        '<div className="test">',
+        '<div className="test">',
       );
     });
 
@@ -90,40 +90,66 @@ describe('enhanceCodeInline', () => {
       const output = await processHtml(input);
 
       expect(output).toBe(
-        '</div>',
+        '</div>',
+      );
+    });
+
+    it('wraps tag with highlighted attribute spans between tag name and closing bracket', async () => {
+      const input =
+        '<div className="x">';
+
+      const output = await processHtml(input);
+
+      expect(output).toBe(
+        '<div className="x">',
+      );
+    });
+
+    it('skips > in the middle of intermediate text and wraps at the real tag close', async () => {
+      // An intermediate text node with ">" in the middle (not at start or end)
+      // is not a tag-close token. The scan skips it and finds the real close.
+      const input =
+        '<div a>b >';
+
+      const output = await processHtml(input);
+
+      expect(output).toBe(
+        '<div a>b >',
       );
     });
   });
 
   describe('syntax constant enhancement (pl-c1)', () => {
-    it('wraps < and > around pl-c1 span into the span', async () => {
-      const input = '<Box>';
+    it('wraps < and > around pl-c1 span in a di-jt wrapper', async () => {
+      const input =
+        '<Box>';
 
       const output = await processHtml(input);
 
       expect(output).toBe(
-        '<Box>',
+        '<Box>',
       );
     });
 
     it('handles multiple syntax constants in sequence', async () => {
       const input =
-        '<Box><Stack>';
+        '<Box><Stack>';
 
       const output = await processHtml(input);
 
       expect(output).toBe(
-        '<Box><Stack>',
+        '<Box><Stack>',
       );
     });
 
     it('handles closing tags with pl-c1', async () => {
-      const input = '</Box>';
+      const input =
+        '</Box>';
 
       const output = await processHtml(input);
 
       expect(output).toBe(
-        '</Box>',
+        '</Box>',
       );
     });
   });
@@ -131,34 +157,34 @@ describe('enhanceCodeInline', () => {
   describe('mixed scenarios', () => {
     it('handles pl-ent and pl-c1 in the same code element', async () => {
       const input =
-        '<div><Box>';
+        '<div><Box>';
 
       const output = await processHtml(input);
 
       expect(output).toBe(
-        '<div><Box>',
+        '<div><Box>',
       );
     });
 
     it('preserves other content around enhanced elements', async () => {
       const input =
-        'const x = <Box>;';
+        'const x = <Box>;';
 
       const output = await processHtml(input);
 
       expect(output).toBe(
-        'const x = <Box>;',
+        'const x = <Box>;',
       );
     });
 
     it('preserves other spans without pl-ent or pl-c1 classes', async () => {
       const input =
-        'const <Box>';
+        'const <Box>';
 
       const output = await processHtml(input);
 
       expect(output).toBe(
-        'const <Box>',
+        'const <Box>',
       );
     });
   });
@@ -201,7 +227,9 @@ describe('enhanceCodeInline', () => {
 
       const output = await processHtml(input);
 
-      expect(output).toBe('<div>');
+      expect(output).toBe(
+        '<div>',
+      );
     });
 
     it('handles empty code elements', async () => {
@@ -223,12 +251,12 @@ describe('enhanceCodeInline', () => {
     it('handles nested spans within pl-ent/pl-c1', async () => {
       // Unlikely scenario but should be handled gracefully
       const input =
-        '<Box>';
+        '<Box>';
 
       const output = await processHtml(input);
 
       expect(output).toBe(
-        '<Box>',
+        '<Box>',
       );
     });
   });
@@ -241,6 +269,7 @@ describe('enhanceCodeInline', () => {
       const output = await processHtml(input);
 
       expect(output).toContain('class="pl-ent custom-class"');
+      expect(output).toContain('class="di-ht"');
     });
 
     it('preserves other attributes on the span element', async () => {
@@ -262,113 +291,210 @@ describe('enhanceCodeInline', () => {
     });
   });
 
-  describe('nullish value enhancement', () => {
-    it('adds di-n class to span containing "undefined"', async () => {
-      const input = 'undefined';
+  describe('normalized standalone closing tags (text brackets)', () => {
+    it('wraps closing JSX component tag (pl-c1 di-jsx with text brackets) as di-jt', async () => {
+      // After extendSyntaxTokens: pl-k("</Stack>';
 
       const output = await processHtml(input);
 
-      expect(output).toBe('undefined');
+      expect(output).toBe(
+        '</Stack>',
+      );
     });
 
-    it('adds di-n class to span containing "null"', async () => {
-      const input = 'null';
+    it('wraps closing HTML element tag (pl-ent with text brackets) as di-ht', async () => {
+      // After extendSyntaxTokens: pl-k("</span>';
 
       const output = await processHtml(input);
 
-      expect(output).toBe('null');
+      expect(output).toBe(
+        '</span>',
+      );
     });
 
-    it('adds di-n class to span containing empty double-quoted string', async () => {
-      const input = '""';
+    it('does not wrap spans with no tag-name class (pl-k brackets pass through)', async () => {
+      const input =
+        '<foo>';
 
       const output = await processHtml(input);
 
-      expect(output).toBe('""');
+      expect(output).toBe(
+        '<foo>',
+      );
     });
 
-    it('adds di-n class to span containing empty single-quoted string', async () => {
-      const input = '\'\'';
+    it('does not wrap pl-smi with opening bracket (not a tag name)', async () => {
+      const input =
+        '<x>';
 
       const output = await processHtml(input);
 
-      expect(output).toBe('\'\'');
+      expect(output).toBe(
+        '<x>',
+      );
     });
+  });
 
-    it('does not modify spans with non-nullish values', async () => {
-      const input = 'true';
+  describe('token reclassification', () => {
+    it('reclassifies pl-en "function" to pl-k', async () => {
+      const input = 'function';
 
       const output = await processHtml(input);
 
-      expect(output).toBe('true');
+      expect(output).toBe('function');
     });
 
-    it('does not modify spans containing "undefined" as part of a longer string', async () => {
-      const input = 'undefinedValue';
+    it('does not reclassify pl-en spans with other text', async () => {
+      const input = 'myFunction';
+
+      const output = await processHtml(input);
+
+      expect(output).toBe('myFunction');
+    });
+
+    it('does not reclassify "function" in other classes', async () => {
+      const input = 'function';
+
+      const output = await processHtml(input);
+
+      expect(output).toBe('function');
+    });
+
+    it('does not reclassify inside pre elements', async () => {
+      const input =
+        '
function
'; const output = await processHtml(input); expect(output).toBe( - 'undefinedValue', + '
function
', ); }); + }); - it('does not modify nullish values inside pre elements', async () => { - const input = - '
undefined
'; + describe('built-in type enhancement', () => { + it('reclassifies pl-smi "string" to pl-c1 di-bt', async () => { + const input = 'string'; const output = await processHtml(input); expect(output).toBe( - '
undefined
', + 'string', ); }); - it('handles nullish values alongside tag enhancement', async () => { + it('reclassifies pl-smi "number" to pl-c1 di-bt', async () => { + const input = 'number'; + + const output = await processHtml(input); + + expect(output).toBe( + 'number', + ); + }); + + it('reclassifies pl-smi "boolean" to pl-c1 di-bt', async () => { + const input = 'boolean'; + + const output = await processHtml(input); + + expect(output).toBe( + 'boolean', + ); + }); + + it('reclassifies pl-k "void" to pl-c1 di-bt', async () => { + const input = 'void'; + + const output = await processHtml(input); + + expect(output).toBe( + 'void', + ); + }); + + it('does not reclassify pl-k spans with non-type text', async () => { + const input = 'const'; + + const output = await processHtml(input); + + expect(output).toBe('const'); + }); + + it('does not add di-bt to pl-smi spans with non-type text', async () => { + const input = 'myVariable'; + + const output = await processHtml(input); + + expect(output).toBe( + 'myVariable', + ); + }); + + it('does not add di-bt to pl-c1 spans (already handled by extendSyntaxTokens)', async () => { + const input = 'string'; + + const output = await processHtml(input); + + expect(output).toBe('string'); + }); + + it('does not add di-bt inside pre elements', async () => { const input = - '<Box> | undefined'; + '
string
'; const output = await processHtml(input); expect(output).toBe( - '<Box> | undefined', + '
string
', ); }); - }); - describe('token reclassification', () => { - it('reclassifies pl-en "function" to pl-k', async () => { - const input = 'function'; + it('does not reclassify pl-smi in JavaScript code', async () => { + const input = 'string'; const output = await processHtml(input); - expect(output).toBe('function'); + expect(output).toBe('string'); }); - it('does not reclassify pl-en spans with other text', async () => { - const input = 'myFunction'; + it('does not reclassify pl-smi in CSS code', async () => { + const input = 'number'; const output = await processHtml(input); - expect(output).toBe('myFunction'); + expect(output).toBe('number'); }); - it('does not reclassify "function" in other classes', async () => { - const input = 'function'; + it('does not reclassify pl-smi without a language class', async () => { + const input = 'string'; const output = await processHtml(input); - expect(output).toBe('function'); + expect(output).toBe('string'); }); - it('does not reclassify inside pre elements', async () => { + it('reclassifies in language-ts code', async () => { + const input = 'string'; + + const output = await processHtml(input); + + expect(output).toBe( + 'string', + ); + }); + + it('does not reclassify pl-k "void" when used as operator with siblings', async () => { const input = - '
function
'; + 'void fn()'; const output = await processHtml(input); expect(output).toBe( - '
function
', + 'void fn()', ); }); }); diff --git a/packages/docs-infra/src/pipeline/enhanceCodeInline/enhanceCodeInline.ts b/packages/docs-infra/src/pipeline/enhanceCodeInline/enhanceCodeInline.ts index 78012912a..efdbd521d 100644 --- a/packages/docs-infra/src/pipeline/enhanceCodeInline/enhanceCodeInline.ts +++ b/packages/docs-infra/src/pipeline/enhanceCodeInline/enhanceCodeInline.ts @@ -1,20 +1,20 @@ import type { Root as HastRoot, Element, Text, ElementContent } from 'hast'; import { visit } from 'unist-util-visit'; +import { getShallowTextContent } from '../loadServerTypes/hastTypeUtils'; +import { getLanguageCapabilities } from '../enhanceCodeTypes/getLanguageCapabilities'; +import { BUILT_IN_TYPES } from '../parseSource/extendSyntaxTokens'; /** - * Classes whose spans represent tag names that should have their - * surrounding angle brackets wrapped into the same span. - * - pl-ent: HTML entity tag (e.g., div, span) - * - pl-c1: Syntax constant (e.g., React components like Box, Stack) - */ -const TAG_NAME_CLASSES = ['pl-ent', 'pl-c1']; - -/** - * Values that should be styled with the nullish class (di-n). - * These are special values that benefit from distinct styling - * to visually distinguish them from regular code. + * Maps tag-name span classes to their wrapper class. + * - pl-ent (HTML entity tag like div, span) → di-ht (HTML tag) + * - pl-c1 (syntax constant like Box, Stack) → di-jt (JSX tag) + * + * When the element also has `di-jsx`, the wrapper is always `di-jt`. */ -const NULLISH_VALUES = ['undefined', 'null', '""', "''"]; +const TAG_NAME_CLASS_MAP: Record = { + 'pl-ent': 'di-ht', + 'pl-c1': 'di-jt', +}; /** * Map of class → text values that should be reclassified to a different class. @@ -28,14 +28,30 @@ const CLASS_RECLASSIFICATIONS: Record> = { }; /** - * Checks if an element has any of the tag name classes. + * Returns the wrapper class for a tag-name element, or undefined if not a tag name. + * If the element has `di-jsx`, always returns `di-jt` (JSX component tag). */ -function hasTagNameClass(element: Element): boolean { +function getTagWrapperClass(element: Element): string | undefined { const className = element.properties?.className; if (!Array.isArray(className)) { - return false; + return undefined; } - return className.some((c) => typeof c === 'string' && TAG_NAME_CLASSES.includes(c)); + let baseWrapper: string | undefined; + let hasDiJsx = false; + for (const cls of className) { + if (typeof cls === 'string') { + if (TAG_NAME_CLASS_MAP[cls] && !baseWrapper) { + baseWrapper = TAG_NAME_CLASS_MAP[cls]; + } + if (cls === 'di-jsx') { + hasDiJsx = true; + } + } + } + if (hasDiJsx) { + return 'di-jt'; + } + return baseWrapper; } /** @@ -91,7 +107,17 @@ function findClosingBracket(text: string): { position: number; suffix: string } } /** - * Wraps HTML tag angle brackets into their associated tag name spans. + * Wraps HTML/JSX tag patterns in a wrapper span that groups the opening bracket, + * tag-name span, and closing bracket into one element. + * + * - HTML tags (pl-ent) get `` (HTML tag) + * - JSX component tags (pl-c1 with di-jsx) get `` (JSX tag) + * + * Expects the pattern: text(`<`) + span(tagName) + text(`>`) + * where `extendSyntaxTokens` has already normalized bracket spans to text nodes. + * + * The original `pl-*` spans are preserved intact inside the wrapper — no + * semantic information is destroyed. * * This function processes nodes iteratively, but when text is split during * enhancement, it re-inserts the remaining text back into the processing queue @@ -114,47 +140,92 @@ function enhanceTagBrackets(children: ElementContent[]): ElementContent[] { const nextElement = queue[0] as Element; const { match, prefix } = endsWithOpenBracket(textNode.value); + const wrapperClass = match ? getTagWrapperClass(nextElement) : undefined; + + if (wrapperClass) { + // Scan forward past the tag name span to find the closing bracket text node. + // It may be immediately after (simple tags like
) or separated by + // attribute spans (e.g.
). + // Stop scanning if we hit a text node containing '<' (new tag context). + let closingBracketIndex = -1; + let closingBracket: { position: number; suffix: string } | null = null; + + for (let scanIdx = 1; scanIdx < queue.length; scanIdx += 1) { + const scanNode = queue[scanIdx]; + if (scanNode.type === 'text') { + const scanText = scanNode.value; + closingBracket = findClosingBracket(scanText); + if (closingBracket) { + const matchEnd = closingBracket.position + closingBracket.suffix.length; + if (closingBracket.position === 0 || matchEnd === scanText.length) { + // > at the start or end of text is a tag-close token + closingBracketIndex = scanIdx; + break; + } + // The earliest > is in the middle of text — not a tag-close + // token. Check for a > at the end of the text instead. + closingBracket = null; + if (scanText.endsWith(' />')) { + closingBracket = { position: scanText.length - 3, suffix: ' />' }; + } else if (scanText.endsWith('/>')) { + closingBracket = { position: scanText.length - 2, suffix: '/>' }; + } else if (scanText.endsWith('>')) { + closingBracket = { position: scanText.length - 1, suffix: '>' }; + } + if (closingBracket) { + closingBracketIndex = scanIdx; + break; + } + } + // A '<' in text before any '>' means a new tag context — stop scanning + if (scanText.includes('<')) { + break; + } + } + } - if (match && hasTagNameClass(nextElement)) { - // Check if there's a closing bracket after the span - const afterSpan = queue[1]; - const closingBracket = - afterSpan && afterSpan.type === 'text' - ? findClosingBracket((afterSpan as Text).value) - : null; - - if (closingBracket) { + if (closingBracket && closingBracketIndex !== -1) { // Add the text before the < (if any) const textBeforeBracket = textNode.value.slice(0, -prefix.length); if (textBeforeBracket) { newChildren.push({ type: 'text', value: textBeforeBracket }); } - // Create enhanced span with brackets included - // Include any attributes/content between the tag name and closing bracket - const afterText = (afterSpan as Text).value; - const contentBeforeClose = afterText.slice(0, closingBracket.position); - const enhancedSpan: Element = { + // Build the wrapper children: bracket text + tag name span + intermediate nodes + closing text + const closingTextNode = queue[closingBracketIndex] as Text; + const contentBeforeClose = closingTextNode.value.slice(0, closingBracket.position); + + const wrapperChildren: ElementContent[] = [{ type: 'text', value: prefix }]; + + // Add the tag name span and any intermediate nodes (attributes, etc.) + for (let takeIdx = 0; takeIdx <= closingBracketIndex; takeIdx += 1) { + if (takeIdx === closingBracketIndex) { + // Last node is the text containing >; include content before + bracket + wrapperChildren.push({ + type: 'text', + value: contentBeforeClose + closingBracket.suffix, + }); + } else { + wrapperChildren.push(queue[takeIdx]); + } + } + + const wrapperSpan: Element = { type: 'element', tagName: 'span', - properties: { ...nextElement.properties }, - children: [ - { type: 'text', value: prefix }, - ...nextElement.children, - { type: 'text', value: contentBeforeClose + closingBracket.suffix }, - ], + properties: { className: [wrapperClass] }, + children: wrapperChildren, }; - newChildren.push(enhancedSpan); + newChildren.push(wrapperSpan); - // Remove the span and the text with > from the queue - queue.shift(); // Remove the span - queue.shift(); // Remove the text with > + // Remove all consumed nodes from the queue + const textAfterBracket = closingTextNode.value.slice( + closingBracket.position + closingBracket.suffix.length, + ); + queue.splice(0, closingBracketIndex + 1); // If there's remaining text after the closing bracket, re-insert it at the front of the queue // so it can be processed for the next pattern (e.g., consecutive tags) - const textAfterBracket = afterText.slice( - closingBracket.position + closingBracket.suffix.length, - ); if (textAfterBracket) { queue.unshift({ type: 'text', value: textAfterBracket }); } @@ -171,17 +242,6 @@ function enhanceTagBrackets(children: ElementContent[]): ElementContent[] { return newChildren; } -/** - * Gets the text content of an element's first text child. - */ -function getFirstTextValue(element: Element): string | undefined { - const firstChild = element.children[0]; - if (firstChild && firstChild.type === 'text') { - return firstChild.value; - } - return undefined; -} - /** * Reclassifies spans whose class + text content indicate a wrong token type. * For example, `function` is reclassified to @@ -198,7 +258,7 @@ function reclassifyTokens(children: ElementContent[]): void { continue; } - const text = getFirstTextValue(child); + const text = getShallowTextContent(child); if (!text) { continue; } @@ -213,30 +273,48 @@ function reclassifyTokens(children: ElementContent[]): void { } /** - * Enhances nullish values (`undefined`, `null`, `""`, `''`) by adding the `di-n` - * class to their containing span elements. This allows CSS to style these - * values distinctly from regular code, improving readability. + * Reclassifies `pl-smi` and `pl-k` spans whose text is a built-in type keyword + * (e.g. `string`, `number`, `void`) to `pl-c1 di-bt`. + * + * Only applies to TypeScript-family languages, matching the contract in + * `extendSyntaxTokens` which gates `di-bt` on `isTs`. * - * Mirrors the behavior of base-ui's `rehypeInlineCode` plugin, but uses - * CSS classes (from the prettylights/docs-infra extension system) instead - * of inline styles. + * Starry Night tokenizes standalone type keywords inconsistently when there is + * no surrounding type context: most (`string`, `number`, …) become `pl-smi` + * (identifier), while `void` becomes `pl-k` (keyword). In inline code this is + * the common case — e.g. `` `string` `` — so we reclassify them to match the + * output of `type x = string` (where starry-night produces `pl-c1`) and add + * `di-bt` for semantic styling. + * + * For `pl-k` tokens (like `void`), we only reclassify when the token is the + * sole child of the code element to avoid mis-highlighting the unary `void` + * operator in expressions like `void fn()`. */ -function enhanceNullishValues(children: ElementContent[]): void { - for (const child of children) { +function enhanceBuiltInTypes(children: ElementContent[]): void { + for (let index = 0; index < children.length; index += 1) { + const child = children[index]; if (child.type !== 'element' || child.tagName !== 'span') { continue; } - const text = getFirstTextValue(child); - if (text && NULLISH_VALUES.includes(text)) { - const className = child.properties?.className; - if (Array.isArray(className)) { - // Replace existing classes with di-n since nullish styling should take precedence - child.properties!.className = ['di-n']; - } else { - child.properties = child.properties || {}; - child.properties.className = ['di-n']; - } + const className = child.properties?.className; + if (!Array.isArray(className)) { + continue; + } + + const smiIndex = className.indexOf('pl-smi'); + // Only reclassify pl-k when it is the only child (standalone keyword), + // so the void *operator* in multi-token expressions is left alone. + const kIndex = smiIndex === -1 && children.length === 1 ? className.indexOf('pl-k') : -1; + const targetIndex = smiIndex !== -1 ? smiIndex : kIndex; + if (targetIndex === -1) { + continue; + } + + const text = getShallowTextContent(child); + if (text && BUILT_IN_TYPES.has(text)) { + className[targetIndex] = 'pl-c1'; + className.push('di-bt'); } } } @@ -244,26 +322,24 @@ function enhanceNullishValues(children: ElementContent[]): void { /** * A rehype plugin that enhances inline code elements in three ways: * - * 1. **Tag bracket wrapping**: Wraps HTML tag angle brackets into the - * syntax highlighting span, so `
` is styled as one unit. + * 1. **Tag bracket wrapping**: Wraps HTML/JSX tag patterns (opening bracket, + * tag-name span, closing bracket) in a wrapper span. HTML tags (`pl-ent`) + * get ``, JSX component tags (`pl-c1`) get + * ``. The original `pl-*` spans are preserved + * inside — no semantic information is destroyed. * * 2. **Token reclassification**: Corrects misidentified token classes, * e.g., `function` marked as `pl-en` is changed to `pl-k` (keyword). * - * 3. **Nullish value styling**: Adds the `di-n` class to spans containing - * `undefined`, `null`, `""`, or `''` for distinct visual treatment. + * 3. **Built-in type enhancement** (TypeScript only): Reclassifies standalone + * type keywords (`string`, `number`, `void`, etc.) from `pl-smi`/`pl-k` + * to `pl-c1 di-bt`, matching `extendSyntaxTokens` output in type context. * * Transforms patterns like: * `<div>` * * Into: - * `<div>` - * - * And: - * `undefined` - * - * Into: - * `undefined` + * `<div>` * * **Important**: This plugin should run after syntax highlighting plugins * (like transformHtmlCodeInline) as it modifies the structure @@ -295,8 +371,10 @@ export default function enhanceCodeInline() { // Reclassify misidentified tokens (e.g., pl-en "function" → pl-k) reclassifyTokens(node.children); - // Enhance nullish values (adds di-n class for distinct styling) - enhanceNullishValues(node.children); + // Reclassify standalone built-in type keywords (TypeScript only) + if (getLanguageCapabilities(node).supportsTypes) { + enhanceBuiltInTypes(node.children); + } }); }; } diff --git a/packages/docs-infra/src/pipeline/enhanceCodeTypes/getLanguageCapabilities.ts b/packages/docs-infra/src/pipeline/enhanceCodeTypes/getLanguageCapabilities.ts index d3ad83d3c..787346b3e 100644 --- a/packages/docs-infra/src/pipeline/enhanceCodeTypes/getLanguageCapabilities.ts +++ b/packages/docs-infra/src/pipeline/enhanceCodeTypes/getLanguageCapabilities.ts @@ -1,24 +1,8 @@ import type { Element } from 'hast'; import { getClassName } from './hastUtils'; +import type { LanguageCapabilities } from '../parseSource/languageCapabilities'; -/** - * Language capabilities derived from the code element's `language-*` class. - * - * - `ts`/`typescript`: types ✓, JSX ✗, JS semantics ✓ - * - `tsx`: types ✓, JSX ✓, JS semantics ✓ - * - `js`/`javascript`: types ✗, JSX ✗, JS semantics ✓ - * - `jsx`: types ✗, JSX ✓, JS semantics ✓ - * - `css`/`scss`/`less`/`sass`: CSS semantics ✓ - * - no class / unknown: all ✗ - */ -export interface LanguageCapabilities { - /** Whether `type Name` and `const name: Name =` syntax is recognized. */ - supportsTypes: boolean; - /** Whether JSX `` syntax is recognized. */ - supportsJsx: boolean; - /** Which platform semantics apply: `'js'` for function calls / JS patterns, `'css'` for CSS patterns, or `undefined` for unknown languages. */ - semantics?: 'js' | 'css'; -} +export type { LanguageCapabilities }; const BASE_CAPABILITIES: LanguageCapabilities = { supportsTypes: false, diff --git a/packages/docs-infra/src/pipeline/loadServerTypes/__snapshots__/highlightTypes.test.ts.snap b/packages/docs-infra/src/pipeline/loadServerTypes/__snapshots__/highlightTypes.test.ts.snap index 02c5b1959..6c0152287 100644 --- a/packages/docs-infra/src/pipeline/loadServerTypes/__snapshots__/highlightTypes.test.ts.snap +++ b/packages/docs-infra/src/pipeline/loadServerTypes/__snapshots__/highlightTypes.test.ts.snap @@ -213,6 +213,7 @@ exports[`highlightTypes > snapshot tests - precomputed output verification > sho "properties": { "className": [ "pl-c1", + "di-bt", ], }, "tagName": "span", @@ -780,6 +781,7 @@ exports[`highlightTypes > snapshot tests - precomputed output verification > sho "properties": { "className": [ "pl-c1", + "di-cp", ], }, "tagName": "span", @@ -799,6 +801,7 @@ exports[`highlightTypes > snapshot tests - precomputed output verification > sho "properties": { "className": [ "pl-c1", + "di-cv", ], }, "tagName": "span", @@ -891,6 +894,7 @@ exports[`highlightTypes > snapshot tests - precomputed output verification > sho "properties": { "className": [ "pl-c1", + "di-jsx", ], }, "tagName": "span", @@ -929,6 +933,7 @@ exports[`highlightTypes > snapshot tests - precomputed output verification > sho "properties": { "className": [ "pl-c1", + "di-jsx", ], }, "tagName": "span", @@ -948,6 +953,7 @@ exports[`highlightTypes > snapshot tests - precomputed output verification > sho "properties": { "className": [ "pl-c1", + "di-jsx", ], }, "tagName": "span", @@ -986,6 +992,7 @@ exports[`highlightTypes > snapshot tests - precomputed output verification > sho "properties": { "className": [ "pl-c1", + "di-jsx", ], }, "tagName": "span", diff --git a/packages/docs-infra/src/pipeline/loaderUtils/parseImportsAndComments.test.ts b/packages/docs-infra/src/pipeline/loaderUtils/parseImportsAndComments.test.ts index b6e31d2f8..26d0ec665 100644 --- a/packages/docs-infra/src/pipeline/loaderUtils/parseImportsAndComments.test.ts +++ b/packages/docs-infra/src/pipeline/loaderUtils/parseImportsAndComments.test.ts @@ -1131,6 +1131,10 @@ export default function CheckboxBasic() { @import "//fonts.googleapis.com/css2?family=Inter"; @import url("//cdn.example.com/style.css"); + /* Scoped npm package imports */ + @import "@wooorm/starry-night/style/light" layer(starry-night); + @import "@scope/pkg/styles.css"; + body { font-family: sans-serif; } `; const filePath = '/src/styles/main.css'; @@ -1183,6 +1187,14 @@ export default function CheckboxBasic() { positions: [{ start: 510, end: 552 }], }, '//cdn.example.com/style.css': { names: [], positions: [{ start: 574, end: 603 }] }, + '@wooorm/starry-night/style/light': { + names: [], + positions: [{ start: 672, end: 706 }], + }, + '@scope/pkg/styles.css': { + names: [], + positions: [{ start: 744, end: 767 }], + }, }, }); }); diff --git a/packages/docs-infra/src/pipeline/loaderUtils/parseImportsAndComments.ts b/packages/docs-infra/src/pipeline/loaderUtils/parseImportsAndComments.ts index 1abf6d9db..976f1d1ae 100644 --- a/packages/docs-infra/src/pipeline/loaderUtils/parseImportsAndComments.ts +++ b/packages/docs-infra/src/pipeline/loaderUtils/parseImportsAndComments.ts @@ -977,25 +977,20 @@ function detectCssImport( importResult.pathStart !== undefined && importResult.pathEnd !== undefined ) { - // In CSS, imports are relative unless they have a protocol/hostname - // Examples of external: "http://...", "https://...", "//example.com/style.css" - // Examples of relative: "print.css", "./local.css", "../parent.css" + // In CSS, imports are relative unless they have a protocol, hostname, + // or are scoped npm packages (start with @scope/) const hasProtocol = /^https?:\/\//.test(importResult.modulePath); const hasHostname = /^\/\//.test(importResult.modulePath); - const isExternal = hasProtocol || hasHostname; + const isScopedPackage = /^@[^/]+\//.test(importResult.modulePath); + const isRelative = !hasProtocol && !hasHostname && !isScopedPackage; const position: ImportPathPosition = { start: positionMapper(importResult.pathStart), end: positionMapper(importResult.pathEnd), }; - if (isExternal) { - if (!cssExternals[importResult.modulePath]) { - cssExternals[importResult.modulePath] = { names: [], positions: [] }; - } - cssExternals[importResult.modulePath].positions.push(position); - } else { - // Treat as relative import - normalize the path if it doesn't start with ./ or ../ + if (isRelative) { + // Normalize bare filenames (e.g. "reset.css") to relative paths let normalizedPath = importResult.modulePath; if (!normalizedPath.startsWith('./') && !normalizedPath.startsWith('../')) { normalizedPath = `./${normalizedPath}`; @@ -1009,6 +1004,11 @@ function detectCssImport( }; } cssResult[importResult.modulePath].positions.push(position); + } else { + if (!cssExternals[importResult.modulePath]) { + cssExternals[importResult.modulePath] = { names: [], positions: [] }; + } + cssExternals[importResult.modulePath].positions.push(position); } } return { found: true, nextPos: importResult.nextPos }; diff --git a/packages/docs-infra/src/pipeline/parseSource/extendSyntaxTokens.test.ts b/packages/docs-infra/src/pipeline/parseSource/extendSyntaxTokens.test.ts new file mode 100644 index 000000000..512a38c69 --- /dev/null +++ b/packages/docs-infra/src/pipeline/parseSource/extendSyntaxTokens.test.ts @@ -0,0 +1,1357 @@ +import { describe, it, expect } from 'vitest'; +import type { Root, Element, ElementContent, Text } from 'hast'; +import { extendSyntaxTokens } from './extendSyntaxTokens'; + +/** + * Helper to create a span element with a class and text content. + */ +function span(className: string, textValue: string): Element { + return { + type: 'element', + tagName: 'span', + properties: { className: [className] }, + children: [{ type: 'text', value: textValue }], + }; +} + +/** + * Helper to create a span with multiple classes. + */ +function spanMultiClass(classNames: string[], textValue: string): Element { + return { + type: 'element', + tagName: 'span', + properties: { className: [...classNames] }, + children: [{ type: 'text', value: textValue }], + }; +} + +/** + * Helper to create a text node. + */ +function textNode(value: string): Text { + return { type: 'text', value }; +} + +/** + * Helper to create a pl-s (string) span with pl-pds delimiters as starry-night would. + * e.g. `"hello"` → `"hello"` + */ +function stringSpan(quote: string, content: string): Element { + const children: ElementContent[] = [ + span('pl-pds', quote), + ...(content ? [textNode(content)] : []), + span('pl-pds', quote), + ]; + return { + type: 'element', + tagName: 'span', + properties: { className: ['pl-s'] }, + children, + }; +} + +/** + * Helper to wrap children in a Root node. + */ +function root(children: ElementContent[]): Root { + return { type: 'root', children }; +} + +/** + * Helper to get className array from an element. + */ +function getClasses(element: Element): string[] { + const className = element.properties?.className; + if (Array.isArray(className)) { + return className.map(String); + } + return []; +} + +describe('extendSyntaxTokens', () => { + describe('number enhancement (di-num)', () => { + it('adds di-num to integer constants', () => { + const node = span('pl-c1', '42'); + const tree = root([node]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(node)).toEqual(['pl-c1', 'di-num']); + }); + + it('adds di-num to float constants', () => { + const node = span('pl-c1', '3.14'); + const tree = root([node]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(node)).toEqual(['pl-c1', 'di-num']); + }); + + it('adds di-num to negative numbers', () => { + const node = span('pl-c1', '-1'); + const tree = root([node]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(node)).toEqual(['pl-c1', 'di-num']); + }); + + it('adds di-num to decimal starting with dot', () => { + const node = span('pl-c1', '.5'); + const tree = root([node]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(node)).toEqual(['pl-c1', 'di-num']); + }); + + it('adds di-num to hex constants', () => { + const node = span('pl-c1', '0xFF'); + const tree = root([node]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(node)).toEqual(['pl-c1', 'di-num']); + }); + + it('adds di-num to zero', () => { + const node = span('pl-c1', '0'); + const tree = root([node]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(node)).toEqual(['pl-c1', 'di-num']); + }); + + it('adds di-num to CSS numeric values with units', () => { + const node = span('pl-c1', '100px'); + const tree = root([node]); + + extendSyntaxTokens(tree, 'source.css'); + + expect(getClasses(node)).toContain('di-num'); + }); + + it('adds di-num to percentage values', () => { + const node = span('pl-c1', '50%'); + const tree = root([node]); + + extendSyntaxTokens(tree, 'source.css'); + + expect(getClasses(node)).toContain('di-num'); + }); + + it('adds di-num when unit is nested as pl-smi child', () => { + // Starry-night tokenizes `1rem` as `1rem` + const node: Element = { + type: 'element', + tagName: 'span', + properties: { className: ['pl-c1'] }, + children: [textNode('1'), span('pl-smi', 'rem')], + }; + const tree = root([node]); + + extendSyntaxTokens(tree, 'source.css'); + + expect(getClasses(node)).toContain('di-num'); + }); + + it('does not add di-num to named constants like color', () => { + const node = span('pl-c1', 'color'); + const tree = root([node]); + + extendSyntaxTokens(tree, 'source.css'); + + expect(getClasses(node)).not.toContain('di-num'); + }); + + it('does not add di-num to component names like Button', () => { + const node = span('pl-c1', 'Button'); + const tree = root([node]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(node)).toEqual(['pl-c1']); + }); + + it('does not add di-num to non-pl-c1 spans', () => { + const node = span('pl-k', '42'); + const tree = root([node]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(node)).toEqual(['pl-k']); + }); + + it('does not add di-num to NaN', () => { + const node = span('pl-c1', 'NaN'); + const tree = root([node]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(node)).toEqual(['pl-c1']); + }); + + it('does not add di-num to Infinity', () => { + const node = span('pl-c1', 'Infinity'); + const tree = root([node]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(node)).toEqual(['pl-c1']); + }); + }); + + describe('boolean enhancement (di-bool)', () => { + it('adds di-bool to true', () => { + const node = span('pl-c1', 'true'); + const tree = root([node]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(node)).toEqual(['pl-c1', 'di-bool']); + }); + + it('adds di-bool to false', () => { + const node = span('pl-c1', 'false'); + const tree = root([node]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(node)).toEqual(['pl-c1', 'di-bool']); + }); + + it('does not add di-bool to non-pl-c1 spans', () => { + const node = span('pl-s', 'true'); + const tree = root([node]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(node)).toEqual(['pl-s']); + }); + }); + + describe('nullish enhancement (di-n)', () => { + it('adds di-n to null', () => { + const node = span('pl-c1', 'null'); + const tree = root([node]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(node)).toEqual(['pl-c1', 'di-n']); + }); + + it('adds di-n to undefined', () => { + const node = span('pl-c1', 'undefined'); + const tree = root([node]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(node)).toEqual(['pl-c1', 'di-n']); + }); + + it('does not add di-n to undefinedValue', () => { + const node = span('pl-c1', 'undefinedValue'); + const tree = root([node]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(node)).toEqual(['pl-c1']); + }); + + it('adds di-n to empty double-quoted string', () => { + const node = stringSpan('"', ''); + const tree = root([node]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(node)).toEqual(['pl-s', 'di-n']); + }); + + it('adds di-n to empty single-quoted string', () => { + const node = stringSpan("'", ''); + const tree = root([node]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(node)).toEqual(['pl-s', 'di-n']); + }); + + it('does not add di-n to non-empty strings', () => { + const node = stringSpan('"', 'hello'); + const tree = root([node]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(node)).toEqual(['pl-s']); + }); + + it('does not add di-n to strings with spaces', () => { + const node = stringSpan('"', ' '); + const tree = root([node]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(node)).toEqual(['pl-s']); + }); + }); + + describe('additive behavior', () => { + it('preserves existing pl-c1 class when adding di-num', () => { + const node = span('pl-c1', '42'); + const tree = root([node]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(node)).toContain('pl-c1'); + expect(getClasses(node)).toContain('di-num'); + }); + + it('preserves existing pl-c1 class when adding di-bool', () => { + const node = span('pl-c1', 'true'); + const tree = root([node]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(node)).toContain('pl-c1'); + expect(getClasses(node)).toContain('di-bool'); + }); + + it('preserves additional classes on the element', () => { + const node = spanMultiClass(['pl-c1', 'custom-class'], '42'); + const tree = root([node]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(node)).toEqual(['pl-c1', 'custom-class', 'di-num']); + }); + }); + + describe('CSS attribute selector enhancement (di-da)', () => { + it('adds di-da to pl-c1 span preceded by [ text node', () => { + // Current starry-night: &[data-starting-style] + const attrSpan = span('pl-c1', 'data-starting-style'); + const tree = root([span('pl-ent', '&'), textNode('['), attrSpan, textNode(']')]); + + extendSyntaxTokens(tree, 'source.css'); + + expect(getClasses(attrSpan)).toContain('pl-c1'); + expect(getClasses(attrSpan)).toContain('di-da'); + }); + + it('adds di-da to pl-e span preceded by [ text node (future starry-night)', () => { + const attrSpan = span('pl-e', 'data-ending-style'); + const tree = root([textNode('['), attrSpan, textNode(']')]); + + extendSyntaxTokens(tree, 'source.css'); + + expect(getClasses(attrSpan)).toContain('pl-e'); + expect(getClasses(attrSpan)).toContain('di-da'); + }); + + it('adds di-da for any attribute name in brackets, not just data-*', () => { + const attrSpan = span('pl-c1', 'open'); + const tree = root([textNode('['), attrSpan, textNode(']')]); + + extendSyntaxTokens(tree, 'source.css'); + + expect(getClasses(attrSpan)).toContain('di-da'); + }); + + it('does not add di-da to CSS class selectors', () => { + // .my-class is a pl-e span not preceded by [ + const classSpan = span('pl-e', '.my-class'); + const tree = root([classSpan]); + + extendSyntaxTokens(tree, 'source.css'); + + expect(getClasses(classSpan)).toEqual(['pl-e']); + }); + + it('does not add di-da when not preceded by [', () => { + const attrSpan = span('pl-c1', 'data-foo'); + const tree = root([textNode(' '), attrSpan, textNode(']')]); + + extendSyntaxTokens(tree, 'source.css'); + + expect(getClasses(attrSpan)).toEqual(['pl-c1']); + }); + + it('does not add di-da for non-CSS grammar scopes', () => { + const attrSpan = span('pl-c1', 'data-foo'); + const tree = root([textNode('['), attrSpan, textNode(']')]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(attrSpan)).not.toContain('di-da'); + }); + + it('also applies constant enhancement alongside di-da for numeric attribute names', () => { + // Hypothetical: [0] — the 0 is pl-c1 and numeric + const attrSpan = span('pl-c1', '0'); + const tree = root([textNode('['), attrSpan, textNode(']')]); + + extendSyntaxTokens(tree, 'source.css'); + + // Gets both di-num (from constant enhancement) and di-da (from CSS attr selector) + expect(getClasses(attrSpan)).toContain('di-num'); + expect(getClasses(attrSpan)).toContain('di-da'); + }); + + it('does not modify & already tokenized as pl-ent', () => { + // If a future starry-night version tokenizes & as pl-ent, it should pass through + const ampersand = span('pl-ent', '&'); + const attrSpan = span('pl-c1', 'data-starting-style'); + const tree = root([ampersand, textNode('['), attrSpan, textNode(']')]); + + extendSyntaxTokens(tree, 'source.css'); + + expect(getClasses(ampersand)).toEqual(['pl-ent']); + expect(getClasses(attrSpan)).toContain('di-da'); + }); + }); + + describe('CSS nesting selector enhancement (pl-ent)', () => { + it('wraps bare & in text nodes with pl-ent span', () => { + // starry-night v3.x produces: "&[" as a text node + const tree = root([textNode('&['), span('pl-e', 'data-starting-style'), textNode(']')]); + + extendSyntaxTokens(tree, 'source.css'); + + // & should be extracted into its own pl-ent span + expect(tree.children[0]).toEqual(span('pl-ent', '&')); + expect(tree.children[1]).toEqual({ type: 'text', value: '[' }); + }); + + it('wraps & before a space (descendant combinator)', () => { + const tree = root([textNode('& '), span('pl-e', '.child'), textNode(' { }')]); + + extendSyntaxTokens(tree, 'source.css'); + + expect(tree.children[0]).toEqual(span('pl-ent', '&')); + expect(tree.children[1]).toEqual({ type: 'text', value: ' ' }); + }); + + it('wraps & before pseudo-class', () => { + // &:hover → text "&" then text ":" then span + const tree = root([textNode('&'), span('pl-c1', ':hover')]); + + extendSyntaxTokens(tree, 'source.css'); + + expect(tree.children[0]).toEqual(span('pl-ent', '&')); + }); + + it('wraps & before class selector', () => { + const tree = root([textNode('&'), span('pl-e', '.active')]); + + extendSyntaxTokens(tree, 'source.css'); + + expect(tree.children[0]).toEqual(span('pl-ent', '&')); + }); + + it('wraps multiple & in separate text nodes', () => { + const tree = root([textNode('&'), span('pl-e', '.a'), textNode(', &'), span('pl-e', '.b')]); + + extendSyntaxTokens(tree, 'source.css'); + + expect(tree.children[0]).toEqual(span('pl-ent', '&')); + // ", &" should be split into ", " text + pl-ent span + expect(tree.children[2]).toEqual({ type: 'text', value: ', ' }); + expect(tree.children[3]).toEqual(span('pl-ent', '&')); + }); + + it('does not wrap & for non-CSS grammars', () => { + const tree = root([textNode('a && b')]); + + extendSyntaxTokens(tree, 'source.tsx'); + + // Should remain as plain text + expect(tree.children[0]).toEqual({ type: 'text', value: 'a && b' }); + }); + + it('wraps & inside nested rule context', () => { + // .parent { &:hover { color: blue; } } + const tree = root([ + span('pl-e', '.parent'), + textNode(' { &'), + span('pl-e', ':hover'), + textNode(' { '), + span('pl-c1', 'color'), + textNode(': '), + span('pl-c1', 'blue'), + textNode('; } }'), + ]); + + extendSyntaxTokens(tree, 'source.css'); + + // " { &" should be split into " { " + pl-ent(&) + expect(tree.children[1]).toEqual({ type: 'text', value: ' { ' }); + expect(tree.children[2]).toEqual(span('pl-ent', '&')); + }); + }); + + describe('HTML/JSX attribute enhancement', () => { + describe('attribute key (di-ak)', () => { + it('adds di-ak to pl-e span inside a tag', () => { + const attrName = span('pl-e', 'className'); + const tree = root([ + textNode('<'), + span('pl-ent', 'div'), + textNode(' '), + attrName, + span('pl-k', '='), + stringSpan('"', 'x'), + textNode('>'), + ]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(attrName)).toContain('pl-e'); + expect(getClasses(attrName)).toContain('di-ak'); + }); + + it('does not add di-ak to pl-e span outside a tag', () => { + const entitySpan = span('pl-e', 'something'); + const tree = root([entitySpan]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(entitySpan)).toEqual(['pl-e']); + }); + + it('does not add di-ak for non-HTML/JSX grammars', () => { + const attrName = span('pl-e', 'className'); + const tree = root([ + textNode('<'), + span('pl-ent', 'div'), + textNode(' '), + attrName, + span('pl-k', '='), + stringSpan('"', 'x'), + textNode('>'), + ]); + + extendSyntaxTokens(tree, 'source.css'); + + expect(getClasses(attrName)).toEqual(['pl-e']); + }); + + it('adds di-ak in MDX grammar scope', () => { + const attrName = span('pl-e', 'className'); + const tree = root([ + textNode('<'), + span('pl-ent', 'div'), + textNode(' '), + attrName, + span('pl-k', '='), + stringSpan('"', 'x'), + textNode('>'), + ]); + + extendSyntaxTokens(tree, 'source.mdx'); + + expect(getClasses(attrName)).toContain('di-ak'); + }); + + it('resets after > so pl-e outside tag does not get di-ak', () => { + const insideAttr = span('pl-e', 'className'); + const outsideEntity = span('pl-e', 'something'); + const tree = root([ + textNode('<'), + span('pl-ent', 'div'), + textNode(' '), + insideAttr, + span('pl-k', '='), + stringSpan('"', 'x'), + textNode('>'), + outsideEntity, + ]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(insideAttr)).toContain('di-ak'); + expect(getClasses(outsideEntity)).toEqual(['pl-e']); + }); + }); + + describe('attribute equals (di-ae)', () => { + it('wraps = in a di-ae span inside a tag context', () => { + //
+ const tree = root([ + textNode('<'), + span('pl-ent', 'div'), + textNode(' className='), + stringSpan('"', 'test'), + textNode('>'), + ]); + + extendSyntaxTokens(tree, 'source.tsx'); + + const children = tree.children; + const aeSpan = children.find( + (child) => + child.type === 'element' && + child.tagName === 'span' && + Array.isArray(child.properties?.className) && + (child.properties.className as string[]).includes('di-ae'), + ) as Element | undefined; + + expect(aeSpan).toBeDefined(); + expect(aeSpan!.children).toEqual([{ type: 'text', value: '=' }]); + }); + + it('preserves text before = when splitting', () => { + const tree = root([ + textNode('<'), + span('pl-ent', 'div'), + textNode(' className='), + stringSpan('"', 'test'), + textNode('>'), + ]); + + extendSyntaxTokens(tree, 'source.tsx'); + + // Should have text ' className' before the di-ae span + const children = tree.children; + const beforeText = children.find( + (child) => child.type === 'text' && child.value === ' className', + ); + expect(beforeText).toBeDefined(); + }); + + it('does not wrap = outside a tag context', () => { + // const x = "test" — = is pl-k in assignment context, not plain text + // But testing with plain text = outside tags to be safe + const tree = root([textNode('x='), stringSpan('"', 'test')]); + + extendSyntaxTokens(tree, 'source.tsx'); + + // No < was seen, so insideTag is false, no di-ae should exist + const children = tree.children; + const aeSpan = children.find( + (child) => + child.type === 'element' && + child.tagName === 'span' && + Array.isArray(child.properties?.className) && + (child.properties.className as string[]).includes('di-ae'), + ) as Element | undefined; + + expect(aeSpan).toBeUndefined(); + }); + + it('does not wrap = for non-HTML/JSX grammars', () => { + const tree = root([ + textNode('<'), + span('pl-ent', 'div'), + textNode(' className='), + stringSpan('"', 'test'), + textNode('>'), + ]); + + extendSyntaxTokens(tree, 'source.css'); + + const children = tree.children; + const aeSpan = children.find( + (child) => + child.type === 'element' && + child.tagName === 'span' && + Array.isArray(child.properties?.className) && + (child.properties.className as string[]).includes('di-ae'), + ) as Element | undefined; + + expect(aeSpan).toBeUndefined(); + }); + + it('wraps = in MDX grammar scope', () => { + const tree = root([ + textNode('<'), + span('pl-ent', 'div'), + textNode(' className='), + stringSpan('"', 'test'), + textNode('>'), + ]); + + extendSyntaxTokens(tree, 'source.mdx'); + + const children = tree.children; + const aeSpan = children.find( + (child) => + child.type === 'element' && + child.tagName === 'span' && + Array.isArray(child.properties?.className) && + (child.properties.className as string[]).includes('di-ae'), + ) as Element | undefined; + + expect(aeSpan).toBeDefined(); + }); + + it('resets tag context after >', () => { + //
x="test" + const tree = root([ + textNode('<'), + span('pl-ent', 'div'), + textNode('> x='), + stringSpan('"', 'test'), + ]); + + extendSyntaxTokens(tree, 'source.tsx'); + + // After > the tag context is closed, so = should NOT be wrapped + const children = tree.children; + const aeSpan = children.find( + (child) => + child.type === 'element' && + child.tagName === 'span' && + Array.isArray(child.properties?.className) && + (child.properties.className as string[]).includes('di-ae'), + ) as Element | undefined; + + expect(aeSpan).toBeUndefined(); + }); + + it('adds di-ae to pl-k span containing = inside a tag', () => { + // Real TSX output: className=... + const equalsSpan = span('pl-k', '='); + const tree = root([ + textNode('<'), + span('pl-ent', 'div'), + textNode(' '), + span('pl-e', 'className'), + equalsSpan, + stringSpan('"', 'x'), + textNode('>'), + ]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(equalsSpan)).toContain('pl-k'); + expect(getClasses(equalsSpan)).toContain('di-ae'); + }); + + it('does not add di-ae to pl-k = outside a tag', () => { + const equalsSpan = span('pl-k', '='); + const tree = root([ + span('pl-smi', 'x'), + textNode(' '), + equalsSpan, + textNode(' '), + stringSpan('"', 'test'), + ]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(equalsSpan)).toEqual(['pl-k']); + }); + + it('adds di-ae to pl-k = when next sibling is an expression (pl-pse)', () => { + // JSX: + const equalsSpan = span('pl-k', '='); + const tree = root([ + textNode('<'), + span('pl-ent', 'Component'), + textNode(' '), + span('pl-e', 'onClick'), + equalsSpan, + span('pl-pse', '{'), + span('pl-smi', 'handler'), + span('pl-pse', '}'), + textNode('>'), + ]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(equalsSpan)).toContain('di-ae'); + }); + + it('adds di-ae to bare text = when next sibling is an expression (pl-pse)', () => { + // JSX:
+ const tree = root([ + textNode('<'), + span('pl-ent', 'div'), + textNode(' className='), + span('pl-pse', '{'), + span('pl-smi', 'styles'), + textNode('.root'), + span('pl-pse', '}'), + textNode('>'), + ]); + + extendSyntaxTokens(tree, 'source.tsx'); + + const aeSpan = tree.children.find( + (child) => + child.type === 'element' && (child.properties.className as string[]).includes('di-ae'), + ); + expect(aeSpan).toBeDefined(); + }); + + it('does not add di-av when next sibling is an expression', () => { + // di-av should only apply to string literals (pl-s), not expressions + const expressionSpan = span('pl-pse', '{'); + const tree = root([ + textNode('<'), + span('pl-ent', 'div'), + textNode(' '), + span('pl-e', 'onClick'), + span('pl-k', '='), + expressionSpan, + span('pl-smi', 'handler'), + span('pl-pse', '}'), + textNode('>'), + ]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(expressionSpan)).toEqual(['pl-pse']); + }); + + it('does not add di-ae/di-av in source.js (comparison misread as tag)', () => { + // Plain JS: `a < b` followed by `x = "hi"` — the `<` is a comparison, not a tag + const valueSpan = stringSpan('"', 'hi'); + const tree = root([ + span('pl-c1', 'a'), + textNode(' < '), + span('pl-c1', 'b'), + textNode(' x = '), + valueSpan, + ]); + + extendSyntaxTokens(tree, 'source.js'); + + // No di-ae span should have been created + const aeSpan = tree.children.find( + (child) => + child.type === 'element' && (child.properties.className as string[]).includes('di-ae'), + ); + expect(aeSpan).toBeUndefined(); + // String should not get di-av + expect(getClasses(valueSpan)).toEqual(['pl-s']); + }); + }); + + describe('attribute value (di-av)', () => { + it('adds di-av to pl-s span that is an attribute value', () => { + const valueSpan = stringSpan('"', 'test'); + const tree = root([ + textNode('<'), + span('pl-ent', 'div'), + textNode(' className='), + valueSpan, + textNode('>'), + ]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(valueSpan)).toContain('di-av'); + expect(getClasses(valueSpan)).toContain('pl-s'); + }); + + it('does not add di-av outside tag context', () => { + const valueSpan = stringSpan('"', 'test'); + const tree = root([textNode('x='), valueSpan]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(valueSpan)).toEqual(['pl-s']); + }); + + it('handles multiple attributes in a single tag', () => { + const idValue = stringSpan('"', 'main'); + const classValue = stringSpan('"', 'test'); + const tree = root([ + textNode('<'), + span('pl-ent', 'div'), + textNode(' id='), + idValue, + textNode(' className='), + classValue, + textNode('>'), + ]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(idValue)).toContain('di-av'); + expect(getClasses(classValue)).toContain('di-av'); + }); + + it('adds di-av to attribute values in MDX grammar scope', () => { + const valueSpan = stringSpan('"', 'test'); + const tree = root([ + textNode('<'), + span('pl-ent', 'div'), + textNode(' className='), + valueSpan, + textNode('>'), + ]); + + extendSyntaxTokens(tree, 'source.mdx'); + + expect(getClasses(valueSpan)).toContain('di-av'); + expect(getClasses(valueSpan)).toContain('pl-s'); + }); + + it('adds di-av when = is a pl-k span', () => { + const valueSpan = stringSpan('"', 'x'); + const tree = root([ + textNode('<'), + span('pl-ent', 'div'), + textNode(' '), + span('pl-e', 'className'), + span('pl-k', '='), + valueSpan, + textNode('>'), + ]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(valueSpan)).toContain('di-av'); + expect(getClasses(valueSpan)).toContain('pl-s'); + }); + }); + }); + + describe('nested structures', () => { + it('enhances tokens inside frame/line structures', () => { + const numSpan = span('pl-c1', '42'); + const boolSpan = span('pl-c1', 'true'); + const line: Element = { + type: 'element', + tagName: 'span', + properties: { className: ['line'], dataLn: 1 }, + children: [span('pl-k', 'const'), textNode(' x = '), numSpan], + }; + const line2: Element = { + type: 'element', + tagName: 'span', + properties: { className: ['line'], dataLn: 2 }, + children: [span('pl-k', 'const'), textNode(' y = '), boolSpan], + }; + const frame: Element = { + type: 'element', + tagName: 'span', + properties: { className: ['frame'] }, + children: [line, textNode('\n'), line2], + }; + + const tree = root([frame]); + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(numSpan)).toEqual(['pl-c1', 'di-num']); + expect(getClasses(boolSpan)).toEqual(['pl-c1', 'di-bool']); + }); + }); + + describe('empty tree', () => { + it('handles empty root gracefully', () => { + const tree = root([]); + extendSyntaxTokens(tree, 'source.tsx'); + expect(tree.children).toEqual([]); + }); + + it('handles root with only text nodes', () => { + const tree = root([textNode('plain text')]); + extendSyntaxTokens(tree, 'source.tsx'); + expect(tree.children).toEqual([{ type: 'text', value: 'plain text' }]); + }); + }); + + describe('combined enhancements', () => { + it('applies multiple enhancements in the same tree', () => { + const numSpan = span('pl-c1', '42'); + const boolSpan = span('pl-c1', 'true'); + const nullSpan = span('pl-c1', 'null'); + const emptyString = stringSpan('"', ''); + const namedConst = span('pl-c1', 'Button'); + + const tree = root([ + numSpan, + textNode(', '), + boolSpan, + textNode(', '), + nullSpan, + textNode(', '), + emptyString, + textNode(', '), + namedConst, + ]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(numSpan)).toEqual(['pl-c1', 'di-num']); + expect(getClasses(boolSpan)).toEqual(['pl-c1', 'di-bool']); + expect(getClasses(nullSpan)).toEqual(['pl-c1', 'di-n']); + expect(getClasses(emptyString)).toEqual(['pl-s', 'di-n']); + expect(getClasses(namedConst)).toEqual(['pl-c1']); + }); + }); + + describe('this/super enhancement (di-this)', () => { + it('adds di-this to pl-c1 span containing this', () => { + const thisSpan = span('pl-c1', 'this'); + const tree = root([thisSpan, textNode('.'), span('pl-c1', 'name')]); + + extendSyntaxTokens(tree, 'source.ts'); + + expect(getClasses(thisSpan)).toContain('di-this'); + }); + + it('adds di-this to pl-c1 span containing super', () => { + const superSpan = span('pl-c1', 'super'); + const tree = root([superSpan, textNode('.'), span('pl-en', 'method'), textNode('()')]); + + extendSyntaxTokens(tree, 'source.ts'); + + expect(getClasses(superSpan)).toContain('di-this'); + }); + + it('does not add di-this to other pl-c1 spans', () => { + const consoleSpan = span('pl-c1', 'console'); + const tree = root([consoleSpan]); + + extendSyntaxTokens(tree, 'source.ts'); + + expect(getClasses(consoleSpan)).not.toContain('di-this'); + }); + + it('does not add di-this for non-JS grammars', () => { + const thisSpan = span('pl-c1', 'this'); + const tree = root([thisSpan]); + + extendSyntaxTokens(tree, 'source.css'); + + expect(getClasses(thisSpan)).not.toContain('di-this'); + }); + }); + + describe('built-in type enhancement (di-bt)', () => { + it('adds di-bt to pl-c1 string type', () => { + const typeSpan = span('pl-c1', 'string'); + const tree = root([ + span('pl-k', 'let'), + textNode(' '), + span('pl-smi', 'x'), + span('pl-k', ':'), + textNode(' '), + typeSpan, + ]); + + extendSyntaxTokens(tree, 'source.ts'); + + expect(getClasses(typeSpan)).toContain('di-bt'); + }); + + it('adds di-bt to pl-c1 number type', () => { + const typeSpan = span('pl-c1', 'number'); + const tree = root([typeSpan]); + + extendSyntaxTokens(tree, 'source.ts'); + + expect(getClasses(typeSpan)).toContain('di-bt'); + }); + + it('adds di-bt to all built-in type keywords', () => { + const types = [ + 'string', + 'number', + 'boolean', + 'void', + 'never', + 'symbol', + 'object', + 'any', + 'unknown', + 'bigint', + ]; + for (const typeName of types) { + const typeSpan = span('pl-c1', typeName); + const tree = root([typeSpan]); + + extendSyntaxTokens(tree, 'source.ts'); + + expect(getClasses(typeSpan)).toContain('di-bt'); + } + }); + + it('does not add di-bt to non-type pl-c1 spans', () => { + const consoleSpan = span('pl-c1', 'console'); + const tree = root([consoleSpan]); + + extendSyntaxTokens(tree, 'source.ts'); + + expect(getClasses(consoleSpan)).not.toContain('di-bt'); + }); + + it('does not add di-bt to undefined (already di-n)', () => { + const undefinedSpan = span('pl-c1', 'undefined'); + const tree = root([undefinedSpan]); + + extendSyntaxTokens(tree, 'source.ts'); + + expect(getClasses(undefinedSpan)).toContain('di-n'); + expect(getClasses(undefinedSpan)).not.toContain('di-bt'); + }); + + it('does not add di-bt for non-JS grammars', () => { + const typeSpan = span('pl-c1', 'string'); + const tree = root([typeSpan]); + + extendSyntaxTokens(tree, 'source.css'); + + expect(getClasses(typeSpan)).not.toContain('di-bt'); + }); + + it('does not add di-bt for plain JS (string is a valid variable name)', () => { + const typeSpan = span('pl-c1', 'string'); + const tree = root([typeSpan]); + + extendSyntaxTokens(tree, 'source.js'); + + expect(getClasses(typeSpan)).not.toContain('di-bt'); + }); + }); + + describe('JSX component enhancement (di-jsx)', () => { + it('adds di-jsx to pl-c1 after < text in opening tag', () => { + const component = span('pl-c1', 'Button'); + const tree = root([textNode('<'), component, textNode(' />')]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(component)).toContain('di-jsx'); + }); + + it('reclassifies pl-smi to pl-c1 with di-jsx for PascalCase names in standalone closing tags', () => { + const component = span('pl-smi', 'Button'); + const tree = root([span('pl-k', '')]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(component)).toEqual(['pl-c1', 'di-jsx']); + // Bracket spans are replaced with text nodes + expect(tree.children[0]).toEqual({ type: 'text', value: '' }); + }); + + it('reclassifies pl-smi to pl-ent for lowercase HTML element names in standalone closing tags', () => { + const element = span('pl-smi', 'span'); + const tree = root([span('pl-k', '')]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(element)).toEqual(['pl-ent']); + expect(tree.children[0]).toEqual({ type: 'text', value: '' }); + }); + + it('adds di-jsx to pl-c1 and replaces bracket spans in standalone closing tags', () => { + // Single-letter component names like produce pl-c1 instead of pl-smi + const component = span('pl-c1', 'A'); + const tree = root([span('pl-k', '')]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(component)).toContain('di-jsx'); + expect(tree.children[0]).toEqual({ type: 'text', value: '' }); + }); + + it('adds di-jsx to pl-c1 after text ending in " { + const component = span('pl-c1', 'Button'); + const tree = root([textNode('>hi')]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(component)).toContain('di-jsx'); + }); + + it('does not add di-jsx to HTML elements (pl-ent)', () => { + const div = span('pl-ent', 'div'); + const tree = root([textNode('<'), div, textNode('>')]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(div)).not.toContain('di-jsx'); + }); + + it('does not add di-jsx for non-JSX grammars like source.ts', () => { + // source.ts is in JS_GRAMMARS but NOT JSX_GRAMMARS — generic call syntax + // like f() produces the same text("<") + pl-c1 pattern as JSX + const component = span('pl-c1', 'Button'); + const tree = root([textNode('<'), component]); + + extendSyntaxTokens(tree, 'source.ts'); + + expect(getClasses(component)).not.toContain('di-jsx'); + }); + + it('does not add di-jsx for generics (< is pl-k)', () => { + const typeArg = span('pl-smi', 'string'); + const tree = root([span('pl-c1', 'Array'), span('pl-k', '<'), typeArg, span('pl-k', '>')]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(typeArg)).not.toContain('di-jsx'); + }); + + it('does not add di-jsx for less-than comparison (< is pl-k, not text)', () => { + // `a < MAX_SIZE` — starry-night tokenizes < as pl-k, so the text before pl-c1 + // is " " not "<", preventing a false match + const constant = span('pl-c1', 'MAX_SIZE'); + const tree = root([ + span('pl-smi', 'a'), + textNode(' '), + span('pl-k', '<'), + textNode(' '), + constant, + ]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(constant)).not.toContain('di-jsx'); + }); + }); + + describe('CSS property/value enhancement', () => { + describe('CSS property name (di-cp)', () => { + it('adds di-cp to pl-c1 before colon inside declaration block', () => { + // .x { color: red; } + const propName = span('pl-c1', 'color'); + const tree = root([textNode('{ '), propName, textNode(': '), span('pl-c1', 'red')]); + + extendSyntaxTokens(tree, 'source.css'); + + expect(getClasses(propName)).toContain('di-cp'); + expect(getClasses(propName)).not.toContain('di-cv'); + }); + + it('does not add di-cp for non-CSS grammars', () => { + const propName = span('pl-c1', 'color'); + const tree = root([textNode('{ '), propName, textNode(': '), span('pl-c1', 'red')]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(propName)).not.toContain('di-cp'); + }); + + it('resets after semicolon', () => { + // { color: red; display: flex; } + const prop1 = span('pl-c1', 'color'); + const val1 = span('pl-c1', 'red'); + const prop2 = span('pl-c1', 'display'); + const val2 = span('pl-c1', 'flex'); + const tree = root([ + textNode('{ '), + prop1, + textNode(': '), + val1, + textNode('; '), + prop2, + textNode(': '), + val2, + ]); + + extendSyntaxTokens(tree, 'source.css'); + + expect(getClasses(prop1)).toContain('di-cp'); + expect(getClasses(val1)).toContain('di-cv'); + expect(getClasses(prop2)).toContain('di-cp'); + expect(getClasses(val2)).toContain('di-cv'); + }); + + it('resets after closing brace', () => { + // } .x { display: ... + const prop = span('pl-c1', 'display'); + const tree = root([textNode('} .x { '), prop, textNode(': ')]); + + extendSyntaxTokens(tree, 'source.css'); + + expect(getClasses(prop)).toContain('di-cp'); + }); + + it('does not add di-cp to selector tokens outside declaration blocks', () => { + // [data-active] { color: red } + const selectorAttr = span('pl-c1', 'data-active'); + const propName = span('pl-c1', 'color'); + const tree = root([ + textNode('['), + selectorAttr, + textNode('] { '), + propName, + textNode(': '), + span('pl-c1', 'red'), + textNode(' }'), + ]); + + extendSyntaxTokens(tree, 'source.css'); + + expect(getClasses(selectorAttr)).not.toContain('di-cp'); + expect(getClasses(selectorAttr)).not.toContain('di-cv'); + expect(getClasses(propName)).toContain('di-cp'); + }); + + it('does not add di-cp to attribute selector inside a declaration block', () => { + // .parent { &[data-starting-style] { color: red } } + const attrName = span('pl-c1', 'data-starting-style'); + const propName = span('pl-c1', 'color'); + const tree = root([ + span('pl-e', '.parent'), + textNode(' { &['), + attrName, + textNode('] { '), + propName, + textNode(': '), + span('pl-c1', 'red'), + textNode(' } }'), + ]); + + extendSyntaxTokens(tree, 'source.css'); + + expect(getClasses(attrName)).toContain('di-da'); + expect(getClasses(attrName)).not.toContain('di-cp'); + expect(getClasses(propName)).toContain('di-cp'); + }); + }); + + describe('CSS property value (di-cv)', () => { + it('adds di-cv to pl-c1 after colon inside declaration block', () => { + const propValue = span('pl-c1', 'red'); + const tree = root([textNode('{ '), span('pl-c1', 'color'), textNode(': '), propValue]); + + extendSyntaxTokens(tree, 'source.css'); + + expect(getClasses(propValue)).toContain('di-cv'); + expect(getClasses(propValue)).not.toContain('di-cp'); + }); + + it('adds di-cv to multiple values after colon', () => { + // { transition: transform 150ms } + const transitionProp = span('pl-c1', 'transition'); + const numValue = span('pl-c1', '150'); + const tree = root([ + textNode('{ '), + transitionProp, + textNode(':\n transform '), + numValue, + span('pl-k', 'ms'), + ]); + + extendSyntaxTokens(tree, 'source.css'); + + expect(getClasses(transitionProp)).toContain('di-cp'); + expect(getClasses(numValue)).toContain('di-cv'); + expect(getClasses(numValue)).toContain('di-num'); + }); + + it('does not add di-cv for non-CSS grammars', () => { + const propValue = span('pl-c1', 'red'); + const tree = root([textNode('{ '), span('pl-c1', 'color'), textNode(': '), propValue]); + + extendSyntaxTokens(tree, 'source.tsx'); + + expect(getClasses(propValue)).not.toContain('di-cv'); + }); + }); + }); +}); diff --git a/packages/docs-infra/src/pipeline/parseSource/extendSyntaxTokens.ts b/packages/docs-infra/src/pipeline/parseSource/extendSyntaxTokens.ts new file mode 100644 index 000000000..2831c4598 --- /dev/null +++ b/packages/docs-infra/src/pipeline/parseSource/extendSyntaxTokens.ts @@ -0,0 +1,457 @@ +import type { Root, Element, ElementContent, Text } from 'hast'; +import { getShallowTextContent } from '../loadServerTypes/hastTypeUtils'; +import { getLanguageCapabilitiesFromScope } from './languageCapabilities'; + +/** + * Classes that can represent CSS attribute selector names inside `[...]`. + * Current starry-night uses `pl-c1`, but a future fix may use `pl-e`. + */ +const CSS_ATTR_SELECTOR_CLASSES = new Set(['pl-c1', 'pl-e']); + +/** + * TypeScript built-in type keywords that starry-night classifies as `pl-c1`. + * These are language primitives from the TypeScript specification. + */ +export const BUILT_IN_TYPES = new Set([ + 'string', + 'number', + 'boolean', + 'void', + 'never', + 'symbol', + 'object', + 'any', + 'unknown', + 'bigint', +]); + +/** + * Checks whether a `pl-c1` token's text represents a numeric value. + * + * Since starry-night already classified the token as a constant (`pl-c1`), + * we only need to distinguish numbers from named constants like `Button` or `color`. + * A simple first-character check is sufficient: numbers start with a digit, + * optional `-` sign, or `.` followed by a digit. + * + * Matches: `42`, `3.14`, `-1`, `.5`, `0xFF`, `100px`, `50%`, `3em` + * Does not match: `color`, `red`, `Button`, `NaN`, `Infinity` + */ +function isNumericConstant(text: string): boolean { + if (text.length === 0) { + return false; + } + + const start = text[0] === '-' ? 1 : 0; + if (start >= text.length) { + return false; + } + + const charCode = text.charCodeAt(start); + + // Starts with a digit (0-9) + if (charCode >= 48 && charCode <= 57) { + return true; + } + + // Starts with '.' followed by a digit + if (charCode === 46 && start + 1 < text.length) { + const nextCharCode = text.charCodeAt(start + 1); + return nextCharCode >= 48 && nextCharCode <= 57; + } + + return false; +} + +/** + * Gets the first CSS class from an element's className array. + */ +function getFirstClass(element: Element): string | undefined { + const className = element.properties?.className; + if (Array.isArray(className) && typeof className[0] === 'string') { + return className[0]; + } + return undefined; +} + +/** + * Adds a CSS class to an element's className array (additive, never removes existing classes). + */ +function addClass(element: Element, cls: string): void { + if (!element.properties) { + element.properties = {}; + } + if (Array.isArray(element.properties.className)) { + element.properties.className.push(cls); + } else { + element.properties.className = [cls]; + } +} + +/** + * Replaces one CSS class with another in an element's className array. + */ +function replaceClass(element: Element, oldCls: string, newCls: string): void { + const className = element.properties?.className; + if (Array.isArray(className)) { + const idx = className.indexOf(oldCls); + if (idx !== -1) { + className[idx] = newCls; + } + } +} + +/** + * Enhances `pl-c1` (constant) spans with more specific `di-*` classes + * based on the text content. + * + * Language-agnostic: + * - Numbers → `di-num` + * - Booleans (`true`, `false`) → `di-bool` + * - Nullish (`null`, `undefined`) → `di-n` + * + * JS/TS family only (`isJs`): + * - `this`, `super` → `di-this` + * + * TS family only (`isTs`): + * - Built-in type keywords (`string`, `number`, etc.) → `di-bt` + */ +function enhanceConstantSpan(element: Element, isJs: boolean, isTs: boolean): void { + const text = getShallowTextContent(element); + if (!text) { + return; + } + + if (text === 'true' || text === 'false') { + addClass(element, 'di-bool'); + } else if (text === 'null' || text === 'undefined') { + addClass(element, 'di-n'); + } else if (isNumericConstant(text)) { + addClass(element, 'di-num'); + } else if (isJs && (text === 'this' || text === 'super')) { + addClass(element, 'di-this'); + } else if (isTs && BUILT_IN_TYPES.has(text)) { + addClass(element, 'di-bt'); + } +} + +/** + * Enhances `pl-s` (string) spans for empty string literals (`""`, `''`) + * by adding the `di-n` (nullish) class. + * + * Starry-night tokenizes an empty string as exactly two `pl-pds` quote-delimiter + * spans with no content between them: + * `""` + * so we can detect it structurally without recursively serializing the text. + */ +function enhanceStringSpan(element: Element): void { + const { children } = element; + if (children.length !== 2) { + return; + } + const [open, close] = children; + if ( + open.type === 'element' && + getFirstClass(open) === 'pl-pds' && + close.type === 'element' && + getFirstClass(close) === 'pl-pds' + ) { + addClass(element, 'di-n'); + } +} + +/** + * Single-pass enhancement of a HAST children array. Processes each child exactly + * once, applying all per-element and sibling-context enhancements in one iteration. + * Recursively enhances nested elements. + * + * Per-element enhancements (applied to individual spans): + * - `pl-c1` → `di-num`, `di-bool`, `di-n`, `di-this`, `di-bt` via enhanceConstantSpan + * - `pl-s` → `di-n` for empty strings via enhanceStringSpan + * + * Sibling-context enhancements (depend on neighbor nodes or positional state): + * - CSS `&` nesting selector → wraps in `pl-ent` span + * - CSS `[attr]` → `di-da` on attribute name spans + * - CSS `property: value` → `di-cp` / `di-cv` based on colon position + * - HTML/JSX `` → `di-ak`, `di-ae`, `di-av` + * - JSX `` → `di-jsx` on component name spans + */ +function enhanceChildren( + children: ElementContent[], + isCss: boolean, + isHtmlJsx: boolean, + isJs: boolean, + isTs: boolean, + isJsx: boolean, +): void { + // CSS declaration state: tracks position relative to { } : ; [ ] + let cssInsideBlock = false; + let cssInsideBracket = false; + let cssAfterColon = false; + + // HTML/JSX tag state: whether we're between < and > + let htmlInsideTag = false; + + // Whether a span appeared between the last text node and the current position. + // Used to detect attribute context for = wrapping (replaces backward scanning). + let hasSpanSinceLastText = false; + + for (let index = 0; index < children.length; index += 1) { + const child = children[index]; + + // ── Text nodes: state tracking and structural splits ── + if (child.type === 'text') { + const savedSpanFlag = hasSpanSinceLastText; + hasSpanSinceLastText = false; + const { value } = child; + + // CSS: track { } [ ] : ; state and wrap & nesting selectors + if (isCss) { + const ampIndex = value.indexOf('&'); + const trackEnd = ampIndex !== -1 ? ampIndex : value.length; + + for (let ci = 0; ci < trackEnd; ci += 1) { + const char = value[ci]; + if (char === '{') { + cssInsideBlock = true; + cssAfterColon = false; + } else if (char === '}') { + cssInsideBlock = false; + cssAfterColon = false; + } else if (char === '[') { + cssInsideBracket = true; + } else if (char === ']') { + cssInsideBracket = false; + } else if (char === ':' && cssInsideBlock && !cssInsideBracket) { + cssAfterColon = true; + } else if (char === ';') { + cssAfterColon = false; + } + } + + // Wrap bare & in a pl-ent span to match GitHub rendering of CSS nesting selector + if (ampIndex !== -1) { + const before = value.slice(0, ampIndex); + const after = value.slice(ampIndex + 1); + + const ampSpan: Element = { + type: 'element', + tagName: 'span', + properties: { className: ['pl-ent'] }, + children: [{ type: 'text', value: '&' }], + }; + + const newNodes: ElementContent[] = []; + if (before) { + newNodes.push({ type: 'text', value: before } as Text); + } + newNodes.push(ampSpan); + if (after) { + newNodes.push({ type: 'text', value: after } as Text); + } + + children.splice(index, 1, ...newNodes); + // Advance past the inserted span to process remaining text for more & chars + index += newNodes.indexOf(ampSpan); + continue; + } + } + + // HTML/JSX: track < > tag boundaries and wrap bare = in attribute context + if (isHtmlJsx) { + for (let ci = 0; ci < value.length; ci += 1) { + if (value[ci] === '<') { + htmlInsideTag = true; + } else if (value[ci] === '>') { + htmlInsideTag = false; + } + } + + if (htmlInsideTag && savedSpanFlag) { + const equalsIndex = value.indexOf('='); + if (equalsIndex !== -1) { + // Tag the following pl-s span as attribute value + const nextChild = children[index + 1]; + if ( + nextChild && + nextChild.type === 'element' && + nextChild.tagName === 'span' && + getFirstClass(nextChild) === 'pl-s' + ) { + addClass(nextChild, 'di-av'); + } + + // Split text around = and wrap in di-ae span + const before = value.slice(0, equalsIndex); + const after = value.slice(equalsIndex + 1); + + const equalsSpan: Element = { + type: 'element', + tagName: 'span', + properties: { className: ['di-ae'] }, + children: [{ type: 'text', value: '=' }], + }; + + const newNodes: ElementContent[] = []; + if (before) { + newNodes.push({ type: 'text', value: before } as Text); + } + newNodes.push(equalsSpan); + if (after) { + newNodes.push({ type: 'text', value: after } as Text); + } + + children.splice(index, 1, ...newNodes); + index += newNodes.length - 1; + hasSpanSinceLastText = newNodes[newNodes.length - 1].type === 'element'; + } + } + } + + continue; + } + + // ── Non-element nodes: skip ── + if (child.type !== 'element') { + continue; + } + + // Recurse into nested elements (frames, lines, nested spans) + if (child.children.length > 0) { + enhanceChildren(child.children, isCss, isHtmlJsx, isJs, isTs, isJsx); + } + + if (child.tagName !== 'span') { + continue; + } + + const hadPrecedingSpan = hasSpanSinceLastText; + hasSpanSinceLastText = true; + const firstClass = getFirstClass(child); + + // ── Per-element enhancements (all grammars) ── + if (firstClass === 'pl-c1') { + enhanceConstantSpan(child, isJs, isTs); + } else if (firstClass === 'pl-s') { + enhanceStringSpan(child); + } + + // ── CSS-specific enhancements ── + if (isCss) { + // CSS attribute selector name: span preceded by text ending with [ + if (firstClass && CSS_ATTR_SELECTOR_CLASSES.has(firstClass) && index > 0) { + const prev = children[index - 1]; + if (prev.type === 'text' && prev.value.endsWith('[')) { + addClass(child, 'di-da'); + } + } + + // CSS property name / value classification based on : position + if (firstClass === 'pl-c1' && cssInsideBlock && !cssInsideBracket) { + addClass(child, cssAfterColon ? 'di-cv' : 'di-cp'); + } + } + + // ── HTML/JSX attribute enhancements ── + if (isHtmlJsx && htmlInsideTag) { + // Attribute key: pl-e inside a tag + if (firstClass === 'pl-e') { + addClass(child, 'di-ak'); + } + + // Attribute equals: pl-k span containing = + if (firstClass === 'pl-k' && getShallowTextContent(child) === '=' && hadPrecedingSpan) { + addClass(child, 'di-ae'); + const nextChild = children[index + 1]; + if ( + nextChild && + nextChild.type === 'element' && + nextChild.tagName === 'span' && + getFirstClass(nextChild) === 'pl-s' + ) { + addClass(nextChild, 'di-av'); + } + } + } + + // ── JSX component name detection ── + if (isJsx && index > 0) { + const prev = children[index - 1]; + + // Opening/closing: text ending in < or ") + const closeBracket = children[index + 1]; + const hasCloseBracket = + closeBracket && + closeBracket.type === 'element' && + closeBracket.tagName === 'span' && + getFirstClass(closeBracket) === 'pl-k' && + getShallowTextContent(closeBracket) === '>'; + + if (firstClass === 'pl-c1') { + addClass(child, 'di-jsx'); + } else { + const tagText = getShallowTextContent(child); + const isComponent = + tagText && + tagText[0] === tagText[0].toUpperCase() && + tagText[0] !== tagText[0].toLowerCase(); + + if (isComponent) { + // JSX component: pl-smi → pl-c1 + di-jsx + replaceClass(child, 'pl-smi', 'pl-c1'); + addClass(child, 'di-jsx'); + } else { + // HTML element: pl-smi → pl-ent + replaceClass(child, 'pl-smi', 'pl-ent'); + } + } + + // Replace bracket spans with text nodes to match the text-bracket pattern. + // This allows enhanceCodeInline to handle both patterns uniformly. + const prevText = getShallowTextContent(prev) ?? ''; + children[index + 1] = { type: 'text', value: closeText } as Text; + } + } + } + } +} + +/** + * Extends a syntax-highlighted HAST tree with additional `di-*` CSS classes + * for fine-grained styling control. All extensions are **additive** — existing + * `pl-*` classes from starry-night are never removed. + * + * @param tree - The HAST root node produced by starry-night's `highlight()` + * @param grammarScope - The grammar scope used for highlighting (e.g., 'source.tsx', 'source.css') + */ +export function extendSyntaxTokens(tree: Root, grammarScope: string): void { + const caps = getLanguageCapabilitiesFromScope(grammarScope); + const isCss = caps.semantics === 'css'; + const isHtmlJsx = caps.supportsJsx || grammarScope === 'text.html.basic'; + const isJs = caps.semantics === 'js'; + const isTs = caps.supportsTypes; + const isJsx = caps.supportsJsx; + + enhanceChildren(tree.children as ElementContent[], isCss, isHtmlJsx, isJs, isTs, isJsx); +} diff --git a/packages/docs-infra/src/pipeline/parseSource/languageCapabilities.ts b/packages/docs-infra/src/pipeline/parseSource/languageCapabilities.ts new file mode 100644 index 000000000..3ea62b8b0 --- /dev/null +++ b/packages/docs-infra/src/pipeline/parseSource/languageCapabilities.ts @@ -0,0 +1,46 @@ +/** + * Language capabilities derived from a grammar scope or language class. + * + * Shared by both `extendSyntaxTokens` (which receives grammar scopes like + * `'source.tsx'`) and `enhanceCodeTypes` (which reads `language-*` CSS classes). + */ +export interface LanguageCapabilities { + /** Whether `type Name` and `const name: Name =` syntax is recognized. */ + supportsTypes: boolean; + /** Whether JSX `` syntax is recognized. */ + supportsJsx: boolean; + /** + * Which platform semantics apply: `'js'` for function calls / JS patterns, + * `'css'` for CSS patterns, or `undefined` for unknown languages. + */ + semantics?: 'js' | 'css'; +} + +const BASE_CAPABILITIES: LanguageCapabilities = { + supportsTypes: false, + supportsJsx: false, +}; + +/** + * Resolves language capabilities from a starry-night grammar scope string. + * + * Note: `.jsx` files map to `source.tsx` via the extension map, so there is + * no separate `source.jsx` scope. MDX is treated as JS+TS+JSX because it + * embeds TypeScript JSX. + */ +export function getLanguageCapabilitiesFromScope(grammarScope: string): LanguageCapabilities { + switch (grammarScope) { + case 'source.js': + return { supportsTypes: false, supportsJsx: false, semantics: 'js' }; + case 'source.ts': + return { supportsTypes: true, supportsJsx: false, semantics: 'js' }; + case 'source.tsx': + return { supportsTypes: true, supportsJsx: true, semantics: 'js' }; + case 'source.mdx': + return { supportsTypes: true, supportsJsx: true, semantics: 'js' }; + case 'source.css': + return { supportsTypes: false, supportsJsx: false, semantics: 'css' }; + default: + return BASE_CAPABILITIES; + } +} diff --git a/packages/docs-infra/src/pipeline/parseSource/parseSource.ts b/packages/docs-infra/src/pipeline/parseSource/parseSource.ts index b33c3bc00..37b4d135a 100644 --- a/packages/docs-infra/src/pipeline/parseSource/parseSource.ts +++ b/packages/docs-infra/src/pipeline/parseSource/parseSource.ts @@ -2,6 +2,7 @@ import { createStarryNight } from '@wooorm/starry-night'; import type { ParseSource } from '../../CodeHighlighter/types'; import { grammars, extensionMap, getGrammarFromLanguage } from './grammars'; import { starryNightGutter } from './addLineGutters'; +import { extendSyntaxTokens } from './extendSyntaxTokens'; type StarryNight = Awaited>; @@ -51,6 +52,7 @@ export const parseSource: ParseSource = (source, fileName, language) => { } const highlighted = starryNight.highlight(source, grammarScope); + extendSyntaxTokens(highlighted, grammarScope); // mutates the tree to add di-* classes const sourceLines = source.split(/\r?\n|\r/); starryNightGutter(highlighted, sourceLines); // mutates the tree to add line gutters diff --git a/packages/docs-infra/src/pipeline/transformHtmlCodeInline/transformHtmlCodeInline.ts b/packages/docs-infra/src/pipeline/transformHtmlCodeInline/transformHtmlCodeInline.ts index fc6ccfef4..9b47e8894 100644 --- a/packages/docs-infra/src/pipeline/transformHtmlCodeInline/transformHtmlCodeInline.ts +++ b/packages/docs-infra/src/pipeline/transformHtmlCodeInline/transformHtmlCodeInline.ts @@ -2,6 +2,7 @@ import { createStarryNight } from '@wooorm/starry-night'; import type { Root as HastRoot, Element } from 'hast'; import { visit } from 'unist-util-visit'; import { grammars, extensionMap } from '../parseSource/grammars'; +import { extendSyntaxTokens } from '../parseSource/extendSyntaxTokens'; import { removePrefixFromHighlightedNodes } from './removePrefixFromHighlightedNodes'; type StarryNight = Awaited>; @@ -134,6 +135,7 @@ export default function transformHtmlCodeInline(options: TransformHtmlCodeInline // Apply syntax highlighting const highlighted = starryNight.highlight(sourceToHighlight, extensionMap[fileType]); + extendSyntaxTokens(highlighted, extensionMap[fileType]); // Replace the code element's children with the highlighted nodes if (highlighted.type === 'root' && highlighted.children) {