diagramming/mod.ts

232 lines
5.9 KiB
TypeScript
Raw Normal View History

2024-06-07 12:49:25 -05:00
#!/usr/bin/env -S deno run --allow-read --allow-write --allow-net --allow-env --ext=ts
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'
const sockets: Set<WebSocket> = new Set([])
const Args = z.object({
input: z.string().default('./src'),
output: z.string().default('./build'),
open: z.boolean().default(false),
server: z.boolean().default(true),
host: z.string().default('localhost'),
port: z.number().default(8080),
})
type Args = z.infer<typeof Args>
const ClientEvent = z.discriminatedUnion('type', [
z.object({ type: z.literal('open'), connectionAttempt: z.number() }),
])
type ClientEvent = z.infer<typeof ClientEvent>
const command = new Command()
.name('diagrammer')
.description('A server to facilitate editing of Mermaid diagrams')
.version('v1.0.0')
.option(
'-i, --input <input_directory:path>',
'The directory containing .mmd files to compile.',
{
default: './src',
},
)
.option(
'-o, --output <output_directory:path>',
'The directory to put compiled .svg files into.',
{
default: './build',
},
)
.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 <hostname>', 'The host name for the local server.', {
default: 'localhost',
})
.option('-p, --port <port:number>', 'The port number for the local server.', {
default: 8080,
})
async function openBrowser(args: Args): Promise<void> {
if (args.open) {
2024-06-07 13:58:08 -05:00
// TODO: this is broken?
2024-06-07 12:49:25 -05:00
const url = `http://${args.host}:${args.port}`
console.log(`opening browser to ${url}`)
const process = new Deno.Command('xdg-open', {
args: [url],
}).spawn()
console.log(`opened browser to ${url}: ${await process.status}`)
}
}
async function runServer(args: Args): Promise<void> {
const opts = { hostname: args.host, port: args.port }
2024-06-07 13:58:08 -05:00
await Deno.serve(opts, async (req: Request, info: Deno.ServeHandlerInfo) => {
2024-06-07 12:49:25 -05:00
if (req.headers.get('upgrade') == 'websocket') {
2024-06-07 13:58:08 -05:00
return handleWebSocket(req, info)
2024-06-07 12:49:25 -05:00
}
const url = new URL(req.url)
if (url.pathname == '/client.js') {
return new Response(await Deno.readTextFile('./client.js'), {
2024-06-07 13:58:08 -05:00
headers: new Headers({
'content-type': 'text/javascript;charset=UTF-8',
}),
2024-06-07 12:49:25 -05:00
})
} else if (url.pathname == '/') {
2024-06-07 13:58:08 -05:00
return new Response(page(null, await listDiagramsHtml(args.input)), {
headers: new Headers({ 'content-type': 'text/html', charset: 'utf-8' }),
2024-06-07 12:49:25 -05:00
})
2024-06-07 13:58:08 -05:00
} 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',
}),
},
)
}
2024-06-07 12:49:25 -05:00
}
return new Response(`Hello, ${url.pathname}!`)
2024-06-07 13:58:08 -05:00
}).finished
2024-06-07 12:49:25 -05:00
}
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?
}
}
}
2024-06-07 13:58:08 -05:00
function handleWebSocket(req: Request, info: Deno.ServeHandlerInfo): Response {
2024-06-07 12:49:25 -05:00
const { socket, response } = Deno.upgradeWebSocket(req)
socket.addEventListener('open', (_ev) => {
2024-06-07 13:58:08 -05:00
console.log(`websocket open from ${JSON.stringify(info.remoteAddr)}`)
2024-06-07 12:49:25 -05:00
socket.send('hi')
sockets.add(socket)
})
socket.addEventListener('message', (ev) => {
2024-06-07 13:58:08 -05:00
console.log(`websocket message: ${JSON.stringify(ev)}`)
2024-06-07 12:49:25 -05:00
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) => {
2024-06-07 13:58:08 -05:00
console.log(`websocket close: ${JSON.stringify(ev)}`)
2024-06-07 12:49:25 -05:00
sockets.delete(socket)
})
return response
}
function handleClientEvent(socket: WebSocket, ce: ClientEvent) {
switch (ce.type) {
case 'open':
if (ce.connectionAttempt > 0) socket.send('reload')
break
}
}
2024-06-07 13:58:08 -05:00
const listDiagramsHtml = async (dir: string): Promise<string> => {
const list = []
for await (const entry of Deno.readDir(dir)) {
if (entry.isFile && entry.name.endsWith('.mmd')) {
list.push(`<li><a href="/${entry.name}">${entry.name}</a></li>`)
}
}
return `<ul>${list.join('\n')}</ul>`
2024-06-07 12:49:25 -05:00
}
2024-06-07 13:58:08 -05:00
const diagramContent = async (args: Args, file: string): Promise<string> => {
let content = await Deno.readTextFile(args.input + file)
return `<div id="diagram" class="mermaid">${content}</div>`
}
const page = (title: string | null, content: string) =>
`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta viewport="">
<title>${title !== null ? `${title} - ` : ''}Diagram</title>
</head>
<body>
<h1><a href="/">Diagram Index</a>${
title !== null ? ` <a href="${title}">${title}</a>` : ''
}</h1>
<script type="module" src="./client.js"></script>
${content}
<style>
@media (prefers-color-scheme: dark) {
:root {
--bg: #111111;
--text: #ffffff;
}
}
body {
background-color: var(--bg);
color: var(--text);
}
</style>
</body>
</html>`
2024-06-07 12:49:25 -05:00
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()