Loader
.ts.md
を Node.js で読み込むための ESM ローダーです。
型定義
共通で利用する型を定義します。
export type Resolve = ( specifier: string, context: { parentURL?: string | undefined }, defaultResolve: Resolve,) => Promise<{ url: string }>;
export type Load = ( url: string, context: { format?: string | undefined }, defaultLoad: Load,) => Promise<{ format: string; source: string }>;
const VIRTUAL_PREFIX = 'ts-md:';
resolve
関数
モジュールのパス解決を担当します。.ts.md
の場合はチャンク名を含む仮想 URL を返します。
import path from 'node:path';import { fileURLToPath, pathToFileURL } from 'node:url';import { resolveImport } from '@sterashima78/ts-md-core';
export type Resolve = ( specifier: string, context: { parentURL?: string | undefined }, defaultResolve: Resolve,) => Promise<{ url: string }>;
const VIRTUAL_PREFIX = 'ts-md:';
export const resolve: Resolve = async (specifier, context, defaultResolve) => { let parentURL: string | undefined; if (context.parentURL) { if (context.parentURL.startsWith(VIRTUAL_PREFIX)) { const body = context.parentURL.slice(VIRTUAL_PREFIX.length); const m = /(.*\.ts\.md)__(.+)\.ts$/.exec(body); parentURL = m ? m[1] : body; } else { parentURL = fileURLToPath(context.parentURL); } } const specPath = specifier.startsWith('file:') ? fileURLToPath(specifier) : specifier; if (parentURL) { const info = resolveImport(specifier, parentURL); if (info) { const abs = path.resolve(info.absPath); const url = `${VIRTUAL_PREFIX}${abs}__${info.chunk}.ts`; return { url, format: 'module', shortCircuit: true }; } }
if (specPath.endsWith('.ts.md')) { const abs = parentURL ? path.resolve(path.dirname(parentURL), specPath) : path.resolve(specPath); return { url: `${VIRTUAL_PREFIX}${abs}__main.ts`, format: 'module', shortCircuit: true, }; }
if (specPath.endsWith('.ts')) { const abs = parentURL ? path.resolve(path.dirname(parentURL), specPath) : path.resolve(specPath); return { url: pathToFileURL(abs).href, format: 'module', shortCircuit: true, }; }
return defaultResolve(specifier, context, defaultResolve);};
load
関数
仮想 URL からコードを読み込み、TypeScript をトランスパイルします。
import fs from 'node:fs';import path from 'node:path';import { fileURLToPath } from 'node:url';import { parseChunks } from '@sterashima78/ts-md-core';import ts from 'typescript';
export type Load = ( url: string, context: { format?: string | undefined }, defaultLoad: Load,) => Promise<{ format: string; source: string }>;
const VIRTUAL_PREFIX = 'ts-md:';
export const load: Load = async (url, context, defaultLoad) => { if (url.startsWith(VIRTUAL_PREFIX)) { const body = url.slice(VIRTUAL_PREFIX.length); const m = /(.*\.ts\.md)__(.+)\.ts$/.exec(body); if (!m) return defaultLoad(url, context, defaultLoad); const [, file, name] = m; const md = fs.readFileSync(file, 'utf8'); const chunks = parseChunks(md, file); const chunk = chunks[name]; if (!chunk) { throw new Error(`chunk '${name}' not found in ${file}`); } const tsResult = ts.transpileModule(chunk, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ESNext, sourceMap: false, }, }); return { format: 'module', source: tsResult.outputText, shortCircuit: true, }; }
if (url.startsWith('file:') && url.endsWith('.ts')) { const file = fileURLToPath(url); const source = fs.readFileSync(file, 'utf8'); const tsResult = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ESNext, sourceMap: false, }, }); return { format: 'module', source: tsResult.outputText, shortCircuit: true, }; }
return defaultLoad(url, context, defaultLoad);};
公開インタフェース
export { resolve } from ':resolve';export { load } from ':load';
if (import.meta.vitest) { await import(':loader.test');}
Tests
import { execSync } from 'node:child_process';import fs from 'node:fs';import path from 'node:path';import { afterAll, beforeAll, describe, expect, it } from 'vitest';
describe('ts-md-loader', () => { const dir = path.join(process.cwd(), 'test', 'fixtures'); const md = path.join(dir, 'doc.ts.md'); const loaderSrc = path.join(process.cwd(), 'dist', 'index.js'); const builtLoader = path.join(dir, 'loader.mjs');
beforeAll(() => { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync( md, [ '# Doc', '', '```ts foo', "export const msg = 'loader works'", '```', '', '```ts main', 'import { msg } from ":foo"', 'console.log(msg)', '```', ].join('\n'), ); const source = fs.readFileSync(loaderSrc, 'utf8'); const loaderCode = source; fs.writeFileSync(builtLoader, loaderCode); });
afterAll(() => { fs.rmSync(dir, { recursive: true, force: true }); });
it('runs markdown file', () => { const out = execSync(`node --loader ${builtLoader} ${md}`, { encoding: 'utf8', }); expect(out.trim()).toBe('loader works'); });});