├── .babelrc.js ├── .gitattributes ├── .github └── workflows │ └── build.yml ├── .gitignore ├── .prettierignore ├── .vscode └── settings.json ├── README.md ├── assets ├── app.css ├── base.css ├── blog-highlight.css ├── blog.css ├── calipers │ ├── apple-touch.png │ ├── favicon.png │ ├── preview-1.png │ └── preview-2.png ├── cv.css ├── fonts │ ├── CrimsonPro-Italic-VF.woff2 │ ├── CrimsonPro-Roman-VF.woff2 │ ├── Inter-italic.latin.var.woff2 │ └── Inter-roman.latin.var.woff2 ├── freebies.css ├── icons │ ├── calipers.svg │ ├── link.svg │ ├── piano-tabs.svg │ ├── pocket-jam.svg │ ├── rail-app.svg │ ├── reason-ml.svg │ ├── technicalc.svg │ └── za.svg ├── index.css ├── lightbox.css ├── lightbox.js ├── piano-tabs │ ├── apple-touch.png │ ├── favicon.png │ ├── press-kit-1.png │ ├── press-kit-2.png │ ├── preview-1.png │ ├── preview-2.png │ ├── preview-3.png │ └── promo.png ├── pocket-jam │ ├── apple-touch.png │ ├── favicon.png │ ├── press-kit-1.png │ ├── press-kit-2.png │ ├── press-kit-3.png │ ├── preview-1.png │ ├── preview-2.png │ ├── preview-3.png │ └── promo.png ├── posts │ ├── face-camera-angles.png │ ├── face-camera.png │ ├── face-order.png │ ├── faces-1.png │ ├── faces-2.png │ ├── faces-3.png │ ├── faces-4.png │ ├── faces-intersecting.png │ ├── faces-reversed.png │ └── technicalc-bottom-sheet.mp4 ├── privacy.css ├── set-hairline-width.js ├── technicalc │ ├── apple-touch.png │ ├── computation-critical.css │ ├── computation-critical.js │ ├── computation.css │ ├── computation.d.ts │ ├── computation.js │ ├── computation.tsx │ ├── dist │ │ ├── client.min.js │ │ └── worker.min.js │ ├── ellipsis.svg │ ├── favicon.png │ ├── icon-advanced.svg │ ├── icon-constants.svg │ ├── icon-currencies.svg │ ├── icon-dates.svg │ ├── icon-equations.svg │ ├── icon-graphs.svg │ ├── icon-grid.css │ ├── icon-stats.svg │ ├── icon-units.svg │ ├── icon-variables.svg │ ├── loading.svg │ ├── press-kit-1.png │ ├── press-kit-2.png │ ├── press-kit-3.png │ ├── press-kit-4.png │ ├── press-kit-5.png │ ├── preview-1.png │ ├── preview-2.png │ ├── preview-3.png │ ├── preview-4.png │ ├── preview-5.png │ ├── promo.png │ ├── style-decimal.svg │ ├── style-engineering.svg │ ├── style-natural-mixed.svg │ ├── style-natural.svg │ └── trailer.mp4 └── vendor │ ├── app-store.svg │ ├── google-play.svg │ └── twitter.svg ├── components ├── AppBlock.tsx ├── AppPromoImage.tsx ├── AppleTouchIcon.tsx ├── BetterTogether.tsx ├── FavIcon.tsx ├── Icon.tsx ├── Layout.tsx ├── Lead.tsx ├── LegalLinks.tsx ├── LightBox.tsx ├── Posts.tsx ├── PressKit.tsx ├── ReuseSvg.tsx ├── TechnicalcComputation.tsx ├── Tweet.tsx └── ZipDownload.tsx ├── core ├── assetTransformer.ts ├── assetTransforms │ ├── index.ts │ ├── transformAsset.ts │ ├── transformCss.ts │ ├── transformHtml.tsx │ ├── transformJs.ts │ └── transformSvg.ts ├── cli │ ├── api-bridge-types.ts │ ├── api-bridge.ts │ ├── api-direct.ts │ ├── api-worker.js │ ├── api-worker.ts │ ├── cli.ts │ ├── getConfigPages.ts │ ├── index.js │ ├── index.ts │ ├── pageBuilder.ts │ └── register.ts ├── components │ ├── A.tsx │ ├── Code.tsx │ ├── ExternalCss.tsx │ ├── ExternalJs.tsx │ ├── Image.tsx │ ├── InlineCss.tsx │ ├── InlineJs.tsx │ ├── InlineSvg.tsx │ ├── Video.tsx │ └── index.ts ├── config.ts ├── css.ts ├── index.ts ├── renderPage.tsx ├── transformPage.tsx ├── useContent.ts ├── useTableOfContents.ts └── util │ ├── AsyncWritable.ts │ ├── castArray.ts │ ├── nestedAsync.ts │ └── set.ts ├── package.json ├── pages ├── 404.mdx ├── blog.mdx ├── calipers.mdx ├── cv.mdx ├── freebies.mdx ├── index.mdx ├── piano-tabs.mdx ├── pocket-jam.mdx ├── privacy.mdx └── technicalc.mdx ├── posts ├── 2019-11-01-polymorphic-variants-in-reason-ml.mdx ├── 2020-04-08-a-primer-in-reason-ml.mdx ├── 2020-05-30-dark-mode-in-react-native.mdx ├── 2020-06-04-a-guide-to-app-store-screenshots.mdx ├── 2020-10-09-react-native-bottom-sheets.mdx ├── 2020-11-20-react-native-on-macos.mdx ├── 2020-12-30-super-optimised-static-sites.mdx ├── 2021-12-04-css-layers.mdx ├── 2022-12-07-font-scaling-in-css.mdx └── 2025-05-18-precomputing-transparency-order-in-3d.mdx ├── site.config.ts ├── tsconfig.json └── yarn.lock /.babelrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | "@babel/preset-typescript", 4 | [ 5 | "@babel/preset-env", 6 | { 7 | exclude: ["@babel/plugin-transform-regenerator"], 8 | modules: "commonjs", 9 | }, 10 | ], 11 | ["@babel/preset-react", { runtime: "automatic" }], 12 | ], 13 | }; 14 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobp100/jacob-does-code/ecaeae674f2793fff8a0b17f987419cf2229316c/.gitattributes -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | workflow_dispatch: 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | matrix: 17 | node-version: [24.x] 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | 22 | - name: Dependencies 23 | uses: bahmutov/npm-install@v1 24 | 25 | - name: Build 26 | run: yarn run build 27 | 28 | - name: Deploy 29 | uses: JamesIves/github-pages-deploy-action@v4.3.3 30 | with: 31 | BRANCH: gh-pages 32 | FOLDER: site 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /site 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Prettier breaks mdx2 syntax 2 | cv.mdx -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.language": "en-GB" 3 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jacob Does Code 2 | 3 | > [http://jacobdoescode.com](http://jacobdoescode.com) 4 | 5 | Custom static site builder. Like Jekyll, but uses React heavily under the hood. It has the following advantages: 6 | 7 | - Assets get tree shaken (for better or worse) 8 | - Optimises assets, including automatic conversion of images to WebP 9 | - Assets get renamed to their hashes for long-term caching 10 | - MDX-based - sp you can use React components in Markdown 11 | - Generally more React-based - no `{% if blocks %}` 12 | - No more `_includes` hacks 13 | - Granular control of how assets get used - including inlining critical CSS/JS if required 14 | 15 | Using and referencing assets requires React components to do the lifting. The elements ` */ 2 | 3 | export default () => null; 4 | -------------------------------------------------------------------------------- /components/ZipDownload.tsx: -------------------------------------------------------------------------------- 1 | import JSZip from "jszip"; 2 | import { assetTransform, classNames, useContent } from "../core"; 3 | import path from "path"; 4 | 5 | const transform = assetTransform( 6 | async (content, files) => { 7 | const zip = new JSZip(); 8 | for (const file of files) { 9 | zip.file(path.basename(file), content.readBuffer(file)); 10 | } 11 | const buffer = await zip.generateAsync({ type: "nodebuffer" }); 12 | return content.write(buffer, { extension: ".zip" }); 13 | }, 14 | { cacheKey: "PressKit" } 15 | ); 16 | 17 | type Props = { 18 | files: string[]; 19 | className?: string | string[]; 20 | download?: string; 21 | children?: any; 22 | }; 23 | 24 | export default ({ files, className, download, children }: Props) => { 25 | const content = useContent(); 26 | const zip = transform(content, files); 27 | 28 | return ( 29 | 30 | {children} 31 | 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /core/assetTransformer.ts: -------------------------------------------------------------------------------- 1 | import { Content, createContentContext } from "./useContent"; 2 | 3 | type FileCacheKey = string; 4 | 5 | type CacheResult = { 6 | output: any; 7 | dependencies: Set; 8 | encodable: boolean; 9 | }; 10 | 11 | type PromiseResolution = 12 | | { resolved: false; promise: Promise } 13 | | { resolved: true; value: T }; 14 | 15 | const cache = new Map>(); 16 | 17 | let keyId = 0; 18 | let generateKey = () => { 19 | let output = keyId.toString(16).padStart(4, "0"); 20 | keyId += 1; 21 | return output; 22 | }; 23 | 24 | type Options = { cacheKey?: string }; 25 | 26 | export const assetTransform = ( 27 | fn: (content: Content, ...args: Args) => T | Promise, 28 | options: Options = {} 29 | ) => { 30 | const baseKey = options.cacheKey ?? generateKey(); 31 | const encodable = options.cacheKey != null; 32 | 33 | return (content: Content, ...args: Args): T => { 34 | const key = `${baseKey}/${args.map((x) => JSON.stringify(x)).join(":")}`; 35 | 36 | const cached = cache.get(key); 37 | 38 | const addDependencies = (dependencies: Set) => { 39 | dependencies.forEach((dependency) => { 40 | content.dependencies.add(dependency); 41 | }); 42 | }; 43 | 44 | if (cached?.resolved === true) { 45 | const { output, dependencies } = cached.value; 46 | addDependencies(dependencies); 47 | return output; 48 | } else if (cached?.resolved === false) { 49 | throw cached.promise; 50 | } 51 | 52 | const emptyContent = createContentContext(); 53 | 54 | const promiseOrValue = fn(emptyContent, ...args); 55 | 56 | const resolveAndCacheOutput = (output: T) => { 57 | const dependencies = emptyContent.dependencies; 58 | 59 | addDependencies(dependencies); 60 | 61 | cache.set(key, { 62 | resolved: true, 63 | value: { output, dependencies, encodable }, 64 | }); 65 | 66 | return output; 67 | }; 68 | 69 | if (!(promiseOrValue instanceof Promise)) { 70 | return resolveAndCacheOutput(promiseOrValue); 71 | } 72 | 73 | const promise = promiseOrValue.then( 74 | (output) => { 75 | if (cache.get(key) === pendingPromiseResolution) { 76 | resolveAndCacheOutput(output); 77 | } else { 78 | throw new Error("Aborted"); 79 | } 80 | }, 81 | (e) => { 82 | console.error("Error in assetTransformer transform"); 83 | console.error(e); 84 | throw e; 85 | } 86 | ); 87 | 88 | const pendingPromiseResolution: PromiseResolution = { 89 | resolved: false, 90 | promise, 91 | }; 92 | 93 | cache.set(key, pendingPromiseResolution); 94 | 95 | throw promise; 96 | }; 97 | }; 98 | 99 | export const clearAssetTransformCacheForFile = (filename: string) => { 100 | const cacheKeys = new Set(); 101 | 102 | cache.forEach((promiseResult, cacheKey) => { 103 | const value = promiseResult.resolved ? promiseResult.value : undefined; 104 | if (value?.dependencies.has(filename) === true) { 105 | cacheKeys.add(cacheKey); 106 | } 107 | }); 108 | 109 | cacheKeys.forEach((cacheKey) => cache.delete(cacheKey)); 110 | }; 111 | 112 | export type AssetTransformCacheEntry = { 113 | output: any; 114 | dependencies: string[]; 115 | }; 116 | 117 | export type AssetTransformCache = Record; 118 | 119 | export const encodeAssetTransformCache = (): AssetTransformCache => { 120 | const output: AssetTransformCache = {}; 121 | 122 | cache.forEach((promiseResult, cacheKey) => { 123 | const value = promiseResult.resolved ? promiseResult.value : undefined; 124 | if (value != null && value.encodable) { 125 | output[cacheKey] = { 126 | output: value.output, 127 | dependencies: Array.from(value.dependencies), 128 | }; 129 | } 130 | }); 131 | 132 | return output; 133 | }; 134 | 135 | export const restoreAssetTransformCache = ( 136 | assetTransformCache: AssetTransformCache 137 | ) => { 138 | Object.entries(assetTransformCache).forEach(([cacheKey, cacheEntry]) => { 139 | const { output } = cacheEntry; 140 | const dependencies = new Set(cacheEntry.dependencies); 141 | 142 | cache.set(cacheKey, { 143 | resolved: true, 144 | value: { output, dependencies, encodable: true }, 145 | }); 146 | }); 147 | }; 148 | -------------------------------------------------------------------------------- /core/assetTransforms/index.ts: -------------------------------------------------------------------------------- 1 | export { default as transformAsset } from "./transformAsset"; 2 | export { default as transformCss } from "./transformCss"; 3 | export { default as transformHtml } from "./transformHtml"; 4 | export { default as transformJs } from "./transformJs"; 5 | export { 6 | default as transformSvg, 7 | metadata as svgMetadata, 8 | } from "./transformSvg"; 9 | -------------------------------------------------------------------------------- /core/assetTransforms/transformAsset.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import type { Content } from "../useContent"; 3 | import transformCss from "./transformCss"; 4 | import transformHtml from "./transformHtml"; 5 | import transformJs from "./transformJs"; 6 | 7 | // NB - doesn't run `transformPage` 8 | export default async (content: Content, src: string) => { 9 | const extension = path.extname(src); 10 | 11 | switch (extension) { 12 | case ".js": { 13 | const js = await transformJs(content, content.read(src), { 14 | module: true, 15 | }); 16 | return content.write(js, { extension: ".js" }); 17 | } 18 | case ".min.js": { 19 | const js = content.read(src); 20 | return content.write(js, { extension: ".js" }); 21 | } 22 | case ".css": { 23 | const css = await transformCss(content, content.read(src)); 24 | return content.write(css, { extension: ".css" }); 25 | } 26 | case ".html": { 27 | const html = await transformHtml(content, content.read(src)); 28 | return content.write(html, { extension: ".html" }); 29 | } 30 | default: 31 | return content.write(content.readBuffer(src), { extension }); 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /core/assetTransforms/transformCss.ts: -------------------------------------------------------------------------------- 1 | import postcss, { Root } from "postcss"; 2 | // @ts-expect-error 3 | import transformClasses from "postcss-transform-classes"; 4 | // @ts-expect-error 5 | import csso from "csso"; 6 | import { classNameForOrigin, cssVariable, Origin } from "../css"; 7 | import type { Content } from "../useContent"; 8 | import nestedAsync from "../util/nestedAsync"; 9 | import transformAsset from "./transformAsset"; 10 | 11 | const transformUrls = (content: Content) => { 12 | const urlRegExp = /url\(['"]?(\/[^'")]+)['"]?\)/g; 13 | 14 | return (root: Root) => 15 | nestedAsync((asyncTransform) => { 16 | root.walkDecls((decl) => { 17 | asyncTransform(async () => { 18 | const replacements = await Promise.all( 19 | Array.from(decl.value.matchAll(urlRegExp), async ([_, url]) => { 20 | const asset = await transformAsset(content, url); 21 | return `url(${asset})`; 22 | }) 23 | ); 24 | 25 | decl.value = decl.value.replace( 26 | urlRegExp, 27 | () => replacements.shift()! 28 | ); 29 | }); 30 | }); 31 | }); 32 | }; 33 | 34 | const transformVariables = 35 | ({ transform }: any) => 36 | (root: Root) => 37 | root.walkDecls((decl) => { 38 | if (decl.prop.startsWith("--")) { 39 | decl.prop = transform(decl.prop); 40 | } 41 | 42 | decl.value = decl.value.replace( 43 | /var\s*\(\s*(--[_a-z0-9-]+)\s*(?:,\s*([^)]+))?\)/gi, 44 | (_, name, fallback) => { 45 | const transformed = transform(name); 46 | return fallback 47 | ? `var(${transformed}, ${fallback})` 48 | : `var(${transformed})`; 49 | } 50 | ); 51 | }); 52 | 53 | const className = (input: string) => classNameForOrigin(input, Origin.CSS); 54 | 55 | export default async (content: Content, input: string) => { 56 | let { css } = await postcss([ 57 | transformUrls(content), 58 | transformClasses({ transform: className }), 59 | transformVariables({ transform: cssVariable }), 60 | ]).process(input, { 61 | from: undefined, 62 | }); 63 | 64 | if (process.env.NODE_ENV === "development") { 65 | return css; 66 | } 67 | 68 | css = csso.minify(css).css; 69 | 70 | return css; 71 | }; 72 | -------------------------------------------------------------------------------- /core/assetTransforms/transformHtml.tsx: -------------------------------------------------------------------------------- 1 | import posthtml from "posthtml"; 2 | // @ts-expect-error 3 | import minifier from "posthtml-minifier"; 4 | import { classNames } from "../css"; 5 | import type { Content } from "../useContent"; 6 | 7 | const transformClassNames = () => (tree: any) => 8 | tree.walk((node: any) => { 9 | if (node.attrs?.class != null) { 10 | node.attrs.class = classNames(node.attrs.class); 11 | } 12 | 13 | return node; 14 | }); 15 | 16 | export default async (_content: Content, input: string) => { 17 | const postHtmlResult = await posthtml() 18 | .use(transformClassNames()) 19 | .use(minifier({ collapseWhitespace: true, removeComments: true })) 20 | .process(input); 21 | 22 | return postHtmlResult.html; 23 | }; 24 | -------------------------------------------------------------------------------- /core/assetTransforms/transformJs.ts: -------------------------------------------------------------------------------- 1 | import { parse } from "@babel/parser"; 2 | // @ts-expect-error 3 | import traverse from "@babel/traverse"; 4 | // @ts-expect-error 5 | import generate from "@babel/generator"; 6 | import * as t from "@babel/types"; 7 | import { minify } from "terser"; 8 | import { className, cssVariable } from "../css"; 9 | import { Content } from "../useContent"; 10 | import nestedAsync from "../util/nestedAsync"; 11 | import transformAsset from "./transformAsset"; 12 | 13 | const stringHandlers: Record string> = { 14 | className, 15 | cssVariable, 16 | }; 17 | 18 | const isImportedFromCore = (path: any) => { 19 | const callee = path.get("callee"); 20 | const importSpecifier = path.scope.getBinding(callee.node.name)?.path 21 | .parentPath; 22 | 23 | return ( 24 | importSpecifier != null && 25 | importSpecifier.isImportDeclaration() && 26 | importSpecifier.get("source").isStringLiteral({ value: "super-ssg" }) 27 | ); 28 | }; 29 | 30 | export default async (content: Content, input: string, { module = false }) => { 31 | const ast = parse(input, { 32 | sourceType: "module", 33 | }); 34 | 35 | await nestedAsync((asyncTransform) => { 36 | const importsToRemove: any = []; 37 | 38 | traverse(ast, { 39 | Program: { 40 | exit() { 41 | importsToRemove.forEach((path: any) => { 42 | path.remove(); 43 | }); 44 | }, 45 | }, 46 | CallExpression(path: any) { 47 | const callee = path.get("callee"); 48 | const argument = path.get("arguments.0"); 49 | 50 | if ( 51 | callee.isMemberExpression() && 52 | callee.get("object").isIdentifier({ name: "require" }) && 53 | callee.get("property").isIdentifier({ name: "resolve" }) && 54 | argument.isStringLiteral() 55 | ) { 56 | asyncTransform(async () => { 57 | const asset = await transformAsset(content, argument.node.value); 58 | path.replaceWith(t.stringLiteral(asset)); 59 | }); 60 | } else if (callee.isImport()) { 61 | asyncTransform(async () => { 62 | const asset = await transformAsset(content, argument.node.value); 63 | argument.node.value = asset; 64 | }); 65 | } else if (callee.isIdentifier() && isImportedFromCore(path)) { 66 | const { name } = callee.node; 67 | const stringHandler = stringHandlers[name]; 68 | 69 | if (stringHandler == null) { 70 | return; 71 | } else if (!argument.isStringLiteral()) { 72 | throw new Error( 73 | `Only string literals are handled in calls to ${name} within JS assets` 74 | ); 75 | } else { 76 | const stringValue = argument.node.value; 77 | path.replaceWith(t.stringLiteral(stringHandler(stringValue))); 78 | } 79 | } 80 | }, 81 | ImportDeclaration(path: any) { 82 | const importPath = path.node.source.value; 83 | 84 | if (importPath.startsWith("/")) { 85 | asyncTransform(async () => { 86 | content.read(importPath); 87 | const asset = await transformAsset(content, importPath); 88 | path.node.source.value = asset; 89 | }); 90 | } else if (importPath === "super-ssg") { 91 | const allHandlers = Object.keys(stringHandlers); 92 | 93 | path.get("specifiers").forEach((specifier: any) => { 94 | const imported = specifier.node.imported.name; 95 | if (!allHandlers.includes(imported)) { 96 | throw new Error( 97 | `Export ${imported} is not available in JS assets` 98 | ); 99 | } 100 | }); 101 | 102 | importsToRemove.push(path); 103 | } 104 | }, 105 | }); 106 | }); 107 | 108 | let js = generate(ast).code; 109 | 110 | if (process.env.NODE_ENV === "development") { 111 | return js; 112 | } 113 | 114 | const { code } = await minify(js, { 115 | module, 116 | }); 117 | 118 | if (code == null) { 119 | throw new Error("Unknown error"); 120 | } 121 | 122 | js = code; 123 | 124 | return js; 125 | }; 126 | -------------------------------------------------------------------------------- /core/assetTransforms/transformSvg.ts: -------------------------------------------------------------------------------- 1 | import svgo from "svgo"; 2 | import { Response } from "@miniflare/core"; 3 | import { HTMLRewriter } from "@miniflare/html-rewriter"; 4 | import { Content } from "../useContent"; 5 | import transformAsset from "./transformAsset"; 6 | 7 | export const metadata = (input: string) => { 8 | const { 1: attributesString, 2: __html } = input.match( 9 | /]*)>([\W\w]*)<\/svg>/m 10 | )!; 11 | const attribteEnitries = Array.from( 12 | attributesString.matchAll(/(\w+)="([^"]*)"/g), 13 | (match) => [match[1], match[2]] 14 | ); 15 | const attributes: Record = 16 | Object.fromEntries(attribteEnitries); 17 | 18 | return { attributes, __html }; 19 | }; 20 | 21 | export default async (content: Content, input: string) => { 22 | const rewriter = new HTMLRewriter().on("use, image", { 23 | async element(element) { 24 | const href = element.getAttribute("href"); 25 | if (href?.startsWith("/") === true) { 26 | const protocol = "file://"; 27 | const url = new URL(href, protocol); 28 | url.pathname = await transformAsset(content, url.pathname); 29 | element.setAttribute("href", String(url).slice(protocol.length)); 30 | } 31 | }, 32 | }); 33 | 34 | let svg = await rewriter.transform(new Response(input)).text(); 35 | 36 | // if (process.env.NODE_ENV === "development") { 37 | // return svg; 38 | // } 39 | 40 | const output = svgo.optimize(svg, { 41 | plugins: [ 42 | "removeDoctype", 43 | "removeXMLProcInst", 44 | "removeComments", 45 | "removeMetadata", 46 | "removeEditorsNSData", 47 | "cleanupAttrs", 48 | "mergeStyles", 49 | "inlineStyles", 50 | "minifyStyles", 51 | "cleanupIDs", 52 | // "removeUselessDefs", 53 | "cleanupNumericValues", 54 | "convertColors", 55 | "removeUnknownsAndDefaults", 56 | "removeNonInheritableGroupAttrs", 57 | "removeUselessStrokeAndFill", 58 | // "removeViewBox", 59 | "cleanupEnableBackground", 60 | "removeHiddenElems", 61 | "removeEmptyText", 62 | "convertShapeToPath", 63 | "convertEllipseToCircle", 64 | "moveElemsAttrsToGroup", 65 | "moveGroupAttrsToElems", 66 | "collapseGroups", 67 | "convertPathData", 68 | "convertTransform", 69 | "removeEmptyAttrs", 70 | "removeEmptyContainers", 71 | "mergePaths", 72 | "removeUnusedNS", 73 | "sortDefsChildren", 74 | "removeTitle", 75 | "removeDesc", 76 | ], 77 | }); 78 | 79 | if (output.error != null) { 80 | throw new Error(output.error); 81 | } 82 | 83 | svg = output.data; 84 | 85 | return svg; 86 | }; 87 | -------------------------------------------------------------------------------- /core/cli/api-bridge-types.ts: -------------------------------------------------------------------------------- 1 | import type { API } from "./api-direct"; 2 | 3 | export enum Status { 4 | Ready = "Ready", 5 | } 6 | 7 | export type StatusMessage = { type: Status; payload: null }; 8 | export type IpcMessage = { type: keyof API; payload: any }; 9 | export type AnyMessage = StatusMessage | IpcMessage; 10 | -------------------------------------------------------------------------------- /core/cli/api-bridge.ts: -------------------------------------------------------------------------------- 1 | import type { ChildProcess } from "child_process"; 2 | import { fork } from "child_process"; 3 | import type { API } from "./api-direct"; 4 | import type { AnyMessage } from "./api-bridge-types"; 5 | import { Status } from "./api-bridge-types"; 6 | 7 | type WorkItem = { 8 | type: keyof API; 9 | payload: any; 10 | res: (arg: any) => void; 11 | rej: (arg: Error) => void; 12 | }; 13 | 14 | let inProgress: WorkItem | undefined; 15 | let queue: WorkItem[] = []; 16 | let worker: ChildProcess | undefined; 17 | let workerReady = false; 18 | 19 | const abortWorkItems = () => { 20 | inProgress?.rej(new Error("Aborted")); 21 | 22 | queue.forEach((queueItem) => { 23 | queueItem.rej(new Error("Aborted")); 24 | }); 25 | 26 | queue = []; 27 | }; 28 | 29 | const runInProgressWorkIfNeeded = () => { 30 | if (inProgress !== undefined && workerReady) { 31 | const { type, payload } = inProgress; 32 | worker?.send({ type, payload }); 33 | } 34 | }; 35 | 36 | const scheduleWorkItem = (work: WorkItem) => { 37 | if (inProgress === undefined) { 38 | inProgress = work; 39 | runInProgressWorkIfNeeded(); 40 | } else { 41 | queue.push(work); 42 | } 43 | }; 44 | 45 | const handleMessage = ({ type, payload }: AnyMessage) => { 46 | if (type === Status.Ready) { 47 | workerReady = true; 48 | runInProgressWorkIfNeeded(); 49 | return; 50 | } 51 | 52 | if (inProgress === undefined) { 53 | console.warn("Unhandled message"); 54 | return; 55 | } else if (inProgress.type !== type) { 56 | inProgress.rej(new Error(`Invalid message type of "${type}" received`)); 57 | inProgress = undefined; 58 | abortWorkItems(); 59 | return; 60 | } 61 | 62 | inProgress.res(payload); 63 | inProgress = undefined; 64 | 65 | const nextWorkItem = queue.shift(); 66 | if (nextWorkItem !== undefined) { 67 | scheduleWorkItem(nextWorkItem); 68 | } 69 | }; 70 | 71 | const workerModulePath = require.resolve("./api-worker.js"); 72 | 73 | export const startWorker = () => { 74 | if (worker !== undefined) { 75 | throw new Error("Worker already exists"); 76 | } 77 | 78 | worker = fork(workerModulePath, { 79 | env: { ...process.env, NODE_ENV: "development" }, 80 | }); 81 | workerReady = false; 82 | worker.on("message", handleMessage); 83 | worker.on("close", () => { 84 | worker = undefined; 85 | workerReady = false; 86 | }); 87 | 88 | runInProgressWorkIfNeeded(); 89 | }; 90 | 91 | export const terminateWorker = () => { 92 | abortWorkItems(); 93 | 94 | if (worker !== undefined) { 95 | worker.removeAllListeners(); 96 | worker.kill(); 97 | worker = undefined; 98 | workerReady = false; 99 | } 100 | }; 101 | 102 | export const restartWorker = () => { 103 | terminateWorker(); 104 | startWorker(); 105 | }; 106 | 107 | startWorker(); 108 | 109 | type WorkerFunction< 110 | T extends keyof API, 111 | Input = Parameters[0], 112 | Output = ReturnType 113 | > = Parameters[0] extends undefined 114 | ? () => Output 115 | : (payload: Input) => Output; 116 | 117 | const createWorkerFunction = ( 118 | type: T 119 | ): WorkerFunction => { 120 | const fn = (payload: any = null) => { 121 | return new Promise((res, rej) => { 122 | scheduleWorkItem({ type, payload, res, rej }); 123 | }); 124 | }; 125 | 126 | return fn as any; 127 | }; 128 | 129 | const api: API = { 130 | renderPage: createWorkerFunction("renderPage"), 131 | encodeAssetTransformCache: createWorkerFunction("encodeAssetTransformCache"), 132 | restoreAssetTransformCache: createWorkerFunction( 133 | "restoreAssetTransformCache" 134 | ), 135 | clearAssetTransformCacheForFiles: createWorkerFunction( 136 | "clearAssetTransformCacheForFiles" 137 | ), 138 | generateCssStats: createWorkerFunction("generateCssStats"), 139 | resetCssStats: createWorkerFunction("resetCssStats"), 140 | }; 141 | 142 | export default api; 143 | -------------------------------------------------------------------------------- /core/cli/api-direct.ts: -------------------------------------------------------------------------------- 1 | import type { AssetTransformCache } from "../assetTransformer"; 2 | import { 3 | clearAssetTransformCacheForFile, 4 | encodeAssetTransformCache, 5 | restoreAssetTransformCache, 6 | } from "../assetTransformer"; 7 | import type { Page, ResolvedConfig } from "../config"; 8 | import { generateCssStats, resetCssStats } from "../css"; 9 | import renderPage from "../renderPage"; 10 | 11 | const isUsingJsModule = (filename: string) => { 12 | try { 13 | const moduleId = require.resolve(filename); 14 | return require.cache[moduleId] != null; 15 | } catch { 16 | return false; 17 | } 18 | }; 19 | 20 | export type API = { 21 | renderPage: (opts: { 22 | page: Page; 23 | pages: ResolvedConfig["pages"]; 24 | }) => Promise<{ dependencies: string[] }>; 25 | encodeAssetTransformCache: () => Promise; 26 | restoreAssetTransformCache: ( 27 | assetTransformCache: AssetTransformCache 28 | ) => Promise; 29 | clearAssetTransformCacheForFiles: ( 30 | filenames: string[] 31 | ) => Promise<{ jsModulesInvalidated: boolean }>; 32 | generateCssStats: () => Promise<{ 33 | unusedClassNames: string[]; 34 | undeclaredClassNames: string[]; 35 | }>; 36 | resetCssStats: () => Promise; 37 | }; 38 | 39 | const api: API = { 40 | async renderPage({ page, pages }) { 41 | const data = await renderPage({ page, pages }); 42 | const dependencies = Array.from(data.dependencies); 43 | return { dependencies }; 44 | }, 45 | async encodeAssetTransformCache() { 46 | const assetTransformCache = encodeAssetTransformCache(); 47 | return assetTransformCache; 48 | }, 49 | async restoreAssetTransformCache(assetTransformCache) { 50 | restoreAssetTransformCache(assetTransformCache); 51 | }, 52 | async clearAssetTransformCacheForFiles(filenames) { 53 | filenames.forEach(clearAssetTransformCacheForFile); 54 | const jsModulesInvalidated = filenames.some(isUsingJsModule); 55 | return { jsModulesInvalidated }; 56 | }, 57 | async generateCssStats() { 58 | const stats = generateCssStats(); 59 | const unusedClassNames = Array.from(stats.unusedClassNames); 60 | const undeclaredClassNames = Array.from(stats.undeclaredClassNames); 61 | return { unusedClassNames, undeclaredClassNames }; 62 | }, 63 | async resetCssStats() { 64 | resetCssStats(); 65 | }, 66 | }; 67 | 68 | export default api; 69 | -------------------------------------------------------------------------------- /core/cli/api-worker.js: -------------------------------------------------------------------------------- 1 | require("@babel/register")({ 2 | extensions: [".js", ".ts", ".tsx"], 3 | }); 4 | 5 | require("./api-worker.ts"); 6 | -------------------------------------------------------------------------------- /core/cli/api-worker.ts: -------------------------------------------------------------------------------- 1 | import type { IpcMessage } from "./api-bridge-types"; 2 | import { Status } from "./api-bridge-types"; 3 | import api from "./api-direct"; 4 | import "./register"; 5 | 6 | let queue = Promise.resolve(); 7 | 8 | process.send!({ type: Status.Ready, payload: null }); 9 | 10 | process.on("message", (message: IpcMessage) => { 11 | queue = queue.then(async () => { 12 | try { 13 | const { type } = message; 14 | const payload: any = (await api[type](message.payload)) ?? null; 15 | process.send!({ type, payload }); 16 | } catch (e) { 17 | console.error(e); 18 | process.exit(1); 19 | } 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /core/cli/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import chalk from "chalk"; 4 | import chokidar from "chokidar"; 5 | import * as fs from "fs"; 6 | import * as path from "path"; 7 | import { cwd } from "process"; 8 | import type { Page } from "../config"; 9 | import { API } from "./api-direct"; 10 | import { 11 | buildAllPages, 12 | buildPages, 13 | clearCachesForFiles, 14 | refetchAndBuildPages, 15 | } from "./pageBuilder"; 16 | 17 | const mode = process.argv[2] === "watch" ? "watch" : "build"; 18 | 19 | /** 20 | * API-Direct.ts just calls into the API, then transforms the return value into 21 | * something JSON serializable 22 | * 23 | * API-Bridge runs a worker thread (API-Worker), which does the actual work 24 | * 25 | * These two files have a default export with the same type 26 | * 27 | * Running on a worker is not so much for performance - but rather to be able 28 | * to restart the instance at any point should some JS change 29 | * 30 | * In the single build mode, we use API directly and avoid the worker thread. 31 | * In watch mode, we use the worker thread. The reason we don't run the worker 32 | * thread all the time is because we use the worker as an opportunity to set 33 | * NODE_ENV=development 34 | */ 35 | const api: API = 36 | mode === "build" 37 | ? require("./api-direct").default 38 | : require("./api-bridge").default; 39 | 40 | // Number here is kind of random 41 | // The expensive asset transforms happen in native code on another thread 42 | // so the larger this number is, the more these transforms happen concurrently 43 | // But there's also overhead from the extra bookkeeping React has to do 44 | const concurrentLimit = mode === "build" ? 6 : 1; 45 | 46 | const projectPath = cwd(); 47 | 48 | const sitePath = path.join(projectPath, "site"); 49 | const clearSiteFolder = () => { 50 | fs.rmSync(sitePath, { recursive: true, force: true }); 51 | fs.mkdirSync(sitePath); 52 | }; 53 | 54 | clearSiteFolder(); 55 | 56 | const logBuildPage = (page: Page) => { 57 | console.log(`- Building ${page.filename}`); 58 | }; 59 | 60 | const logDuration = (duration: number) => { 61 | const durationSeconds = (duration / 1000).toFixed(2); 62 | console.log(chalk.green(`[Build completed in ${durationSeconds}s]`)); 63 | }; 64 | 65 | const runFullBuild = async () => { 66 | console.log(chalk.dim("[Building site...]")); 67 | 68 | const { duration, cssStats } = await buildAllPages( 69 | api, 70 | concurrentLimit, 71 | logBuildPage 72 | ); 73 | 74 | logDuration(duration); 75 | 76 | const logIfNotEmpty = (array: string[], message: string) => { 77 | if (array.length > 0) { 78 | console.warn(chalk.yellow(message)); 79 | console.warn(array.join(", ")); 80 | } 81 | }; 82 | 83 | logIfNotEmpty( 84 | cssStats.unusedClassNames, 85 | "The following classes were defined in CSS, but never used in any non-CSS files:" 86 | ); 87 | logIfNotEmpty( 88 | cssStats.undeclaredClassNames, 89 | "The following classes used one more non-CSS files, but never defined in CSS:" 90 | ); 91 | }; 92 | 93 | let queue = Promise.resolve(); 94 | 95 | const queueAsync = (fn: () => Promise | void) => { 96 | queue = queue.then(fn); 97 | }; 98 | 99 | queueAsync(runFullBuild); 100 | 101 | if (mode !== "build") { 102 | const { restartWorker, terminateWorker } = require("./api-bridge"); 103 | 104 | queueAsync(() => { 105 | console.log(chalk.dim("[Watching for changes site...]")); 106 | }); 107 | 108 | /* Start server */ 109 | const portArgIndex = process.argv.indexOf("--port"); 110 | const port = 111 | portArgIndex !== -1 ? Number(process.argv[portArgIndex + 1]) : 8080; 112 | 113 | console.log( 114 | chalk.whiteBright.bgYellow(`[Dev mode - listening on port ${port}]`) 115 | ); 116 | const httpServer = require("http-server"); 117 | httpServer 118 | .createServer({ 119 | root: sitePath, 120 | cache: -1, 121 | }) 122 | .listen(port, "0.0.0.0"); 123 | 124 | /* Watch for file changes */ 125 | const runRebuild = async (filesToRebuild: string[]) => { 126 | const { invalidatedPages, jsModulesInvalidated } = 127 | await clearCachesForFiles(api, filesToRebuild); 128 | 129 | if (jsModulesInvalidated) { 130 | const assetTransformCache = await api.encodeAssetTransformCache(); 131 | restartWorker(); 132 | await api.restoreAssetTransformCache(assetTransformCache); 133 | } 134 | 135 | if (invalidatedPages.size > 0) { 136 | console.log(chalk.dim(`[Partial rebuild...]`)); 137 | const { duration } = await buildPages( 138 | api, 139 | invalidatedPages, 140 | concurrentLimit, 141 | logBuildPage 142 | ); 143 | logDuration(duration); 144 | } 145 | }; 146 | 147 | const runRefetchAndRebuildPagesIfNeeded = (filename: string) => { 148 | if (filename.endsWith(".mdx")) { 149 | queueAsync(async () => { 150 | await refetchAndBuildPages(api, concurrentLimit, logBuildPage); 151 | }); 152 | } 153 | }; 154 | 155 | let changedFiles: string[] = []; 156 | let queueRebuildTimeout: NodeJS.Timeout | undefined; 157 | chokidar 158 | .watch(projectPath, { 159 | ignored: [path.join(projectPath, "node_modules"), sitePath], 160 | }) 161 | .on("add", runRefetchAndRebuildPagesIfNeeded) 162 | .on("unlink", runRefetchAndRebuildPagesIfNeeded) 163 | .on("change", (filename: string) => { 164 | changedFiles.push(filename); 165 | 166 | clearTimeout(queueRebuildTimeout!); 167 | queueRebuildTimeout = setTimeout(() => { 168 | const filesToRebuild = changedFiles; 169 | changedFiles = []; 170 | 171 | queueAsync(() => runRebuild(filesToRebuild)); 172 | }, 50); 173 | }); 174 | 175 | /* Watch for `r` input to trigger rebuild */ 176 | const stdin = process.stdin; 177 | stdin.setRawMode(true); 178 | stdin.setEncoding("utf8"); 179 | stdin.on("data", (key: string) => { 180 | if (key === "\u0003") { 181 | terminateWorker(); 182 | process.exit(); 183 | } else if (key.trim() == "r") { 184 | queueAsync(async () => { 185 | console.log(chalk.whiteBright.bgGray("[Cache cleared...]")); 186 | restartWorker(); 187 | await runFullBuild(); 188 | }); 189 | } 190 | }); 191 | } 192 | -------------------------------------------------------------------------------- /core/cli/getConfigPages.ts: -------------------------------------------------------------------------------- 1 | import glob from "glob"; 2 | import path from "path"; 3 | import type { Config, Page } from "../config"; 4 | import { projectPath } from "../config"; 5 | import castArray from "../util/castArray"; 6 | import "./register"; 7 | 8 | export default ({ pages, urlForPage }: Config): Page[] => { 9 | const pageFilenames = new Set( 10 | castArray(pages).flatMap((fileGlob) => { 11 | return glob.sync(fileGlob, { cwd: projectPath }); 12 | }) 13 | ); 14 | 15 | return Array.from(pageFilenames, (absolutePath): Page => { 16 | const filename = "/" + path.relative(projectPath, absolutePath); 17 | const extname = path.extname(absolutePath); 18 | const url = urlForPage?.(filename) ?? filename.slice(0, -extname.length); 19 | 20 | if (!url.startsWith("/")) { 21 | throw new Error("Page urls must start with a `/`"); 22 | } 23 | 24 | return { filename, url }; 25 | }); 26 | }; 27 | -------------------------------------------------------------------------------- /core/cli/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require("@babel/register")({ 4 | extensions: [".js", ".ts", ".tsx"], 5 | }); 6 | 7 | require("./index.ts"); 8 | -------------------------------------------------------------------------------- /core/cli/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import "./register"; 4 | import "./cli"; 5 | -------------------------------------------------------------------------------- /core/cli/pageBuilder.ts: -------------------------------------------------------------------------------- 1 | import { getConfig, Page } from "../config"; 2 | import { subtract } from "../util/set"; 3 | import type { API } from "./api-direct"; 4 | import getConfigPages from "./getConfigPages"; 5 | 6 | const getAllPages = () => { 7 | const config = getConfig({ require }); 8 | const allPages = getConfigPages(config); 9 | return new Set(allPages); 10 | }; 11 | 12 | let allPages = getAllPages(); 13 | let pageDependencies = new Map(); 14 | 15 | export const refetchAndBuildPages = async ( 16 | api: API, 17 | concurrentLimit: number, 18 | logger: (page: Page) => void 19 | ) => { 20 | const nextPages = getAllPages(); 21 | 22 | const oldFilenames = new Set(Array.from(allPages, (page) => page.filename)); 23 | const newFilenames = new Set(Array.from(nextPages, (page) => page.filename)); 24 | 25 | const hasChanges = 26 | subtract(oldFilenames, newFilenames).size !== 0 || 27 | subtract(newFilenames, oldFilenames).size !== 0; 28 | 29 | if (!hasChanges) { 30 | return; 31 | } 32 | 33 | allPages = nextPages; 34 | pageDependencies = new Map(); 35 | // Must rebuild all pages in case any use useTableOfContents 36 | buildAllPages(api, concurrentLimit, logger); 37 | }; 38 | 39 | export const buildPages = async ( 40 | api: API, 41 | pagesToBuild: Set, 42 | concurrentLimit: number, 43 | logger: (page: Page) => void 44 | ) => { 45 | const start = Date.now(); 46 | 47 | // Preserve imports to work with node imports 48 | const { default: pAll } = await eval(`import("p-all")`); 49 | 50 | const pages = Array.from(allPages); 51 | await pAll( 52 | Array.from(pagesToBuild, (page) => async () => { 53 | logger(page); 54 | 55 | const { dependencies } = await api.renderPage({ 56 | page, 57 | pages, 58 | }); 59 | 60 | pageDependencies.set(page, dependencies); 61 | }), 62 | { concurrency: concurrentLimit } 63 | ); 64 | 65 | const end = Date.now(); 66 | const duration = end - start; 67 | 68 | return { duration }; 69 | }; 70 | 71 | export const buildAllPages = async ( 72 | api: API, 73 | concurrentLimit: number, 74 | logger: (page: Page) => void 75 | ) => { 76 | api.resetCssStats(); 77 | 78 | const { duration } = await buildPages(api, allPages, concurrentLimit, logger); 79 | 80 | const cssStats = await api.generateCssStats(); 81 | 82 | return { duration, cssStats }; 83 | }; 84 | 85 | export const clearCachesForFiles = async ( 86 | api: API, 87 | filenames: string[] 88 | ): Promise<{ invalidatedPages: Set; jsModulesInvalidated: boolean }> => { 89 | const invertedPageDependencies = new Map>(); 90 | pageDependencies.forEach((dependencies, page) => { 91 | dependencies.forEach((dependency) => { 92 | let pages = invertedPageDependencies.get(dependency); 93 | if (pages == null) { 94 | pages = new Set(); 95 | invertedPageDependencies.set(dependency, pages); 96 | } 97 | pages.add(page); 98 | }); 99 | }); 100 | 101 | const invalidatedPages = new Set(); 102 | filenames.forEach((filename) => { 103 | invertedPageDependencies.get(filename)?.forEach((page) => { 104 | invalidatedPages.add(page); 105 | }); 106 | }); 107 | 108 | const { jsModulesInvalidated } = await api.clearAssetTransformCacheForFiles( 109 | filenames 110 | ); 111 | 112 | return { invalidatedPages, jsModulesInvalidated }; 113 | }; 114 | -------------------------------------------------------------------------------- /core/cli/register.ts: -------------------------------------------------------------------------------- 1 | require("@babel/register")({ 2 | extensions: [".js", ".ts", ".tsx"], 3 | }); 4 | -------------------------------------------------------------------------------- /core/components/A.tsx: -------------------------------------------------------------------------------- 1 | import { AnchorHTMLAttributes } from "react"; 2 | import { ClassNames, classNames } from "../css"; 3 | import { useTableOfContents } from "../useTableOfContents"; 4 | 5 | type Props = Omit, "className"> & { 6 | className?: ClassNames; 7 | }; 8 | 9 | export default (props: Props) => { 10 | const contents = useTableOfContents(); 11 | 12 | let href: string | undefined; 13 | if (props.href?.startsWith("/")) { 14 | const filename = decodeURIComponent(props.href); 15 | const page = contents.find((page) => page.filename === filename); 16 | 17 | if (page == null) { 18 | throw new Error(`Could not page with href "${filename}"`); 19 | } 20 | 21 | href = page.url; 22 | } else { 23 | href = props.href; 24 | } 25 | 26 | return ; 27 | }; 28 | -------------------------------------------------------------------------------- /core/components/Code.tsx: -------------------------------------------------------------------------------- 1 | import hljs from "highlight.js"; 2 | import { classNames } from "../css"; 3 | 4 | export default (props: any) => { 5 | if (props.className != null) { 6 | const language = props.className.match(/language-([^\s]+)/)[1]; 7 | let __html = hljs.highlight(props.children, { language }).value; 8 | __html = __html.replace(/class="([^"]+)"/g, (_fullMatch, className) => { 9 | return `class="${classNames(className)}"`; 10 | }); 11 | return ; 12 | } else { 13 | return {props.children}; 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /core/components/ExternalCss.tsx: -------------------------------------------------------------------------------- 1 | import { assetTransform } from "../assetTransformer"; 2 | import { transformCss } from "../assetTransforms"; 3 | import useContent from "../useContent"; 4 | 5 | const transform = assetTransform( 6 | async (content, src) => { 7 | const input = Array.isArray(src) 8 | ? src.map(content.read).join("\n") 9 | : content.read(src); 10 | const output = await transformCss(content, input); 11 | return content.write(output, { extension: ".css" }); 12 | }, 13 | { cacheKey: "core/ExternalCss" } 14 | ); 15 | 16 | type Props = { 17 | src: string | string[]; 18 | }; 19 | 20 | export default ({ src }: Props) => { 21 | const content = useContent(); 22 | return ; 23 | }; 24 | -------------------------------------------------------------------------------- /core/components/ExternalJs.tsx: -------------------------------------------------------------------------------- 1 | import type { ScriptHTMLAttributes } from "react"; 2 | import { assetTransform } from "../assetTransformer"; 3 | import { transformJs } from "../assetTransforms"; 4 | import useContent from "../useContent"; 5 | 6 | const transform = assetTransform( 7 | async (content, src, options) => { 8 | const output = await transformJs(content, content.read(src), options); 9 | return content.write(output, { extension: ".js" }); 10 | }, 11 | { cacheKey: "core/ExternalJs" } 12 | ); 13 | 14 | type Props = Omit, "src"> & { 15 | src: string; 16 | }; 17 | 18 | export default (props: Props) => { 19 | const content = useContent(); 20 | const module = props.type === "module"; 21 | return