2024-06-07 12:49:25 -05:00
|
|
|
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'
|
2024-06-07 15:12:50 -05:00
|
|
|
import { open } from 'https://deno.land/x/opener/mod.ts'
|
2024-06-07 12:49:25 -05:00
|
|
|
|
|
|
|
const sockets: Set<WebSocket> = new Set([])
|
|
|
|
|
|
|
|
const Args = z.object({
|
2024-06-07 14:58:13 -05:00
|
|
|
input: z.string().default('./'),
|
2024-06-07 12:49:25 -05:00
|
|
|
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.',
|
|
|
|
{
|
2024-06-07 14:58:13 -05:00
|
|
|
default: './',
|
2024-06-07 12:49:25 -05:00
|
|
|
},
|
|
|
|
)
|
|
|
|
.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}`
|
2024-06-07 15:12:50 -05:00
|
|
|
console.log(`Opening ${url} in web browser...`)
|
|
|
|
await open(url)
|
2024-06-07 12:49:25 -05:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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') {
|
2024-06-07 14:01:52 -05:00
|
|
|
return new Response(clientJs, {
|
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 14:01:52 -05:00
|
|
|
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)
|
|
|
|
`
|
|
|
|
|
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()
|