diff --git a/app/components/ascii-art-generator.tsx b/app/components/ascii-art-generator.tsx index 6ffed67..4976328 100644 --- a/app/components/ascii-art-generator.tsx +++ b/app/components/ascii-art-generator.tsx @@ -805,6 +805,12 @@ export function AsciiArtGenerator() { }} disabled={!program} exportSettings={settings.export} + sourceCode={settings.source.code} + characterSet={settings.output.characterSet} + frameRate={settings.animation.frameRate} + esbuildService={esbuildService} + imageData={currentImageData} + frames={currentFrames} />
instead of a canvas.
+ 'mitos:renderer': textRendererSource,
+ 'mitos:fps': fpsSource,
+ 'mitos:program': program,
+ 'mitos:utils': `// Pattern generation utilities\n${utilsExports}`,
+ 'mitos:settings': `export const characterSet = ${JSON.stringify(params.characterSet)};
+export const textColor = ${JSON.stringify(params.textColor)};
+export const backgroundColor = ${JSON.stringify(params.backgroundColor)};
+export const settings = {};`,
+ 'mitos:imageData': imageDataModule({
+ columns: params.columns,
+ rows: params.rows,
+ imageData: params.imageData,
+ frames: params.frames,
+ }),
+ }
+}
+
+// Maps every import specifier seen during the build onto one of the virtual
+// module names above, or returns null for things that should come from unpkg.
+function resolveVirtualName(specifier: string): string | null {
+ if (specifier === 'mitos:entry') return 'mitos:entry'
+ if (specifier === 'mitos:program') return 'mitos:program'
+ if (specifier === 'mitos:animation' || /(^|\/)animation(\.ts)?$/.test(specifier))
+ return 'mitos:animation'
+ if (/canvas-renderer(\.ts)?$/.test(specifier)) return 'mitos:renderer'
+ if (/(^|\/)fps(\.ts)?$/.test(specifier)) return 'mitos:fps'
+ if (specifier === '@/utils') return 'mitos:utils'
+ if (specifier === '@/settings') return 'mitos:settings'
+ if (specifier === '@/imageData') return 'mitos:imageData'
+ return null
+}
+
+function createBundlePlugin(modules: Record): Plugin {
+ const moduleCache = new Map()
+
+ return {
+ name: 'mitos-react-export',
+ setup(build) {
+ // Internal virtual modules (runtime, program, injected globals).
+ build.onResolve({ filter: /.*/ }, (args) => {
+ const name = resolveVirtualName(args.path)
+ if (name) return { path: name, namespace: 'mitos' }
+
+ // Everything else is an npm/url import: resolve through unpkg and fetch.
+ if (isUrl(args.path)) return { path: args.path, namespace: 'http' }
+ return { path: `https://unpkg.com/${args.path}?module`, namespace: 'http' }
+ })
+
+ build.onLoad({ filter: /.*/, namespace: 'mitos' }, (args) => ({
+ loader: 'ts',
+ contents: modules[args.path] ?? '',
+ }))
+
+ // Resolve relative imports inside fetched packages against their URL.
+ build.onResolve({ filter: /.*/, namespace: 'http' }, (args) => {
+ if (isUrl(args.path)) return { path: args.path, namespace: 'http' }
+ return {
+ path: new URL(args.path, 'https://unpkg.com' + args.resolveDir + '/').href,
+ namespace: 'http',
+ }
+ })
+
+ build.onLoad({ filter: /.*/, namespace: 'http' }, async (args) => {
+ const cached = moduleCache.get(args.path)
+ if (cached) {
+ return {
+ loader: 'ts',
+ contents: cached,
+ resolveDir: new URL('./', args.path).pathname,
+ }
+ }
+ const response = await fetch(args.path)
+ if (!response.ok) {
+ throw new Error(`Failed to load ${args.path}: HTTP ${response.status}`)
+ }
+ const contents = await response.text()
+ moduleCache.set(args.path, contents)
+ return {
+ loader: 'ts',
+ contents,
+ resolveDir: new URL('./', response.url).pathname,
+ }
+ })
+ },
+ }
+}
+
+async function bundleRuntime(
+ esbuildService: EsbuildService,
+ modules: Record,
+): Promise {
+ const result: BuildResult = await esbuildService.build({
+ entryPoints: ['mitos:entry'],
+ bundle: true,
+ minify: true,
+ format: 'iife',
+ globalName: '__mitosRuntime',
+ platform: 'browser',
+ target: 'es2020',
+ write: false,
+ treeShaking: true,
+ plugins: [createBundlePlugin(modules)],
+ define: {
+ global: 'globalThis',
+ 'process.env.NODE_ENV': '"production"',
+ },
+ })
+
+ if (!result.outputFiles || result.outputFiles.length === 0) {
+ throw new Error('esbuild produced no output')
+ }
+ return result.outputFiles[0].text.trim()
+}
+
+/**
+ * Produce a self-contained .tsx source string that runs the user's program live
+ * (via the bundled Mitos runtime + text renderer) inside a . The runtime is
+ * a small fixed cost and nothing is fetched at runtime. For image sources the
+ * derived 0–1 value grid is baked in (not the source image), so the program
+ * runs live against it just like the in-app preview.
+ */
+export async function generateReactComponentSource(
+ params: GenerateReactComponentSourceParams,
+): Promise {
+ const componentName = sanitizeComponentName(params.componentName ?? 'AsciiArtEmbed')
+ const animated = params.animationLength > 1
+
+ const runtimeBundle = await bundleRuntime(
+ params.esbuildService,
+ buildVirtualModules({
+ code: params.code,
+ characterSet: params.characterSet,
+ textColor: params.settings.textColor,
+ backgroundColor: params.settings.backgroundColor,
+ columns: params.columns,
+ rows: params.rows,
+ imageData: params.imageData,
+ frames: params.frames,
+ }),
+ )
+
+ const paddingPx = params.settings.padding * CHAR_WIDTH
+ const lineHeightPx = ASCII_FONT_SIZE_PX * ASCII_LINE_HEIGHT_RATIO
+
+ const style = [
+ '{',
+ ` margin: 0,`,
+ ` color: ${JSON.stringify(params.settings.textColor)},`,
+ ` backgroundColor: ${JSON.stringify(params.settings.backgroundColor)},`,
+ ` padding: ${paddingPx},`,
+ ` fontFamily: ${JSON.stringify('GT America Mono, ui-monospace, monospace')},`,
+ ` fontSize: ${ASCII_FONT_SIZE_PX},`,
+ ` lineHeight: '${lineHeightPx}px',`,
+ ` whiteSpace: 'pre' as const,`,
+ ` }`,
+ ].join('\n')
+
+ return `/*
+ * Generated by Mitos (https://github.com/oxidecomputer/mitos).
+ * Self-contained: the ASCII program and its runtime are bundled below — nothing
+ * is fetched at runtime. Requires React 18+ and "jsx": "react-jsx".
+ * GT America Mono may need a separate font license; ui-monospace/monospace are fallbacks.
+ */
+/* eslint-disable */
+// @ts-nocheck
+// prettier-ignore
+import { useEffect, useRef } from 'react'
+
+// --- Mitos runtime + program (generated, do not edit) ----------------------
+${runtimeBundle}
+// ---------------------------------------------------------------------------
+
+const COLS = ${params.columns}
+const ROWS = ${params.rows}
+const FPS = ${Math.round(params.fps)}
+const FRAMES = ${animated ? params.animationLength : 1}
+
+export interface ${componentName}Props {
+ className?: string
+}
+
+export function ${componentName}({ className }: ${componentName}Props) {
+ const ref = useRef(null)
+
+ useEffect(() => {
+ const element = ref.current
+ if (!element) return
+
+ const controller = __mitosRuntime.mount(element, {
+ cols: COLS,
+ rows: ROWS,
+ fps: FPS,
+ maxFrames: FRAMES,
+ })
+
+ ${animated ? 'controller.togglePlay(true)' : 'controller.setFrame(0)'}
+ return () => controller.cleanup()
+ }, [])
+
+ return (
+
+ )
+}
+
+export default ${componentName}
+`
+}
diff --git a/sandbox/index.html b/sandbox/index.html
new file mode 100644
index 0000000..c98ee43
--- /dev/null
+++ b/sandbox/index.html
@@ -0,0 +1,158 @@
+
+
+
+
+
+ Mitos React Export Sandbox
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/vercel.json b/vercel.json
index 6dbaf1c..d95d62c 100644
--- a/vercel.json
+++ b/vercel.json
@@ -4,6 +4,7 @@
"source": "/js/viewscript.js",
"destination": "https://trck.oxide.computer/js/plausible.js"
},
- { "source": "/api/event", "destination": "https://trck.oxide.computer/api/event" }
+ { "source": "/api/event", "destination": "https://trck.oxide.computer/api/event" },
+ { "source": "/sandbox", "destination": "/sandbox/index.html" }
]
}
diff --git a/vite.config.ts b/vite.config.ts
index 8bb2bfb..056406b 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -6,12 +6,36 @@
* Copyright Oxide Computer Company
*/
+import { resolve } from 'node:path'
import react from '@vitejs/plugin-react'
-import { defineConfig } from 'vite'
+import { defineConfig, type Plugin } from 'vite'
import tsconfigPaths from 'vite-tsconfig-paths'
+// Serve the /sandbox page (no trailing slash) in dev. Vite's static handling
+// only resolves the directory index for `/sandbox/`, so rewrite the bare path.
+// The deployed equivalent lives in vercel.json.
+function sandboxCleanUrl(): Plugin {
+ return {
+ name: 'sandbox-clean-url',
+ configureServer(server) {
+ server.middlewares.use((req, _res, next) => {
+ if (req.url === '/sandbox') req.url = '/sandbox/'
+ next()
+ })
+ },
+ }
+}
+
// https://vite.dev/config/
export default defineConfig({
- plugins: [react(), tsconfigPaths()],
+ plugins: [react(), tsconfigPaths(), sandboxCleanUrl()],
server: { port: 3000 },
+ build: {
+ rollupOptions: {
+ input: {
+ main: resolve(import.meta.dirname, 'index.html'),
+ sandbox: resolve(import.meta.dirname, 'sandbox/index.html'),
+ },
+ },
+ },
})