diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 9757ed4..0b664f5 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -4,8 +4,6 @@ "denoland.vscode-deno", "dprint.dprint", "esbenp.prettier-vscode", - "ms-azuretools.vscode-docker", - "streetsidesoftware.code-spell-checker", - "vadimcn.vscode-lldb" + "streetsidesoftware.code-spell-checker" ] } diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 431c922..0000000 --- a/Dockerfile +++ /dev/null @@ -1,59 +0,0 @@ -FROM denoland/deno:latest - -ENV PATH=${DENO_INSTALL}/bin:${PATH} \ - DENO_DIR=/home/vscode/.cache/deno -ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium - -# Ripped from https://github.com/lucacasonato/deno-puppeteer/blob/main/Dockerfile -RUN apt-get -qq update \ - && apt-get -qq install -y --no-install-recommends \ - curl \ - ca-certificates \ - unzip \ - fonts-liberation \ - libappindicator3-1 \ - libasound2 \ - libatk-bridge2.0-0 \ - libatk1.0-0 \ - libc6 \ - libcairo2 \ - libcups2 \ - libdbus-1-3 \ - libexpat1 \ - libfontconfig1 \ - libgbm1 \ - libgcc1 \ - libglib2.0-0 \ - libgtk-3-0 \ - libnspr4 \ - libnss3 \ - libpango-1.0-0 \ - libpangocairo-1.0-0 \ - libstdc++6 \ - libx11-6 \ - libx11-xcb1 \ - libxcb1 \ - libxcomposite1 \ - libxcursor1 \ - libxdamage1 \ - libxext6 \ - libxfixes3 \ - libxi6 \ - libxrandr2 \ - libxrender1 \ - libxss1 \ - libxtst6 \ - lsb-release \ - wget \ - xdg-utils \ - libdrm2 \ - libxkbcommon0 \ - libxshmfence1 \ - chromium - -RUN curl -fsSL https://dprint.dev/install.sh | DPRINT_INSTALL=/usr/local sh \ - && PUPPETEER_PRODUCT=chrome deno run -A --unstable https://deno.land/x/puppeteer@16.2.0/install.ts \ - && curl -fsSL https://deb.nodesource.com/setup_18.x | bash - - -WORKDIR /trun -COPY . . diff --git a/README.md b/README.md index cdf70d1..fc48bab 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,3 @@ -# `trun` +# `egts` -CLI test utility for Deno and the Browser - -## Flags - -```sh ---browser # whether to run tests in the browser ---browser-exec-path # path to the browser binary ---concurrency # number of tests to be run in parallel ---headless # whether to run the browser in headless mode ---ignore # name of ignore file; should contain list of glob patterns to not match ---include # glob pattern of files to match ---import-map # name of deno import map file ---output # name of file to output test results to ---reload # passed through to deno -``` +Example-related utilities used in [Capi](https://github.com/paritytech/capi). diff --git a/deno.jsonc b/deno.jsonc index 46b67c5..84ad58d 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -7,6 +7,7 @@ "noUncheckedIndexedAccess": true, "useUnknownInCatchVariables": true }, + "importMap": "import_map.json", "include": ["."], "lock": false, "lint": { @@ -28,6 +29,7 @@ } }, "tasks": { - "udd": "deno run -A _tasks/udd.ts" + "udd": "deno run -A _tasks/udd.ts", + "moderate": "deno run -A https://deno.land/x/moderate@0.0.5/mod.ts && dprint fmt" } } diff --git a/deps/case.ts b/deps/case.ts new file mode 100644 index 0000000..e02be3d --- /dev/null +++ b/deps/case.ts @@ -0,0 +1 @@ +export * from "https://deno.land/x/case@2.1.1/mod.ts" diff --git a/deps/esbuild.ts b/deps/esbuild.ts index 1fcfb46..a0282ad 100644 --- a/deps/esbuild.ts +++ b/deps/esbuild.ts @@ -1 +1 @@ -export * from "https://deno.land/x/esbuild@v0.17.12/mod.js" +export * from "https://deno.land/x/esbuild@v0.17.15/mod.js" diff --git a/deps/pqueue.ts b/deps/pqueue.ts deleted file mode 100644 index 12f0373..0000000 --- a/deps/pqueue.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as PromiseQueue } from "https://deno.land/x/p_queue@1.0.1/mod.ts" diff --git a/deps/puppeteer.ts b/deps/puppeteer.ts deleted file mode 100644 index 98194be..0000000 --- a/deps/puppeteer.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from "https://deno.land/x/puppeteer@16.2.0/mod.ts" -export * from "https://deno.land/x/puppeteer@16.2.0/mod.ts" diff --git a/deps/scale.ts b/deps/scale.ts new file mode 100644 index 0000000..ce99b08 --- /dev/null +++ b/deps/scale.ts @@ -0,0 +1 @@ +export * from "https://deno.land/x/scale@v0.11.2/mod.ts" diff --git a/deps/std/assert.ts b/deps/std/assert.ts index 3f78800..3fc11a5 100644 --- a/deps/std/assert.ts +++ b/deps/std/assert.ts @@ -1 +1 @@ -export * from "https://deno.land/std@0.180.0/testing/asserts.ts" +export * from "https://deno.land/std@0.182.0/testing/asserts.ts" diff --git a/deps/std/async.ts b/deps/std/async.ts index 85bc5f5..8f93938 100644 --- a/deps/std/async.ts +++ b/deps/std/async.ts @@ -1 +1 @@ -export * from "https://deno.land/std@0.180.0/async/mod.ts" +export * from "https://deno.land/std@0.182.0/async/deferred.ts" diff --git a/deps/std/datetime.ts b/deps/std/datetime.ts new file mode 100644 index 0000000..a622f2b --- /dev/null +++ b/deps/std/datetime.ts @@ -0,0 +1 @@ +export * from "https://deno.land/std@0.182.0/datetime/mod.ts" diff --git a/deps/std/encoding/hex.ts b/deps/std/encoding/hex.ts new file mode 100644 index 0000000..22898b8 --- /dev/null +++ b/deps/std/encoding/hex.ts @@ -0,0 +1 @@ +export * from "https://deno.land/std@0.182.0/encoding/hex.ts" diff --git a/deps/std/flags.ts b/deps/std/flags.ts index a304372..804eccf 100644 --- a/deps/std/flags.ts +++ b/deps/std/flags.ts @@ -1 +1 @@ -export * from "https://deno.land/std@0.180.0/flags/mod.ts" +export * from "https://deno.land/std@0.182.0/flags/mod.ts" diff --git a/deps/std/fmt/colors.ts b/deps/std/fmt/colors.ts new file mode 100644 index 0000000..5c5655e --- /dev/null +++ b/deps/std/fmt/colors.ts @@ -0,0 +1 @@ +export * from "https://deno.land/std@0.182.0/fmt/colors.ts" diff --git a/deps/std/fs.ts b/deps/std/fs.ts index 6aa7106..0069b81 100644 --- a/deps/std/fs.ts +++ b/deps/std/fs.ts @@ -1 +1 @@ -export * from "https://deno.land/std@0.180.0/fs/mod.ts" +export * from "https://deno.land/std@0.182.0/fs/mod.ts" diff --git a/deps/std/http.ts b/deps/std/http.ts deleted file mode 100644 index 00efa34..0000000 --- a/deps/std/http.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "https://deno.land/std@0.180.0/http/mod.ts" diff --git a/deps/std/io.ts b/deps/std/io.ts index 133229e..3fadc19 100644 --- a/deps/std/io.ts +++ b/deps/std/io.ts @@ -1 +1 @@ -export * from "https://deno.land/std@0.180.0/io/mod.ts" +export * from "https://deno.land/std@0.182.0/io/mod.ts" diff --git a/deps/std/path.ts b/deps/std/path.ts index 3d1544c..d8936fd 100644 --- a/deps/std/path.ts +++ b/deps/std/path.ts @@ -1 +1 @@ -export * from "https://deno.land/std@0.180.0/path/mod.ts" +export * from "https://deno.land/std@0.182.0/path/mod.ts" diff --git a/deps/std/streams.ts b/deps/std/streams.ts index 60b5926..0c364bb 100644 --- a/deps/std/streams.ts +++ b/deps/std/streams.ts @@ -1 +1 @@ -export * from "https://deno.land/std@0.180.0/streams/mod.ts" +export * from "https://deno.land/std@0.182.0/streams/mod.ts" diff --git a/dprint.json b/dprint.json index 11adb02..97ca671 100644 --- a/dprint.json +++ b/dprint.json @@ -11,10 +11,9 @@ "lineWidth": 80, "textWrap": "always" }, - "includes": ["**.{dockerfile,json,md,toml,ts,tsx}"], + "includes": ["**.{json,md,toml,ts,tsx}"], "excludes": [""], "plugins": [ - "https://plugins.dprint.dev/dockerfile-0.3.0.wasm", "https://plugins.dprint.dev/json-0.17.0.wasm", "https://plugins.dprint.dev/markdown-0.15.2.wasm", "https://plugins.dprint.dev/toml-0.5.4.wasm", diff --git a/examples/basic.eg.md b/examples/basic.eg.md new file mode 100644 index 0000000..e6ebe99 --- /dev/null +++ b/examples/basic.eg.md @@ -0,0 +1,46 @@ + + +# Example + +Welcome to egts. This very script is an egts. +The jsdoc section above serves as frontmatter. +The code serves as to-be markdown code fences. +And the comments serve as ordinary lines of markdown body. + +Bring imports into scope. + +```ts +import { date, description, stability, tags, title, toMarkdown } from "egts" +``` + +Compile an egts of this very script! + +```ts +const result = toMarkdown("basic.eg.ts", await Deno.readTextFile(new URL(import.meta.url)), { + title, + tags: tags(["example", "docs", "deno", "typescript"]), + date: date("yyyy-MM-dd"), + description, + stability, +}) +``` + +Log out the typed frontmatter. + +```ts +console.log(result.frontmatter) +``` + +Write the result to the fs. + +```ts +await Deno.writeTextFile( + new URL(import.meta.resolve("./basic.eg.md")), + ` + +# ${result.frontmatter.title} + +${result.content} +`, +) +``` diff --git a/examples/basic.eg.ts b/examples/basic.eg.ts new file mode 100644 index 0000000..1660a24 --- /dev/null +++ b/examples/basic.eg.ts @@ -0,0 +1,39 @@ +/** + * @title Example + * @tags example,docs,deno,typescript + * @date 2023-10-10 + * @description This description can be very long and even span + * multiple lines. It will be parsed as expected. + * @stability experiment + */ + +/// Welcome to egts. This very script is an egts. +/// The jsdoc section above serves as frontmatter. +/// The code serves as to-be markdown code fences. +/// And the comments serve as ordinary lines of markdown body. + +/// Bring imports into scope. +import { date, description, stability, tags, title, toMarkdown } from "egts" + +/// Compile an egts of this very script! +const result = toMarkdown("basic.eg.ts", await Deno.readTextFile(new URL(import.meta.url)), { + title, + tags: tags(["example", "docs", "deno", "typescript"]), + date: date("yyyy-MM-dd"), + description, + stability, +}) + +/// Log out the typed frontmatter. +console.log(result.frontmatter) + +/// Write the result to the fs. +await Deno.writeTextFile( + new URL(import.meta.resolve("./basic.eg.md")), + ` + +# ${result.frontmatter.title} + +${result.content} +`, +) diff --git a/frontmatter.ts b/frontmatter.ts new file mode 100644 index 0000000..6802ad6 --- /dev/null +++ b/frontmatter.ts @@ -0,0 +1,43 @@ +const rFrontmatterFile = /^\s*\/\*\*(?.+?)\*\/\s*(?.*)$/s +const rLeadingAsterisk = /^\s*(?:\* ?)?/gm +const rTagStart = /^(?=@\w+)/m +const rTag = /^@(?\w+)(\s+(?.*))?$/s + +export function parseFrontmatter>( + pathname: string, + src: string, + parsers: FrontmatterParsers, +): ParseFrontmatterResult { + const fileMatch = rFrontmatterFile.exec(src) + if (!fileMatch) throw new Error(`Could not extract module comment from "${pathname}".`) + const { comment = "", body = "" } = fileMatch.groups ?? {} + const commentContent = comment.replace(rLeadingAsterisk, "").trim() + const tagsText = commentContent.split(rTagStart) + const frontmatterRaw = Object.fromEntries( + tagsText.map((pairText) => { + const tagMatch = rTag.exec(pairText) + if (!tagMatch) throw new Error(`Error when attempting to match tag in "${pathname}"`) + const { key = "", value = "" } = tagMatch.groups ?? {} + return [key, value.trim()] + }), + ) + const frontmatter = {} as F + for (const [key, parse] of Object.entries(parsers)) { + try { + frontmatter[key as keyof F] = parse(frontmatterRaw[key]) + } catch (e) { + throw new Error(`Failed to parse "${key}" from "${pathname}"\n${Deno.inspect(e)}`) + } + } + return { frontmatter, body } +} + +export type FrontmatterParsers> = { + [K in keyof F]: FrontmatterParser +} +export type FrontmatterParser = (raw: string | undefined) => T + +export interface ParseFrontmatterResult> { + frontmatter: F + body: string +} diff --git a/frontmatter_parsers.ts b/frontmatter_parsers.ts new file mode 100644 index 0000000..4e4c8ec --- /dev/null +++ b/frontmatter_parsers.ts @@ -0,0 +1,35 @@ +import { titleCase } from "./deps/case.ts" +import * as $ from "./deps/scale.ts" +import * as datetime from "./deps/std/datetime.ts" +import { FrontmatterParser } from "./frontmatter.ts" + +export const title: FrontmatterParser = (raw) => { + $.assert($.str, raw) + return titleCase(raw) +} + +export const description: FrontmatterParser = (raw) => { + $.assert($.str, raw) + return raw +} + +const $stability = $.literalUnion(["experiment", "unstable", "nearing", "stable"]) +export const stability: FrontmatterParser<$.Native> = (raw) => { + $.assert($stability, raw) + return raw +} + +export function tags(allowed: T[]): FrontmatterParser { + return (raw) => { + const tags = raw?.split(",") + $.assert($.array($.literalUnion(allowed)), tags) + return tags + } +} + +export function date(format: string): FrontmatterParser { + return (raw) => { + $.assert($.str, raw) + return datetime.parse(raw, format) + } +} diff --git a/import_map.json b/import_map.json new file mode 100644 index 0000000..7e3cf43 --- /dev/null +++ b/import_map.json @@ -0,0 +1,7 @@ +{ + "scopes": { + "examples/": { + "egts": "./mod.ts" + } + } +} diff --git a/main.ts b/main.ts deleted file mode 100644 index d795769..0000000 --- a/main.ts +++ /dev/null @@ -1,111 +0,0 @@ -import * as esbuild from "./deps/esbuild.ts" -import puppeteer from "./deps/puppeteer.ts" -import { parse } from "./deps/std/flags.ts" -import * as fs from "./deps/std/fs.ts" -import * as path from "./deps/std/path.ts" -import { run, runWithBrowser, runWithDeno } from "./run.ts" - -const flags = parse(Deno.args, { - string: [ - "browser-exec-path", - "concurrency", - "include", - "ignore", - "import-map", - "output", - "reload", - ], - boolean: ["browser", "headless"], - default: { - "browser-exec-path": "/usr/bin/chromium", - headless: true, - ignore: ".trunignore", - }, - alias: { reload: "r" }, -}) - -const { ignore, browser, output, headless, include } = flags -const importMap = flags["import-map"] -const concurrency = flags.concurrency ? Number(flags.concurrency) : Infinity -if (!include) { - throw new Error("include flag is required") -} - -if (!path.isGlob(include)) { - throw new Error("include flag must be a glob pattern") -} - -const controller = new AbortController() -const { signal } = controller - -const skip = await Deno.stat(ignore) - .then(() => Deno.readTextFile(ignore)) - .then((s) => s.split("\n").map((glob) => path.globToRegExp(glob))) - .catch(() => []) - -const paths = [] -for await ( - const entry of fs.walk(".", { - match: [path.globToRegExp(include)], - skip, - includeDirs: false, - }) -) { - paths.push(entry.path) -} - -async function shutdown(exitCode: number) { - console.log(`\nshutting down with exitcode ${exitCode}`) - - self.addEventListener("unload", () => Deno.exit(exitCode)) - - esbuild.stop() - controller.abort() -} - -Deno.addSignalListener("SIGINT", () => shutdown(1)) -Deno.addSignalListener("SIGTERM", () => shutdown(1)) - -const createBrowser = async () => { - const browser = await puppeteer.launch({ - headless, - executablePath: flags["browser-exec-path"], - args: ["--no-sandbox", "--disable-setuid-sandbox"], - }) - - signal.addEventListener("abort", async () => { - await browser.close() - }) - - return browser -} - -const importMapUrl = importMap - ? path.toFileUrl(path.resolve(importMap)) - : undefined - -const results: [fileName: string, exitCode: number][] = [] -const runner = browser - ? await runWithBrowser({ createBrowser, importMapUrl, results }) - : await runWithDeno({ reloadUrl: flags.reload, signal, results }) - -console.log(`${paths.length} files found`) -console.log(paths) - -await run({ paths, runner, concurrency, signal }) -const failedTests = results - .filter(([_, exitCode]) => exitCode !== 0) - .map(([fileName, _]) => fileName) -const isFailed = failedTests.length > 0 - -console.log(`test results -- ${failedTests.length} failure(s)`) -if (output) { - await Deno.writeTextFile(output, JSON.stringify(failedTests)) -} - -if (isFailed) { - console.log(failedTests) - shutdown(1) -} else { - shutdown(0) -} diff --git a/mod.ts b/mod.ts new file mode 100644 index 0000000..9a5e5e6 --- /dev/null +++ b/mod.ts @@ -0,0 +1,5 @@ +// moderate --exclude console.js test.ts + +export * from "./frontmatter.ts" +export * from "./frontmatter_parsers.ts" +export * from "./toMarkdown.ts" diff --git a/run.ts b/run.ts deleted file mode 100644 index c86f210..0000000 --- a/run.ts +++ /dev/null @@ -1,138 +0,0 @@ -import * as esbuild from "./deps/esbuild.ts" -import { denoPlugin } from "./deps/esbuild_deno_loader.ts" -import { PromiseQueue } from "./deps/pqueue.ts" -import { Browser } from "./deps/puppeteer.ts" -import { deferred } from "./deps/std/async.ts" -import { Buffer, readLines } from "./deps/std/io.ts" -import { readerFromStreamReader, writeAll } from "./deps/std/streams.ts" - -export interface RunOptions { - paths: readonly string[] - concurrency: number - runner: (filePath: string) => Promise - signal: AbortSignal -} - -export async function run({ paths, concurrency, runner, signal }: RunOptions) { - const runQueue = new PromiseQueue({ concurrency }) - runQueue.addAll(paths.map((filePath) => () => runner(filePath))) - - signal.addEventListener("abort", () => runQueue.clear()) - - await runQueue.onIdle() -} - -export interface RunWithBrowserOptions { - createBrowser: () => Promise - importMapUrl?: URL - results: (readonly [filePath: string, exitCode: number])[] -} - -export async function runWithBrowser( - { createBrowser, importMapUrl, results }: RunWithBrowserOptions, -) { - const browser = await createBrowser() - const consoleJs = await fetch(import.meta.resolve("./console.js")).then((r) => r.text()) - - return (async (filePath: string) => { - const result = await executionWrapper(filePath, async (outputBuffer) => { - const page = await browser.newPage() - const buildResult = await esbuild.build({ - plugins: [ - denoPlugin({ - importMapURL: importMapUrl, - }) as any, - ], - entryPoints: [filePath], - bundle: true, - write: false, - format: "esm", - }) - - await page.exposeFunction("__trun_injected_log", (...[msg]: [string, number]) => { - outputBuffer.writeSync(new TextEncoder().encode(msg)) - }) - - const exitCode = deferred() - await page.exposeFunction("exit", (args: string) => { - exitCode.resolve(Number(args)) - }) - - await page.addScriptTag({ content: consoleJs, type: "module" }) - const code = wrapCode(buildResult.outputFiles[0]?.text!) - await page.addScriptTag({ content: code, type: "module" }) - - return exitCode - }) - - results.push(result) - }) -} - -export interface RunWithDenoOptions { - results: (readonly [filePath: string, exitCode: number])[] - reloadUrl?: string - signal: AbortSignal -} - -export async function runWithDeno({ reloadUrl, results, signal }: RunWithDenoOptions) { - return (async (filePath: string) => { - const result = await executionWrapper(filePath, async (outputBuffer) => { - const command = new Deno.Command(Deno.execPath(), { - args: ["run", "-A", ...reloadUrl ? [`-r=${reloadUrl}`] : [], filePath], - stdout: "piped", - stderr: "piped", - signal, - }) - - const task = command.spawn() - - await Promise.all([ - pipeThrough(readerFromStreamReader(task.stdout.getReader()), outputBuffer), - pipeThrough(readerFromStreamReader(task.stderr.getReader()), outputBuffer), - ]) - - const status = await task.status - - return status.code - }) - - results.push(result) - }) -} - -async function executionWrapper( - filePath: string, - run: (outputBuffer: Buffer) => Promise, -): Promise<[string, number]> { - console.log(`running ${filePath}`) - - const outputBuffer = new Buffer() - const exitCode = await run(outputBuffer) - - if (exitCode !== 0) { - console.log(`${filePath} failed -- console output:`) - console.log(new TextDecoder().decode(outputBuffer.bytes())) - } - - console.log(`finished ${filePath}`) - - return [filePath, exitCode] -} - -async function pipeThrough(reader: Deno.Reader, writer: Deno.Writer) { - const encoder = new TextEncoder() - for await (const line of readLines(reader)) { - await writeAll(writer, encoder.encode(`${line}\n`)) - } -} - -const wrapCode = (code: string) => ` -try { - ${code} - exit(0) -} catch (err) { - console.error(err) - exit(1) -} -` diff --git a/test.ts b/test.ts new file mode 100644 index 0000000..73d0c94 --- /dev/null +++ b/test.ts @@ -0,0 +1,143 @@ +import { unimplemented } from "./deps/std/assert.ts" +import { deferred } from "./deps/std/async.ts" +import { parse as parseFlags } from "./deps/std/flags.ts" +import { blue, dim, gray, green, red, yellow } from "./deps/std/fmt/colors.ts" +import { walk } from "./deps/std/fs.ts" +import { Buffer, readLines } from "./deps/std/io.ts" +import * as path from "./deps/std/path.ts" +import { readerFromStreamReader, writeAll } from "./deps/std/streams.ts" +import { parseFrontmatter } from "./frontmatter.ts" + +const { _: includePatterns, reload, ...rest } = parseFlags(Deno.args, { + alias: { + b: "browser", + c: "concurrency", + p: "project", + r: "reload", + }, + string: ["browser", "concurrency", "project", "reload"], + default: { + concurrency: Infinity, + }, +}) + +const include: string[] = [] +for await ( + const { path: pathname } of walk(".", { + exts: [".ts", ".tsx"], + followSymlinks: true, + includeDirs: false, + match: includePatterns.map((value) => { + if (typeof value !== "string") { + throw new Error( + `Specified an invalid include \`${value}\` (expected a glob or path to example file)`, + ) + } + return path.isGlob(value) ? path.globToRegExp(value) : new RegExp(value) + }), + }) +) include.push(pathname) + +const concurrency = +rest.concurrency + +// const project = rest.project ?? await (async () => { +// for (const pathname of ["deno.json", "deno.jsonc"]) { +// try { +// return JSON.parse(await Deno.readTextFile(pathname)) +// } catch (_e) {} +// } +// return +// })() +// const _importMapURL = project.importMap +// ? path.toFileUrl(path.join(Deno.cwd(), project.importMap)) +// : undefined + +const browser = rest.browser === undefined ? undefined : rest.browser || "chromium" + +const failed: string[] = [] +let passed = 0 +let skipped = 0 + +await runWithConcurrency( + include.map((pathname) => async () => { + const { frontmatter } = parseFrontmatter(pathname, await Deno.readTextFile(pathname), { + test_skip(value) { + return value !== undefined + }, + }) + const quotedPathname = `"${pathname}"` + if (frontmatter.test_skip) { + console.log(yellow("Skipping"), quotedPathname) + skipped++ + return + } + console.log(gray("Testing"), quotedPathname) + const logs = new Buffer() + const code = await (browser ? runBrowser : runDeno)(pathname, logs) + passed++ + const progress = dim(`(${passed + skipped}/${include.length})`) + if (code) { + failed.push(pathname) + console.log(red("Failed"), progress, quotedPathname) + console.log(new TextDecoder().decode(logs.bytes())) + } else { + console.log(green("Passed"), progress, quotedPathname) + } + }), + concurrency, +) + +if (failed.length) { + console.log(`${red("Erroring examples")}: \n - "${failed.join(`"\n - "`)}"`) + Deno.exit(1) +} else { + if (passed) console.log(blue(`${passed} examples passed`)) + if (skipped) console.log(gray(`${skipped} examples skipped`)) + Deno.exit() +} + +async function runDeno(pathname: string, logs: Buffer): Promise { + const flags = reload ? [`-r${reload === "" ? "" : `=${reload}`}`] : [] + const process = new Deno.Command(Deno.execPath(), { + args: ["run", "-A", ...flags, pathname], + stdout: "piped", + stderr: "piped", + }).spawn() + const [{ code }] = await Promise.all([ + process.status, + ...[process.stdout, process.stderr].map(async (stream) => { + const lineIter = readLines(readerFromStreamReader(stream.getReader())) + const encoder = new TextEncoder() + for await (const line of lineIter) await writeAll(logs, encoder.encode(`${line}\n`)) + }), + ]) + return code +} + +async function runBrowser(_pathname: string, _logs: Buffer) { + return unimplemented() +} + +function runWithConcurrency(fns: ReadonlyArray<() => Promise>, concurrency: number) { + const queue = [...fns] + let running = 0 + const results: Promise[] = [] + const final = deferred() + flushQueue() + return final + + function flushQueue() { + for (; running < concurrency; running++) { + if (!queue.length) { + final.resolve(Promise.all(results)) + return + } + const promise = queue.shift()!() + results.push(promise) + promise.finally(() => { + running-- + flushQueue() + }) + } + } +} diff --git a/toMarkdown.ts b/toMarkdown.ts new file mode 100644 index 0000000..f7af996 --- /dev/null +++ b/toMarkdown.ts @@ -0,0 +1,85 @@ +import { FrontmatterParsers, parseFrontmatter } from "./frontmatter.ts" + +type Directive = "hide-start" | "hide-end" | "hide-next-line" +const rDirective = /^\s*\/{2}\s*(?hide-start|hide-end|hide-next-line)(?:\s+.*)?$/ +const rMarkdownLine = /^\s*\/{3} ?(?.+)/ + +type Line = + | { kind: "markdown"; content: string } + | { kind: "code"; content: string } + | { kind: "directive"; directive: Directive } + | { kind: "blank"; content: "" } + +export function toMarkdown>( + pathname: string, + src: string, + parsers: FrontmatterParsers, +) { + const { frontmatter, body } = parseFrontmatter(pathname, src, parsers) + + const lines = body.split("\n").map((line): Line => { + if (line.trim() === "") return { kind: "blank", content: "" } + + const directiveMatch = rDirective.exec(line) + if (directiveMatch) { + const directive = directiveMatch.groups!.directive! as Directive + return { kind: "directive", directive } + } + + const markdownMatch = rMarkdownLine.exec(line) + if (markdownMatch) { + const content = markdownMatch.groups!.content! + return { kind: "markdown", content } + } + + return { kind: "code", content: line } + }) + + const filteredLines = [] + for (let i = 0; i < lines.length;) { + const line = lines[i]! + if (line.kind === "directive") { + if (line.directive === "hide-next-line") { + i += 2 + } else if (line.directive === "hide-start") { + while (true) { + i++ + const line = lines[i] + if (!line) throw new Error("unmatched hide-start comment") + if (line.kind === "directive" && line.directive === "hide-end") { + i++ + break + } + } + } else if (line.directive === "hide-end") { + throw new Error("unmatched hide-end comment") + } else { + assertNever(line.directive) + } + } else { + filteredLines.push(line) + i++ + } + } + + type Section = { kind: "code" | "markdown"; lines: string[] } + const sections: Section[] = [] + + for (const line of filteredLines) { + if ((line.kind === "code" || line.kind === "markdown") && sections.at(-1)?.kind !== line.kind) { + sections.push({ kind: line.kind, lines: [] }) + } + sections.at(-1)?.lines.push(line.content) + } + + const content = sections.map((section) => { + const inner = section.lines.join("\n") + return section.kind === "code" ? `\`\`\`ts\n${inner}\`\`\`` : inner + }).join("\n\n") + + return { frontmatter, content } +} + +function assertNever(value: never) { + throw new Error(`Expected to never be called; got ${Deno.inspect(value)}`) +} diff --git a/words.txt b/words.txt index e69de29..4f44af5 100644 --- a/words.txt +++ b/words.txt @@ -0,0 +1,5 @@ +capi +datetime +dprint +egts +frontmatter