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" + ] +}