Skip to content

Latest commit

 

History

History
341 lines (310 loc) · 9.44 KB

build.md

File metadata and controls

341 lines (310 loc) · 9.44 KB

Build

This builds Ristretto. It runs a Markdown file with Deno, using a wrapper that gives it access to specific things through postMessage, much like how Ristretto runs.

Run it with this command:

run-build.js

import { join } from 'https://deno.land/[email protected]/path/mod.ts'

let writeReady = false

async function initWrite() {
  if (!writeReady) {
    for (const dir of ['build', 'out']) {
      try {
        await Deno.stat(join('.', dir))
      } catch (e) {
        if (e instanceof Deno.errors.NotFound) {
          await Deno.mkdir(join('.', dir))
        }
      }
    }
    writeReady = true
  }
}

async function* readPaths(suffix = '.md', parent = []) {
  const dirs = []
  for await (const file of Deno.readDir(join('.', ...parent))) {
    if (!(file.name.startsWith('.') || file.name === '_notas')) {
      if (file.isDirectory) {
        dirs.push(file.name)
      } else if (file.name.endsWith(suffix)) {
        yield [...parent, file.name]
      }
    }
  }
  for (const dir of dirs) {
    for await (const path of readPaths(suffix, [...parent, dir])) {
      yield path
    }
  }
}

async function readFile(path) {
  return await Deno.readFile(join('.', ...path))
}

async function writeFile(path, data) {
  await initWrite()
  const [topDir, ...rest] = path
  if (['build', 'out'].includes(topDir)) {
    const writePath = join('.', topDir, ...rest)
    if (writePath.match(/\.(md|html|json|js|svg)$/)) {
      await Deno.writeTextFile(writePath, new TextDecoder().decode(data))
    } else if (writePath.match(/\.(png|webm|jpe?g|ico)$|Dockerfile\.?/)) {
      await Deno.writeFile(writePath, data)
    } else {
      throw new Error('File type not allowed')
    }
  } else {
    throw new Error('Access denied')
  }
  await Deno.readFile(join('.', ...path))
}

async function handleMessage(e) {
  const [cmd, ...args] = e.data
  const port = e.ports[0]
  try {
    if (cmd === 'readPaths') {
      const paths = await Array.fromAsync(readPaths())
      port.postMessage(paths)
    } else if (cmd === 'readFile') {
      const [path] = args
      const data = await readFile(path)
      port.postMessage(data, [data.buffer])
    } else if (cmd === 'writeFile') {
      const [path, data] = args
      port.postMessage(await writeFile(path, data))
    } else {
      throw new Error(`Invalid command sent from worker: ${cmd}`)
    }
  } catch (err) {
    console.error('Error providing output for worker', err)
    port.postMessage(false)
  }
  port.close()
}

const re = /(?:^|\n)\s*\n`entry.js`\n\s*\n```.*?\n(.*?)```\s*(?:\n|$)/s
const runEntry = `
const re = new RegExp(${JSON.stringify(re.source)}, ${JSON.stringify(re.flags)})
addEventListener('message', async e => {
  if (e.data[0] === 'notebook') {
    globalThis.__source = new TextDecoder().decode(e.data[1])
    const entrySrc = globalThis.__source.match(re)[1]
    await import(\`data:text/javascript;base64,\${btoa(entrySrc)}\`)
  }
}, {once: true})
`
const worker = new Worker(`data:text/javascript;base64,${btoa(runEntry)}`, {
  type: 'module',
  permissions: 'none',
})
worker.addEventListener('message', handleMessage)
const data = await readFile(['build.md'])
worker.postMessage(['notebook', data], [data.buffer])

build.js

async function parentRequest(...data) {
  const channel = new MessageChannel()
  const result = await new Promise((resolve, _) => {
    channel.port1.onmessage = (message) => {
      channel.port1.close()
      resolve(message.data)
    }
    postMessage(data, [channel.port2])
  })
  if (result === false) {
    throw new Error(
      `Received false from parent request ${JSON.stringify(data[0])} in worker`
    )
  }
  return result
}

async function readPaths() {
  return await parentRequest('readPaths')
}

async function readFile(path) {
  return await parentRequest('readFile', path)
}

async function writeFile(path, data) {
  await parentRequest('writeFile', path, data)
}

function arrEquals(a1, a2) {
  return a1.length === a2.length && a1.every((v, i) => v === a2[i])
}

async function renderNotebook() {
  const appViewSrc = new TextDecoder().decode(await readFile(['app-view.md']))
  const loaderSrc = new TextDecoder().decode(await readFile(['loader.md']))
  let entry = ''
  for (const block of readBlocksWithNames(loaderSrc)) {
    if (block.name === 'entry.js') {
      entry = loaderSrc.slice(...block.blockRange)
    }
  }
  return `${appViewSrc}\n\n${entry}`
}

async function buildNotebook() {
  try {
    const allPaths = await readPaths()
    const sortedPaths = allPaths.sort((a, b) => {
      const withBundle = [a, b].map(path => [path.some(s => s.includes('codemirror-bundle.md')) ? 'b' : 'a', ...path])
      const len = Math.max(...withBundle.map(path => path.length))
      for (let i=0; i < len; i++) {
        const result = (withBundle[0][i] ?? '').localeCompare(withBundle[1][i] ?? '')
        if (result !== 0) {
          return result
        }
      }
      return 0
    })
    const paths = [
      ...sortedPaths.filter(path => (
        path.at('-1').endsWith('.md') &&
        path.at(0) !== 'build' &&
        path.at(0) !== 'out'
      )).map(path => ([path, true])),
    ]
    let output = await renderNotebook()
    for (const [path, wrap] of paths) {
      const text = new TextDecoder().decode(await readFile(path))
      if (wrap) {
        const quotes = '`'.repeat(Math.max(
          (
            text
            .matchAll(new RegExp('^\\s*(`+)', 'gm'))
            .map(m => m[1].length)
            .toArray()
            .toSorted((a, b) => a - b)
            .at(-1) ?? 0
          ) + 1,
          3
        ))
        if (path.some(part => part.includes('/'))) {
          throw new Error('/ found in path component')
        }
        const strPath = path.join('/')
        output = (
          output.trimRight() +
          `\n\n\`${strPath}\`\n\n${quotes}\n${text}\n${quotes}\n`
        )
      } else {
        output = output.trimRight() + "\n\n" + text + "\n"
      }
    }
    const data = new TextEncoder().encode(output.trimLeft())
    await writeFile(['out', 'notebook.md'], data)
    close()
  } catch (err) {
    console.error(err)
    close()
  }
}

async function buildScript(path, blockName, out) {
  const src = new TextDecoder().decode(await readFile(path))
  if (blockName !== undefined) {
    for (const block of readBlocksWithNames(src)) {
      if (block.name === blockName) {
        const data = new TextEncoder().encode(src.slice(...block.contentRange))
        await writeFile(out, data)
      }
    }
  } else {
    await writeFile(out, new TextEncoder().encode(src))
  }
}

async function buildScripts() {
  await buildScript(
    ['build-docker.md'],
    'Dockerfile',
    ['build', 'build-docker', 'Dockerfile']
  )
  await buildScript(
    ['build-libraries.md'],
    'run-container-build.js',
    ['build', 'build-libraries', 'run-container-build.js']
  )
  await buildScript(
    ['build-libraries.md'],
    'Dockerfile.proxy',
    ['build', 'build-libraries', 'Dockerfile.proxy']
  )
  await buildScript(
    ['build-libraries.md'],
    'proxy.js',
    ['build', 'build-libraries', 'proxy.js']
  )
  await buildScript(
    ['build-libraries.md'],
    undefined,
    ['build', 'build-libraries', 'build-libraries.md']
  )
  await buildScript(
    ['build-libraries.md'],
    'Dockerfile.build-in-container',
    ['build', 'build-libraries', 'Dockerfile.build-in-container']
  )
  await buildScript(
    ['build-libraries.md'],
    'run-build-in-container.js',
    ['build', 'build-libraries', 'run-build-in-container.js']
  )
  await buildScript(
    ['bundle-libraries.md'],
    'run-bundle-libraries.js',
    ['build', 'build-libraries', 'run-bundle-libraries.js']
  )
}

async function build() {
  await buildScripts()
  await buildNotebook()
}

await build()

entry.js

function* readBlocks(input) {
  const re = /(?:^|\n)([ \t]*)(`{3,}|~{3,})([^\n]*\n)/
  let index = 0
  while (index < input.length) {
    const open = input.substring(index).match(re)
    if (!open) {
      break
    } else if (open[1].length > 0 || open[2][0] === '~') {
      throw new Error(`Invalid open fence at ${index + open.index}`)
    }
    const contentStart = index + open.index + open[0].length
    const close = input.substring(contentStart).match(
      new RegExp(`\n([ ]{0,3})${open[2]}(\`*)[ \t]*\r?(?:\n|$)`)
    )
    if (!(close && close[1] === '')) {
      throw new Error(`Missing or invalid close fence at ${index + open.index}`)
    }
    const contentRange = [contentStart, contentStart + close.index]
    const blockRange = [index + open.index, contentRange.at(-1) + close[0].length]
    yield { blockRange, contentRange, info: open[3].trim() }
    index = blockRange.at(-1)
  }
}

function* readBlocksWithNames(input) {
  for (const block of readBlocks(input)) {
    const match = input.slice(0, block.blockRange[0]).match(
      new RegExp('(?<=\\n\\r?[ \\t]*\\n\\r?)`([^`]+)`\\s*\\n\\s*$')
    )
    yield ({...block, ...(match ? {name: match[1], blockRange: [block.blockRange[0] - match[0].length, block.blockRange[1]]} : undefined)})
  }
}

async function run(src) {
  globalThis.readBlocks = readBlocks
  globalThis.readBlocksWithNames = readBlocksWithNames
  for (const block of readBlocksWithNames(src)) {
    if (
      block.name !== undefined && block.name.endsWith('.js') &&
      block.name !== 'run-build.js' && block.name !== 'entry.js'
    ) {
      const blockSrc = src.slice(...block.contentRange)
      await import(`data:text/javascript;base64,${btoa(blockSrc)}`)
    }
  }
}

run(__source)

``

deno run --allow-read=. --allow-write=./build,./out --unstable-worker-options run-build.js