import { Command } from 'https://deno.land/x/cliffy@v1.0.0-rc.4/command/mod.ts' import { z } from 'https://deno.land/x/zod@v3.23.8/mod.ts' import { open } from 'https://deno.land/x/open@v0.0.6/index.ts' const sockets: Set = new Set([]) const Args = z.object({ input: z.string().default('./'), open: z.boolean().default(false), server: z.boolean().default(true), host: z.string().default('localhost'), port: z.number().default(8080), }) type Args = z.infer const ClientEvent = z.discriminatedUnion('type', [ z.object({ type: z.literal('open'), connectionAttempt: z.number() }), ]) type ClientEvent = z.infer const command = new Command() .name('diagrammer') .description('A server to facilitate editing of Mermaid diagrams') .version('v1.0.0') .option( '-i, --input ', 'The directory containing .mmd files to compile.', { default: './', }, ) .option('--open', 'Include this flag to open your browser automatically.', { default: false, }) .option( '--no-server', 'Include this flag if you only want Mermaid compilation.', ) .option('-H, --host ', 'The host name for the local server.', { default: 'localhost', }) .option('-p, --port ', 'The port number for the local server.', { default: 8080, }) async function openBrowser(args: Args): Promise { if (args.open) { // TODO: this is broken? try { const url = `http://${args.host}:${args.port}` console.log(`Opening ${url} in web browser...`) // TODO: this won't work on NixOS for the time being I expect await open(url) } catch (err) { console.error(`Failed to open browser: ${err}`) } } } async function runServer(args: Args): Promise { const opts = { hostname: args.host, port: args.port } await Deno.serve(opts, async (req: Request, info: Deno.ServeHandlerInfo) => { if (req.headers.get('upgrade') == 'websocket') { return handleWebSocket(req, info) } const url = new URL(req.url) if (url.pathname == '/client.js') { return new Response(clientJs, { headers: new Headers({ 'content-type': 'text/javascript;charset=UTF-8', }), }) } else if (url.pathname == '/') { return new Response(page(null, await listDiagramsHtml(args.input)), { headers: new Headers({ 'content-type': 'text/html', charset: 'utf-8' }), }) } else { const file = url.pathname.replaceAll('..', '') if (file.endsWith('.mmd')) { if (req.headers.get('accepts') == 'text/mermaid') { return new Response(Deno.readTextFileSync(file), { headers: { 'content-type': 'text/mermaid' }, }) } return new Response( page( url.pathname, await diagramContent(args, url.pathname.replaceAll('..', '')), ), { headers: new Headers({ 'content-type': 'text/html', charset: 'utf-8', }), }, ) } } return new Response(`Hello, ${url.pathname}!`) }).finished } async function runMermaidFileWatcher(args: Args) { console.log('Starting Mermaid file watcher...') for await ( const { kind, paths } of Deno.watchFs(args.input, { recursive: true }) ) { console.log( `Mermaid file watcher event: ${kind} ${paths.join(', ')}`, ) for (const p of paths) { console.log(p) if (p.endsWith('.mmd') && (kind == 'create' || kind == 'modify')) { const contents = await Deno.readTextFile(p) sockets.forEach((s) => s.send(JSON.stringify({ type: 'mermaid', contents })) ) } // TODO: handle removals? } } } function handleWebSocket(req: Request, info: Deno.ServeHandlerInfo): Response { const { socket, response } = Deno.upgradeWebSocket(req) socket.addEventListener('open', (_ev) => { console.log(`websocket open from ${JSON.stringify(info.remoteAddr)}`) socket.send('hi') sockets.add(socket) }) socket.addEventListener('message', (ev) => { console.log(`websocket message: ${JSON.stringify(ev)}`) try { if (ev.data) { const data = JSON.parse(ev.data) const clientEvent = ClientEvent.parse(data) handleClientEvent(socket, clientEvent) } } catch (err) { console.error(`invalid client message: ${err}`) } }) socket.addEventListener('close', (ev) => { console.log(`websocket close: ${JSON.stringify(ev)}`) sockets.delete(socket) }) return response } function handleClientEvent(socket: WebSocket, ce: ClientEvent) { switch (ce.type) { case 'open': if (ce.connectionAttempt > 0) socket.send('reload') break } } const listDiagramsHtml = async (dir: string): Promise => { const list = [] for await (const entry of Deno.readDir(dir)) { if (entry.isFile && entry.name.endsWith('.mmd')) { list.push(`
  • ${entry.name}
  • `) } } return `
      ${list.join('\n')}
    ` } const diagramContent = async (args: Args, file: string): Promise => { let content = await Deno.readTextFile(args.input + file) return `
    ${content}
    ` } const page = (title: string | null, content: string) => ` ${title !== null ? `${title} - ` : ''}Diagram

    Diagram Index${ title !== null ? ` ${title}` : '' }

    ${content} ` const clientJs = ` import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs' let lastDiagram = '' let rendering = false let theme = 'dark' function setTheme(lightMatches) { theme = lightMatches ? 'default' : 'dark' mermaid.initialize({ startOnLoad: true, theme }) renderLastDiagram() } if (window.matchMedia) { let lightMatcher = window.matchMedia('(prefers-color-scheme: light)') lightMatcher.addEventListener('change', ({ matches }) => setTheme(matches)) setTheme(lightMatcher.matches) } let connectionAttempt = 0 function connectSocket() { const socket = new WebSocket('ws://localhost:8080') socket.addEventListener('open', () => { socket.send(JSON.stringify({ type: 'open', connectionAttempt })) }) socket.addEventListener('close', () => { connectionAttempt += 1 setTimeout(() => requestAnimationFrame(connectSocket), 1000) }) socket.addEventListener('message', handleMessage) } async function handleMessage({ data }) { if (data == 'hi') { console.log('👋') } else if (data == 'reload') { window.location.reload() } else { try { await handleStructuredMessage(JSON.parse(data)) } catch (err) { console.error(\`failed to process structured websocket message: \${err}\`) } } } async function handleStructuredMessage(data) { switch (data.type) { case 'mermaid': { if (rendering) return rendering = true lastDiagram = data.contents await renderLastDiagram() rendering = false break } } } async function renderLastDiagram() { if (lastDiagram == '') return document.getElementById('diagram').innerHTML = (await mermaid.render('mermaid', lastDiagram)).svg } addEventListener('DOMContentLoaded', connectSocket) ` async function run(args: Args) { await Promise.all([ runMermaidFileWatcher(args), runServer(args), openBrowser(args), ]) } command .action(async (rawArgs) => { const args = Args.parse(rawArgs) console.log({ args }) await run(args) }) .parse()