diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 3e26682..55859c6 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -34,6 +34,10 @@ jobs:
- name: Run tests
run: pnpm run test:built
+ - name: Run source coverage
+ if: matrix.node-version == '22'
+ run: pnpm run test:coverage
+
# Reuses this job's checkout, dependencies, and build. The smoke query
# is milliseconds with the binary lookup but catches a linear regression.
- name: Run source-map benchmark smoke
diff --git a/__tests__/unit/source-map.spec.ts b/__tests__/unit/source-map.spec.ts
new file mode 100644
index 0000000..af80c0b
--- /dev/null
+++ b/__tests__/unit/source-map.spec.ts
@@ -0,0 +1,116 @@
+import {
+ parseMd,
+ parseMdWithSourceMap,
+ SourceMapConsistencyError,
+ SourceMapUnavailableError,
+} from '../../src/index';
+import {
+ parseMdx,
+ parseMdxWithSourceMap,
+} from '../../src/mdx';
+
+function firstNode(root: any, type: string): any {
+ const queue = [root];
+ while (queue.length > 0) {
+ const node = queue.shift();
+ if (node.type === type) return node;
+ queue.push(...(node.children || []));
+ }
+ throw new Error(`No ${type} node found`);
+}
+
+function rawRange(source: string, range: any): string {
+ return source.slice(range.start.offset, range.end.offset);
+}
+
+describe('TypeScript source entry points', () => {
+ test('parses Markdown', () => {
+ const ast = parseMd('# Heading');
+ expect(ast.type).toBe('root');
+ expect(ast.children[0].type).toBe('heading');
+ });
+
+ test('maps a plain text value', () => {
+ const source = 'Hello world';
+ const { ast, sourceMap } = parseMdWithSourceMap(source);
+ const text = firstNode(ast, 'text');
+ expect(text.value).toBe(source);
+ expect(sourceMap.getRaw(text)).toBe(source);
+ expect(rawRange(source, sourceMap.getSourceRange(text, 6, 11)))
+ .toBe('world');
+ });
+
+ test('maps a character reference to its full source range', () => {
+ const source = 'A & B';
+ const { ast, sourceMap } = parseMdWithSourceMap(source);
+ const text = firstNode(ast, 'text');
+ expect(text.value).toBe('A & B');
+ expect(sourceMap.getRaw(text)).toBe(source);
+ expect(rawRange(source, sourceMap.getSourceRange(text, 2, 3)))
+ .toBe('&');
+ });
+
+ test('maps a link URL field', () => {
+ const source = '[link](a&b)';
+ const { ast, sourceMap } = parseMdWithSourceMap(source);
+ const link = firstNode(ast, 'link');
+ expect(link.url).toBe('a&b');
+ expect(rawRange(source, sourceMap.getFieldSourceRange(link, 'url', 1, 2)))
+ .toBe('&');
+ });
+
+ test('maps an inline code value', () => {
+ const source = '` a b `';
+ const { ast, sourceMap } = parseMdWithSourceMap(source);
+ const code = firstNode(ast, 'inlineCode');
+ expect(code.value).toBe('a b');
+ expect(rawRange(source, sourceMap.getSourceRange(code, 0, code.value.length)))
+ .toBe('a b');
+ });
+
+ test('reports a fenced code opening and insert point', () => {
+ const source = '```\ncode\n```';
+ const { ast, sourceMap } = parseMdWithSourceMap(source);
+ const code = firstNode(ast, 'code');
+ const info = sourceMap.getCodeSourceInfo(code);
+ expect(info.kind).toBe('fenced');
+ if (info.kind !== 'fenced') throw new Error('Expected fenced code');
+ expect(rawRange(source, info.openingFence)).toBe('```');
+ expect(info.infoInsertPoint.offset).toBe(3);
+ });
+
+ test('reports an indented code block', () => {
+ const { ast, sourceMap } = parseMdWithSourceMap(' code\n');
+ const code = firstNode(ast, 'code');
+ expect(sourceMap.getCodeSourceInfo(code)).toEqual({ kind: 'indented' });
+ });
+
+ test('rejects a text node modified after parsing', () => {
+ const { ast, sourceMap } = parseMdWithSourceMap('original');
+ const text = firstNode(ast, 'text');
+ text.value = 'changed';
+ expect(() => sourceMap.getSourceRange(text, 0, 1))
+ .toThrow(SourceMapConsistencyError);
+ });
+
+ test('rejects a node from another document', () => {
+ const first = parseMdWithSourceMap('first');
+ const second = parseMdWithSourceMap('second');
+ const foreignText = firstNode(second.ast, 'text');
+ expect(() => first.sourceMap.getRaw(foreignText))
+ .toThrow(SourceMapUnavailableError);
+ });
+
+ test('parses MDX and maps Markdown text within it', () => {
+ const source = '\n\nA & B';
+ const ast = parseMdx(source);
+ expect(firstNode(ast, 'mdxJsxFlowElement').name).toBe('Card');
+
+ const mapped = parseMdxWithSourceMap(source);
+ const text = firstNode(mapped.ast, 'text');
+ expect(text.value).toBe('A & B');
+ expect(mapped.sourceMap.getRaw(text)).toBe('A & B');
+ expect(rawRange(source, mapped.sourceMap.getSourceRange(text, 2, 3)))
+ .toBe('&');
+ });
+});
diff --git a/jest.config.js b/jest.config.js
index 894a9e7..43d4e08 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -2,6 +2,7 @@ module.exports = {
preset: 'ts-jest',
collectCoverage: false,
testRegex: '(./__tests__/.*\\.(test|spec))\\.ts$',
+ testPathIgnorePatterns: ['/node_modules/', '/__tests__/unit/'],
collectCoverageFrom: ['src/**/*.ts'],
coverageDirectory: '/coverage/',
testEnvironment: 'node',
diff --git a/jest.source.config.js b/jest.source.config.js
new file mode 100644
index 0000000..730f017
--- /dev/null
+++ b/jest.source.config.js
@@ -0,0 +1,29 @@
+module.exports = {
+ testEnvironment: 'node',
+ testMatch: [
+ '/__tests__/unit/**/*.spec.ts',
+ ],
+ extensionsToTreatAsEsm: ['.ts'],
+ transform: {
+ '^.+\\.tsx?$': [
+ 'ts-jest',
+ {
+ useESM: true,
+ tsconfig: '/tsconfig.source-test.json',
+ },
+ ],
+ },
+ collectCoverageFrom: [
+ '/src/**/*.ts',
+ '!/src/**/*.d.ts',
+ '!/src/types.ts',
+ '!/src/source-map/types.ts',
+ ],
+ coverageProvider: 'v8',
+ coverageDirectory: '/coverage/source',
+ coverageReporters: [
+ 'text',
+ 'lcov',
+ 'json-summary',
+ ],
+};
diff --git a/package.json b/package.json
index 3379543..aa7f672 100644
--- a/package.json
+++ b/package.json
@@ -60,7 +60,9 @@
"build:ts-declaration": "tsc && api-extractor run --local --verbose && api-extractor run --local --verbose --config api-extractor.mdx.json && cp dist/lint-md-parser.d.ts dist/lint-md-parser.d.cts && cp dist/lint-md-parser.d.ts dist/lint-md-parser.d.mts && cp dist/lint-md-parser-mdx.d.ts dist/lint-md-parser-mdx.d.cts && cp dist/lint-md-parser-mdx.d.ts dist/lint-md-parser-mdx.d.mts && for f in dist/lint-md-parser.d.ts dist/lint-md-parser.d.cts dist/lint-md-parser.d.mts dist/lint-md-parser-mdx.d.ts dist/lint-md-parser-mdx.d.cts dist/lint-md-parser-mdx.d.mts; do cat scripts/mdast-augment.d.ts >> \"$f\"; done",
"lint": "eslint --ext js,jsx,ts,tsx ./src",
"test": "pnpm run build && pnpm run test:built",
- "test:built": "jest && pnpm run test:types && pnpm run test:package && pnpm run test:mdx3 && pnpm run test:bench-entry",
+ "test:built": "jest && pnpm run test:source && pnpm run test:types && pnpm run test:package && pnpm run test:mdx3 && pnpm run test:bench-entry",
+ "test:source": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --config jest.source.config.js",
+ "test:coverage": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --config jest.source.config.js --coverage",
"test:bench-entry": "node scripts/test-bench-markdown-versions.mjs",
"test:mdx3": "node scripts/test-mdx3.mjs",
"test:types": "tsc -p __tests__/types/tsconfig.json",
diff --git a/tsconfig.source-test.json b/tsconfig.source-test.json
new file mode 100644
index 0000000..7239c2c
--- /dev/null
+++ b/tsconfig.source-test.json
@@ -0,0 +1,18 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "rootDir": ".",
+ "outDir": ".tmp/jest-source",
+ "module": "ESNext",
+ "declaration": false
+ },
+ "include": [
+ "src/**/*.ts",
+ "__tests__/unit/**/*.ts"
+ ],
+ "exclude": [
+ "node_modules",
+ "dist",
+ "coverage"
+ ]
+}