Files
Hayden Bleasel 0b80aed583 Switch to plugin architecture (#352)
* Plugin trial

* Update docs and tests

* Update homepage add new section

* Update usage.tsx

* Split out CJK plugin

* Extract plugins into new packages

* Misc fixes

* Fix lint / formatting

* Fix: The Streamdown component's memo comparison doesn't compare the plugins prop, preventing re-renders when plugins change while other props stay the same

Co-authored-by: haydenbleasel <hello@haydenbleasel.com>

* Fix: TypeScript type mismatch at line 89 in `index.ts`: the visitor function's `index` parameter is typed as `number | undefined`, but the `visit` function expects `number | null`. The `undefined` type is not assignable to `number | null`.

This commit fixes the issue reported at /vercel/path0/packages/streamdown-cjk/index.ts:89

## TypeScript type incompatibility in visitor callback

**What fails:** TypeScript compiler fails during DTS (type definitions) build in `packages/streamdown-cjk/index.ts` at line 89

**How to reproduce:**
```bash
cd packages/streamdown-cjk
pnpm build
```

**Result:**
```
index.ts(89,3): error TS2769: No overload matches this call.
  Overload 1 of 2, '(tree: Root, check: "link", visitor: BuildVisitor<Root, "link">, reverse?: boolean | null | undefined): undefined', gave the following error.
    Argument of type '(node: Link, index: number | null, parent?: Parent) => number | undefined' is not assignable to parameter of type 'BuildVisitor<Root, "link">'.
      Types of parameters 'index' and 'index' are incompatible.
        Type 'number | undefined' is not assignable to type 'number | null'.
          Type 'undefined' is not assignable to type 'number | null'.
```

**Root cause:** The visitor callback function passed to the `visit()` function from `unist-util-visit` was declaring the `index` parameter as `number | null`, but the visitor can also receive `undefined` as the index value. The `BuildVisitor` type signature expects the callback to accept `number | null | undefined` to properly match the library's API.

**Fix:** Updated the `index` parameter type annotation on line 89 from `number | null` to `number | null | undefined` to match the actual possible values that the visitor receives and the expected `BuildVisitor` type signature.

Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
Co-authored-by: haydenbleasel <hello@haydenbleasel.com>

* Fix tests

* Remove CDN paths

* Fix tests

* More cleanup

* Remove shiki from main bundle

* Update index.ts

* Cleanup test repo

* Cleanup website

* Delete next-env files

* Update .gitignore

* Fix lockfile

* Upgrade Next and React

* Simplify themes

* Update test site

* Misc fixes

* Type fixes

* Create perky-cobras-punch.md

---------

Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
2026-01-17 14:38:20 -08:00

116 lines
3.2 KiB
JavaScript

import { readFileSync, unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { gzipSync } from "node:zlib";
import { build } from "esbuild";
async function analyzeBundle(name, entryPoint, external) {
const outfile = join(tmpdir(), `streamdown-${name}.js`);
const result = await build({
entryPoints: [entryPoint],
bundle: true,
minify: true,
format: "esm",
outfile,
external,
loader: {
".woff2": "empty",
".woff": "empty",
".ttf": "empty",
".css": "empty",
},
metafile: true,
logLevel: "silent",
});
const outputKey = Object.keys(result.metafile.outputs)[0];
const totalBytes = result.metafile.outputs[outputKey]?.bytes || 0;
// Get gzipped size
const bundleContent = readFileSync(outfile);
const gzippedBytes = gzipSync(bundleContent).length;
// Cleanup
try {
unlinkSync(outfile);
} catch {
// ignore
}
return { totalBytes, gzippedBytes, metafile: result.metafile };
}
function formatSize(bytes) {
if (bytes >= 1024 * 1024) {
return `${(bytes / 1024 / 1024).toFixed(2)}MB`;
}
return `${(bytes / 1024).toFixed(0)}KB`;
}
async function main() {
console.log("\n=== Streamdown Bundle Analysis ===\n");
// Core bundle (what users get with just `import { Streamdown } from 'streamdown'`)
console.log("1. CORE (no plugins) - externals: react, shiki, mermaid, katex");
const core = await analyzeBundle("core", "dist/index.js", [
"react",
"react-dom",
"shiki",
"mermaid",
"katex",
"rehype-katex",
"remark-math",
]);
console.log(` Minified: ${formatSize(core.totalBytes)}`);
console.log(` Gzipped: ${formatSize(core.gzippedBytes)}`);
// Shiki plugin only
console.log("\n2. SHIKI PLUGIN - externals: react, shiki");
const shikiPlugin = await analyzeBundle(
"shiki-plugin",
"dist/plugins/shiki/index.js",
["react", "react-dom", "shiki"]
);
console.log(` Minified: ${formatSize(shikiPlugin.totalBytes)}`);
console.log(` Gzipped: ${formatSize(shikiPlugin.gzippedBytes)}`);
// Full bundle with shiki bundled (what Cloudflare would see)
console.log("\n3. CORE + SHIKI BUNDLED (Cloudflare scenario)");
const withShiki = await analyzeBundle("with-shiki", "dist/index.js", [
"react",
"react-dom",
"mermaid",
"katex",
"rehype-katex",
"remark-math",
]);
console.log(` Minified: ${formatSize(withShiki.totalBytes)}`);
console.log(` Gzipped: ${formatSize(withShiki.gzippedBytes)}`);
console.log(
" Cloudflare Workers limit: 1MB compressed (free) / 10MB (paid)"
);
// Top dependencies in full bundle
console.log("\n4. TOP 15 LARGEST FILES (in full bundle):\n");
const inputs = Object.entries(withShiki.metafile.inputs)
.map(([path, data]) => ({ path, bytes: data.bytes }))
.sort((a, b) => b.bytes - a.bytes)
.slice(0, 15);
for (const { path, bytes } of inputs) {
const shortPath = path.replace(
/node_modules\/\.pnpm\/[^/]+\/node_modules\//g,
""
);
console.log(` ${formatSize(bytes).padStart(8)} - ${shortPath}`);
}
console.log("");
}
main().catch((err) => {
console.error("Bundle analysis failed:", err.message);
process.exit(1);
});