|  | 
|  | 1 | +import fs from 'fs'; | 
|  | 2 | +import { readFile, rename } from 'fs/promises'; | 
|  | 3 | +import pathlib from 'path'; | 
|  | 4 | +import { pipeline } from 'stream/promises'; | 
|  | 5 | +import { parseArgs } from 'util'; | 
|  | 6 | +import { JSDOM } from 'jsdom'; | 
|  | 7 | +import { RewritingStream } from 'parse5-html-rewriting-stream'; | 
|  | 8 | +import tmp from 'tmp'; | 
|  | 9 | + | 
|  | 10 | +const { positionals: cliArgs } = parseArgs({ | 
|  | 11 | +  allowPositionals: true, | 
|  | 12 | +  options: {}, | 
|  | 13 | +}); | 
|  | 14 | +if (cliArgs.length < 3) { | 
|  | 15 | +  const self = pathlib.relative(process.cwd(), process.argv[1]); | 
|  | 16 | +  console.error(`Usage: node ${self} <template.html> <data.json> <file.html>... | 
|  | 17 | +
 | 
|  | 18 | +{{identifier}} substrings in template.html are replaced from data.json, then | 
|  | 19 | +the result is inserted into each file.html.`); | 
|  | 20 | +  process.exit(64); | 
|  | 21 | +} | 
|  | 22 | + | 
|  | 23 | +const main = async ([templateFile, dataFile, ...files]) => { | 
|  | 24 | +  // Substitute data into the template. | 
|  | 25 | +  const [ | 
|  | 26 | +    template, | 
|  | 27 | +    data, | 
|  | 28 | +  ] = await Promise.all([ | 
|  | 29 | +    readFile(templateFile, 'utf8'), | 
|  | 30 | +    readFile(dataFile, 'utf8').then(JSON.parse), | 
|  | 31 | +  ]) | 
|  | 32 | +  const formatErrors = []; | 
|  | 33 | +  const placeholderPatt = /[{][{](?:([\p{ID_Start}$_][\p{ID_Continue}$]*)[}][}]|.*?(?:[}][}]|(?=[{][{])|$))/gsu; | 
|  | 34 | +  const resolved = template.replaceAll(placeholderPatt, (m, name, i) => { | 
|  | 35 | +    if (!name) { | 
|  | 36 | +      const trunc = m.replace(/([^\n]{29}(?!$)|[^\n]{,29}(?=\n)).*/s, '$1…'); | 
|  | 37 | +      formatErrors.push(new SyntaxError(`bad placeholder at index ${i}: ${trunc}`)); | 
|  | 38 | +    } else if (!Object.hasOwn(data, name)) { | 
|  | 39 | +      formatErrors.push(new ReferenceError(`no data for ${m}`)); | 
|  | 40 | +    } | 
|  | 41 | +    return data[name]; | 
|  | 42 | +  }); | 
|  | 43 | +  if (formatErrors.length > 0) { | 
|  | 44 | +    throw new AggregateError(formatErrors); | 
|  | 45 | +  } | 
|  | 46 | + | 
|  | 47 | +  // Parse the template into DOM nodes for appending to page head (metadata such | 
|  | 48 | +  // as <style> elements) or prepending to page body (everything else). | 
|  | 49 | +  const jsdomOpts = { contentType: 'text/html; charset=utf-8' }; | 
|  | 50 | +  const { document } = new JSDOM(resolved, jsdomOpts).window; | 
|  | 51 | +  const headHTML = document.head.innerHTML; | 
|  | 52 | +  const bodyHTML = document.body.innerHTML; | 
|  | 53 | + | 
|  | 54 | +  // Perform the insertions. | 
|  | 55 | +  const work = files.map(async (file) => { | 
|  | 56 | +    await null; | 
|  | 57 | +    const { name: tmpName, fd, removeCallback } = tmp.fileSync({ | 
|  | 58 | +      tmpdir: pathlib.dirname(file), | 
|  | 59 | +      prefix: pathlib.basename(file), | 
|  | 60 | +      postfix: '.tmp', | 
|  | 61 | +      detachDescriptor: true, | 
|  | 62 | +    }); | 
|  | 63 | +    try { | 
|  | 64 | +      // Make a pipeline: fileReader -> inserter -> finisher -> fileWriter | 
|  | 65 | +      const fileReader = fs.createReadStream(file, 'utf8'); | 
|  | 66 | +      const fileWriter = fs.createWriteStream('', { fd, flush: true }); | 
|  | 67 | + | 
|  | 68 | +      // Insert headHTML at the end of a possibly implied head, and bodyHTML at | 
|  | 69 | +      // the beginning of a possibly implied body. | 
|  | 70 | +      // https://html.spec.whatwg.org/multipage/parsing.html#parsing-main-inhtml | 
|  | 71 | +      let mode = 'before html'; // | 'before head' | 'in head' | 'after head' | '$DONE' | 
|  | 72 | +      const stayInHead = new Set([ | 
|  | 73 | +        'base', | 
|  | 74 | +        'basefont', | 
|  | 75 | +        'bgsound', | 
|  | 76 | +        'head', | 
|  | 77 | +        'link', | 
|  | 78 | +        'meta', | 
|  | 79 | +        'noframes', | 
|  | 80 | +        'noscript', | 
|  | 81 | +        'script', | 
|  | 82 | +        'style', | 
|  | 83 | +        'template', | 
|  | 84 | +        'title', | 
|  | 85 | +      ]); | 
|  | 86 | +      const inserter = new RewritingStream(); | 
|  | 87 | +      const onEndTag = function (tag) { | 
|  | 88 | +        if (tag.tagName === 'head') { | 
|  | 89 | +          this.emitRaw(headHTML); | 
|  | 90 | +          mode = 'after head'; | 
|  | 91 | +        } | 
|  | 92 | +        this.emitEndTag(tag); | 
|  | 93 | +      }; | 
|  | 94 | +      const onStartTag = function (tag) { | 
|  | 95 | +        const echoTag = () => this.emitStartTag(tag); | 
|  | 96 | +        if (mode === 'before html' && tag.tagName === 'html') { | 
|  | 97 | +          mode = 'before head'; | 
|  | 98 | +        } else if (mode !== 'after head' && stayInHead.has(tag.tagName)) { | 
|  | 99 | +          mode = 'in head'; | 
|  | 100 | +        } else { | 
|  | 101 | +          if (mode !== 'after head') { this.emitRaw(headHTML); } | 
|  | 102 | +          // Emit either `${bodyTag}${bodyHTML}` or `${bodyHTML}${otherTag}`. | 
|  | 103 | +          const emits = [echoTag, () => this.emitRaw(bodyHTML)]; | 
|  | 104 | +          if (tag.tagName !== 'body') { emits.reverse(); } | 
|  | 105 | +          for (const emit of emits) { emit(); } | 
|  | 106 | +          mode = '$DONE'; | 
|  | 107 | +          this.off('endTag', onEndTag).off('startTag', onStartTag); | 
|  | 108 | +          return; | 
|  | 109 | +        } | 
|  | 110 | +        echoTag(); | 
|  | 111 | +      }; | 
|  | 112 | +      inserter.on('endTag', onEndTag).on('startTag', onStartTag); | 
|  | 113 | + | 
|  | 114 | +      // Ensure headHTML/bodyHTML insertion before EOF. | 
|  | 115 | +      const finisher = async function* (source) { | 
|  | 116 | +        for await (const chunk of source) { yield chunk; } | 
|  | 117 | +        if (mode === '$DONE') { return; } | 
|  | 118 | +        if (mode !== 'after head') { yield headHTML; } | 
|  | 119 | +        yield bodyHTML; | 
|  | 120 | +      }; | 
|  | 121 | + | 
|  | 122 | +      await pipeline(fileReader, inserter, finisher, fileWriter); | 
|  | 123 | + | 
|  | 124 | +      // Now that the temp file is complete, overwrite the source file. | 
|  | 125 | +      await rename(tmpName, file); | 
|  | 126 | +    } finally { | 
|  | 127 | +      removeCallback(); | 
|  | 128 | +    } | 
|  | 129 | +  }); | 
|  | 130 | +  const results = await Promise.allSettled(work); | 
|  | 131 | + | 
|  | 132 | +  const failures = results.filter((result) => result.status !== 'fulfilled'); | 
|  | 133 | +  if (failures.length > 0) { | 
|  | 134 | +    throw new AggregateError(failures.map((r) => r.reason)); | 
|  | 135 | +  } | 
|  | 136 | +}; | 
|  | 137 | + | 
|  | 138 | +main(cliArgs).catch((err) => { | 
|  | 139 | +  console.error(err); | 
|  | 140 | +  process.exit(1); | 
|  | 141 | +}); | 
0 commit comments