├── test ├── .npmignore ├── fixtures │ ├── main-default.ts │ └── no-typescript.md ├── TestConfiguration.ts ├── LocalImportSubstituterSpec.ts └── TypeScriptDocsVerifierSpec.ts ├── .npmrc ├── .prettierignore ├── demo.gif ├── .gitignore ├── .github ├── workflows │ └── pull-requests.yaml └── CONTRIBUTING.md ├── Makefile ├── .eslintrc ├── index.ts ├── src ├── CodeBlockExtractor.ts ├── PackageInfo.ts ├── CodeCompiler.ts ├── LocalImportSubstituter.ts └── SnippetCompiler.ts ├── bin └── compile-typescript-docs.ts ├── package.json ├── CHANGELOG ├── tsconfig.json ├── README.md └── LICENSE /test/.npmignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | test-reports 3 | .nyc_output 4 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bbc/typescript-docs-verifier/HEAD/demo.gif -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .nyc_output 2 | node_modules 3 | coverage 4 | dist 5 | package-lock.json 6 | -------------------------------------------------------------------------------- /test/fixtures/main-default.ts: -------------------------------------------------------------------------------- 1 | export class MyTestClass { 2 | doStuff(): void { 3 | // eslint-disable-next-line no-useless-return 4 | return; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /test/TestConfiguration.ts: -------------------------------------------------------------------------------- 1 | import "mocha"; 2 | import "verify-it"; 3 | import * as chai from "chai"; 4 | import "chai/register-should"; 5 | import chaiAsPromised from "chai-as-promised"; 6 | 7 | chai.use(chaiAsPromised); 8 | chai.should(); 9 | -------------------------------------------------------------------------------- /test/fixtures/no-typescript.md: -------------------------------------------------------------------------------- 1 | # A `README.md` file 2 | 3 | This `README` is used to test the `typescript-docs-verifier` project. Some code blocks will be added and compiled. 4 | 5 | ```javascript 6 | const fs = require('fs') 7 | fs.ensureDir('/') 8 | ``` 9 | 10 | And some more: 11 | 12 | ```bash 13 | make 14 | make install 15 | ``` 16 | 17 | And one last block: 18 | 19 | ```markdown 20 | # A title 21 | ``` -------------------------------------------------------------------------------- /.github/workflows/pull-requests.yaml: -------------------------------------------------------------------------------- 1 | name: Pull Request Checks 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | matrix: 17 | node-version: [20.x, 22.x, 24.x] 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v1 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | - run: npm install 26 | - run: npm run build --if-present 27 | - run: npm test 28 | env: 29 | NODE_OPTIONS: "--max_old_space_size=4096" 30 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: release 2 | CURRENT_BRANCH := $(shell git branch --show-current) 3 | PACKAGE_NAME := $(shell cat package.json | jq -r .name) 4 | VERSION := $(shell cat package.json | jq -r .version) 5 | PACKAGE_FILE_NAME := $(PACKAGE_NAME)-$(VERSION).tgz 6 | 7 | tag-release: 8 | @tput setaf 4 9 | @echo "Packaging $(VERSION) of $(PACKAGE_NAME)" 10 | @tput sgr0 11 | @git fetch --all 12 | @npm install 13 | @npm run prepublishOnly 14 | @npm pack 15 | @mv ${PACKAGE_FILE_NAME} .git/ 16 | @git switch release 17 | @git rm -rf . || echo 'No files to delete' 18 | @git clean -fd 19 | @mv ./.git/$(PACKAGE_FILE_NAME) . 20 | @tar -zxf $(PACKAGE_FILE_NAME) 21 | @rm $(PACKAGE_FILE_NAME) 22 | @mv package/* . 23 | @rm -rf package 24 | @git add . 25 | @git commit -m "Release commit for $(VERSION)" 26 | @git tag "$(VERSION)" 27 | @git push --set-upstream origin release 28 | @git push --tags 29 | @git switch $(CURRENT_BRANCH) 30 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "es6": true, 5 | "amd": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "prettier", 10 | "plugin:@typescript-eslint/recommended" 11 | ], 12 | "plugins": [ 13 | "functional", 14 | "node", 15 | "promise" 16 | ], 17 | "parser": "@typescript-eslint/parser", 18 | "rules": { 19 | "@typescript-eslint/strict-boolean-expressions": 0, 20 | "@typescript-eslint/explicit-function-return-type": 0, 21 | "@typescript-eslint/restrict-template-expressions": 0, 22 | "@typescript-eslint/consistent-type-definitions": [ 23 | 2, 24 | "type" 25 | ], 26 | "@typescript-eslint/no-explicit-any": "error", 27 | "@typescript-eslint/no-non-null-assertion": "error", 28 | "@typescript-eslint/no-unused-vars": "error", 29 | "functional/no-let": 2 30 | }, 31 | "parserOptions": { 32 | "project": "tsconfig.json" 33 | }, 34 | "ignorePatterns": [ 35 | "dist" 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | **Please raise feature requests and bugs with this project as a GitHub issue.** 2 | 3 | ### Guidelines for contributions 4 | 5 | * Make sure that your Pull Request has a descriptive title and is documented (What does it do? Why is it needed?). 6 | * All new features and bug fixes *must* have tests (we encourage a Test-Driven Development wherever possible). 7 | * Please understand that communtity contributions are handled by the maintainers in their spare time, so a response may take several weeks 8 | * A PR is more likely to be merged if it fixes an [open issue](https://github.com/bbc/typescript-docs-verifier/issues). 9 | 10 | ### How to contribute 11 | 12 | * Fork bbc/typescript-docs-verifier:master and make your changes. 13 | * Commits should be small, self-contained logical units and should have meaningful messages. 14 | * Check that the tests and code-style checks pass using `npm test`. 15 | * Check that you haven't compromised security or licensing restrictions using `npm audit`. 16 | * When writing the title of your Pull Request, if you have to pause to add an 'and' anywhere in the title it should be two pull requests. -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import { PackageInfo } from "./src/PackageInfo"; 2 | import { 3 | SnippetCompiler, 4 | SnippetCompilationResult, 5 | } from "./src/SnippetCompiler"; 6 | 7 | export type { 8 | SnippetCompilationResult, 9 | CompilationError, 10 | } from "./src/SnippetCompiler"; 11 | 12 | const DEFAULT_FILES = ["README.md"]; 13 | 14 | const parseArguments = (args: CompileSnippetsArguments) => { 15 | if (typeof args === "string") { 16 | return { 17 | markdownFiles: [args], 18 | }; 19 | } 20 | if (Array.isArray(args)) { 21 | return { 22 | markdownFiles: args, 23 | }; 24 | } 25 | return { 26 | project: args.project, 27 | markdownFiles: args.markdownFiles ?? DEFAULT_FILES, 28 | }; 29 | }; 30 | 31 | export type CompileSnippetsArguments = 32 | | string 33 | | string[] 34 | | { 35 | markdownFiles?: string[]; 36 | project?: string; 37 | }; 38 | 39 | export async function compileSnippets( 40 | args: CompileSnippetsArguments = DEFAULT_FILES 41 | ): Promise { 42 | const { project, markdownFiles } = parseArguments(args); 43 | 44 | const packageDefinition = await PackageInfo.read(); 45 | const compiler = new SnippetCompiler( 46 | packageDefinition.packageRoot, 47 | packageDefinition, 48 | project 49 | ); 50 | const results = await compiler.compileSnippets(markdownFiles); 51 | return results; 52 | } 53 | -------------------------------------------------------------------------------- /src/CodeBlockExtractor.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from "fs/promises"; 2 | 3 | // eslint-disable-next-line @typescript-eslint/no-extraneous-class 4 | export class CodeBlockExtractor { 5 | static readonly TYPESCRIPT_CODE_PATTERN = 6 | /(?[\r?\n]*))(?:```(?:(?:typescript)|(tsx?))\r?\n)((?:\r?\n|.)*?)(?:(?=```))/gi; 7 | 8 | /* istanbul ignore next */ 9 | private constructor() { 10 | // 11 | } 12 | 13 | static async extract( 14 | markdownFilePath: string 15 | ): Promise<{ code: string; type: "tsx" | "ts" }[]> { 16 | try { 17 | const contents = await CodeBlockExtractor.readFile(markdownFilePath); 18 | return CodeBlockExtractor.extractCodeBlocksFromMarkdown(contents); 19 | } catch (error) { 20 | throw new Error( 21 | `Error extracting code blocks from ${markdownFilePath}: ${ 22 | error instanceof Error ? error.message : error 23 | }` 24 | ); 25 | } 26 | } 27 | 28 | private static async readFile(path: string): Promise { 29 | return await readFile(path, "utf-8"); 30 | } 31 | 32 | private static extractCodeBlocksFromMarkdown( 33 | markdown: string 34 | ): { code: string; type: "tsx" | "ts" }[] { 35 | const codeBlocks: { code: string; type: "tsx" | "ts" }[] = []; 36 | markdown.replace(this.TYPESCRIPT_CODE_PATTERN, (_, type, code) => { 37 | codeBlocks.push({ 38 | code, 39 | type: type === "tsx" ? "tsx" : "ts", 40 | }); 41 | return code; 42 | }); 43 | return codeBlocks; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/PackageInfo.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import { readFile } from "fs/promises"; 3 | 4 | export type SubpathPattern = "." | string; 5 | 6 | type ConditionalExportKeys = 7 | | "node-addons" 8 | | "node" 9 | | "import" 10 | | "require" 11 | | "default"; 12 | 13 | export type SubpathExports = { 14 | [key in SubpathPattern]?: 15 | | string 16 | | null 17 | | { 18 | [key in ConditionalExportKeys]?: string; 19 | }; 20 | }; 21 | 22 | export type ConditionalExports = { 23 | [key in ConditionalExportKeys]?: string | null; 24 | }; 25 | 26 | export type PackageExports = string | SubpathExports | ConditionalExports; 27 | 28 | export type PackageDefinition = { 29 | readonly name: string; 30 | readonly main?: string; 31 | readonly packageRoot: string; 32 | readonly exports?: PackageExports; 33 | }; 34 | 35 | const searchParentsForPackage = async ( 36 | currentPath: string 37 | ): Promise => { 38 | try { 39 | await readFile(path.join(currentPath, "package.json")); 40 | return currentPath; 41 | } catch { 42 | const parentPath = path.dirname(currentPath); 43 | 44 | if (parentPath === currentPath) { 45 | throw new Error( 46 | "Failed to find package.json — are you running this inside a NodeJS project?" 47 | ); 48 | } 49 | return await searchParentsForPackage(parentPath); 50 | } 51 | }; 52 | 53 | // eslint-disable-next-line @typescript-eslint/no-extraneous-class 54 | export class PackageInfo { 55 | /* istanbul ignore next */ 56 | private constructor() { 57 | // 58 | } 59 | 60 | static async read(): Promise { 61 | const packageRoot = await searchParentsForPackage(process.cwd()); 62 | const packageJsonPath = path.join(packageRoot, "package.json"); 63 | const contents = await readFile(packageJsonPath, "utf-8"); 64 | const packageInfo = JSON.parse(contents); 65 | 66 | return { 67 | name: packageInfo.name, 68 | main: packageInfo.main, 69 | exports: packageInfo.exports, 70 | packageRoot, 71 | }; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /bin/compile-typescript-docs.ts: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | 3 | import ora from "ora"; 4 | import chalk from "chalk"; 5 | import * as yargs from "yargs"; 6 | import * as TypeScriptDocsVerifier from "../index"; 7 | 8 | const cliOptions = yargs 9 | .option("input-files", { 10 | description: "The list of input files to be processed", 11 | array: true, 12 | string: true, 13 | default: ["README.md"], 14 | }) 15 | .option("project", { 16 | description: 17 | "The path (relative to the package root) to the tsconfig.json file to use when compiling snippets (defaults to the `tsconfig.json` in the package root)", 18 | string: true, 19 | requiresArg: false, 20 | }); 21 | 22 | const { "input-files": inputFiles, project } = cliOptions.parseSync(); 23 | 24 | const spinner = ora(); 25 | spinner 26 | .info( 27 | `Compiling documentation TypeScript code snippets from ${inputFiles.join( 28 | ", " 29 | )}` 30 | ) 31 | .start(); 32 | 33 | const formatCode = (code: string, errorLines: number[]) => { 34 | const lines = code.split("\n").map((line, index) => { 35 | const lineNumber = index + 1; 36 | if (errorLines.includes(lineNumber)) { 37 | return chalk`{bold.red ${String(lineNumber).padStart(2)}| ${line}}`; 38 | } else { 39 | return `${String(lineNumber).padStart(2)}| ${line}`; 40 | } 41 | }); 42 | return " " + lines.join("\n "); 43 | }; 44 | 45 | const formatError = (error: Error) => 46 | " " + error.message.split("\n").join("\n "); 47 | 48 | const doCompilation = async () => { 49 | const results = await TypeScriptDocsVerifier.compileSnippets({ 50 | markdownFiles: inputFiles, 51 | project, 52 | }); 53 | spinner.info(`Found ${results.length} TypeScript snippets`).start(); 54 | results.forEach((result) => { 55 | if (result.error) { 56 | process.exitCode = 1; 57 | spinner.fail( 58 | chalk`{red.bold Error compiling example code block ${result.index} in file ${result.file}:}` 59 | ); 60 | console.log(formatError(result.error)); 61 | console.log(); 62 | console.log(chalk`{blue.bold Original code:}`); 63 | console.log(formatCode(result.snippet, result.linesWithErrors)); 64 | } 65 | }); 66 | if (process.exitCode) { 67 | spinner.fail(chalk`{red.bold Compilation failed, see above errors}`); 68 | } else { 69 | spinner.succeed(chalk`{green.bold All snippets compiled OK}`); 70 | } 71 | }; 72 | 73 | doCompilation().catch((error) => { 74 | process.exitCode = 1; 75 | console.error(error); 76 | try { 77 | spinner.fail(); 78 | } catch (error) { 79 | console.error(error); 80 | } 81 | }); 82 | -------------------------------------------------------------------------------- /src/CodeCompiler.ts: -------------------------------------------------------------------------------- 1 | import ts from "typescript"; 2 | import path from "path"; 3 | 4 | const createServiceHost = ( 5 | options: ts.CompilerOptions, 6 | workingDirectory: string, 7 | fileMap: Map 8 | ): ts.LanguageServiceHost => ({ 9 | getScriptFileNames: () => { 10 | return [...fileMap.keys()]; 11 | }, 12 | getScriptVersion: () => "1", 13 | getProjectVersion: () => "1", 14 | getScriptSnapshot: (fileName) => { 15 | const contents = 16 | fileMap.get(fileName) ?? ts.sys.readFile(fileName, "utf-8"); 17 | 18 | return typeof contents === "undefined" 19 | ? contents 20 | : ts.ScriptSnapshot.fromString(contents); 21 | }, 22 | readFile: (fileName) => { 23 | return fileMap.get(fileName) ?? ts.sys.readFile(fileName); 24 | }, 25 | fileExists: (fileName) => { 26 | return fileMap.has(fileName) || ts.sys.fileExists(fileName); 27 | }, 28 | getCurrentDirectory: () => workingDirectory, 29 | getDirectories: ts.sys.getDirectories, 30 | directoryExists: ts.sys.directoryExists, 31 | getCompilationSettings: () => options, 32 | getDefaultLibFileName: () => ts.getDefaultLibFilePath(options), 33 | }); 34 | 35 | export const compile = async ({ 36 | compilerOptions, 37 | workingDirectory, 38 | code, 39 | type, 40 | }: { 41 | compilerOptions: ts.CompilerOptions; 42 | workingDirectory: string; 43 | code: string; 44 | type: "ts" | "tsx"; 45 | }): Promise<{ 46 | hasError: boolean; 47 | diagnostics: ReadonlyArray; 48 | }> => { 49 | const id = process.hrtime.bigint().toString(); 50 | const filename = path.join( 51 | compilerOptions.rootDir || "", 52 | `block-${id}.${type}` 53 | ); 54 | 55 | const fileMap = new Map([[filename, code]]); 56 | 57 | const registry = ts.createDocumentRegistry( 58 | ts.sys.useCaseSensitiveFileNames, 59 | workingDirectory 60 | ); 61 | 62 | const serviceHost = createServiceHost( 63 | { 64 | ...compilerOptions, 65 | noEmit: false, 66 | declaration: false, 67 | sourceMap: false, 68 | noEmitOnError: true, 69 | incremental: false, 70 | composite: false, 71 | declarationMap: false, 72 | noUnusedLocals: false, 73 | }, 74 | workingDirectory, 75 | fileMap 76 | ); 77 | 78 | const service = ts.createLanguageService(serviceHost, registry); 79 | 80 | try { 81 | const output = service.getEmitOutput(filename, false, false); 82 | 83 | if (output.emitSkipped) { 84 | const diagnostics = [ 85 | ...service.getCompilerOptionsDiagnostics(), 86 | ...service.getSemanticDiagnostics(filename), 87 | ...service.getSyntacticDiagnostics(filename), 88 | ]; 89 | 90 | return { 91 | diagnostics, 92 | hasError: true, 93 | }; 94 | } 95 | 96 | return { 97 | diagnostics: [], 98 | hasError: false, 99 | }; 100 | } finally { 101 | service.dispose(); 102 | } 103 | }; 104 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript-docs-verifier", 3 | "description": "Verifies that typescript examples in markdown files actually compile.", 4 | "keywords": [ 5 | "block", 6 | "blocks", 7 | "build", 8 | "check", 9 | "code", 10 | "compilation", 11 | "compile", 12 | "doc", 13 | "docs", 14 | "documentation", 15 | "markdown", 16 | "md", 17 | "ts", 18 | "typescript", 19 | "verify" 20 | ], 21 | "version": "3.0.1", 22 | "main": "dist/index.js", 23 | "@types": "dist/index.d.ts", 24 | "bin": { 25 | "typescript-docs-verifier": "dist/bin/compile-typescript-docs.js" 26 | }, 27 | "license": "Apache-2.0", 28 | "author": "BBC", 29 | "repository": { 30 | "type": "git", 31 | "url": "git+https://github.com/bbc/typescript-docs-verifier.git" 32 | }, 33 | "bugs": { 34 | "url": "https://github.com/bbc/typescript-docs-verifier/issues" 35 | }, 36 | "homepage": "https://github.com/bbc/typescript-docs-verifier#readme", 37 | "engines": { 38 | "node": ">=20" 39 | }, 40 | "scripts": { 41 | "format": "prettier --write '**/*.ts' '**/*.json'", 42 | "pretest": "npm run-script build", 43 | "test": "npm-run-all -p -c format unittest lint", 44 | "posttest": "npm-run-all compile-docs", 45 | "unittest": "nyc mocha --timeout 20000 -r ts-node/register --recursive test/TestConfiguration.ts 'test/*Spec.ts' 'test/**/*Spec.ts'", 46 | "lint": "eslint '**/*.ts'", 47 | "build": "rm -rf dist && tsc", 48 | "compile-docs": "node ./dist/bin/compile-typescript-docs.js", 49 | "prepublishOnly": "npm-run-all -s build compile-docs" 50 | }, 51 | "devDependencies": { 52 | "@istanbuljs/nyc-config-typescript": "^1.0.2", 53 | "@types/chai": "^5.2.2", 54 | "@types/chai-as-promised": "^8.0.1", 55 | "@types/fs-extra": "^11.0.4", 56 | "@types/mocha": "^10.0.10", 57 | "@types/node": "^20.19.1", 58 | "@types/react": "^18.2.12", 59 | "@types/yargs": "^17.0.12", 60 | "@typescript-eslint/eslint-plugin": "^8.24.0", 61 | "@typescript-eslint/parser": "^8.24.0", 62 | "chai": "^5.2.0", 63 | "chai-as-promised": "^8.0.1", 64 | "eslint": "^8.57.1", 65 | "eslint-config-prettier": "^10.0.1", 66 | "eslint-plugin-functional": "^6.6.3", 67 | "eslint-plugin-node": "^11.1.0", 68 | "eslint-plugin-promise": "^7.2.1", 69 | "fs-extra": "^11.3.0", 70 | "mocha": "^11.1.0", 71 | "npm-run-all2": "^7.0.2", 72 | "nyc": "^17.1.0", 73 | "prettier": "^3.5.1", 74 | "react": "^18.2.0", 75 | "ts-node": "^10.9.2", 76 | "typescript": "^4.7.2", 77 | "verify-it": "^2.3.3" 78 | }, 79 | "dependencies": { 80 | "chalk": "^4.1.2", 81 | "ora": "^5.4.1", 82 | "yargs": "^17.5.1" 83 | }, 84 | "peerDependencies": { 85 | "typescript": ">=4.7.2" 86 | }, 87 | "files": [ 88 | "dist/index.js", 89 | "dist/index.d.ts", 90 | "dist/index.js.map", 91 | "dist/bin", 92 | "dist/src" 93 | ], 94 | "nyc": { 95 | "extends": "@istanbuljs/nyc-config-typescript", 96 | "check-coverage": true, 97 | "all": true, 98 | "include": [ 99 | "**/*.ts" 100 | ], 101 | "exclude": [ 102 | "bin", 103 | "dist", 104 | "test" 105 | ], 106 | "reporter": [ 107 | "html", 108 | "text", 109 | "text-summary" 110 | ], 111 | "report-dir": "test/coverage" 112 | }, 113 | "prettier": { 114 | "trailingComma": "es5" 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## Unreleased 9 | 10 | ## [3.0.1] 11 | 12 | ### Changed 13 | 14 | - Fix compilation errors when `rootDir` is used in `tsconfig.json` 15 | - Ensure compiler option diagnostics are reported in the error output 16 | 17 | ## [3.0.0] 18 | 19 | ### Added 20 | 21 | - Support for `import` statements split across multiple lines in snippets 22 | 23 | ### Changed 24 | 25 | - Remove dependency on `ts-node` to avoid memory issues when compiling many snippets. 26 | - **[BREAKING]** Compilation errors are now `CompilationError` instances, not `TSNode.TSError` instances 27 | - Compile snippets in series to avoid memory issues. 28 | 29 | ### Removed 30 | 31 | - **[BREAKING]** Support for TypeScript versions <4.7.2 32 | - **[BREAKING]** Support for Node.js <20 33 | - Production dependency on `tsconfig` and `strip-ansi` and `fs-extra` 34 | 35 | ## [2.5.3] 36 | 37 | ### Changed 38 | 39 | - Use a separate `ts-node` compiler per-snippet to ensure that compilation of snippets is independent 40 | 41 | ## [2.5.2] 42 | 43 | ### Removed 44 | 45 | - Obsolete Travis CI build badge from README 46 | 47 | ## [2.5.1] 48 | 49 | ### Added 50 | 51 | - Override any project-specific `ts-node` `transpileOnly` to force type checking when compiling code snippets 52 | 53 | ## [2.5.0] 54 | 55 | ### Added 56 | 57 | - Support for `tsx` snippets 58 | 59 | ## [2.4.1] 60 | 61 | ### Changed 62 | 63 | - Various fixes for Windows environments 64 | 65 | ## [2.4.0] 66 | 67 | ### Added 68 | 69 | - A new `--project` option that overrides the `tsconfig.json` file to be used when compiling snippets 70 | 71 | ## [2.3.1] 72 | 73 | ### Changed 74 | 75 | - Update dependencies 76 | 77 | ## [2.3.0] 78 | 79 | ### Added 80 | 81 | - Support for `exports` in `package.json` including wildcard subpaths 82 | 83 | ### Changed 84 | 85 | - Compile documentation snippets in the project folder so that dependent packages can be resolved reliably even in nested projects 86 | 87 | ## [2.2.2] 88 | 89 | ### Changed 90 | 91 | - Unpinned `ts-node` dependency to fix issues with the most recent TypeScript versions (required the extraction of the line numbers of compilation errors to be changed) 92 | 93 | ## [2.2.1] 94 | 95 | ### Added 96 | 97 | - Allow code blocks to be ignored by preceding them with a `` comment 98 | 99 | ## [2.2.0] 100 | 101 | ### Changed 102 | 103 | - Link project `node_modules` to snippet compilation directory so you can import from the current project's dependencies in snippets 104 | 105 | ### Added 106 | 107 | - Support for importing sub-paths within packages 108 | - Support for scoped package names 109 | 110 | ## [2.1.0] 111 | 112 | ### Changed 113 | 114 | - No longer wrap TypeScript code blocks in functions before compilation 115 | - Write temporary files to the OS temporary directory 116 | 117 | ### Added 118 | 119 | - An 1-indexed `index` property to the `CodeBlock` type to indicate where in the file the code block was found 120 | - Add a `linesWithErrors` property to the compilation result to indicate which lines contained errors 121 | 122 | ## [2.0.1] - 2021-10-08 123 | 124 | ### Changed 125 | 126 | - Updated dependencies (including dropping `tslint` in favour of `eslint`) 127 | - Pin to version 5.x.x of `ora` to avoid issues with ESM 128 | 129 | ## [2.0.0-rc.1] - 2021-09-27 130 | 131 | ### Added 132 | 133 | - Support for TypeScript code blocked marked with \`\`\`ts as well as ```typescript 134 | - This changelog 🎉 135 | 136 | ### Changed 137 | 138 | - [BREAKING] TypeScript is now a peerDependency and must be installed by the client. 139 | - Tagging of the source is now done using a `release` branch. 140 | - Migrated code to `async` / `await`. 141 | 142 | ### Removed 143 | 144 | - [BREAKING] Support for NodeJS versions prior to version 12. 145 | - Bluebird dependency. 146 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "ES2019" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */, 5 | "module": "commonjs" /* Specify module code generation: 'commonjs', 'amd', 'system', 'umd' or 'es2015'. */, 6 | "esModuleInterop": true, 7 | // "lib": [], /* Specify library files to be included in the compilation: */ 8 | // "allowJs": true, /* Allow javascript files to be compiled. */ 9 | // "checkJs": true, /* Report errors in .js files. */ 10 | "jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */, 11 | "declaration": true /* Generates corresponding '.d.ts' file. */, 12 | "sourceMap": true /* Generates corresponding '.map' file. */, 13 | // "outFile": "./", /* Concatenate and emit output to single file. */ 14 | "outDir": "./dist" /* Redirect output structure to the directory. */, 15 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 16 | // "removeComments": true, /* Do not emit comments to output. */ 17 | // "noEmit": true, /* Do not emit outputs. */ 18 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 19 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 20 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 21 | "noEmitOnError": true, 22 | "pretty": true, 23 | 24 | /* Strict Type-Checking Options */ 25 | "strict": true /* Enable all strict type-checking options. */, 26 | "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */, 27 | "strictNullChecks": true /* Enable strict null checks. */, 28 | "noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */, 29 | "alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */, 30 | 31 | /* Additional Checks */ 32 | "noUnusedLocals": true /* Report errors on unused locals. */, 33 | "noUnusedParameters": true /* Report errors on unused parameters. */, 34 | "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, 35 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 36 | 37 | /* Module Resolution Options */ 38 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 39 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 40 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 41 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 42 | "typeRoots": [ 43 | "node_modules/@types" 44 | ] /* List of folders to include type definitions from. */, 45 | // "types": [], /* Type declaration files to be included in compilation. */ 46 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 47 | 48 | /* Source Map Options */ 49 | // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 50 | // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */ 51 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 52 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 53 | 54 | /* Experimental Options */ 55 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 56 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 57 | "skipLibCheck": true 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `typescript-docs-verifier` 2 | 3 | _Verifies that typescript examples in markdown files actually compile._ 4 | 5 | [![TypeScript](https://img.shields.io/badge/%3C/%3E-TypeScript-blue.svg)](https://www.typescriptlang.org/) 6 | [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) 7 | [![Apache 2.0](https://img.shields.io/hexpm/l/plug.svg)](https://www.apache.org/licenses/LICENSE-2.0) 8 | [![TypeScript docs verifier](https://img.shields.io/badge/checked_with_%E2%9C%93-TS_docs_verifier-blue.svg)](https://github.com/bbc/typescript-docs-verifier) 9 | 10 | ## Why? 11 | 12 | Ever copied a TypeScript code example from a README and found that it didn't even compile? This tool can help by verifying that all of your code examples compile correctly. And yes, the TypeScript code samples in this `README` are checked using this tool. 13 | 14 | ![demo](demo.gif) 15 | 16 | Inspired the by the [tut](https://github.com/tpolecat/tut) documentation compilation tool for scala. 17 | 18 | ## How it works 19 | 20 | The selected markdown files are searched for `TypeScript` code blocks marked like this: 21 | 22 | ````Markdown 23 | ```typescript 24 | // Some TypeScript code here 25 | const write = 'some code'; 26 | ``` 27 | ```` 28 | 29 | These code blocks are extracted and any imports from the current project are replaced with an import of the `main` or `exports` from `package.json` (e.g. `import { compileSnippets } from 'typescript-docs-verifier'` would be replaced with `import { compileSnippets } from './dist/index'` for this project). 30 | 31 | Each code snippet is compiled (but not run) and any compilation errors are reported. Code snippets must compile independently from any other code snippets in the file. 32 | 33 | The library can also be used to type check `.tsx` files: 34 | 35 | ````Markdown 36 | ```tsx 37 | import React from 'react' 38 | 39 | const SomeComponent = () => ( 40 |
41 | This is a TSX component! 42 |
43 | ) 44 | ``` 45 | ```` 46 | 47 | ### Ignoring code blocks 48 | 49 | Individual code blocks can be ignored by preceding them with a `` comment: 50 | 51 | ````Markdown 52 | 53 | ```typescript 54 | // This block won't be compiled by typescript-docs-verifier 55 | ``` 56 | ```` 57 | 58 | ## Script usage 59 | 60 | ```bash 61 | node_modules/.bin/typescript-docs-verifier [--input-files ] [--project ] 62 | ``` 63 | 64 | - `--input-files` is optional and defaults to `README.md`. 65 | - `--project` is optional and defaults to the `tsconfig.json` file in the package root. 66 | - Any compilation errors will be reported on the console. 67 | - The exit code is 1 if there are any compilation errors and 0 otherwise. 68 | 69 | ## Library usage 70 | 71 | ### TypeScript 72 | 73 | ```typescript 74 | import { 75 | compileSnippets, 76 | SnippetCompilationResult, 77 | } from "typescript-docs-verifier"; 78 | 79 | const markdownFiles = ["README", "examples.md"]; // defaults to 'README.md' if not provided 80 | const tsconfigPath = "docs-tsconfig.json"; // defaults to the 'tsconfig.json' file in the package root 81 | compileSnippets({ markdownFiles, project: tsconfigPath }) 82 | .then((results: SnippetCompilationResult[]) => { 83 | results.forEach((result: SnippetCompilationResult) => { 84 | if (result.error) { 85 | console.log( 86 | `Error compiling example code block ${result.index} in file ${result.file}` 87 | ); 88 | console.log(result.error.message); 89 | console.log("Original code:"); 90 | console.log(result.snippet); 91 | } 92 | }); 93 | }) 94 | .catch((error: unknown) => { 95 | console.error("Error compiling TypeScript snippets", error); 96 | }); 97 | ``` 98 | 99 | ### JavaScript 100 | 101 | ```javascript 102 | const { compileSnippets } = require("typescript-docs-verifier"); 103 | 104 | const markdownFiles = ["README.md", "examples.md"]; // defaults to 'README.md' if not provided 105 | const tsconfigPath = "docs-tsconfig.json"; // defaults to the 'tsconfig.json' file in the package root 106 | compileSnippets({ markdownFiles, project: tsconfigPath }) 107 | .then((results) => { 108 | results.forEach((result) => { 109 | if (result.error) { 110 | console.log( 111 | `Error compiling example code block ${result.index} in file ${result.file}` 112 | ); 113 | console.log(result.error.message); 114 | console.log("Original code:"); 115 | console.log(result.snippet); 116 | } 117 | }); 118 | }) 119 | .catch((error) => { 120 | console.error("Error compiling TypeScript snippets", error); 121 | }); 122 | ``` 123 | 124 | ## Development 125 | 126 | Run the tests: 127 | 128 | ```sh 129 | npm install 130 | npm test 131 | ``` 132 | 133 | ## Contributing 134 | 135 | See [these notes](./.github/CONTRIBUTING.md) for information for contributors. 136 | 137 | ## License 138 | 139 | `typescript-docs-verifier` is available to all via the [Apache-2.0](./LICENSE) license. 140 | 141 | Copyright © 2017 BBC 142 | -------------------------------------------------------------------------------- /src/LocalImportSubstituter.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import { 3 | ConditionalExports, 4 | PackageDefinition, 5 | PackageExports, 6 | SubpathExports, 7 | } from "./PackageInfo"; 8 | 9 | class ExportResolver { 10 | private readonly packageName: string; 11 | private readonly packageMain?: string; 12 | private readonly packageExports?: PackageExports; 13 | 14 | constructor( 15 | packageName: string, 16 | packageMain?: string, 17 | packageExports?: PackageExports 18 | ) { 19 | if (!packageMain && !packageExports) { 20 | throw new Error( 21 | "Failed to find a valid main or exports entry in package.json file" 22 | ); 23 | } 24 | 25 | this.packageName = packageName; 26 | this.packageMain = packageMain; 27 | this.packageExports = packageExports; 28 | } 29 | 30 | private findMatchingExport(path = "."): string { 31 | if (!this.packageExports) { 32 | throw new Error("No exports defined in package.json"); 33 | } 34 | 35 | if (typeof this.packageExports === "string" && path === ".") { 36 | return this.packageExports; 37 | } 38 | 39 | const conditionalExports = this.packageExports as ConditionalExports; 40 | 41 | const conditionalExportEntry = 42 | conditionalExports["node-addons"] ?? 43 | conditionalExports.node ?? 44 | conditionalExports.import ?? 45 | conditionalExports.require ?? 46 | conditionalExports.default; 47 | 48 | if (conditionalExportEntry && path === ".") { 49 | return conditionalExportEntry; 50 | } 51 | 52 | const subpathExports = this.packageExports as SubpathExports; 53 | 54 | const lookupPath = path === "." ? path : `.${path}`; 55 | 56 | const [matchingExportPath, matchingSubpath] = 57 | Object.entries(subpathExports).find(([exportedPath]) => { 58 | if (lookupPath === exportedPath) { 59 | return true; 60 | } 61 | const [prefix, suffix] = exportedPath.split("*"); 62 | return ( 63 | exportedPath.includes("*") && 64 | lookupPath.startsWith(prefix) && 65 | lookupPath.endsWith(suffix || "") 66 | ); 67 | }) ?? []; 68 | 69 | if (!matchingExportPath || !matchingSubpath) { 70 | throw new Error( 71 | `Unable to resolve export for path "${this.packageName}${ 72 | path === "." ? "" : path 73 | }"` 74 | ); 75 | } 76 | 77 | const [exportPrefix, exportSuffix = ""] = matchingExportPath.split("*"); 78 | const internalPath = lookupPath.substring( 79 | exportPrefix.length, 80 | lookupPath.length - exportSuffix.length 81 | ); 82 | 83 | const subpathEntry = 84 | typeof matchingSubpath === "string" 85 | ? matchingSubpath 86 | : (matchingSubpath["node-addons"] ?? 87 | matchingSubpath.node ?? 88 | matchingSubpath.import ?? 89 | matchingSubpath.require ?? 90 | matchingSubpath.default); 91 | 92 | if (subpathEntry) { 93 | const [internalPrefix, internalSuffix = ""] = subpathEntry.split("*"); 94 | return `${internalPrefix}${internalPath}${internalSuffix}`; 95 | } 96 | 97 | throw new Error( 98 | `Unable to resolve export for path "${this.packageName}${ 99 | path === "." ? "" : path 100 | }"` 101 | ); 102 | } 103 | 104 | resolveExportPath(path?: string): string { 105 | if (!this.packageExports) { 106 | if (!this.packageMain) { 107 | throw new Error("Failed to find main or exports entry in package.json"); 108 | } 109 | 110 | return path ?? this.packageMain; 111 | } 112 | 113 | const matchingExport = this.findMatchingExport(path); 114 | return matchingExport; 115 | } 116 | } 117 | 118 | export class LocalImportSubstituter { 119 | private readonly packageName: string; 120 | private readonly packageRoot: string; 121 | private readonly exportResolver: ExportResolver; 122 | 123 | constructor(packageDefinition: PackageDefinition) { 124 | this.packageName = packageDefinition.name; 125 | this.packageRoot = packageDefinition.packageRoot; 126 | 127 | this.exportResolver = new ExportResolver( 128 | packageDefinition.name, 129 | packageDefinition.main, 130 | packageDefinition.exports 131 | ); 132 | } 133 | 134 | substituteLocalPackageImports(code: string) { 135 | const escapedPackageName = this.packageName.replace(/\\/g, "\\/"); 136 | const projectImportRegex = new RegExp( 137 | `(from\\s+)?('|")(?:${escapedPackageName})(/[^'"]+)?('|"|)` 138 | ); 139 | const codeLines = code.split("\n"); 140 | 141 | const localisedLines = codeLines.map((line) => { 142 | const match = projectImportRegex.exec(line); 143 | if (!match) { 144 | return line; 145 | } 146 | 147 | const { 1: from, 2: openQuote, 3: subPath, 4: closeQuote, index } = match; 148 | 149 | const prefix = line.substring(0, index); 150 | 151 | const resolvedExportPath = this.exportResolver.resolveExportPath(subPath); 152 | 153 | const fullExportPath = path 154 | .join(this.packageRoot, resolvedExportPath) 155 | .replace(/\\/g, "\\\\"); 156 | return `${prefix}${from || ""}${openQuote}${fullExportPath}${closeQuote}`; 157 | }); 158 | return localisedLines.join("\n"); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/SnippetCompiler.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import ts from "typescript"; 3 | import { PackageDefinition } from "./PackageInfo"; 4 | import { CodeBlockExtractor } from "./CodeBlockExtractor"; 5 | import { LocalImportSubstituter } from "./LocalImportSubstituter"; 6 | import { compile } from "./CodeCompiler"; 7 | 8 | type CodeBlock = { 9 | readonly file: string; 10 | readonly index: number; 11 | readonly snippet: string; 12 | readonly sanitisedCode: string; 13 | readonly type: "ts" | "tsx"; 14 | }; 15 | 16 | export type SnippetCompilationResult = { 17 | readonly file: string; 18 | readonly index: number; 19 | readonly snippet: string; 20 | readonly linesWithErrors: number[]; 21 | readonly error?: CompilationError | Error; 22 | }; 23 | 24 | export class CompilationError extends Error { 25 | diagnosticCodes: number[]; 26 | name: string; 27 | diagnosticText: string; 28 | diagnostics: ts.Diagnostic[]; 29 | 30 | constructor(diagnosticText: string, diagnostics?: ts.Diagnostic[]) { 31 | super(diagnosticText); 32 | this.name = this.constructor.name; 33 | this.diagnosticText = diagnosticText; 34 | this.diagnosticCodes = diagnostics?.map(({ code }) => code) ?? []; 35 | this.diagnostics = diagnostics ?? []; 36 | } 37 | } 38 | 39 | export class SnippetCompiler { 40 | private readonly compilerOptions: ts.CompilerOptions; 41 | 42 | constructor( 43 | private readonly workingDirectory: string, 44 | private readonly packageDefinition: PackageDefinition, 45 | project?: string 46 | ) { 47 | const configOptions = SnippetCompiler.loadTypeScriptConfig( 48 | packageDefinition.packageRoot, 49 | project 50 | ); 51 | this.compilerOptions = configOptions.options; 52 | } 53 | 54 | private static loadTypeScriptConfig( 55 | packageRoot: string, 56 | project?: string 57 | ): ts.ParsedCommandLine { 58 | const configFile = ts.findConfigFile( 59 | packageRoot, 60 | ts.sys.fileExists, 61 | project 62 | ); 63 | 64 | if (!configFile) { 65 | throw new Error( 66 | `Unable to find TypeScript configuration file in ${packageRoot}` 67 | ); 68 | } 69 | 70 | const fileContents = fs.readFileSync(configFile, "utf-8"); 71 | const { config: configJSON, error } = ts.parseConfigFileTextToJson( 72 | configFile, 73 | fileContents 74 | ); 75 | 76 | if (error) { 77 | throw new Error( 78 | `Error reading tsconfig from ${configFile}: ${ts.flattenDiagnosticMessageText(error.messageText, ts.sys.newLine)}` 79 | ); 80 | } 81 | 82 | const parsedConfig = ts.parseJsonConfigFileContent( 83 | configJSON, 84 | { 85 | fileExists: ts.sys.fileExists, 86 | readDirectory: ts.sys.readDirectory, 87 | readFile: ts.sys.readFile, 88 | useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames, 89 | }, 90 | packageRoot 91 | ); 92 | 93 | return parsedConfig; 94 | } 95 | async compileSnippets( 96 | documentationFiles: string[] 97 | ): Promise { 98 | const results: SnippetCompilationResult[] = []; 99 | const examples = await this.extractAllCodeBlocks(documentationFiles); 100 | 101 | for (const example of examples) { 102 | const result = await this.testCodeCompilation(example); 103 | results.push(result); 104 | 105 | // Yield to event loop 106 | await new Promise((resolve) => setImmediate(resolve)); 107 | } 108 | return results; 109 | } 110 | 111 | private async extractAllCodeBlocks(documentationFiles: string[]) { 112 | const importSubstituter = new LocalImportSubstituter( 113 | this.packageDefinition 114 | ); 115 | 116 | const codeBlocks = await Promise.all( 117 | documentationFiles.map( 118 | async (file) => 119 | await this.extractFileCodeBlocks(file, importSubstituter) 120 | ) 121 | ); 122 | return codeBlocks.flat(); 123 | } 124 | 125 | private async extractFileCodeBlocks( 126 | file: string, 127 | importSubstituter: LocalImportSubstituter 128 | ): Promise { 129 | const blocks = await CodeBlockExtractor.extract(file); 130 | return blocks.map(({ code, type }, index) => { 131 | return { 132 | file, 133 | type, 134 | snippet: code, 135 | index: index + 1, 136 | sanitisedCode: this.sanitiseCodeBlock(importSubstituter, code), 137 | }; 138 | }); 139 | } 140 | 141 | private sanitiseCodeBlock( 142 | importSubstituter: LocalImportSubstituter, 143 | block: string 144 | ): string { 145 | const localisedBlock = 146 | importSubstituter.substituteLocalPackageImports(block); 147 | return localisedBlock; 148 | } 149 | 150 | private async testCodeCompilation( 151 | example: CodeBlock 152 | ): Promise { 153 | try { 154 | const { hasError, diagnostics } = await compile({ 155 | compilerOptions: this.compilerOptions, 156 | workingDirectory: this.workingDirectory, 157 | code: example.sanitisedCode, 158 | type: example.type, 159 | }); 160 | 161 | if (!hasError) { 162 | return { 163 | snippet: example.snippet, 164 | file: example.file, 165 | index: example.index, 166 | linesWithErrors: [], 167 | }; 168 | } 169 | 170 | const linesWithErrors = new Set(); 171 | 172 | const enrichedDiagnostics = diagnostics.map((diagnostic) => { 173 | if (typeof diagnostic.start !== "undefined") { 174 | const startLine = 175 | [...example.sanitisedCode.substring(0, diagnostic.start)].filter( 176 | (char) => char === ts.sys.newLine 177 | ).length + 1; 178 | linesWithErrors.add(startLine); 179 | } 180 | 181 | return { 182 | ...diagnostic, 183 | file: diagnostic.file 184 | ? { 185 | ...diagnostic.file, 186 | fileName: `${example.file} → Code Block ${example.index}`, 187 | } 188 | : undefined, 189 | }; 190 | }); 191 | 192 | const formatter = process.stdout.isTTY 193 | ? ts.formatDiagnosticsWithColorAndContext 194 | : ts.formatDiagnostics; 195 | 196 | const diagnosticText = formatter(enrichedDiagnostics, { 197 | getCanonicalFileName: (fileName) => fileName, 198 | getCurrentDirectory: () => this.workingDirectory, 199 | getNewLine: () => ts.sys.newLine, 200 | }); 201 | 202 | const error = new CompilationError( 203 | `⨯ Unable to compile TypeScript:\n${diagnosticText}`, 204 | enrichedDiagnostics 205 | ); 206 | 207 | return { 208 | snippet: example.snippet, 209 | file: example.file, 210 | error: error, 211 | index: example.index, 212 | linesWithErrors: [...linesWithErrors], 213 | }; 214 | } catch (rawError) { 215 | const error = 216 | rawError instanceof Error ? rawError : new Error(String(rawError)); 217 | 218 | return { 219 | snippet: example.snippet, 220 | error: error, 221 | linesWithErrors: [], 222 | file: example.file, 223 | index: example.index, 224 | }; 225 | } 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /test/LocalImportSubstituterSpec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { LocalImportSubstituter } from "../src/LocalImportSubstituter"; 3 | 4 | const defaultPackageInfo = { 5 | name: "my-package", 6 | main: "index.ts", 7 | packageRoot: "/path/to/package", 8 | }; 9 | 10 | const scenarios = [ 11 | { 12 | importLine: `import something from 'awesome'`, 13 | expected: `import something from '/path/to/package/index.ts'`, 14 | name: "single quotes", 15 | }, 16 | { 17 | importLine: `import something from 'awesome';`, 18 | expected: `import something from '/path/to/package/index.ts'`, 19 | name: "a trailing semicolon", 20 | }, 21 | { 22 | importLine: `import something from "awesome"`, 23 | expected: `import something from "/path/to/package/index.ts"`, 24 | name: "double quotes", 25 | }, 26 | { 27 | importLine: `import something from "awesome" `, 28 | expected: `import something from "/path/to/package/index.ts"`, 29 | name: "trailing whitespace", 30 | }, 31 | { 32 | importLine: `import something from 'awesome'`, 33 | expected: `import something from '/path/to/package/main.ts'`, 34 | packageInfo: { 35 | exports: "main.ts", 36 | }, 37 | name: "where exports is a string", 38 | }, 39 | { 40 | importLine: `import something from 'awesome'`, 41 | expected: `import something from '/path/to/package/main.ts'`, 42 | packageInfo: { 43 | exports: { 44 | ".": "main.ts", 45 | }, 46 | }, 47 | name: 'where exports is { ".": "{some-file}" }', 48 | }, 49 | { 50 | importLine: `import something from 'awesome'`, 51 | expected: `import something from '/path/to/package/main.ts'`, 52 | packageInfo: { 53 | exports: { 54 | "node-addons": "main.ts", 55 | }, 56 | }, 57 | name: 'where exports is { "node-addons": "{some-file}" }', 58 | }, 59 | { 60 | importLine: `import something from 'awesome'`, 61 | expected: `import something from '/path/to/package/main.ts'`, 62 | packageInfo: { 63 | exports: { 64 | node: "main.ts", 65 | }, 66 | }, 67 | name: 'where exports is { "node": "{some-file}" }', 68 | }, 69 | { 70 | importLine: `import something from 'awesome'`, 71 | expected: `import something from '/path/to/package/main.ts'`, 72 | packageInfo: { 73 | exports: { 74 | require: "main.ts", 75 | }, 76 | }, 77 | name: 'where exports is { "require": "{some-file}" }', 78 | }, 79 | { 80 | importLine: `import something from 'awesome'`, 81 | expected: `import something from '/path/to/package/main.ts'`, 82 | packageInfo: { 83 | exports: { 84 | default: "main.ts", 85 | }, 86 | }, 87 | name: 'where exports is { "default": "{some-file}" }', 88 | }, 89 | { 90 | importLine: `import something from 'awesome'`, 91 | expected: `import something from '/path/to/package/main.ts'`, 92 | packageInfo: { 93 | exports: { 94 | ".": { 95 | import: "main.ts", 96 | }, 97 | }, 98 | }, 99 | name: 'where exports is { ".": { "import": "{some-file}" } }', 100 | }, 101 | { 102 | importLine: `import something from 'awesome'`, 103 | expected: `import something from '/path/to/package/main.ts'`, 104 | packageInfo: { 105 | exports: { 106 | ".": { 107 | require: "main.ts", 108 | }, 109 | }, 110 | }, 111 | name: 'where exports is { ".": { "require": "{some-file}" } }', 112 | }, 113 | { 114 | importLine: `import something from 'awesome'`, 115 | expected: `import something from '/path/to/package/main.ts'`, 116 | packageInfo: { 117 | exports: { 118 | ".": { 119 | default: "main.ts", 120 | }, 121 | }, 122 | }, 123 | name: 'where exports is { ".": { "default": "{some-file}" } }" }', 124 | }, 125 | { 126 | importLine: `import something from 'awesome/some/path'`, 127 | expected: `import something from '/path/to/package/internal/path.ts'`, 128 | packageInfo: { 129 | exports: { 130 | ".": "main.ts", 131 | "./some/path": "./internal/path.ts", 132 | }, 133 | }, 134 | name: 'imports a subpath where exports is { "./path": "./some/path.ts" }" }', 135 | }, 136 | { 137 | importLine: `import something from 'awesome/some/path'`, 138 | expected: `import something from '/path/to/package/internal/path.ts'`, 139 | packageInfo: { 140 | exports: { 141 | ".": "main.ts", 142 | "./some/path": { 143 | default: "./internal/path.ts", 144 | }, 145 | }, 146 | }, 147 | name: 'imports a subpath where exports is { "./path": { default: "./some/path.ts" } } }', 148 | }, 149 | { 150 | importLine: `import something from 'awesome/lib/some/thing'`, 151 | expected: `import something from '/path/to/package/internal/some/thing.ts'`, 152 | packageInfo: { 153 | exports: { 154 | ".": "main.ts", 155 | "./lib/*/thing": "./internal/*/thing.ts", 156 | }, 157 | }, 158 | name: 'imports a subpath where exports is { "./lib/*/thing": "./internal/*/thing.ts" } }', 159 | }, 160 | { 161 | importLine: `import something from 'awesome/lib/some/thing'`, 162 | expected: `import something from '/path/to/package/internal/some/thing.ts'`, 163 | packageInfo: { 164 | exports: { 165 | ".": "main.ts", 166 | "./lib/*/thing": { 167 | import: "./internal/*/thing.ts", 168 | }, 169 | }, 170 | }, 171 | name: 'imports a subpath where exports is { "./lib/*/thing": { import: "./internal/*/thing.ts" } } }', 172 | }, 173 | { 174 | importLine: `import something from '@my-scope/awesome'`, 175 | expected: `import something from '/path/to/package/index.ts'`, 176 | name: "a scoped package name", 177 | packageName: "@my-scope/awesome", 178 | }, 179 | { 180 | importLine: `import something from 'awesome/some/inner/path'`, 181 | expected: `import something from '/path/to/package/some/inner/path'`, 182 | name: "imports of paths within a package", 183 | }, 184 | { 185 | importLine: `import something from '@my-scope/awesome/some/inner/path'`, 186 | expected: `import something from '/path/to/package/some/inner/path'`, 187 | name: "imports of paths within a scoped package", 188 | packageName: "@my-scope/awesome", 189 | }, 190 | { 191 | importLine: `import lib from 'lib/lib/lib'`, 192 | expected: `import lib from '/path/to/package/lib/lib'`, 193 | name: "overlapping library and path names", 194 | packageName: "lib", 195 | }, 196 | { 197 | importLine: `import lib from '@lib/lib/lib/lib'`, 198 | expected: `import lib from '/path/to/package/lib/lib'`, 199 | name: "overlapping library, path and scope names", 200 | packageName: "@lib/lib", 201 | }, 202 | ]; 203 | 204 | describe("LocalImportSubstituter", () => { 205 | it("does not change imports for different packages", () => { 206 | const substituter = new LocalImportSubstituter(defaultPackageInfo); 207 | 208 | const code = `import * as other from "package" 209 | 210 | console.log('Should not be mutated')`; 211 | const result = substituter.substituteLocalPackageImports(code); 212 | 213 | expect(result).to.eql(code); 214 | }); 215 | 216 | it("throws an error if main and exports are both not defined", () => { 217 | expect( 218 | () => 219 | new LocalImportSubstituter({ 220 | name: "my-package", 221 | packageRoot: "/path/to/package", 222 | }) 223 | ).to.throw( 224 | "Failed to find a valid main or exports entry in package.json file" 225 | ); 226 | }); 227 | 228 | it("throws an error if exports does not contain a valid entry under default", () => { 229 | const substituter = new LocalImportSubstituter({ 230 | name: "my-package", 231 | packageRoot: "/path/to/package", 232 | exports: { 233 | "./some/specific/path": { 234 | default: "./main.ts", 235 | }, 236 | }, 237 | }); 238 | expect(() => 239 | substituter.substituteLocalPackageImports('import {} from "my-package"') 240 | ).to.throw('Unable to resolve export for path "my-package"'); 241 | }); 242 | 243 | it("throws an error if exports contains only an undefined value under default", () => { 244 | const substituter = new LocalImportSubstituter({ 245 | name: "my-package", 246 | packageRoot: "/path/to/package", 247 | exports: { 248 | ".": { 249 | default: undefined, 250 | }, 251 | }, 252 | }); 253 | expect(() => 254 | substituter.substituteLocalPackageImports('import {} from "my-package"') 255 | ).to.throw('Unable to resolve export for path "my-package"'); 256 | }); 257 | 258 | scenarios.forEach( 259 | ({ 260 | importLine, 261 | expected, 262 | name, 263 | packageName = "awesome", 264 | packageInfo = {}, 265 | }) => { 266 | it(`localises imports with ${name}`, () => { 267 | const substituter = new LocalImportSubstituter({ 268 | ...defaultPackageInfo, 269 | ...packageInfo, 270 | name: packageName, 271 | }); 272 | 273 | const code = ` 274 | ${importLine} 275 | 276 | console.log('Something happened') 277 | `; 278 | 279 | const localised = substituter.substituteLocalPackageImports(code); 280 | 281 | expect(localised).satisfies((actual: string) => { 282 | return actual.trim().startsWith(expected); 283 | }, `${localised} should start with ${expected}`); 284 | }); 285 | } 286 | ); 287 | }); 288 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS -------------------------------------------------------------------------------- /test/TypeScriptDocsVerifierSpec.ts: -------------------------------------------------------------------------------- 1 | import * as os from "os"; 2 | import * as path from "path"; 3 | import * as FsExtra from "fs-extra"; 4 | import { Gen } from "verify-it"; 5 | import * as TypeScriptDocsVerifier from "../index"; 6 | import { PackageDefinition } from "../src/PackageInfo"; 7 | import "chai/register-should"; 8 | 9 | const workingDirectory = path.join( 10 | os.tmpdir(), 11 | "typescript-docs-verifier-test" 12 | ); 13 | const fixturePath = path.join(__dirname, "fixtures"); 14 | 15 | const defaultPackageJson = { 16 | name: Gen.string(), 17 | main: `${Gen.string()}.js`, 18 | }; 19 | const defaultMainFile = { 20 | name: defaultPackageJson.main, 21 | contents: FsExtra.readFileSync( 22 | path.join(fixturePath, "main-default.ts") 23 | ).toString(), 24 | }; 25 | 26 | const defaultMarkdownFile = { 27 | name: "README.md", 28 | contents: FsExtra.readFileSync(path.join(fixturePath, "no-typescript.md")), 29 | }; 30 | 31 | const defaultTsConfig = { 32 | compilerOptions: { 33 | target: "ES2015", 34 | module: "commonjs", 35 | sourceMap: true, 36 | allowJs: true, 37 | outDir: "./dist", 38 | noEmitOnError: true, 39 | pretty: true, 40 | strict: true, 41 | noImplicitAny: true, 42 | strictNullChecks: true, 43 | noImplicitThis: true, 44 | alwaysStrict: true, 45 | noImplicitReturns: true, 46 | typeRoots: [path.join(workingDirectory, "node_modules", "@types")], 47 | }, 48 | exclude: ["node_modules", "example"], 49 | }; 50 | 51 | type File = { 52 | readonly name: string; 53 | readonly contents: string | Buffer; 54 | }; 55 | 56 | type ProjectFiles = { 57 | readonly packageJson?: Partial; 58 | readonly markdownFiles?: File[]; 59 | readonly mainFile?: File; 60 | readonly otherFiles?: File[]; 61 | readonly tsConfig?: string; 62 | }; 63 | 64 | const createProject = async (files: ProjectFiles = {}) => { 65 | const filesToWrite: File[] = [ 66 | { 67 | name: "package.json", 68 | contents: JSON.stringify(files.packageJson ?? defaultPackageJson), 69 | }, 70 | { 71 | name: (files.mainFile ?? defaultMainFile).name, 72 | contents: (files.mainFile ?? defaultMainFile).contents, 73 | }, 74 | { 75 | name: "tsconfig.json", 76 | contents: files.tsConfig ?? JSON.stringify(defaultTsConfig), 77 | }, 78 | ...(files.otherFiles ?? []), 79 | ...(files.markdownFiles ?? [defaultMarkdownFile]), 80 | ]; 81 | 82 | await Promise.all( 83 | filesToWrite.map(async (file: File) => { 84 | const filePath = path.join(workingDirectory, file.name); 85 | await FsExtra.ensureFile(filePath); 86 | await FsExtra.writeFile(filePath, file.contents); 87 | }) 88 | ); 89 | 90 | const nodeModulesFolder = path.join(__dirname, "..", "node_modules"); 91 | await FsExtra.symlink( 92 | nodeModulesFolder, 93 | path.join(workingDirectory, "node_modules") 94 | ); 95 | }; 96 | 97 | const genSnippet = () => { 98 | const name = "a" + Gen.string().replace(/[-]/g, ""); 99 | const value = Gen.string(); 100 | return `const ${name} = "${value}"`; 101 | }; 102 | 103 | const wrapSnippet = (snippet: string, snippetType = "typescript") => { 104 | return `\`\`\`${snippetType} 105 | ${snippet}\`\`\``; 106 | }; 107 | 108 | describe("TypeScriptDocsVerifier", () => { 109 | describe("compileSnippets", () => { 110 | beforeEach(async () => { 111 | await FsExtra.remove(workingDirectory); 112 | await FsExtra.ensureDir(path.join(workingDirectory)); 113 | process.chdir(workingDirectory); 114 | }); 115 | 116 | afterEach(async () => { 117 | await FsExtra.remove(workingDirectory); 118 | }); 119 | 120 | verify.it( 121 | "returns an empty array if no code snippets are present", 122 | async () => { 123 | await createProject(); 124 | return await TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( 125 | [] 126 | ); 127 | } 128 | ); 129 | 130 | verify.it( 131 | "returns an empty array if no typescript code snippets are present", 132 | Gen.array(Gen.string, 4), 133 | async (strings) => { 134 | const noTypeScriptMarkdown = ` 135 | # A \`README.md\` file 136 | 137 | ${strings[0]} 138 | 139 | ${wrapSnippet(strings[1], "javascript")} 140 | 141 | ${strings[2]} 142 | 143 | ${wrapSnippet(strings[3], "bash")} 144 | `; 145 | 146 | await createProject({ 147 | markdownFiles: [ 148 | { name: "README.md", contents: noTypeScriptMarkdown }, 149 | ], 150 | }); 151 | return await TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( 152 | [] 153 | ); 154 | } 155 | ); 156 | 157 | verify.it( 158 | "returns an error if a documentation file does not exist", 159 | Gen.string, 160 | async (filename) => { 161 | await createProject(); 162 | return await TypeScriptDocsVerifier.compileSnippets([ 163 | "README.md", 164 | filename, 165 | ]).should.be.rejectedWith(filename); 166 | } 167 | ); 168 | 169 | verify.it( 170 | 'returns a single element result array when a valid typescript block marked "typescript" is supplied', 171 | genSnippet, 172 | Gen.string, 173 | async (snippet, fileName) => { 174 | const typeScriptMarkdown = wrapSnippet(snippet); 175 | await createProject({ 176 | markdownFiles: [{ name: fileName, contents: typeScriptMarkdown }], 177 | }); 178 | return await TypeScriptDocsVerifier.compileSnippets( 179 | fileName 180 | ).should.eventually.eql([ 181 | { 182 | file: fileName, 183 | index: 1, 184 | snippet, 185 | linesWithErrors: [], 186 | }, 187 | ]); 188 | } 189 | ); 190 | 191 | verify.it( 192 | 'returns a single element result array when a valid typescript block marked "ts" is supplied', 193 | genSnippet, 194 | Gen.string, 195 | async (snippet, fileName) => { 196 | const typeScriptMarkdown = wrapSnippet(snippet, "ts"); 197 | await createProject({ 198 | markdownFiles: [{ name: fileName, contents: typeScriptMarkdown }], 199 | }); 200 | return await TypeScriptDocsVerifier.compileSnippets( 201 | fileName 202 | ).should.eventually.eql([ 203 | { 204 | file: fileName, 205 | index: 1, 206 | snippet, 207 | linesWithErrors: [], 208 | }, 209 | ]); 210 | } 211 | ); 212 | 213 | verify.it( 214 | 'returns a single element result array when a valid typescript block marked "tsx" is supplied', 215 | Gen.string, 216 | async (fileName) => { 217 | const typeScriptMarkdown = `import React from 'react'; 218 | export const bob = () => (
); 219 | `; 220 | await createProject({ 221 | markdownFiles: [ 222 | { 223 | name: fileName, 224 | contents: wrapSnippet(typeScriptMarkdown, "tsx"), 225 | }, 226 | ], 227 | tsConfig: JSON.stringify({ 228 | ...defaultTsConfig, 229 | compilerOptions: { 230 | ...defaultTsConfig.compilerOptions, 231 | esModuleInterop: true, 232 | jsx: "react", 233 | }, 234 | }), 235 | }); 236 | return await TypeScriptDocsVerifier.compileSnippets( 237 | fileName 238 | ).should.eventually.eql([ 239 | { 240 | file: fileName, 241 | index: 1, 242 | snippet: typeScriptMarkdown, 243 | linesWithErrors: [], 244 | }, 245 | ]); 246 | } 247 | ); 248 | 249 | verify.it( 250 | "ignores code blocks preceded by ", 251 | genSnippet, 252 | Gen.string, 253 | async (snippet, fileName) => { 254 | const ignoreString = ""; 255 | const typeScriptMarkdown = `${ignoreString}${wrapSnippet( 256 | snippet, 257 | "ts" 258 | )}`; 259 | await createProject({ 260 | markdownFiles: [{ name: fileName, contents: typeScriptMarkdown }], 261 | }); 262 | return await TypeScriptDocsVerifier.compileSnippets( 263 | fileName 264 | ).should.eventually.eql([]); 265 | } 266 | ); 267 | 268 | verify.it( 269 | "compiles snippets from multiple files", 270 | Gen.distinct(genSnippet, 6), 271 | Gen.distinct(Gen.string, 3), 272 | async (snippets, fileNames) => { 273 | const markdownFiles = fileNames.map((fileName, index) => { 274 | return { 275 | name: fileName, 276 | contents: 277 | wrapSnippet(snippets[2 * index]) + 278 | wrapSnippet(snippets[2 * index + 1]), 279 | }; 280 | }); 281 | 282 | const expected = fileNames.flatMap((fileName, index) => { 283 | return [ 284 | { 285 | file: fileName, 286 | index: 1, 287 | snippet: snippets[2 * index], 288 | linesWithErrors: [], 289 | }, 290 | { 291 | file: fileName, 292 | index: 2, 293 | snippet: snippets[2 * index + 1], 294 | linesWithErrors: [], 295 | }, 296 | ]; 297 | }); 298 | 299 | await createProject({ markdownFiles: markdownFiles }); 300 | return await TypeScriptDocsVerifier.compileSnippets( 301 | fileNames 302 | ).should.eventually.eql(expected); 303 | } 304 | ); 305 | 306 | verify.it( 307 | "reads from README.md if no file paths are supplied", 308 | genSnippet, 309 | async (snippet) => { 310 | const typeScriptMarkdown = wrapSnippet(snippet); 311 | 312 | await createProject({ 313 | markdownFiles: [{ name: "README.md", contents: typeScriptMarkdown }], 314 | }); 315 | return await TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( 316 | [ 317 | { 318 | file: "README.md", 319 | index: 1, 320 | snippet, 321 | linesWithErrors: [], 322 | }, 323 | ] 324 | ); 325 | } 326 | ); 327 | 328 | verify.it( 329 | "returns an empty array if an empty array is provided", 330 | genSnippet, 331 | async (snippet) => { 332 | const typeScriptMarkdown = wrapSnippet(snippet); 333 | 334 | await createProject({ 335 | markdownFiles: [{ name: "README.md", contents: typeScriptMarkdown }], 336 | }); 337 | return await TypeScriptDocsVerifier.compileSnippets( 338 | [] 339 | ).should.eventually.eql([]); 340 | } 341 | ); 342 | 343 | verify.it( 344 | "returns multiple results when multiple TypeScript snippets are supplied", 345 | Gen.array(genSnippet, Gen.integerBetween(2, 6)()), 346 | async (snippets) => { 347 | const markdownBlocks = snippets.map((snippet) => wrapSnippet(snippet)); 348 | const markdown = markdownBlocks.join("\n"); 349 | const expected = snippets.map((snippet, index) => { 350 | return { 351 | file: "README.md", 352 | index: index + 1, 353 | snippet, 354 | linesWithErrors: [], 355 | }; 356 | }); 357 | 358 | await createProject({ 359 | markdownFiles: [{ name: "README.md", contents: markdown }], 360 | }); 361 | return () => 362 | TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( 363 | expected 364 | ); 365 | } 366 | ); 367 | 368 | verify.it( 369 | "compiles snippets with import statements", 370 | genSnippet, 371 | async (snippet) => { 372 | snippet = `import * as path from 'path' 373 | path.join('.', 'some-path') 374 | ${snippet}`; 375 | const typeScriptMarkdown = wrapSnippet(snippet); 376 | await createProject({ 377 | markdownFiles: [{ name: "README.md", contents: typeScriptMarkdown }], 378 | }); 379 | return await TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( 380 | [ 381 | { 382 | file: "README.md", 383 | index: 1, 384 | snippet, 385 | linesWithErrors: [], 386 | }, 387 | ] 388 | ); 389 | } 390 | ); 391 | 392 | verify.it( 393 | "compiles snippets when rootDir is a compiler option", 394 | genSnippet, 395 | Gen.word, 396 | async (snippet, rootDir) => { 397 | const typeScriptMarkdown = wrapSnippet(snippet); 398 | await createProject({ 399 | markdownFiles: [{ name: "README.md", contents: typeScriptMarkdown }], 400 | tsConfig: JSON.stringify({ 401 | ...defaultTsConfig, 402 | compilerOptions: { 403 | ...defaultTsConfig, 404 | rootDir, 405 | }, 406 | }), 407 | }); 408 | return await TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( 409 | [ 410 | { 411 | file: "README.md", 412 | index: 1, 413 | snippet, 414 | linesWithErrors: [], 415 | }, 416 | ] 417 | ); 418 | } 419 | ); 420 | 421 | verify.it("compiles snippets independently", async () => { 422 | const snippet1 = `interface Foo { bar: 123 }`; 423 | const snippet2 = `interface Foo { bar: () => void }`; 424 | const typeScriptMarkdown = wrapSnippet(snippet1) + wrapSnippet(snippet2); 425 | await createProject({ 426 | markdownFiles: [{ name: "README.md", contents: typeScriptMarkdown }], 427 | }); 428 | return await TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( 429 | [ 430 | { 431 | file: "README.md", 432 | index: 1, 433 | snippet: snippet1, 434 | linesWithErrors: [], 435 | }, 436 | { 437 | file: "README.md", 438 | index: 2, 439 | snippet: snippet2, 440 | linesWithErrors: [], 441 | }, 442 | ] 443 | ); 444 | }); 445 | 446 | verify.it( 447 | "compiles snippets containing modules", 448 | genSnippet, 449 | async (snippet) => { 450 | snippet = `declare module "url" { 451 | export interface Url { 452 | someAdditionalProperty: boolean 453 | } 454 | } 455 | ${snippet}`; 456 | const typeScriptMarkdown = wrapSnippet(snippet); 457 | await createProject({ 458 | markdownFiles: [{ name: "README.md", contents: typeScriptMarkdown }], 459 | }); 460 | return await TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( 461 | [ 462 | { 463 | file: "README.md", 464 | index: 1, 465 | snippet, 466 | linesWithErrors: [], 467 | }, 468 | ] 469 | ); 470 | } 471 | ); 472 | 473 | verify.it( 474 | "compiles snippets that use the current project dependencies", 475 | genSnippet, 476 | async (snippet) => { 477 | snippet = ` 478 | // These are some of the TypeScript dependencies of this project 479 | import {} from 'mocha' 480 | import { Gen } from 'verify-it' 481 | import * as chai from 'chai' 482 | 483 | Gen.string() 484 | ${snippet}`; 485 | const typeScriptMarkdown = wrapSnippet(snippet); 486 | await createProject({ 487 | markdownFiles: [{ name: "README.md", contents: typeScriptMarkdown }], 488 | }); 489 | return await TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( 490 | [ 491 | { 492 | file: "README.md", 493 | index: 1, 494 | snippet, 495 | linesWithErrors: [], 496 | }, 497 | ] 498 | ); 499 | } 500 | ); 501 | 502 | verify.it( 503 | "reports compilation failures", 504 | genSnippet, 505 | Gen.string, 506 | async (validSnippet, invalidSnippet) => { 507 | const validTypeScriptMarkdown = wrapSnippet(validSnippet); 508 | const invalidTypeScriptMarkdown = wrapSnippet(invalidSnippet); 509 | const markdown = [ 510 | validTypeScriptMarkdown, 511 | invalidTypeScriptMarkdown, 512 | ].join("\n"); 513 | await createProject({ 514 | markdownFiles: [{ name: "README.md", contents: markdown }], 515 | }); 516 | return await TypeScriptDocsVerifier.compileSnippets().should.eventually.satisfy( 517 | (results: TypeScriptDocsVerifier.SnippetCompilationResult[]) => { 518 | results.should.have.length(2); 519 | results[0].should.not.have.property("error"); 520 | const errorResult = results[1]; 521 | errorResult.should.have.property("file", "README.md"); 522 | errorResult.should.have.property("index", 2); 523 | errorResult.should.have.property("snippet", invalidSnippet); 524 | errorResult.should.have.property("error"); 525 | errorResult.linesWithErrors.should.deep.equal([1]); 526 | errorResult?.error?.message.should.include("README.md"); 527 | errorResult?.error?.message.should.include("Code Block 2"); 528 | errorResult?.error?.message.should.not.include("block-"); 529 | 530 | Object.values(errorResult.error || {}).forEach((value: unknown) => { 531 | (value as string).should.not.include("block-"); 532 | }); 533 | 534 | return true; 535 | } 536 | ); 537 | } 538 | ); 539 | 540 | verify.it( 541 | "reports compilation failures when ts-node is configured to transpile only in tsconfig.json", 542 | genSnippet, 543 | async (validSnippet) => { 544 | const invalidSnippet = `import('fs').thisFunctionDoesNotExist();`; 545 | const validTypeScriptMarkdown = wrapSnippet(validSnippet); 546 | const invalidTypeScriptMarkdown = wrapSnippet(invalidSnippet); 547 | const markdown = [ 548 | validTypeScriptMarkdown, 549 | invalidTypeScriptMarkdown, 550 | ].join("\n"); 551 | await createProject({ 552 | markdownFiles: [{ name: "README.md", contents: markdown }], 553 | tsConfig: JSON.stringify({ 554 | ...defaultTsConfig, 555 | "ts-node": { 556 | transpileOnly: true, 557 | }, 558 | }), 559 | }); 560 | return await TypeScriptDocsVerifier.compileSnippets().should.eventually.satisfy( 561 | (results: TypeScriptDocsVerifier.SnippetCompilationResult[]) => { 562 | results.should.have.length(2); 563 | results[0].should.not.have.property("error"); 564 | const errorResult = results[1]; 565 | errorResult.should.have.property("file", "README.md"); 566 | errorResult.should.have.property("index", 2); 567 | errorResult.should.have.property("snippet", invalidSnippet); 568 | errorResult.should.have.property("error"); 569 | errorResult.linesWithErrors.should.deep.equal([1]); 570 | errorResult?.error?.message.should.include("README.md"); 571 | errorResult?.error?.message.should.include("Code Block 2"); 572 | errorResult?.error?.message.should.not.include("block-"); 573 | 574 | Object.values(errorResult.error || {}).forEach((value: unknown) => { 575 | (value as string).should.not.include("block-"); 576 | }); 577 | 578 | return true; 579 | } 580 | ); 581 | } 582 | ); 583 | 584 | verify.it("reports compilation failures on the correct line", async () => { 585 | const mainFile = { 586 | name: `${defaultPackageJson.main}`, 587 | contents: "export class MyClass {}", 588 | }; 589 | 590 | const invalidSnippet = `import { MyClass } from '${defaultPackageJson.name}'; 591 | 592 | const thisIsOK = true; 593 | firstLineOK = false; 594 | console.log('This line is also OK'); 595 | `; 596 | const invalidTypeScriptMarkdown = wrapSnippet(invalidSnippet); 597 | await createProject({ 598 | markdownFiles: [ 599 | { name: "README.md", contents: invalidTypeScriptMarkdown }, 600 | ], 601 | mainFile, 602 | }); 603 | 604 | return await TypeScriptDocsVerifier.compileSnippets().should.eventually.satisfy( 605 | (results: TypeScriptDocsVerifier.SnippetCompilationResult[]) => { 606 | results.should.have.length(1); 607 | const errorResult = results[0]; 608 | errorResult.should.have.property("file", "README.md"); 609 | errorResult.should.have.property("index", 1); 610 | errorResult.should.have.property("snippet", invalidSnippet); 611 | errorResult.should.have.property("error"); 612 | errorResult.linesWithErrors.should.deep.equal([4]); 613 | errorResult?.error?.message.should.include("README.md"); 614 | errorResult?.error?.message.should.include("Code Block 1"); 615 | errorResult?.error?.message.should.not.include("block-"); 616 | 617 | Object.values(errorResult?.error || {}).forEach((value) => { 618 | (value as string).should.not.include("block-"); 619 | }); 620 | 621 | return true; 622 | } 623 | ); 624 | }); 625 | 626 | verify.it( 627 | "localises imports of the current package if the package main is a js file", 628 | async () => { 629 | const snippet = ` 630 | import { MyClass } from '${defaultPackageJson.name}' 631 | const instance = new MyClass() 632 | instance.doStuff()`; 633 | const mainFile = { 634 | name: `${defaultPackageJson.main}`, 635 | contents: ` 636 | export class MyClass { 637 | doStuff (): void { 638 | return 639 | } 640 | }`, 641 | }; 642 | const typeScriptMarkdown = wrapSnippet(snippet); 643 | await createProject({ 644 | markdownFiles: [{ name: "README.md", contents: typeScriptMarkdown }], 645 | mainFile, 646 | }); 647 | return await TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( 648 | [ 649 | { 650 | file: "README.md", 651 | index: 1, 652 | snippet, 653 | linesWithErrors: [], 654 | }, 655 | ] 656 | ); 657 | } 658 | ); 659 | 660 | verify.it( 661 | "localises imports of the current package if the package main is a jsx file", 662 | async () => { 663 | const snippet = ` 664 | import React from 'react'; 665 | import { MyComponent } from '${defaultPackageJson.name}'; 666 | const App = () => ( 667 | 668 | );`; 669 | const mainFile = { 670 | name: `main.tsx`, 671 | contents: ` 672 | import React from 'react'; 673 | export const MyComponent = ({ value: number }) => ( 674 | {value.toLocaleString()} 675 | );`, 676 | }; 677 | const typeScriptMarkdown = wrapSnippet(snippet, "tsx"); 678 | await createProject({ 679 | markdownFiles: [{ name: "README.md", contents: typeScriptMarkdown }], 680 | mainFile, 681 | packageJson: { 682 | ...defaultPackageJson, 683 | main: "main.jsx", 684 | }, 685 | tsConfig: JSON.stringify({ 686 | ...defaultTsConfig, 687 | compilerOptions: { 688 | ...defaultTsConfig.compilerOptions, 689 | esModuleInterop: true, 690 | jsx: "react", 691 | }, 692 | }), 693 | }); 694 | return await TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( 695 | [ 696 | { 697 | file: "README.md", 698 | index: 1, 699 | snippet, 700 | linesWithErrors: [], 701 | }, 702 | ] 703 | ); 704 | } 705 | ); 706 | 707 | verify.it( 708 | "localises imports of the current package using exports.require if it exists", 709 | async () => { 710 | const snippet = ` 711 | import { MyClass } from '${defaultPackageJson.name}' 712 | const instance = new MyClass() 713 | instance.doStuff()`; 714 | const mainFile = { 715 | name: `${defaultPackageJson.main}`, 716 | contents: ` 717 | export class MyClass { 718 | doStuff (): void { 719 | return 720 | } 721 | }`, 722 | }; 723 | const packageJson = { 724 | ...defaultPackageJson, 725 | exports: { 726 | require: defaultPackageJson.main, 727 | }, 728 | main: "some/other/file.js", 729 | }; 730 | 731 | const typeScriptMarkdown = wrapSnippet(snippet); 732 | await createProject({ 733 | markdownFiles: [{ name: "README.md", contents: typeScriptMarkdown }], 734 | mainFile, 735 | packageJson, 736 | }); 737 | return await TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( 738 | [ 739 | { 740 | file: "README.md", 741 | index: 1, 742 | snippet, 743 | linesWithErrors: [], 744 | }, 745 | ] 746 | ); 747 | } 748 | ); 749 | 750 | verify.it( 751 | 'localises imports of the current package using exports["."].require if it exists', 752 | async () => { 753 | const snippet = ` 754 | import { MyClass } from '${defaultPackageJson.name}' 755 | const instance = new MyClass() 756 | instance.doStuff()`; 757 | const mainFile = { 758 | name: `${defaultPackageJson.main}`, 759 | contents: ` 760 | export class MyClass { 761 | doStuff (): void { 762 | return 763 | } 764 | }`, 765 | }; 766 | const packageJson = { 767 | ...defaultPackageJson, 768 | exports: { 769 | ".": { 770 | require: defaultPackageJson.main, 771 | }, 772 | }, 773 | main: "some/other/file.js", 774 | }; 775 | 776 | const typeScriptMarkdown = wrapSnippet(snippet); 777 | await createProject({ 778 | markdownFiles: [{ name: "README.md", contents: typeScriptMarkdown }], 779 | mainFile, 780 | packageJson, 781 | }); 782 | return await TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( 783 | [ 784 | { 785 | file: "README.md", 786 | index: 1, 787 | snippet, 788 | linesWithErrors: [], 789 | }, 790 | ] 791 | ); 792 | } 793 | ); 794 | 795 | verify.it( 796 | 'localises imports of named files within the current package using exports["./something/*"] if it exists', 797 | async () => { 798 | const sourceFolder = Gen.word(); 799 | const snippet = ` 800 | import { MyClass } from '${defaultPackageJson.name}/something/other' 801 | const instance = new MyClass() 802 | instance.doStuff()`; 803 | const mainFile = { 804 | name: defaultMainFile.name, 805 | contents: ` 806 | export class MainClass { 807 | doStuff (): void { 808 | return 809 | } 810 | }`, 811 | }; 812 | const otherFile = { 813 | name: path.join(sourceFolder, "src", "other.ts"), 814 | contents: ` 815 | export class MyClass { 816 | doStuff (): void { 817 | return 818 | } 819 | }`, 820 | }; 821 | 822 | const packageJson = { 823 | ...defaultPackageJson, 824 | exports: { 825 | "./something/*": `./${sourceFolder}/src/*`, 826 | }, 827 | main: "some/other/file.js", 828 | }; 829 | const typeScriptMarkdown = wrapSnippet(snippet); 830 | await createProject({ 831 | markdownFiles: [{ name: "README.md", contents: typeScriptMarkdown }], 832 | mainFile, 833 | otherFiles: [otherFile], 834 | packageJson, 835 | }); 836 | 837 | return await TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( 838 | [ 839 | { 840 | file: "README.md", 841 | index: 1, 842 | snippet, 843 | linesWithErrors: [], 844 | }, 845 | ] 846 | ); 847 | } 848 | ); 849 | 850 | verify.it( 851 | 'localises imports of wildcard files within the current package using exports = "./prefix/*/path" if it exists', 852 | async () => { 853 | const sourceFolder = Gen.word(); 854 | const snippet = ` 855 | import { MyClass } from '${defaultPackageJson.name}/some/export' 856 | const instance = new MyClass() 857 | instance.doStuff()`; 858 | const mainFile = { 859 | name: defaultMainFile.name, 860 | contents: ` 861 | export class MainClass { 862 | doStuff (): void { 863 | return 864 | } 865 | }`, 866 | }; 867 | const otherTranspiledFile = path.join(sourceFolder, "other.js"); 868 | 869 | const otherFile = { 870 | name: path.join(sourceFolder, "other.ts"), 871 | contents: ` 872 | export class MyClass { 873 | doStuff (): void { 874 | return 875 | } 876 | }`, 877 | }; 878 | 879 | const packageJson = { 880 | ...defaultPackageJson, 881 | exports: { 882 | "./some/export": { 883 | require: otherTranspiledFile, 884 | }, 885 | }, 886 | main: "some/other/file.js", 887 | }; 888 | const typeScriptMarkdown = wrapSnippet(snippet); 889 | await createProject({ 890 | markdownFiles: [{ name: "README.md", contents: typeScriptMarkdown }], 891 | mainFile, 892 | otherFiles: [otherFile], 893 | packageJson, 894 | }); 895 | 896 | return await TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( 897 | [ 898 | { 899 | file: "README.md", 900 | index: 1, 901 | snippet, 902 | linesWithErrors: [], 903 | }, 904 | ] 905 | ); 906 | } 907 | ); 908 | 909 | verify.it( 910 | "localises imports of files within the current package", 911 | async () => { 912 | const sourceFolder = Gen.word(); 913 | const snippet = ` 914 | import { MyClass } from '${defaultPackageJson.name}/${sourceFolder}/other' 915 | const instance = new MyClass() 916 | instance.doStuff()`; 917 | const mainFile = { 918 | name: defaultMainFile.name, 919 | contents: ` 920 | export class MainClass { 921 | doStuff (): void { 922 | return 923 | } 924 | }`, 925 | }; 926 | const otherFile = { 927 | name: path.join(sourceFolder, "other.ts"), 928 | contents: ` 929 | export class MyClass { 930 | doStuff (): void { 931 | return 932 | } 933 | }`, 934 | }; 935 | const typeScriptMarkdown = wrapSnippet(snippet); 936 | await createProject({ 937 | markdownFiles: [{ name: "README.md", contents: typeScriptMarkdown }], 938 | mainFile, 939 | otherFiles: [otherFile], 940 | }); 941 | return await TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( 942 | [ 943 | { 944 | file: "README.md", 945 | index: 1, 946 | snippet, 947 | linesWithErrors: [], 948 | }, 949 | ] 950 | ); 951 | } 952 | ); 953 | 954 | verify.it( 955 | "localises only the package name in imports of files within the current package", 956 | async () => { 957 | const snippet = ` 958 | import lib from 'lib/lib/other' 959 | const instance = new lib.MyClass() 960 | instance.doStuff()`; 961 | const mainFile = { 962 | name: "lib.ts", 963 | contents: ` 964 | export class MainClass { 965 | doStuff (): void { 966 | return 967 | } 968 | } 969 | `, 970 | }; 971 | const otherFile = { 972 | name: path.join("lib", "other.ts"), 973 | contents: ` 974 | export class MyClass { 975 | doStuff (): void { 976 | return 977 | } 978 | } 979 | export default lib = { MyClass } 980 | `, 981 | }; 982 | const typeScriptMarkdown = wrapSnippet(snippet); 983 | await createProject({ 984 | packageJson: { 985 | name: "lib", 986 | main: "lib.js", 987 | }, 988 | markdownFiles: [{ name: "README.md", contents: typeScriptMarkdown }], 989 | mainFile, 990 | otherFiles: [otherFile], 991 | }); 992 | return await TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( 993 | [ 994 | { 995 | file: "README.md", 996 | index: 1, 997 | snippet, 998 | linesWithErrors: [], 999 | }, 1000 | ] 1001 | ); 1002 | } 1003 | ); 1004 | 1005 | verify.it( 1006 | "localises imports of the current package if the package name is scoped", 1007 | async () => { 1008 | const snippet = ` 1009 | import { MyClass } from '@bbc/${defaultPackageJson.name}' 1010 | const instance = new MyClass() 1011 | instance.doStuff()`; 1012 | const mainFile = { 1013 | name: `${defaultPackageJson.main}`, 1014 | contents: ` 1015 | export class MyClass { 1016 | doStuff (): void { 1017 | return 1018 | } 1019 | }`, 1020 | }; 1021 | 1022 | const typeScriptMarkdown = wrapSnippet(snippet); 1023 | await createProject({ 1024 | markdownFiles: [{ name: "README.md", contents: typeScriptMarkdown }], 1025 | mainFile, 1026 | packageJson: { 1027 | main: defaultPackageJson.main, 1028 | name: `@bbc/${defaultPackageJson.name}`, 1029 | }, 1030 | }); 1031 | return await TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( 1032 | [ 1033 | { 1034 | file: "README.md", 1035 | index: 1, 1036 | snippet, 1037 | linesWithErrors: [], 1038 | }, 1039 | ] 1040 | ); 1041 | } 1042 | ); 1043 | 1044 | verify.it( 1045 | "localises imports of files within the current package when the package is scoped", 1046 | async () => { 1047 | const sourceFolder = Gen.word(); 1048 | const snippet = ` 1049 | import { MyClass } from '@bbc/${defaultPackageJson.name}/${sourceFolder}/other' 1050 | const instance = new MyClass() 1051 | instance.doStuff()`; 1052 | const mainFile = { 1053 | name: defaultMainFile.name, 1054 | contents: ` 1055 | export class MainClass { 1056 | doStuff (): void { 1057 | return 1058 | } 1059 | }`, 1060 | }; 1061 | const otherFile = { 1062 | name: path.join(sourceFolder, "other.ts"), 1063 | contents: ` 1064 | export class MyClass { 1065 | doStuff (): void { 1066 | return 1067 | } 1068 | }`, 1069 | }; 1070 | const typeScriptMarkdown = wrapSnippet(snippet); 1071 | await createProject({ 1072 | markdownFiles: [{ name: "README.md", contents: typeScriptMarkdown }], 1073 | mainFile, 1074 | packageJson: { 1075 | main: defaultPackageJson.main, 1076 | name: `@bbc/${defaultPackageJson.name}`, 1077 | }, 1078 | otherFiles: [otherFile], 1079 | }); 1080 | return await TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( 1081 | [ 1082 | { 1083 | file: "README.md", 1084 | index: 1, 1085 | snippet, 1086 | linesWithErrors: [], 1087 | }, 1088 | ] 1089 | ); 1090 | } 1091 | ); 1092 | 1093 | verify.it( 1094 | "localises imports of the current package if the package main is a js file", 1095 | Gen.string, 1096 | Gen.string, 1097 | async (name, main) => { 1098 | const packageJson: Partial = { 1099 | name, 1100 | main: `${main}.js`, 1101 | }; 1102 | const snippet = ` 1103 | import { MyClass } from '${packageJson.name}' 1104 | const instance: any = MyClass() 1105 | instance.doStuff()`; 1106 | const mainFile = { 1107 | name: `${packageJson.main}`, 1108 | contents: ` 1109 | module.exports.MyClass = function MyClass () { 1110 | this.doStuff = () => { 1111 | return 1112 | } 1113 | }`, 1114 | }; 1115 | const typeScriptMarkdown = wrapSnippet(snippet); 1116 | const projectFiles = { 1117 | markdownFiles: [{ name: "README.md", contents: typeScriptMarkdown }], 1118 | mainFile, 1119 | packageJson, 1120 | }; 1121 | await createProject(projectFiles); 1122 | return await TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( 1123 | [ 1124 | { 1125 | file: "README.md", 1126 | index: 1, 1127 | snippet, 1128 | linesWithErrors: [], 1129 | }, 1130 | ] 1131 | ); 1132 | } 1133 | ); 1134 | 1135 | verify.it( 1136 | "localises imports of the current package split across multiple lines", 1137 | Gen.string, 1138 | Gen.string, 1139 | async (name, main) => { 1140 | const packageJson: Partial = { 1141 | name, 1142 | main: `${main}.js`, 1143 | }; 1144 | const snippet = ` 1145 | import { 1146 | MyClass 1147 | } from '${packageJson.name}' 1148 | const instance: any = MyClass() 1149 | instance.doStuff()`; 1150 | const mainFile = { 1151 | name: `${packageJson.main}`, 1152 | contents: ` 1153 | module.exports.MyClass = function MyClass () { 1154 | this.doStuff = () => { 1155 | return 1156 | } 1157 | }`, 1158 | }; 1159 | const typeScriptMarkdown = wrapSnippet(snippet); 1160 | const projectFiles = { 1161 | markdownFiles: [{ name: "README.md", contents: typeScriptMarkdown }], 1162 | mainFile, 1163 | packageJson, 1164 | }; 1165 | await createProject(projectFiles); 1166 | return await TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( 1167 | [ 1168 | { 1169 | file: "README.md", 1170 | index: 1, 1171 | snippet, 1172 | linesWithErrors: [], 1173 | }, 1174 | ] 1175 | ); 1176 | } 1177 | ); 1178 | 1179 | verify.it( 1180 | "localises imports of the current package when from is in a different line", 1181 | Gen.string, 1182 | Gen.string, 1183 | async (name, main) => { 1184 | const packageJson: Partial = { 1185 | name, 1186 | main: `${main}.js`, 1187 | }; 1188 | const snippet = ` 1189 | import { 1190 | MyClass 1191 | } from 1192 | '${packageJson.name}' 1193 | const instance: any = MyClass() 1194 | instance.doStuff()`; 1195 | const mainFile = { 1196 | name: `${packageJson.main}`, 1197 | contents: ` 1198 | module.exports.MyClass = function MyClass () { 1199 | this.doStuff = () => { 1200 | return 1201 | } 1202 | }`, 1203 | }; 1204 | const typeScriptMarkdown = wrapSnippet(snippet); 1205 | const projectFiles = { 1206 | markdownFiles: [{ name: "README.md", contents: typeScriptMarkdown }], 1207 | mainFile, 1208 | packageJson, 1209 | }; 1210 | await createProject(projectFiles); 1211 | return await TypeScriptDocsVerifier.compileSnippets().should.eventually.eql( 1212 | [ 1213 | { 1214 | file: "README.md", 1215 | index: 1, 1216 | snippet, 1217 | linesWithErrors: [], 1218 | }, 1219 | ] 1220 | ); 1221 | } 1222 | ); 1223 | 1224 | verify.it( 1225 | "can be run from a subdirectory within the project", 1226 | Gen.array(Gen.word, 5), 1227 | async (pathElements) => { 1228 | const snippet = ` 1229 | import { MyClass } from '${defaultPackageJson.name}' 1230 | const instance = new MyClass() 1231 | instance.doStuff()`; 1232 | const mainFile = { 1233 | name: `${defaultPackageJson.main}`, 1234 | contents: ` 1235 | export class MyClass { 1236 | doStuff (): void { 1237 | return 1238 | } 1239 | }`, 1240 | }; 1241 | 1242 | const typeScriptMarkdown = wrapSnippet(snippet); 1243 | await createProject({ 1244 | markdownFiles: [{ name: "DOCS.md", contents: typeScriptMarkdown }], 1245 | mainFile, 1246 | }); 1247 | 1248 | const newCurrentDirectory = path.join( 1249 | workingDirectory, 1250 | ...pathElements 1251 | ); 1252 | await FsExtra.ensureDir(newCurrentDirectory); 1253 | process.chdir(path.join(...pathElements)); 1254 | 1255 | const pathToMarkdownFile = path.join( 1256 | path.relative(newCurrentDirectory, workingDirectory), 1257 | "DOCS.md" 1258 | ); 1259 | 1260 | return await TypeScriptDocsVerifier.compileSnippets([ 1261 | pathToMarkdownFile, 1262 | ]).should.eventually.eql([ 1263 | { 1264 | file: pathToMarkdownFile, 1265 | index: 1, 1266 | snippet, 1267 | linesWithErrors: [], 1268 | }, 1269 | ]); 1270 | } 1271 | ); 1272 | 1273 | verify.it("handles a non-JSON content in tsconfig.json file", async () => { 1274 | const snippet = ` 1275 | import { MyClass } from '${defaultPackageJson.name}' 1276 | await Promise.resolve(); 1277 | const instance = new MyClass() 1278 | instance.doStuff()`; 1279 | const mainFile = { 1280 | name: `${defaultPackageJson.main}`, 1281 | contents: ` 1282 | export class MyClass { 1283 | doStuff (): void { 1284 | return 1285 | } 1286 | }`, 1287 | }; 1288 | 1289 | const typeScriptMarkdown = wrapSnippet(snippet); 1290 | await createProject({ 1291 | markdownFiles: [{ name: "DOCS.md", contents: typeScriptMarkdown }], 1292 | mainFile, 1293 | }); 1294 | 1295 | const tsconfigFilename = `tsconfig.json`; 1296 | const tsconfigText = `{ 1297 | "compilerOptions": { 1298 | "target": "es2019", // comments are permitted! 1299 | "module": "esnext", 1300 | }, 1301 | }`; 1302 | 1303 | await FsExtra.writeFile( 1304 | path.join(workingDirectory, tsconfigFilename), 1305 | tsconfigText 1306 | ); 1307 | 1308 | return await TypeScriptDocsVerifier.compileSnippets({ 1309 | markdownFiles: ["DOCS.md"], 1310 | project: tsconfigFilename, 1311 | }).should.eventually.eql([ 1312 | { 1313 | file: "DOCS.md", 1314 | index: 1, 1315 | snippet, 1316 | linesWithErrors: [], 1317 | }, 1318 | ]); 1319 | }); 1320 | 1321 | verify.it("returns an error if the tsconfig file is invalid", async () => { 1322 | await createProject(); 1323 | 1324 | const tsconfigFilename = `tsconfig.json`; 1325 | const tsconfigText = `{ 1326 | "compilerOptions": { 1327 | "target": "es2019", 1328 | "module": "esnext", 1329 | }, 1330 | `; 1331 | 1332 | await FsExtra.writeFile( 1333 | path.join(workingDirectory, tsconfigFilename), 1334 | tsconfigText 1335 | ); 1336 | 1337 | return await TypeScriptDocsVerifier.compileSnippets({ 1338 | markdownFiles: ["DOCS.md"], 1339 | project: tsconfigFilename, 1340 | }).should.be.rejectedWith("Error reading tsconfig from"); 1341 | }); 1342 | 1343 | verify.it( 1344 | "uses the default settings if an empty object is supplied", 1345 | genSnippet, 1346 | Gen.string, 1347 | async (snippet) => { 1348 | const typeScriptMarkdown = wrapSnippet(snippet); 1349 | await createProject({ 1350 | markdownFiles: [{ name: "README.md", contents: typeScriptMarkdown }], 1351 | }); 1352 | return await TypeScriptDocsVerifier.compileSnippets( 1353 | {} 1354 | ).should.eventually.eql([ 1355 | { 1356 | file: "README.md", 1357 | index: 1, 1358 | snippet, 1359 | linesWithErrors: [], 1360 | }, 1361 | ]); 1362 | } 1363 | ); 1364 | 1365 | verify.it( 1366 | "overrides the tsconfig.json path when the --project flag is used", 1367 | async () => { 1368 | const snippet = ` 1369 | import { MyClass } from '${defaultPackageJson.name}' 1370 | await Promise.resolve(); 1371 | const instance = new MyClass() 1372 | instance.doStuff()`; 1373 | const mainFile = { 1374 | name: `${defaultPackageJson.main}`, 1375 | contents: ` 1376 | export class MyClass { 1377 | doStuff (): void { 1378 | return 1379 | } 1380 | }`, 1381 | }; 1382 | 1383 | const typeScriptMarkdown = wrapSnippet(snippet); 1384 | await createProject({ 1385 | markdownFiles: [{ name: "DOCS.md", contents: typeScriptMarkdown }], 1386 | mainFile, 1387 | }); 1388 | 1389 | const tsconfigFilename = `${Gen.word()}-tsconfig.json`; 1390 | const tsconfigJson = { 1391 | compilerOptions: { 1392 | target: "es2019", 1393 | module: "esnext", 1394 | }, 1395 | }; 1396 | 1397 | await FsExtra.writeJSON( 1398 | path.join(workingDirectory, tsconfigFilename), 1399 | tsconfigJson 1400 | ); 1401 | 1402 | return await TypeScriptDocsVerifier.compileSnippets({ 1403 | markdownFiles: ["DOCS.md"], 1404 | project: tsconfigFilename, 1405 | }).should.eventually.eql([ 1406 | { 1407 | file: "DOCS.md", 1408 | index: 1, 1409 | snippet, 1410 | linesWithErrors: [], 1411 | }, 1412 | ]); 1413 | } 1414 | ); 1415 | 1416 | verify.it( 1417 | "supports a file path (not just a file name) with the --project flag", 1418 | async () => { 1419 | const snippet = ` 1420 | import { MyClass } from '${defaultPackageJson.name}' 1421 | await Promise.resolve(); 1422 | const instance = new MyClass() 1423 | instance.doStuff()`; 1424 | const mainFile = { 1425 | name: `${defaultPackageJson.main}`, 1426 | contents: ` 1427 | export class MyClass { 1428 | doStuff (): void { 1429 | return 1430 | } 1431 | }`, 1432 | }; 1433 | 1434 | const typeScriptMarkdown = wrapSnippet(snippet); 1435 | await createProject({ 1436 | markdownFiles: [{ name: "DOCS.md", contents: typeScriptMarkdown }], 1437 | mainFile, 1438 | }); 1439 | 1440 | const tsconfigDirectory = Gen.word(); 1441 | const tsconfigFile = `${Gen.word()}-tsconfig.json`; 1442 | const tsconfigJson = { 1443 | compilerOptions: { 1444 | target: "es2019", 1445 | module: "esnext", 1446 | }, 1447 | }; 1448 | 1449 | await FsExtra.ensureDir(path.join(workingDirectory, tsconfigDirectory)); 1450 | await FsExtra.writeJSON( 1451 | path.join(workingDirectory, tsconfigDirectory, tsconfigFile), 1452 | tsconfigJson 1453 | ); 1454 | 1455 | return await TypeScriptDocsVerifier.compileSnippets({ 1456 | project: path.join(tsconfigDirectory, tsconfigFile), 1457 | markdownFiles: ["DOCS.md"], 1458 | }).should.eventually.eql([ 1459 | { 1460 | file: "DOCS.md", 1461 | index: 1, 1462 | snippet, 1463 | linesWithErrors: [], 1464 | }, 1465 | ]); 1466 | } 1467 | ); 1468 | }); 1469 | }); 1470 | --------------------------------------------------------------------------------