Creating JavaScript examples from TypeScript in Cloudflare Docs
In our developer documentation, especially for Workers and other Developer Platform products, we write out JavaScript and TypeScript examples separately inside tabs.
<Tabs><TabItem label="JavaScript">```jsconst foo = "bar";```</TabItem><TabItem label="TypeScript">```ts// Yes, inference can do this - but take it as an example as TS-specific syntax!const foo: string = "bar";```</TabItem></Tabs>
For larger examples, this leads to a lot of duplication. Most code hasn’t changed and relies on inference, but we’re making sure TypeScript knows our default export is an ExportedHandler
and passing our bindings shape.
TypeScript is usually the easier one to write, your IDE can do a lot more work for you in intellisense and catching bugs, but we need JavaScript for folks who are using Quick Edit or the Workers playground. Are we condemned to writing both? No!
It’s using tsc
, right?
Nope! It’s detype
.
The README for this library talks a little bit about why tsc
isn’t suitable for our use-case:
There are lots of tools for transpiling TypeScript into plain JavaScript (tsc, babel, swc, esbuild, sucrase etc.) but none of them is perfectly suitable for this specific use case. Most of them don’t preserve the formatting at all. sucrase comes close, but it doesn’t remove comments attached to TypeScript-only constructs.
To visualise this, let’s take a pretty standard TypeScript Worker and run it through detype and tsc .
interface Environment { KV: KVNamespace;}
export default { async fetch(req, env, ctx): Promise<Response> { if (req !== "POST") { return new Response("Method Not Allowed", { status: 405, headers: { "Allow": "POST" } }); }
await env.KV.put("foo", "bar");
return new Response(); }} satisfies ExportedHandler<Environment>
export default { async fetch(req, env, ctx) { if (req !== "POST") { return new Response("Method Not Allowed", { status: 405, headers: { "Allow": "POST" } }); } await env.KV.put("foo", "bar"); return new Response(); }};
export default { async fetch(req, env, ctx) { if (req !== "POST") { return new Response("Method Not Allowed", { status: 405, headers: { Allow: "POST", }, }); }
await env.KV.put("foo", "bar");
return new Response(); },};
tsc
removes our newlines, but detype
doesn’t. This isn’t just that detype
runs Prettier for you automatically, since Prettier wouldn’t add in those newlines again anyways!
How do we implement this into an Astro component?
Since Starlight uses Expressive Code which stores the original code as a data attribute for the “Copy to clipboard” button, we can:
- Render the TypeScript code block from the default slot.
- Use
node-html-parser
to select the copy button. - Pull the original code from the
data-code
attribute. - Replace the newline placeholder (
\u007f
) with\n
. - Pass the TypeScript to detype’s transform function.
- Use Starlight’s Tabs and Code components to render our examples!
---import { transform } from "detype";import { parse } from "node-html-parser";import { Code, Tabs, TabItem } from "~/components";
const slot = await Astro.slots.render("default");
const html = parse(slot);
const copy = html.querySelector("div.copy > button");
if (!copy) { throw new Error(`[TypeScriptExample] Unable to find copy button in rendered code block HTML.`);}
let code = copy.attributes["data-code"];
if (!code) { throw new Error(`[TypeScriptExample] Unable to find data-code attribute on copy button.`)}
code = code.replace(/\u007f/g, '\n');
const js = await transform(code, "placeholder.ts");---
<Tabs> <TabItem label="JavaScript" icon="seti:javascript"> <Code lang="js" code={js} /> </TabItem> <TabItem label="TypeScript" icon="seti:typescript"> <Code lang="ts" code={code} /> </TabItem></Tabs>
It’s worth noting that since I wrote this blog, we have moved to the ts-blank-space
package by Bloomberg.