@shikijs/vitepress-twoslash
Version:
Enable Twoslash support in VitePress
319 lines (318 loc) • 9.17 kB
JavaScript
import { transformerTwoslash } from "../index.mjs";
import { createHash } from "node:crypto";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import process from "node:process";
import LZString from "lz-string";
import { hash } from "ohash";
import MagicString from "magic-string";
import MarkdownIt from "markdown-it";
//#region src/cache-inline/file-patcher.ts
var FilePatcher = class {
files = /* @__PURE__ */ new Map();
static key(from, to) {
return `${from}${to ? `:${to}` : ""}`;
}
load(path) {
let file = this.files.get(path);
if (file === void 0) {
if (existsSync(path)) file = {
content: readFileSync(path, { encoding: "utf-8" }),
patches: /* @__PURE__ */ new Map()
};
else file = null;
this.files.set(path, file);
}
return file;
}
patch(path) {
const file = this.files.get(path);
if (file) {
if (file.patches.size) {
const s = new MagicString(file.content);
for (const [key, value] of file.patches) {
const [from, to] = key.split(":").map((s) => s !== "" ? Number(s) : void 0);
if (from === void 0) continue;
if (to !== void 0) s.update(from, to, value);
else s.appendRight(from, value);
}
writeFileSync(path, s.toString(), { encoding: "utf-8" });
}
this.files.delete(path);
}
}
};
//#endregion
//#region src/cache-inline/cache-inline.ts
const CODE_INLINE_CACHE_KEY = "@twoslash-cache";
const CODE_INLINE_CACHE_REGEX = new RegExp(`// ${CODE_INLINE_CACHE_KEY}: (.*)(?:\n|$)`, "g");
function createInlineTypesCache({ remove, ignoreCache } = {}) {
const patcher = new FilePatcher();
const optionsHashCache = /* @__PURE__ */ new WeakMap();
function getOptionsHash(options = {}) {
let hash$1 = optionsHashCache.get(options);
if (!hash$1) {
hash$1 = hash(options);
optionsHashCache.set(options, hash$1);
}
return hash$1;
}
function cacheHash(code, lang, options) {
return sha256Hash(`${getOptionsHash(options)}:${lang ?? ""}:${code}`);
}
function stringifyCachePayload(data, code, lang, options) {
const payload = {
v: 1,
hash: cacheHash(code, lang, options),
data: LZString.compressToBase64(JSON.stringify(data))
};
return JSON.stringify(payload);
}
function resolveCachePayload(cache) {
if (!cache) return null;
try {
const payload = JSON.parse(cache);
if (payload.v === 1) return {
payload,
twoslash: () => {
try {
return JSON.parse(LZString.decompressFromBase64(payload.data));
} catch {
return null;
}
}
};
} catch {}
return null;
}
function resolveSourcePatcher(source, search) {
const file = patcher.load(source.path);
if (file === null) return void 0;
const range = { from: source.from };
let linebreak = true;
if (search) {
const cachePos = file.content.indexOf(search, source.from);
if (cachePos !== -1 && cachePos < source.to) {
range.from = cachePos;
range.to = cachePos + search.length;
linebreak = search.endsWith("\n");
}
}
const patchKey = FilePatcher.key(range.from, range.to);
return (newCache) => {
if (newCache === "") {
if (range.to !== void 0) file.patches.set(patchKey, "");
return;
}
file.patches.set(patchKey, newCache + (linebreak ? "\n" : ""));
};
}
return {
typesCache: {
preprocess(code, lang, options, meta) {
if (!meta) return;
let rawCache = "";
let cacheString = "";
code = code.replaceAll(CODE_INLINE_CACHE_REGEX, (full, p1) => {
if (!rawCache.length) {
cacheString = p1;
rawCache = full;
}
return "";
});
if (!ignoreCache && !remove) {
const cache = resolveCachePayload(cacheString);
if (cache?.payload.hash === cacheHash(code, lang, options)) {
const twoslash = cache.twoslash();
if (twoslash) meta.__cache = twoslash;
}
}
if (meta.sourceMap) meta.__patch = resolveSourcePatcher(meta.sourceMap, rawCache);
return code;
},
read(code, lang, options, meta) {
return meta?.__cache ?? null;
},
write(code, data, lang, options, meta) {
if (remove) {
meta?.__patch?.("");
return;
}
const cacheStr = `// ${CODE_INLINE_CACHE_KEY}: ${stringifyCachePayload(simplifyTwoslashReturn(data), code, lang, options)}`;
meta?.__patch?.(cacheStr);
}
},
patcher
};
}
function sha256Hash(str) {
return createHash("SHA256").update(str).digest("hex");
}
function simplifyTwoslashReturn(ret) {
return {
nodes: ret.nodes,
code: ret.code,
meta: ret.meta ? { extension: ret.meta.extension } : void 0
};
}
//#endregion
//#region src/cache-inline/env.ts
function isEnabledEnv(key) {
const val = process.env?.[key]?.toLowerCase();
if (val) return {
true: true,
false: false,
1: true,
0: false,
yes: true,
no: false,
y: true,
n: false
}[val] || null;
return null;
}
//#endregion
//#region src/cache-inline/markdown-fence.ts
function createMarkdownFenceSourceCodec(mapper) {
const FENCE_SOURCE_WRAP = `<fsm-${Math.random().toString(36).slice(2)}>`;
const FENCE_SOURCE_REGEX = new RegExp(`\/\/ ${FENCE_SOURCE_WRAP}(.+?)${FENCE_SOURCE_WRAP}\\n`);
function stringifyFenceSourceMap(sourceMap) {
return `// ${FENCE_SOURCE_WRAP}${JSON.stringify(sourceMap)}${FENCE_SOURCE_WRAP}\n`;
}
function injectToMarkdown(code, path) {
const injects = mapper(code, path);
let newCode = code;
const injectAts = [...injects.keys()].sort((a, b) => b - a);
for (const at of injectAts) {
const data = stringifyFenceSourceMap(injects.get(at));
newCode = newCode.slice(0, at) + data + newCode.slice(at);
}
return newCode;
}
function extractFromFence(code) {
let sourceMap = null;
try {
code = code.replace(FENCE_SOURCE_REGEX, (_, p1) => {
sourceMap = JSON.parse(p1);
return "";
});
} catch {}
return {
code,
sourceMap
};
}
return {
injectToMarkdown,
extractFromFence
};
}
//#endregion
//#region src/cache-inline/markdown-it-mapper.ts
const markdownItMapper = function(code, path) {
const result = new MarkdownIt().parse(code, {});
const pos = getLineStartPositions(code);
const injects = /* @__PURE__ */ new Map();
for (const token of result) if (token.type === "fence") {
if (!token.map) continue;
if (token.map[0] + 1 >= pos.length) continue;
const codeStart = pos[token.map[0] + 1].from;
const codeEnd = pos[token.map[1] - 1].from;
injects.set(codeStart, {
path,
from: codeStart,
to: codeEnd
});
}
return injects;
};
function getLineStartPositions(text) {
const positions = [];
let pos = 0;
while (true) {
const [idx, len] = findNextNewLine(text, pos);
if (idx === -1) {
positions.push({
from: pos,
to: text.length
});
break;
}
positions.push({
from: pos,
to: idx
});
pos = idx + len;
}
return positions;
}
/**
* Finds the next newline starting at or after `position`.
* Supports \n, \r, and \r\n (treated as one newline).
* @returns [newlineStartIndex, newlineLength] or [-1, 0] if none found.
*/
function findNextNewLine(str, position) {
const nIdx = str.indexOf("\n", position);
const rIdx = str.indexOf("\r", position);
if (nIdx === -1 && rIdx === -1) return [-1, 0];
let idx;
if (nIdx === -1) idx = rIdx;
else if (rIdx === -1) idx = nIdx;
else idx = nIdx < rIdx ? nIdx : rIdx;
if (str.charCodeAt(idx) === 13 && str.charCodeAt(idx + 1) === 10) return [idx, 2];
return [idx, 1];
}
//#endregion
//#region src/cache-inline/index.ts
/**
* @experimental This API is experimental and may be changed in the future.
*/
function createTwoslashWithInlineCache(twoslashOptions = {}, { sourceMapper = markdownItMapper, sourceMapCodec = createMarkdownFenceSourceCodec(sourceMapper) } = {}) {
return function(config) {
if (isEnabledEnv("TWOSLASH_INLINE_CACHE") === false) return config;
const { typesCache, patcher } = createInlineTypesCache({
remove: isEnabledEnv("TWOSLASH_INLINE_CACHE_REMOVE") === true,
ignoreCache: isEnabledEnv("TWOSLASH_INLINE_CACHE_IGNORE") === true
});
const transformer = transformerTwoslash({
...twoslashOptions,
typesCache
});
const PatchPlugin = {
name: "vitepress-twoslash:patch",
enforce: "post",
transform(code, id) {
if (id.endsWith(".md")) patcher.patch(id);
}
};
config = withFenceSourceMap(config, sourceMapCodec);
((config.markdown ??= {}).codeTransformers ??= []).push(transformer);
((config.vite ??= {}).plugins ??= []).push(PatchPlugin);
return config;
};
}
function withFenceSourceMap(config, codec) {
const InjectPlugin = {
name: "vitepress-twoslash:inject-fence-source-map",
enforce: "pre",
load(id) {
if (id.endsWith(".md")) {
const code = readFileSync(id, "utf-8");
return { code: codec.injectToMarkdown(code, id) };
}
}
};
const transformer = {
name: "vitepress-twoslash:extract-fence-source-map",
enforce: "pre",
preprocess(code) {
const { code: transformedCode, sourceMap } = codec.extractFromFence(code);
this.meta.sourceMap = sourceMap;
return transformedCode;
}
};
((config.markdown ??= {}).codeTransformers ??= []).unshift(transformer);
((config.vite ??= {}).plugins ??= []).push(InjectPlugin);
return config;
}
//#endregion
export { createTwoslashWithInlineCache };