-
-
Notifications
You must be signed in to change notification settings - Fork 375
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(twoslash): support
@includes
(#737)
Co-authored-by: Anthony Fu <github@antfu.me>
- Loading branch information
Showing
6 changed files
with
253 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,73 @@ | ||
export class TwoslashIncludesManager { | ||
constructor( | ||
public map: Map<string, string> = new Map(), | ||
) {} | ||
|
||
add(name: string, code: string) { | ||
const lines: string[] = [] | ||
|
||
code.split('\n').forEach((l, _i) => { | ||
const trimmed = l.trim() | ||
|
||
if (trimmed.startsWith('// - ')) { | ||
const key = trimmed.split('// - ')[1].split(' ')[0] | ||
this.map.set(`${name}-${key}`, lines.join('\n')) | ||
} | ||
else { | ||
lines.push(l) | ||
} | ||
}) | ||
this.map.set(name, lines.join('\n')) | ||
} | ||
|
||
applyInclude(code: string) { | ||
const reMarker = /\/\/ @include: (.*)$/gm | ||
|
||
// Basically run a regex over the code replacing any // @include: thing with | ||
// 'thing' from the map | ||
|
||
// const toReplace: [index:number, length: number, str: string][] = [] | ||
const toReplace: [number, number, string][] = [] | ||
|
||
for (const match of code.matchAll(reMarker)) { | ||
const key = match[1] | ||
const replaceWith = this.map.get(key) | ||
|
||
if (!replaceWith) { | ||
const msg = `Could not find an include with the key: '${key}'.\nThere is: ${Array.from(this.map.keys())}.` | ||
throw new Error(msg) | ||
} | ||
else { | ||
toReplace.push([match.index, match[0].length, replaceWith]) | ||
} | ||
} | ||
|
||
let newCode = code.toString() | ||
// Go backwards through the found changes so that we can retain index position | ||
toReplace | ||
.reverse() | ||
.forEach((r) => { | ||
newCode = newCode.slice(0, r[0]) + r[2] + newCode.slice(r[0] + r[1]) | ||
}) | ||
return newCode | ||
} | ||
} | ||
|
||
/** | ||
* An "include [name]" segment in a raw meta string is a sequence of words, | ||
* possibly connected by dashes, following "include " and ending at a word boundary. | ||
*/ | ||
const INCLUDE_META_REGEX = /include\s+([\w-]+)\b.*/ | ||
|
||
/** | ||
* Given a raw meta string for code block like 'twoslash include main-hello-world meta=miscellaneous', | ||
* capture the name of the reusable code block as "main-hello-world", and ignore anything | ||
* before and after this segment. | ||
*/ | ||
export function parseIncludeMeta(meta?: string): string | null { | ||
if (!meta) | ||
return null | ||
|
||
const match = meta.match(INCLUDE_META_REGEX) | ||
return match?.[1] ?? null | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,117 @@ | ||
import { expect, it } from 'vitest' | ||
import { codeToHtml } from 'shiki' | ||
import { TwoslashIncludesManager } from '../src/includes' | ||
import { rendererRich, transformerTwoslash } from '../src' | ||
|
||
const styleTag = ` | ||
<link rel="stylesheet" href="../../../style-rich.css" /> | ||
<style> | ||
.dark .shiki, | ||
.dark .shiki span { | ||
color: var(--shiki-dark, inherit); | ||
background-color: var(--shiki-dark-bg, inherit); | ||
--twoslash-popup-bg: var(--shiki-dark-bg, inherit); | ||
} | ||
html:not(.dark) .shiki, | ||
html:not(.dark) .shiki span { | ||
color: var(--shiki-light, inherit); | ||
background-color: var(--shiki-light-bg, inherit); | ||
--twoslash-popup-bg: var(--shiki-light-bg, inherit); | ||
} | ||
</style> | ||
` | ||
|
||
const multiExample = ` | ||
const a = 1 | ||
// - 1 | ||
const b = 2 | ||
// - 2 | ||
const c = 3 | ||
` | ||
|
||
it('creates a set of examples', () => { | ||
const manager = new TwoslashIncludesManager() | ||
manager.add('main', multiExample) | ||
expect(manager.map.size === 3) | ||
|
||
expect(manager.map.get('main')).toContain('const c') | ||
expect(manager.map.get('main-1')).toContain('const a = 1') | ||
expect(manager.map.get('main-2')).toContain('const b = 2') | ||
}) | ||
|
||
it('replaces the code', () => { | ||
const manager = new TwoslashIncludesManager() | ||
manager.add('main', multiExample) | ||
expect(manager.map.size === 3) | ||
|
||
const sample = `// @include: main` | ||
const replaced = manager.applyInclude(sample) | ||
expect(replaced).toMatchInlineSnapshot(` | ||
" | ||
const a = 1 | ||
const b = 2 | ||
const c = 3 | ||
" | ||
`) | ||
}) | ||
|
||
it('throws an error if key not found', () => { | ||
const manager = new TwoslashIncludesManager() | ||
|
||
const sample = `// @include: main` | ||
expect(() => manager.applyInclude(sample)).toThrow() | ||
}) | ||
|
||
it('replaces @include directives with previously transformed code blocks', async () => { | ||
const main = ` | ||
export const hello = { str: "world" }; | ||
`.trim() | ||
|
||
/** | ||
* The @noErrors directive allows the code above the ^| to be invalid, | ||
* i.e. so it can demonstrate what a partial autocomplete looks like. | ||
*/ | ||
const code = ` | ||
// @include: main | ||
// @noErrors | ||
hello. | ||
// ^| | ||
`.trim() | ||
|
||
/** | ||
* Replacing @include directives only renders nicely rendererRich? | ||
*/ | ||
const transformer = transformerTwoslash({ | ||
renderer: rendererRich(), | ||
}) | ||
|
||
const htmlMain = await codeToHtml(main, { | ||
lang: 'ts', | ||
themes: { | ||
dark: 'vitesse-dark', | ||
light: 'vitesse-light', | ||
}, | ||
defaultColor: false, | ||
transformers: [transformer], | ||
meta: { | ||
__raw: 'include main', | ||
}, | ||
}) | ||
|
||
expect(styleTag + htmlMain).toMatchFileSnapshot('./out/includes/main.html') | ||
|
||
const html = await codeToHtml(code, { | ||
lang: 'ts', | ||
themes: { | ||
dark: 'vitesse-dark', | ||
light: 'vitesse-light', | ||
}, | ||
transformers: [transformer], | ||
}) | ||
|
||
expect(styleTag + html).toMatchFileSnapshot( | ||
'./out/includes/replaced_directives.html', | ||
) | ||
}) |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
25 changes: 25 additions & 0 deletions
25
packages/twoslash/test/out/includes/replaced_directives.html
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.