@runbun/astro 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +33 -2
- package/components/Code.astro +37 -0
- package/components/Playground.astro +51 -0
- package/dist/components.d.ts +2 -0
- package/dist/components.js +2 -0
- package/dist/config.d.ts +10 -0
- package/dist/config.js +6 -0
- package/dist/highlight.d.ts +30 -0
- package/dist/highlight.js +119 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +31 -0
- package/dist/markup.d.ts +38 -0
- package/dist/markup.js +71 -0
- package/dist/remark.d.ts +12 -0
- package/dist/remark.js +97 -0
- package/dist/route.d.ts +2 -0
- package/dist/route.js +4 -0
- package/package.json +27 -4
- package/virtual.d.ts +4 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Stefan Kopco
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,34 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @runbun/astro
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Code blocks for Astro 7, highlighted at build time with [@pierre/highlights](https://diffs.com/highlights), that turn
|
|
4
|
+
into a runnable Bun playground ([runbun.dev](https://runbun.dev)) when a reader clicks Run.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
bun add @runbun/astro
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```js
|
|
11
|
+
// astro.config.mjs
|
|
12
|
+
import { unified } from "@astrojs/markdown-remark";
|
|
13
|
+
import runbun from "@runbun/astro";
|
|
14
|
+
import remarkRunbun from "@runbun/astro/remark";
|
|
15
|
+
|
|
16
|
+
export default defineConfig({
|
|
17
|
+
integrations: [runbun()],
|
|
18
|
+
markdown: { processor: unified({ remarkPlugins: [remarkRunbun] }) },
|
|
19
|
+
});
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```mdx
|
|
23
|
+
import { Code } from "@runbun/astro/components";
|
|
24
|
+
|
|
25
|
+
<Code lang="ts" title="index.ts" code={`console.log(Bun.version);`} />
|
|
26
|
+
<Code playground mode="code" title="index.ts" code={`console.log(Bun.version);`} />
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
In Markdown, a ` ```ts playground ` fence does the same. Options, themes and fences:
|
|
30
|
+
[docs.runbun.dev/guides/astro](https://docs.runbun.dev/guides/astro/).
|
|
31
|
+
|
|
32
|
+
The package is MIT-licensed (see LICENSE). The playground it embeds is a hosted service at runbun.dev with its own terms.
|
|
33
|
+
|
|
34
|
+
runbun is an independent project, not affiliated with or endorsed by Oven or Bun.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
// A code block highlighted at build time with @pierre/highlights in the page's code theme. Static by default; with
|
|
3
|
+
// `playground` it gets a Run button that turns it into an embed (Playground.astro) with the code as its file, and
|
|
4
|
+
// every embed option passes through. `title` names the file, as in a fence. With `files` the block has a tab per file,
|
|
5
|
+
// and so does the embed.
|
|
6
|
+
import { config } from "virtual:@runbun/astro/config";
|
|
7
|
+
import { codeBlock, codeTabs, cssLink, EXT, langOf, withEntry, withTabs } from "../dist/markup.js";
|
|
8
|
+
import Playground, { type Props as PlaygroundProps } from "./Playground.astro";
|
|
9
|
+
|
|
10
|
+
export type Props = Omit<PlaygroundProps, "title"> & {
|
|
11
|
+
code: string;
|
|
12
|
+
lang?: string;
|
|
13
|
+
title?: string;
|
|
14
|
+
playground?: boolean;
|
|
15
|
+
};
|
|
16
|
+
const { code, lang = "ts", title, playground, files = {}, expand = "full", features, ...options } = Astro.props;
|
|
17
|
+
const file = title || `index.${EXT[lang] ?? lang}`;
|
|
18
|
+
const extra = Object.entries(files).map(([name, text]) => ({ name, code: text, lang: langOf(name) }));
|
|
19
|
+
const block = extra.length ? codeTabs([{ name: file, code, lang }, ...extra], options.file ?? file) : codeBlock(code, lang, title);
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
<Fragment set:html={cssLink(config.base)} />
|
|
23
|
+
{
|
|
24
|
+
playground ? (
|
|
25
|
+
<Playground
|
|
26
|
+
file={file}
|
|
27
|
+
{...options}
|
|
28
|
+
features={extra.length ? withTabs(features) : features}
|
|
29
|
+
expand={expand}
|
|
30
|
+
files={withEntry({ [file]: code, ...files }, file)}
|
|
31
|
+
>
|
|
32
|
+
<Fragment set:html={block} />
|
|
33
|
+
</Playground>
|
|
34
|
+
) : (
|
|
35
|
+
<Fragment set:html={block} />
|
|
36
|
+
)
|
|
37
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
---
|
|
2
|
+
// A runnable embed. The default slot is the poster (shown until the embed is ready); `files` become inline sources.
|
|
3
|
+
// In Markdown, ```ts playground fences do the same (../dist/remark.js).
|
|
4
|
+
import { config } from "virtual:@runbun/astro/config";
|
|
5
|
+
import { embedScripts, fileScript } from "../dist/markup.js";
|
|
6
|
+
|
|
7
|
+
export interface Props {
|
|
8
|
+
mode?: "code" | "preview" | "console" | "full" | "result";
|
|
9
|
+
features?: string;
|
|
10
|
+
file?: string;
|
|
11
|
+
autorun?: boolean;
|
|
12
|
+
theme?: string;
|
|
13
|
+
readonly?: boolean;
|
|
14
|
+
persist?: string;
|
|
15
|
+
example?: string;
|
|
16
|
+
repo?: string;
|
|
17
|
+
share?: string;
|
|
18
|
+
load?: "eager" | "visible" | "click";
|
|
19
|
+
height?: string;
|
|
20
|
+
title?: string;
|
|
21
|
+
/** A button that opens the embed in a modal: true, or a mode to switch to while open ("full"). */
|
|
22
|
+
expand?: boolean | "code" | "preview" | "console" | "full" | "result";
|
|
23
|
+
files?: Record<string, string>;
|
|
24
|
+
}
|
|
25
|
+
const { files = {}, load = "click", ...attrs } = Astro.props;
|
|
26
|
+
const bool = (v: unknown) => (v === undefined ? undefined : v ? "" : undefined);
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
<!-- not-content: Starlight's spacing between content elements would push the poster down below the file scripts. -->
|
|
30
|
+
<bun-playground
|
|
31
|
+
class="not-content"
|
|
32
|
+
load={load}
|
|
33
|
+
mode={attrs.mode}
|
|
34
|
+
features={attrs.features}
|
|
35
|
+
file={attrs.file}
|
|
36
|
+
autorun={attrs.autorun === undefined ? undefined : String(attrs.autorun)}
|
|
37
|
+
theme={attrs.theme}
|
|
38
|
+
readonly={bool(attrs.readonly)}
|
|
39
|
+
persist={attrs.persist}
|
|
40
|
+
example={attrs.example}
|
|
41
|
+
repo={attrs.repo}
|
|
42
|
+
share={attrs.share}
|
|
43
|
+
height={attrs.height}
|
|
44
|
+
title={attrs.title}
|
|
45
|
+
expand={attrs.expand === undefined || attrs.expand === false ? undefined : attrs.expand === true ? "" : attrs.expand}
|
|
46
|
+
>
|
|
47
|
+
<Fragment set:html={Object.entries(files).map(([path, text]) => fileScript(path, text)).join("\n")} />
|
|
48
|
+
{/* Without a poster of its own, the element shows the first file's code. */}
|
|
49
|
+
{Astro.slots.has("default") && <div slot="poster"><slot /></div>}
|
|
50
|
+
</bun-playground>
|
|
51
|
+
<Fragment set:html={embedScripts(config.playgroundUrl, config.sync)} />
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface ResolvedConfig {
|
|
2
|
+
playgroundUrl: string;
|
|
3
|
+
base: string;
|
|
4
|
+
sync?: {
|
|
5
|
+
scheme?: string;
|
|
6
|
+
family?: string;
|
|
7
|
+
} | undefined;
|
|
8
|
+
}
|
|
9
|
+
export declare const CONFIG: () => ResolvedConfig;
|
|
10
|
+
export declare const setConfig: (config: Partial<ResolvedConfig>) => ResolvedConfig;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The integration's resolved options, for the fence plugin: it runs in the same process, after astro:config:setup.
|
|
2
|
+
const KEY = Symbol.for("@runbun/astro");
|
|
3
|
+
const DEFAULTS = { playgroundUrl: "https://runbun.dev/", base: "/", sync: undefined };
|
|
4
|
+
const store = globalThis;
|
|
5
|
+
export const CONFIG = () => store[KEY] ?? DEFAULTS;
|
|
6
|
+
export const setConfig = (config) => (store[KEY] = { ...DEFAULTS, ...config });
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type Theme } from "@pierre/highlights";
|
|
2
|
+
/** `code` as a `<pre class="highlights">`; languages highlights doesn't know stay plain. */
|
|
3
|
+
export declare function highlight(code: string, lang?: string): string;
|
|
4
|
+
/** A bundled @pierre/highlights theme's name (`pierre-dark`), a Zed theme, or a VS Code theme (`{ name, type, tokenColors }`). */
|
|
5
|
+
export type ThemeOption = string | {
|
|
6
|
+
name: string;
|
|
7
|
+
[key: string]: unknown;
|
|
8
|
+
};
|
|
9
|
+
export type ThemeSelector = (theme: {
|
|
10
|
+
name: string;
|
|
11
|
+
appearance: string;
|
|
12
|
+
}) => string;
|
|
13
|
+
/**
|
|
14
|
+
* A theme option as `{ name, theme }`: a bundled theme's name (`pierre-dark`), a Zed theme (`{ name, appearance,
|
|
15
|
+
* style }`), or a VS Code theme (`{ name, type, tokenColors }`). Objects are named by their `name`.
|
|
16
|
+
*/
|
|
17
|
+
export declare function resolveTheme(option: ThemeOption): {
|
|
18
|
+
name: string;
|
|
19
|
+
theme: Theme;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* The variables of every theme. With `selector`, each goes under `selector({ name, appearance })`. Without, the first
|
|
23
|
+
* light theme applies on `:root` and the first dark one under `prefers-color-scheme: dark`.
|
|
24
|
+
*/
|
|
25
|
+
export declare function themeCss(options: ThemeOption[], selector?: ThemeSelector): string;
|
|
26
|
+
/**
|
|
27
|
+
* The look of a block, around the theme's colors; `--runbun-code-*` properties adjust it. A tabbed block's files share
|
|
28
|
+
* one grid cell, only the checked tab's visible, so the block keeps its longest file's height as tabs switch.
|
|
29
|
+
*/
|
|
30
|
+
export declare const BLOCK_CSS: string;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Code highlighted at build time with @pierre/highlights, the highlighter of the playground's Pierre views. Spans
|
|
2
|
+
// color with CSS variables (its `cssVariables` theme); themeCss() defines them for each configured theme, so one copy
|
|
3
|
+
// of the HTML takes whichever theme the page wears.
|
|
4
|
+
import { codeToHtml, isSupportedLanguage, tokenNames } from "@pierre/highlights";
|
|
5
|
+
import * as bundled from "@pierre/highlights/themes";
|
|
6
|
+
const ALIASES = { typescript: "ts", javascript: "js", shell: "sh", bash: "sh", zsh: "sh", jsonc: "json", md: "markdown" };
|
|
7
|
+
/** `code` as a `<pre class="highlights">`; languages highlights doesn't know stay plain. */
|
|
8
|
+
export function highlight(code, lang = "plain") {
|
|
9
|
+
const l = ALIASES[lang] ?? lang;
|
|
10
|
+
const html = codeToHtml(code, { lang: isSupportedLanguage(l) ? l : "plain", theme: bundled.cssVariables });
|
|
11
|
+
return new TextDecoder().decode(html);
|
|
12
|
+
}
|
|
13
|
+
const camel = (name) => name.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
|
|
14
|
+
/** TextMate scopes to look a Zed scope up by, for themes given as VS Code JSON. */
|
|
15
|
+
const TEXTMATE = {
|
|
16
|
+
comment: ["comment"],
|
|
17
|
+
string: ["string"],
|
|
18
|
+
number: ["constant.numeric"],
|
|
19
|
+
boolean: ["constant.language"],
|
|
20
|
+
constant: ["constant.language", "constant"],
|
|
21
|
+
keyword: ["keyword", "storage"],
|
|
22
|
+
function: ["entity.name.function", "support.function"],
|
|
23
|
+
type: ["entity.name.type", "support.type", "support.class"],
|
|
24
|
+
constructor: ["entity.name.type", "support.class"],
|
|
25
|
+
tag: ["entity.name.tag"],
|
|
26
|
+
attribute: ["entity.other.attribute-name"],
|
|
27
|
+
property: ["variable.other.property", "support.type.property-name"],
|
|
28
|
+
variable: ["variable"],
|
|
29
|
+
};
|
|
30
|
+
/** A VS Code theme as the Zed theme highlights reads. */
|
|
31
|
+
function fromVscode(json) {
|
|
32
|
+
const rules = json.tokenColors ?? [];
|
|
33
|
+
const find = (scope) => rules.find((r) => [r.scope].flat().some((s) => s === scope || scope.startsWith(s + ".")))?.settings.foreground;
|
|
34
|
+
const syntax = {};
|
|
35
|
+
for (const [zed, scopes] of Object.entries(TEXTMATE)) {
|
|
36
|
+
const color = scopes.map(find).find(Boolean);
|
|
37
|
+
if (color)
|
|
38
|
+
syntax[zed] = { color };
|
|
39
|
+
}
|
|
40
|
+
const { "editor.background": background, "editor.foreground": foreground } = json.colors ?? {};
|
|
41
|
+
return { name: json.name, appearance: json.type, style: { "editor.background": background, "editor.foreground": foreground, syntax } };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* A theme option as `{ name, theme }`: a bundled theme's name (`pierre-dark`), a Zed theme (`{ name, appearance,
|
|
45
|
+
* style }`), or a VS Code theme (`{ name, type, tokenColors }`). Objects are named by their `name`.
|
|
46
|
+
*/
|
|
47
|
+
export function resolveTheme(option) {
|
|
48
|
+
if (typeof option === "string") {
|
|
49
|
+
const theme = bundled[camel(option)];
|
|
50
|
+
if (!theme)
|
|
51
|
+
throw new Error(`@runbun/astro: no bundled highlights theme "${option}"`);
|
|
52
|
+
return { name: option, theme };
|
|
53
|
+
}
|
|
54
|
+
return { name: option.name, theme: option.style ? option : fromVscode(option) };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* `theme` with a color for every scope highlights emits, as Zed falls back: `keyword.declaration` takes `keyword`'s
|
|
58
|
+
* when the theme has none. Without that a span's variable would be unset and the token would lose its color.
|
|
59
|
+
*/
|
|
60
|
+
function complete(theme) {
|
|
61
|
+
const syntax = { ...theme.style.syntax };
|
|
62
|
+
for (const name of tokenNames) {
|
|
63
|
+
if (name === "background" || name === "foreground" || syntax[name])
|
|
64
|
+
continue;
|
|
65
|
+
for (let base = name; base.includes(".");) {
|
|
66
|
+
base = base.slice(0, base.lastIndexOf("."));
|
|
67
|
+
if (syntax[base]) {
|
|
68
|
+
syntax[name] = syntax[base];
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { ...theme, style: { ...theme.style, syntax } };
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The variables of every theme. With `selector`, each goes under `selector({ name, appearance })`. Without, the first
|
|
77
|
+
* light theme applies on `:root` and the first dark one under `prefers-color-scheme: dark`.
|
|
78
|
+
*/
|
|
79
|
+
export function themeCss(options, selector) {
|
|
80
|
+
const themes = options.map(resolveTheme);
|
|
81
|
+
const vars = (t) => bundled.toCSS(complete(t.theme));
|
|
82
|
+
if (selector)
|
|
83
|
+
return themes.map((t) => `${selector({ name: t.name, appearance: t.theme.appearance })}{${vars(t)}}`).join("\n");
|
|
84
|
+
const light = themes.find((t) => t.theme.appearance === "light");
|
|
85
|
+
const dark = themes.find((t) => t.theme.appearance === "dark");
|
|
86
|
+
return [light && `:root{${vars(light)}}`, dark && `@media (prefers-color-scheme: dark){:root{${vars(dark)}}}`]
|
|
87
|
+
.filter(Boolean)
|
|
88
|
+
.join("\n");
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The look of a block, around the theme's colors; `--runbun-code-*` properties adjust it. A tabbed block's files share
|
|
92
|
+
* one grid cell, only the checked tab's visible, so the block keeps its longest file's height as tabs switch.
|
|
93
|
+
*/
|
|
94
|
+
export const BLOCK_CSS = `
|
|
95
|
+
:where(.runbun-code){margin:0}
|
|
96
|
+
.runbun-code{border:1px solid var(--runbun-code-border,color-mix(in srgb,currentColor 15%,transparent));border-radius:var(--runbun-code-radius,8px);overflow:hidden;background:var(--hls-background);color:var(--hls-foreground)}
|
|
97
|
+
.runbun-code figcaption{padding:.5rem 1rem;border-bottom:1px solid var(--runbun-code-border,color-mix(in srgb,currentColor 15%,transparent));font-family:var(--runbun-code-font,ui-monospace,monospace);font-size:var(--runbun-code-caption-size,.75rem);opacity:.8}
|
|
98
|
+
.runbun-code pre{margin:0;padding:1rem;overflow-x:auto;font-family:var(--runbun-code-font,ui-monospace,monospace);font-size:var(--runbun-code-size,.875rem);line-height:var(--runbun-code-line-height,1.6)}
|
|
99
|
+
.runbun-tabs{display:grid;border:1px solid var(--runbun-code-border,color-mix(in srgb,currentColor 15%,transparent));border-radius:var(--runbun-code-radius,8px);overflow:hidden;background:var(--hls-background);color:var(--hls-foreground)}
|
|
100
|
+
.runbun-tabs>.runbun-code{grid-area:2/1;visibility:hidden;min-width:0;margin:0;border:0;border-radius:0}
|
|
101
|
+
.runbun-tablist{grid-area:1/1;display:flex;overflow-x:auto;overflow-y:hidden;box-shadow:inset 0 -1px var(--runbun-code-border,color-mix(in srgb,currentColor 15%,transparent));font-family:var(--runbun-code-font,ui-monospace,monospace);font-size:var(--runbun-code-caption-size,.75rem)}
|
|
102
|
+
.runbun-tablist label{position:relative;padding:.5rem 1rem;cursor:pointer;white-space:nowrap;opacity:.65;border-bottom:2px solid transparent}
|
|
103
|
+
.runbun-tablist label:hover{opacity:.9}
|
|
104
|
+
.runbun-tablist label:has(input:checked){opacity:1;border-bottom-color:currentColor}
|
|
105
|
+
.runbun-tablist label:has(input:focus-visible){outline:2px solid currentColor;outline-offset:-2px}
|
|
106
|
+
.runbun-tablist input{position:absolute;opacity:0;pointer-events:none}
|
|
107
|
+
.runbun-tabs:has(>.runbun-tablist>label:nth-of-type(1)>input:checked)>.runbun-code:nth-of-type(1){visibility:visible}
|
|
108
|
+
.runbun-tabs:has(>.runbun-tablist>label:nth-of-type(2)>input:checked)>.runbun-code:nth-of-type(2){visibility:visible}
|
|
109
|
+
.runbun-tabs:has(>.runbun-tablist>label:nth-of-type(3)>input:checked)>.runbun-code:nth-of-type(3){visibility:visible}
|
|
110
|
+
.runbun-tabs:has(>.runbun-tablist>label:nth-of-type(4)>input:checked)>.runbun-code:nth-of-type(4){visibility:visible}
|
|
111
|
+
.runbun-tabs:has(>.runbun-tablist>label:nth-of-type(5)>input:checked)>.runbun-code:nth-of-type(5){visibility:visible}
|
|
112
|
+
.runbun-tabs:has(>.runbun-tablist>label:nth-of-type(6)>input:checked)>.runbun-code:nth-of-type(6){visibility:visible}
|
|
113
|
+
.runbun-tabs:has(>.runbun-tablist>label:nth-of-type(7)>input:checked)>.runbun-code:nth-of-type(7){visibility:visible}
|
|
114
|
+
.runbun-tabs:has(>.runbun-tablist>label:nth-of-type(8)>input:checked)>.runbun-code:nth-of-type(8){visibility:visible}
|
|
115
|
+
.runbun-tabs:has(>.runbun-tablist>label:nth-of-type(9)>input:checked)>.runbun-code:nth-of-type(9){visibility:visible}
|
|
116
|
+
.runbun-tabs:has(>.runbun-tablist>label:nth-of-type(10)>input:checked)>.runbun-code:nth-of-type(10){visibility:visible}
|
|
117
|
+
.runbun-tabs:has(>.runbun-tablist>label:nth-of-type(11)>input:checked)>.runbun-code:nth-of-type(11){visibility:visible}
|
|
118
|
+
.runbun-tabs:has(>.runbun-tablist>label:nth-of-type(12)>input:checked)>.runbun-code:nth-of-type(12){visibility:visible}
|
|
119
|
+
`.trim();
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { AstroIntegration } from "astro";
|
|
2
|
+
import { type ThemeOption } from "./highlight.js";
|
|
3
|
+
export type { ThemeOption };
|
|
4
|
+
export interface RunbunOptions {
|
|
5
|
+
/** Where the playground lives; embeds load `<playgroundUrl>embed.js` and frame `<playgroundUrl>embed`. Default `https://runbun.dev/`. */
|
|
6
|
+
playgroundUrl?: string;
|
|
7
|
+
/** The code themes blocks can wear. Default `["pierre-light", "pierre-dark"]`. */
|
|
8
|
+
themes?: ThemeOption[];
|
|
9
|
+
/**
|
|
10
|
+
* The CSS selector each theme's colors go under, for a site with its own theme switch (e.g. ``({ name }) =>
|
|
11
|
+
* `:root[data-code-theme='${name}']` ``). Without it the first light theme applies, and the first dark one under
|
|
12
|
+
* `prefers-color-scheme: dark`.
|
|
13
|
+
*/
|
|
14
|
+
themeSelector?: (theme: {
|
|
15
|
+
name: string;
|
|
16
|
+
appearance: string;
|
|
17
|
+
}) => string;
|
|
18
|
+
/**
|
|
19
|
+
* Keep embeds in the page's scheme and theme family: the `<html>` attributes that hold them (`scheme` says `light`
|
|
20
|
+
* or `dark`). Without it embeds follow the OS.
|
|
21
|
+
*/
|
|
22
|
+
sync?: {
|
|
23
|
+
scheme?: string;
|
|
24
|
+
family?: string;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export default function runbun(options?: RunbunOptions): AstroIntegration;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { setConfig } from "./config.js";
|
|
2
|
+
import { BLOCK_CSS, themeCss } from "./highlight.js";
|
|
3
|
+
import { CSS_PATH } from "./markup.js";
|
|
4
|
+
const VIRTUAL = "virtual:@runbun/astro/config";
|
|
5
|
+
export default function runbun(options = {}) {
|
|
6
|
+
const { playgroundUrl = "https://runbun.dev/", themes = ["pierre-light", "pierre-dark"], themeSelector, sync } = options;
|
|
7
|
+
return {
|
|
8
|
+
name: "@runbun/astro",
|
|
9
|
+
hooks: {
|
|
10
|
+
"astro:config:setup": ({ config, injectRoute, updateConfig }) => {
|
|
11
|
+
const base = config.base ?? "/";
|
|
12
|
+
const resolved = { playgroundUrl, base, sync };
|
|
13
|
+
setConfig(resolved);
|
|
14
|
+
const css = BLOCK_CSS + "\n" + themeCss(themes, themeSelector);
|
|
15
|
+
const source = `export const config = ${JSON.stringify(resolved)};\nexport const css = ${JSON.stringify(css)};\n`;
|
|
16
|
+
updateConfig({
|
|
17
|
+
vite: {
|
|
18
|
+
plugins: [
|
|
19
|
+
{
|
|
20
|
+
name: "@runbun/astro:config",
|
|
21
|
+
resolveId: (id) => (id === VIRTUAL ? "\0" + VIRTUAL : undefined),
|
|
22
|
+
load: (id) => (id === "\0" + VIRTUAL ? source : undefined),
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
injectRoute({ pattern: "/" + CSS_PATH, entrypoint: new URL("./route.js", import.meta.url), prerender: true });
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
package/dist/markup.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export declare const CSS_PATH = "_runbun/highlights.css";
|
|
2
|
+
/** The stylesheet with the blocks' look and theme colors (a route of the integration's). */
|
|
3
|
+
export declare const cssLink: (base: string) => string;
|
|
4
|
+
/** A highlighted block; `not-content` keeps Starlight's prose styles off it. */
|
|
5
|
+
export declare function codeBlock(code: string, lang: string, title?: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* Several files as one block with a tab per file. Plain HTML: the tabs are radio buttons, and the stylesheet shows the
|
|
8
|
+
* file whose tab is checked, so switching needs no script. `selected` names the tab checked first (default: the
|
|
9
|
+
* first). One file is a plain block.
|
|
10
|
+
*/
|
|
11
|
+
export declare function codeTabs(files: {
|
|
12
|
+
name: string;
|
|
13
|
+
code: string;
|
|
14
|
+
lang?: string;
|
|
15
|
+
}[], selected?: string): string;
|
|
16
|
+
/** A highlights language for a file name: its extension. */
|
|
17
|
+
export declare const langOf: (name: string) => string;
|
|
18
|
+
/** `features` with the embed's tab bar on, unless the author's list already says either way. */
|
|
19
|
+
export declare const withTabs: (features?: string) => string;
|
|
20
|
+
/** A file as an inline source of a <bun-playground>: `<\/script` and `<\!--` are how it is written inside a script. */
|
|
21
|
+
export declare function fileScript(path: string, text: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* The element (embed.js from the playground) and, with `sync`, a script that keeps every embed in the page's scheme
|
|
24
|
+
* and family: `sync.scheme` names the <html> attribute that says `light` or `dark`, `sync.family` the one with the
|
|
25
|
+
* family. Module scripts with the same URL run once, so a page loads embed.js a single time however many embeds it has.
|
|
26
|
+
*/
|
|
27
|
+
export declare function embedScripts(playgroundUrl: string, sync?: {
|
|
28
|
+
scheme?: string;
|
|
29
|
+
family?: string;
|
|
30
|
+
}): string;
|
|
31
|
+
/** Extensions for a file named after its fence's or block's language. */
|
|
32
|
+
export declare const EXT: Record<string, string>;
|
|
33
|
+
/**
|
|
34
|
+
* `files` with `entry` as the file Run starts. Run starts package.json's `main`, else an index file, so a block named
|
|
35
|
+
* anything else gets a package.json that points at it (unless the files bring their own). Test files are left alone:
|
|
36
|
+
* `bun test` finds them.
|
|
37
|
+
*/
|
|
38
|
+
export declare function withEntry(files: Record<string, string>, entry: string): Record<string, string>;
|
package/dist/markup.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// HTML shared by the components and the fence plugin, so a <Code> block and a fence look and load the same.
|
|
2
|
+
import { highlight } from "./highlight.js";
|
|
3
|
+
export const CSS_PATH = "_runbun/highlights.css";
|
|
4
|
+
const esc = (s) => String(s).replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
5
|
+
const slash = (url) => (url.endsWith("/") ? url : url + "/");
|
|
6
|
+
/** The stylesheet with the blocks' look and theme colors (a route of the integration's). */
|
|
7
|
+
export const cssLink = (base) => `<link rel="stylesheet" href="${esc(slash(base) + CSS_PATH)}">`;
|
|
8
|
+
/** A highlighted block; `not-content` keeps Starlight's prose styles off it. */
|
|
9
|
+
export function codeBlock(code, lang, title) {
|
|
10
|
+
const caption = title ? `<figcaption>${esc(title)}</figcaption>` : "";
|
|
11
|
+
return `<figure class="runbun-code not-content">${caption}${highlight(code.replace(/\n$/, ""), lang)}</figure>`;
|
|
12
|
+
}
|
|
13
|
+
// Tab groups' radio names must be unique per page. The remark plugin and the components load separate copies of this
|
|
14
|
+
// module (Astro bundles them apart), so the counter lives on globalThis, shared by every copy.
|
|
15
|
+
const counter = (globalThis[Symbol.for("runbun.tabGroups")] ??= { n: 0 });
|
|
16
|
+
/**
|
|
17
|
+
* Several files as one block with a tab per file. Plain HTML: the tabs are radio buttons, and the stylesheet shows the
|
|
18
|
+
* file whose tab is checked, so switching needs no script. `selected` names the tab checked first (default: the
|
|
19
|
+
* first). One file is a plain block.
|
|
20
|
+
*/
|
|
21
|
+
export function codeTabs(files, selected) {
|
|
22
|
+
if (files.length === 1)
|
|
23
|
+
return codeBlock(files[0].code, files[0].lang ?? langOf(files[0].name), files[0].name);
|
|
24
|
+
const group = `runbun-tabs-${++counter.n}`;
|
|
25
|
+
const checked = Math.max(0, files.findIndex((f) => f.name === selected));
|
|
26
|
+
const tabs = files
|
|
27
|
+
.map((f, i) => `<label><input type="radio" name="${group}"${i === checked ? " checked" : ""}>${esc(f.name)}</label>`)
|
|
28
|
+
.join("");
|
|
29
|
+
const panels = files.map((f) => codeBlock(f.code, f.lang ?? langOf(f.name))).join("");
|
|
30
|
+
return `<div class="runbun-tabs not-content"><div class="runbun-tablist" aria-label="Files">${tabs}</div>${panels}</div>`;
|
|
31
|
+
}
|
|
32
|
+
/** A highlights language for a file name: its extension. */
|
|
33
|
+
export const langOf = (name) => name.slice(name.lastIndexOf(".") + 1) || "plain";
|
|
34
|
+
/** `features` with the embed's tab bar on, unless the author's list already says either way. */
|
|
35
|
+
export const withTabs = (features) => (/(^|,)\s*-?tabs\s*(,|$)/.test(features ?? "") ? features : [features, "tabs"].filter(Boolean).join(","));
|
|
36
|
+
/** A file as an inline source of a <bun-playground>: `<\/script` and `<\!--` are how it is written inside a script. */
|
|
37
|
+
export function fileScript(path, text) {
|
|
38
|
+
const body = text.replace(/<\/(script)/gi, "<\\/$1").replace(/<!--/g, "<\\!--");
|
|
39
|
+
return `<script type="text/plain" data-file="${esc(path)}">\n${body}\n</script>`;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The element (embed.js from the playground) and, with `sync`, a script that keeps every embed in the page's scheme
|
|
43
|
+
* and family: `sync.scheme` names the <html> attribute that says `light` or `dark`, `sync.family` the one with the
|
|
44
|
+
* family. Module scripts with the same URL run once, so a page loads embed.js a single time however many embeds it has.
|
|
45
|
+
*/
|
|
46
|
+
export function embedScripts(playgroundUrl, sync) {
|
|
47
|
+
let out = `<script type="module" src="${esc(slash(playgroundUrl))}embed.js"></script>`;
|
|
48
|
+
if (sync?.scheme || sync?.family) {
|
|
49
|
+
const { scheme, family } = JSON.parse(JSON.stringify(sync));
|
|
50
|
+
out +=
|
|
51
|
+
`<script type="module">const s=${JSON.stringify(scheme ?? null)},f=${JSON.stringify(family ?? null)},h=document.documentElement;` +
|
|
52
|
+
`const sync=()=>{for(const el of document.querySelectorAll("bun-playground")){` +
|
|
53
|
+
`if(s)el.setAttribute("theme",h.getAttribute(s)==="light"?"light":"dark");` +
|
|
54
|
+
`const v=f&&h.getAttribute(f);if(v)el.setAttribute("family",v);}};` +
|
|
55
|
+
`sync();new MutationObserver(sync).observe(h,{attributes:true,attributeFilter:[s,f].filter(Boolean)});</script>`;
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
/** Extensions for a file named after its fence's or block's language. */
|
|
60
|
+
export const EXT = { typescript: "ts", javascript: "js", markdown: "md", jsonc: "json", shell: "sh", bash: "sh" };
|
|
61
|
+
const INDEX = new Set(["index.ts", "index.tsx", "index.js", "src/index.ts"]);
|
|
62
|
+
/**
|
|
63
|
+
* `files` with `entry` as the file Run starts. Run starts package.json's `main`, else an index file, so a block named
|
|
64
|
+
* anything else gets a package.json that points at it (unless the files bring their own). Test files are left alone:
|
|
65
|
+
* `bun test` finds them.
|
|
66
|
+
*/
|
|
67
|
+
export function withEntry(files, entry) {
|
|
68
|
+
if (INDEX.has(entry) || /\.(test|spec)\.[cm]?[jt]sx?$/.test(entry) || "package.json" in files)
|
|
69
|
+
return files;
|
|
70
|
+
return { ...files, "package.json": JSON.stringify({ main: entry }, null, 2) + "\n" };
|
|
71
|
+
}
|
package/dist/remark.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Root } from "mdast";
|
|
2
|
+
/** ```ts playground fences as embeds. Options default to the runbun() integration's. */
|
|
3
|
+
export default function remarkRunbun(options?: {
|
|
4
|
+
playgroundUrl?: string;
|
|
5
|
+
base?: string;
|
|
6
|
+
sync?: {
|
|
7
|
+
scheme?: string;
|
|
8
|
+
family?: string;
|
|
9
|
+
};
|
|
10
|
+
}): (tree: Root, file?: {
|
|
11
|
+
path?: string;
|
|
12
|
+
}) => void;
|
package/dist/remark.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { CONFIG } from "./config.js";
|
|
2
|
+
import { codeTabs, cssLink, embedScripts, EXT, fileScript, withEntry, withTabs } from "./markup.js";
|
|
3
|
+
const OWN_KEYS = new Set(["playground", "mode", "features", "group", "load", "height", "autorun", "readonly", "persist", "theme", "file", "expand"]);
|
|
4
|
+
const ATTRS = ["mode", "features", "height", "autorun", "readonly", "persist", "theme", "file"];
|
|
5
|
+
function tokens(meta) {
|
|
6
|
+
const out = [];
|
|
7
|
+
const re = /(?:([\w-]+)(?:=(?:"([^"]*)"|'([^']*)'|(\S*)))?)|(\S+)/g;
|
|
8
|
+
let m;
|
|
9
|
+
while ((m = re.exec(meta)))
|
|
10
|
+
out.push({ key: m[1], value: m[2] ?? m[3] ?? m[4] ?? null });
|
|
11
|
+
return out;
|
|
12
|
+
}
|
|
13
|
+
const esc = (s) => String(s).replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
14
|
+
/**
|
|
15
|
+
* Raw HTML for the page. MDX turns a raw `<script>`'s text into a literal `set:html` attribute, so the embed's files
|
|
16
|
+
* would arrive empty; there the HTML goes through Astro's `<Fragment set:html>` untouched.
|
|
17
|
+
*/
|
|
18
|
+
let mdx = false;
|
|
19
|
+
const html = (value) => mdx
|
|
20
|
+
? { type: "mdxJsxFlowElement", name: "Fragment", attributes: [{ type: "mdxJsxAttribute", name: "set:html", value }], children: [] }
|
|
21
|
+
: { type: "html", value };
|
|
22
|
+
function parse(node) {
|
|
23
|
+
const toks = tokens(node.meta ?? "");
|
|
24
|
+
if (!toks.some((t) => t.key === "playground" && t.value === null))
|
|
25
|
+
return null;
|
|
26
|
+
const opts = {};
|
|
27
|
+
for (const t of toks)
|
|
28
|
+
if (t.key && OWN_KEYS.has(t.key))
|
|
29
|
+
opts[t.key] = t.value ?? "";
|
|
30
|
+
const title = toks.find((t) => t.key === "title")?.value;
|
|
31
|
+
return { opts, title, node };
|
|
32
|
+
}
|
|
33
|
+
function embed(entries) {
|
|
34
|
+
const { opts } = entries[0];
|
|
35
|
+
const sources = {};
|
|
36
|
+
const shown = [];
|
|
37
|
+
for (const { title, node } of entries) {
|
|
38
|
+
const ext = EXT[node.lang ?? ""] ?? node.lang ?? "txt";
|
|
39
|
+
let name = title || `index.${ext}`;
|
|
40
|
+
for (let n = 2; name in sources; n++)
|
|
41
|
+
name = `index${n}.${ext}`;
|
|
42
|
+
sources[name] = node.value;
|
|
43
|
+
shown.push({ name, code: node.value, lang: node.lang ?? "plain" });
|
|
44
|
+
}
|
|
45
|
+
// Several files show as tabs, before Run and after.
|
|
46
|
+
if (shown.length > 1)
|
|
47
|
+
opts.features = withTabs(opts.features);
|
|
48
|
+
// The first fence is the one Run starts, unless the group has an index file of its own.
|
|
49
|
+
const names = Object.keys(sources);
|
|
50
|
+
const entry = names.find((n) => /^(src\/)?index\.[jt]sx?$/.test(n)) ?? names[0];
|
|
51
|
+
const files = Object.entries(withEntry(sources, entry)).map(([name, text]) => fileScript(name, text));
|
|
52
|
+
const attrs = ATTRS.filter((k) => k in opts).map((k) => ` ${k}="${esc(opts[k])}"`).join("");
|
|
53
|
+
// A docs snippet opens as the whole playground in a modal unless the fence says otherwise (`expand=false`, `expand=on`).
|
|
54
|
+
const expand = opts.expand === "false" ? "" : ` expand="${esc(opts.expand || "full")}"`;
|
|
55
|
+
// The tab checked first is the file the embed opens with: `file`, else the one Run starts.
|
|
56
|
+
const opened = opts.file && opts.file in sources ? opts.file : entry;
|
|
57
|
+
const poster = shown.length > 1 || entries[0].title ? codeTabs(shown, opened) : codeTabs([{ ...shown[0], name: "" }]);
|
|
58
|
+
return html(`<bun-playground class="not-content" load="${esc(opts.load || "click")}"${expand}${attrs}>\n${files.join("\n")}\n` +
|
|
59
|
+
`<div slot="poster">${poster}</div>\n</bun-playground>`);
|
|
60
|
+
}
|
|
61
|
+
function transform(children) {
|
|
62
|
+
const out = [];
|
|
63
|
+
let found = false;
|
|
64
|
+
for (let i = 0; i < children.length; i++) {
|
|
65
|
+
const node = children[i];
|
|
66
|
+
const first = node.type === "code" ? parse(node) : null;
|
|
67
|
+
if (!first) {
|
|
68
|
+
if ("children" in node)
|
|
69
|
+
found = transform(node.children) || found;
|
|
70
|
+
out.push(node);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
found = true;
|
|
74
|
+
const entries = [first];
|
|
75
|
+
const group = first.opts.group;
|
|
76
|
+
while (group && children[i + 1]?.type === "code") {
|
|
77
|
+
const next = parse(children[i + 1]);
|
|
78
|
+
if (!next || next.opts.group !== group)
|
|
79
|
+
break;
|
|
80
|
+
entries.push(next);
|
|
81
|
+
i++;
|
|
82
|
+
}
|
|
83
|
+
out.push(embed(entries));
|
|
84
|
+
}
|
|
85
|
+
children.splice(0, children.length, ...out);
|
|
86
|
+
return found;
|
|
87
|
+
}
|
|
88
|
+
/** ```ts playground fences as embeds. Options default to the runbun() integration's. */
|
|
89
|
+
export default function remarkRunbun(options = {}) {
|
|
90
|
+
return (tree, file) => {
|
|
91
|
+
mdx = /\.mdx$/.test(file?.path ?? "");
|
|
92
|
+
if (!transform(tree.children))
|
|
93
|
+
return;
|
|
94
|
+
const { playgroundUrl, base, sync } = { ...CONFIG(), ...options };
|
|
95
|
+
tree.children.push(html(cssLink(base) + "\n" + embedScripts(playgroundUrl, sync)));
|
|
96
|
+
};
|
|
97
|
+
}
|
package/dist/route.d.ts
ADDED
package/dist/route.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// The blocks' stylesheet, a file of its own so the browser caches it and only pages with a block load it.
|
|
2
|
+
import { css } from "virtual:@runbun/astro/config";
|
|
3
|
+
export const prerender = true;
|
|
4
|
+
export const GET = () => new Response(css, { headers: { "Content-Type": "text/css; charset=utf-8" } });
|
package/package.json
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runbun/astro",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Code blocks highlighted with @pierre/highlights that turn into a runnable Bun playground (runbun.dev) on click.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": ["astro-integration", "astro-component", "bun", "playground", "code", "highlight"],
|
|
8
|
+
"homepage": "https://docs.runbun.dev/guides/astro/",
|
|
9
|
+
"repository": { "type": "git", "url": "git+https://github.com/oddcelot/bun-webcontainer.git", "directory": "packages/astro" },
|
|
10
|
+
"bugs": "https://github.com/oddcelot/bun-webcontainer/issues",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
|
|
13
|
+
"./remark": { "types": "./dist/remark.d.ts", "default": "./dist/remark.js" },
|
|
14
|
+
"./components": { "types": "./dist/components.d.ts", "default": "./dist/components.js" },
|
|
15
|
+
"./Code.astro": "./components/Code.astro",
|
|
16
|
+
"./Playground.astro": "./components/Playground.astro",
|
|
17
|
+
"./virtual": { "types": "./virtual.d.ts" }
|
|
18
|
+
},
|
|
19
|
+
"files": ["dist", "components", "virtual.d.ts", "README.md", "LICENSE"],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "rm -rf dist && tsrs",
|
|
22
|
+
"typecheck": "tsrs --noEmit",
|
|
23
|
+
"prepack": "bun run build"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": { "astro": "^7.0.0" },
|
|
26
|
+
"dependencies": { "@pierre/highlights": "0.3.0" },
|
|
27
|
+
"devDependencies": { "@types/mdast": "^4.0.4", "astro": "^7.4.5", "tsrs": "0.20.1-ts7.1.0-beta" },
|
|
28
|
+
"publishConfig": { "access": "public" }
|
|
29
|
+
}
|
package/virtual.d.ts
ADDED