OurBigBook logoOurBigBook Docs OurBigBook logoOurBigBook.comSite Source code
ourbigbook
#!/usr/bin/env node

const now = performance.now.bind(performance)

const china_dictatorship = require('china-dictatorship');
if (!china_dictatorship.get_data().includes("Tiannmen Square protests")) throw 0;

const child_process = require('child_process')
const fs = require('fs')
const path = require('path')
const os = require('os')
const isConversionWorker = process.argv[2] === '--internal-conversion-worker' && !!process.send
let workerData, grantDatabaseWrite

// This library is terrible, too much magic, hard to understand interface,
// does not do some obvious basics.
const commander = require('commander');
const is_installed_globally = require('is-installed-globally');
const readCb = require('read');
const { Liquid } = require('liquidjs');
const lodash = require('lodash')
const { DataTypes, Op, Sequelize } = require('sequelize')

const ourbigbook = require('ourbigbook');
const {
  cssEscapeDoubleQuotedString,
  DIR_PREFIX,
  FILE_PREFIX,
  FILE_TYPE_FILE,
  FILE_TYPE_DIRECTORY,
  INDEX_BASENAME_NOEXT,
  OURBIGBOOK_EXT,
  RAW_PREFIX,
  URL_SEP,
} = ourbigbook
const ourbigbook_nodejs = require('ourbigbook/nodejs');
const ourbigbook_nodejs_front = require('ourbigbook/nodejs_front');
const ourbigbook_nodejs_webpack_safe = require('ourbigbook/nodejs_webpack_safe');
const { cliInt } = ourbigbook_nodejs_webpack_safe
const {
  ARTICLE_HASH_LIMIT_MAX,
  ARTICLE_RENDER_BATCH_LIMIT,
  articleHash,
  hashToHex,
  read_include,
  WebApi,
} = require('ourbigbook/web_api');

const DEFAULT_TEMPLATE_BASENAME = 'ourbigbook.liquid.html';
const OURBIGBOOK_TEX_BASENAME = 'ourbigbook.tex';
const LOG_OPTIONS = new Set([
  'ast',
  'ast-simple',
  'db',
  'headers',
  'tokens',
]);
const SASS_EXT = '.scss';
const DEFAULT_IGNORE_BASENAMES = [
  ourbigbook.RESERVED_ID_SEPARATOR,
  '.git',
  ourbigbook_nodejs_webpack_safe.TMP_DIRNAME,
  '_raw',
  '_dir',
];
const DEFAULT_IGNORE_BASENAMES_SET = new Set(DEFAULT_IGNORE_BASENAMES);
const MESSAGE_PREFIX_EXTRACT_IDS = 'extract_ids'
const MESSAGE_PREFIX_RENDER = 'render'
const MESSAGE_SKIP_BY_TIMESTAMP = `skipped by timestamp`
const WEB_MAX_RETRIES = 3
const WEB_RETRY_DELAY_MS = 1000

// A bounded pool reused across the extraction and rendering barriers. Each
// worker runs one file at a time; the coordinator serializes its transactions.
// Processes also isolate native SQLite bindings from concurrent JS runtimes.
class CliWorkerPool {
  constructor(filename, jobs, workerData) {
    this.filename = filename
    this.jobs = jobs
    this.workerData = workerData
    this.workers = []
    this.writeQueue = []
  }

  addWorker() {
    const worker = child_process.fork(this.filename, ['--internal-conversion-worker'], {
      serialization: 'advanced',
      stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
    })
    const slot = { worker }
    slot.ready = new Promise((resolve, reject) => { slot.startup = { resolve, reject } })
    slot.exited = new Promise(resolve => {
      worker.on('exit', code => {
        if (!this.closing && !this.error) {
          this.fail(new Error(`conversion worker exited unexpectedly (status ${code})`))
        }
        resolve()
      })
    })
    worker.on('error', error => this.fail(error))
    worker.on('message', result => {
      if (this.error) return
      if (result.ready) {
        slot.startup.resolve()
        slot.startup = undefined
        return
      }
      if (result.acquireWrite) {
        this.writeQueue.push(slot)
        this.grantWrite()
        return
      }
      if (result.releaseWrite) {
        this.writer = undefined
        this.grantWrite()
        return
      }
      const pending = slot.pending
      slot.pending = undefined
      if (result.error) {
        this.fail(new Error(result.error))
        if (pending) pending.reject(this.error)
      } else if (pending) {
        pending.resolve(result)
      }
    })
    this.workers.push(slot)
    worker.send(this.workerData)
  }

  grantWrite() {
    if (!this.writer && this.writeQueue.length && !this.error) {
      this.writer = this.writeQueue.shift()
      this.writer.worker.send({ writeGranted: true })
    }
  }

  fail(error) {
    if (this.error) return
    this.error = error
    for (const slot of this.workers) {
      if (slot.startup) slot.startup.reject(error)
      if (slot.pending) {
        slot.pending.reject(error)
        slot.pending = undefined
      }
      // A crashed worker may have held the write lock. Stop its peers rather
      // than leaving them waiting forever; SQLite rolls back open transactions.
      slot.worker.kill()
    }
  }

  async run(tasks) {
    if (this.error) throw this.error
    while (this.workers.length < Math.min(this.jobs, tasks.length)) this.addWorker()
    const results = new Array(tasks.length)
    let next = 0
    let hadError = false
    await Promise.all(this.workers.map(async slot => {
      await slot.ready
      while (!hadError && !this.error && next < tasks.length) {
        const i = next++
        const result = await new Promise((resolve, reject) => {
          slot.pending = { resolve, reject }
          slot.worker.send(tasks[i])
        })
        results[i] = result
        if (result.had_error) hadError = true
      }
    }))
    return results.filter(result => result !== undefined)
  }

  async close() {
    this.closing = true
    for (const slot of this.workers) {
      if (!this.error) slot.worker.send({ close: true })
    }
    await Promise.all(this.workers.map(slot => slot.exited))
    if (this.error) throw this.error
  }
}


class DbProviderDbAdapter {
  constructor(nonOurbigbookOptions) {
  }
}

function addUsername(idNoUsername, username) {
  if (idNoUsername !== null && idNoUsername !== undefined) {
    if (idNoUsername === '') {
      return  `${ourbigbook.AT_MENTION_CHAR}${username}`
    } else {
      return `${ourbigbook.AT_MENTION_CHAR}${username}/${idNoUsername}`
    }
  }
  return null
}

function assertApiStatus(status, data) {
  //console.log(require('child_process').execSync(`printf 'count '; sqlite3 /home/ciro/bak/git/ourbigbook/web/db.sqlite3 "select to_id_index,from_id,to_id,defined_at from Ref where from_id = '@barack-obama' and type = 0 order by to_id_index" | wc -l`).toString())
  //console.log(require('child_process').execSync(`sqlite3 /home/ciro/bak/git/ourbigbook/web/db.sqlite3 "select to_id_index,from_id,to_id,defined_at from Ref where from_id = '@barack-obama' and type = 0 order by to_id_index"`).toString())
  if (status !== 200) {
    console.error(`HTTP error status: ${status}`);
    console.error(`Error messages from server:`);
    const errors = data.errors
    if (errors instanceof Array) {
      for (const error of data.errors) {
        console.error(error);
      }
    } else {
      if (errors === undefined) {
        console.error(data);
      } else {
        console.error(errors);
      }
    }
    cli_error()
  }
}

async function read(opts) {
  return new Promise((resolve, reject) => {
    // TODO allow program to exit on Ctrl + C, currently ony cancels read
    // https://stackoverflow.com/questions/24037545/how-to-hide-password-in-the-nodejs-console
    readCb(opts, (err, line) => {
      resolve([err, line])
    })
  })
}

// Like read, but:
// * Ctrl + C works and quits program
// * no password support
async function readStdin(opts) {
  const chunks = [];
  for await (const chunk of process.stdin) chunks.push(chunk);
  return Buffer.concat(chunks).toString('utf8');
}

// Reconcile the database with information that depends only on existence of Ourbigbook files, notably:
// - remove any IDs from deleted files https://github.com/ourbigbook/ourbigbook/issues/125
async function reconcile_db_and_filesystem(input_path, ourbigbook_options, nonOurbigbookOptions) {
  const sequelize = nonOurbigbookOptions.sequelize
  if (sequelize) {
    const newNonOurbigbookOptions = ourbigbook.cloneAndSet(
      nonOurbigbookOptions, 'ourbigbook_paths_converted_only', true)
    await convert_directory(
      input_path,
      ourbigbook_options,
      newNonOurbigbookOptions,
    );
    const inputRelpath = path.relative(nonOurbigbookOptions.ourbigbook_json_dir, input_path)
    let pathPrefix
    if (inputRelpath) {
      pathPrefix = inputRelpath + path.sep
    } else {
      pathPrefix = ''
    }
    pathPrefix += '%'
    const { File, Id, Ref, Render } = sequelize.models
    const ourbigbook_paths_converted = newNonOurbigbookOptions.ourbigbook_paths_converted
    const [,,,file_rows] = await Promise.all([
      // Delete IDs from deleted files.
      // It is possible in pure SQL, but not supported in sequelize:
      // https://cirosantilli.com/delete-with-join-sql
      // so we do a double query for now.
      sequelize.models.Id.findAll({
        attributes: ['id'],
        include: [
          {
            model: File,
            as: 'idDefinedAt',
            where: {
              path: {
                [Op.not]: ourbigbook_paths_converted,
                [Op.like]: pathPrefix,
              },
            },
            attributes: [],
          },
        ],
      }).then(ids => Id.destroy({ where: { id: ids.map(id => id.id ) } })),
      // Delete Refs from deleted files.
      Ref.findAll({
        attributes: ['id'],
        include: [
          {
            model: File,
            as: 'definedAt',
            where: {
              path: {
                [Op.not]: ourbigbook_paths_converted,
                [Op.like]: pathPrefix,
              }
            },
            attributes: [],
          },
        ],
      }).then(ids => Ref.destroy({ where: { id: ids.map(id => id.id ) } })),
      // Delete deleted Files
      File.destroy({
        where: {
          path: {
            [Op.not]: ourbigbook_paths_converted,
            [Op.like]: pathPrefix,
          }
        }
      }),
      File.findAll({
        where: { path: ourbigbook_paths_converted },
        include: [{
          model: Render,
          where: {
            type: Render.Types[nonOurbigbookOptions.renderType],
          },
          // We still want to get last_parsed from non-rendered files.
          required: false,
        }],
      }),
    ])
    const file_rows_dict = {}
    for (const file_row of file_rows) {
      file_rows_dict[file_row.path] = file_row
    }
    nonOurbigbookOptions.file_rows_dict[nonOurbigbookOptions.renderType] = file_rows_dict
  }
}

// Do various post conversion checks to verify database integrity:
//
// - duplicate IDs
// - https://docs.ourbigbook.com/x-within-title-restrictions
// - check that all files are included except for the index file
//
// Previously these were done inside ourbigbook.convert. But then we started skipping render by timestamp,
// so if you e.g. move an ID from one file to another, a common operation, then it would still see
// the ID in the previous file depending on conversion order. So we are moving it here instead at the end.
// Having this single query at the end also be slightly more efficient than doing each query separately per file conversion.
async function check_db(nonOurbigbookOptions, { exitOnError=true }={}) {
  if (nonOurbigbookOptions.cli.checkDb) {
    const t1 = now();
    console.log(`check_db`)
    const sequelize = nonOurbigbookOptions.sequelize
    if (sequelize && (nonOurbigbookOptions.cli.render || nonOurbigbookOptions.cli.checkDb)) {
      const error_messages = await ourbigbook_nodejs_webpack_safe.check_db(
        sequelize,
        nonOurbigbookOptions.ourbigbook_paths_converted,
        {
          filterFilesThatDontExist: (aRefs) => aRefs.filter(
            aRef => !fs.existsSync(resolvePublishedMediaPath(nonOurbigbookOptions.ourbigbook_json_dir, aRef.to, nonOurbigbookOptions))),
          options: nonOurbigbookOptions.options,
          perf: false,
        }
      )
      if (error_messages.length > 0) {
        console.error(error_messages.map(m => 'error: ' + m).join('\n'))
        nonOurbigbookOptions.had_error = true
        if (exitOnError) cli_error()
        return
      }
    }
    console.log(`check_db: ${finished_in_ms(now() - t1)}`)
  }
}

function chomp(s) {
  return s.replace(/(\r\n|\n)$/, '')
}

/** Report an error with the CLI usage and exit in error. */
function cli_error(message) {
  if (message !== undefined) {
    console.error(`error: ${message}`)
  }
  process.exit(1)
}

async function convert_directory_callback(input_path, ourbigbook_options, nonOurbigbookOptions, cb, extraPaths=[]) {
  nonOurbigbookOptions.ourbigbook_paths_converted = []
  const tasks = []
  const sourcePaths = walk_directory_recursively(
    input_path,
    DEFAULT_IGNORE_BASENAMES_SET,
    nonOurbigbookOptions.ignore_paths,
    nonOurbigbookOptions.ignore_path_regexps,
    nonOurbigbookOptions.dont_ignore_path_regexps,
    nonOurbigbookOptions.ourbigbook_json_dir,
  )
  function* paths() { yield* sourcePaths; yield* extraPaths }
  for (const entry of paths()) {
    const onePath = typeof entry === 'string' ? entry : entry.path
    const virtualPath = typeof entry === 'string' ? undefined : entry.relpath
    const relpath = virtualPath ?? path.relative(nonOurbigbookOptions.ourbigbook_json_dir, onePath)
    const isPreview = cb === convert_file_preview
    const previewPath = path.join(nonOurbigbookOptions.outdir, FILE_PREFIX, relpath + '.' + ourbigbook.HTML_EXT)
    if (
      nonOurbigbookOptions.parallel &&
      !nonOurbigbookOptions.ourbigbook_paths_converted_only &&
      (
        (isPreview && ourbigbook_options.render && !fs.lstatSync(onePath).isDirectory() &&
          !skipFilePreview(onePath, previewPath, relpath, nonOurbigbookOptions)) ||
        (cb === convert_path_to_file && isBigbPath(onePath, nonOurbigbookOptions.cli) &&
          !do_ignore_convert_path(relpath, nonOurbigbookOptions.ignore_convert_path_regexps,
            nonOurbigbookOptions.dont_ignore_convert_path_regexps) &&
          !skipFileConversion(onePath, relpath, ourbigbook_options, nonOurbigbookOptions))
      )
    ) {
      const row = nonOurbigbookOptions.file_rows_dict[nonOurbigbookOptions.renderType]?.[relpath]
      tasks.push({
        input_path: onePath,
        preview: isPreview,
        relpath,
        virtualPath,
        fileUsageHash: nonOurbigbookOptions.fileUsageHashes?.[FILE_PREFIX + URL_SEP + relpath],
        file_row: row ? row.toJSON() : undefined,
        render: ourbigbook_options.render,
        is_render_after_extract: nonOurbigbookOptions.is_render_after_extract,
      })
    } else {
      await cb(onePath, ourbigbook_options, virtualPath === undefined
        ? nonOurbigbookOptions : { ...nonOurbigbookOptions, virtualPath })
    }
    if (nonOurbigbookOptions.had_error) {
      break
    }
  }
  if (nonOurbigbookOptions.had_error || !tasks.length) return
  // Do not pay worker startup costs for an incremental build of one file.
  if (tasks.length === 1 && !nonOurbigbookOptions.parallel.pool) {
    await cb(tasks[0].input_path, ourbigbook_options, tasks[0].virtualPath === undefined
      ? nonOurbigbookOptions : { ...nonOurbigbookOptions, virtualPath: tasks[0].virtualPath })
    return
  }
  const parallel = nonOurbigbookOptions.parallel
  if (!parallel.pool) {
    const sequelize = nonOurbigbookOptions.sequelize
    if (sequelize.getDialect() === 'sqlite') {
      // Readers run alongside the single writer without blocking its commit.
      await sequelize.query('PRAGMA journal_mode=WAL')
    }
    const plainOptions = Object.fromEntries(Object.entries(ourbigbook_options).filter(
      ([key, value]) => !['db_provider', 'katex_macros'].includes(key) && typeof value !== 'function'))
    const plainNonOptions = Object.fromEntries(Object.entries(nonOurbigbookOptions).filter(
      ([key, value]) => !['sequelize', 'options', 'extra_returns', 'parallel', 'file_rows_dict', 'fileUsageHashes', 'localMediaDirectories'].includes(key) &&
        typeof value !== 'function'))
    parallel.pool = new CliWorkerPool(__filename, nonOurbigbookOptions.cli.jobs, {
      options: plainOptions,
      nonOptions: plainNonOptions,
    })
  }
  for (const result of await parallel.pool.run(tasks)) {
    nonOurbigbookOptions.ourbigbook_paths_converted.push(...result.paths)
    nonOurbigbookOptions.sitemap.push(...result.sitemap)
    nonOurbigbookOptions.had_error ||= result.had_error
    nonOurbigbookOptions.build_errors.push(...result.build_errors)
    nonOurbigbookOptions.last_output_path = result.last_output_path
  }
}

/**
 * @param {String} input_path - path to a directory to convert files in
 */
async function convert_directory(input_path, ourbigbook_options, nonOurbigbookOptions) {
  return convert_directory_callback(input_path, ourbigbook_options, nonOurbigbookOptions, convert_path_to_file)
}

/** Extract IDs from all input files into the ID database, without fully converting. */
async function convert_directory_extract_ids(input_path, ourbigbook_options, nonOurbigbookOptions) {
  await convert_directory(
    input_path,
    ourbigbook.cloneAndSet(ourbigbook_options, 'render', false),
    nonOurbigbookOptions
  )
}

async function convert_directory_extract_ids_and_render(input_dir, ourbigbook_options, nonOurbigbookOptions) {
  // Shared by the coordinator's shallow option copies throughout this build.
  nonOurbigbookOptions.build_errors = []
  if (
    nonOurbigbookOptions.cli.jobs > 1 &&
    !nonOurbigbookOptions.cli.watch && !nonOurbigbookOptions.cli.formatSource &&
    !ourbigbook_options.embed_includes &&
    nonOurbigbookOptions.db_options.storage !== ourbigbook_nodejs_webpack_safe.SQLITE_MAGIC_MEMORY_NAME
  ) {
    nonOurbigbookOptions.parallel = {}
  }
  try {
    await convert_directory_stages(input_dir, ourbigbook_options, nonOurbigbookOptions)
  } finally {
    const pool = nonOurbigbookOptions.parallel?.pool
    if (pool) {
      await pool.close()
      if (nonOurbigbookOptions.sequelize.getDialect() === 'sqlite') {
        await nonOurbigbookOptions.sequelize.query('PRAGMA wal_checkpoint(TRUNCATE)')
      }
    }
    delete nonOurbigbookOptions.parallel
    if (pool && nonOurbigbookOptions.build_errors.length) {
      // Workers inherit stdout/stderr. Wait for their exit before repeating errors,
      // so no in-flight progress output can bury the final diagnostics.
      console.error('\nBuild failed. Errors:')
      for (const error of new Set(nonOurbigbookOptions.build_errors)) console.error(error)
    }
  }
}

async function convert_directory_stages(input_dir, ourbigbook_options, nonOurbigbookOptions) {
  const localMedia = ourbigbook_options.output_format === ourbigbook.OUTPUT_FORMAT_HTML &&
    path.resolve(input_dir) === path.resolve(nonOurbigbookOptions.ourbigbook_json_dir)
    ? collectLocalMedia(nonOurbigbookOptions) : undefined
  nonOurbigbookOptions.localMediaDirectories = localMedia?.directories
  await reconcile_db_and_filesystem(input_dir, ourbigbook_options, nonOurbigbookOptions)
  await convert_directory_extract_ids(input_dir, ourbigbook_options, nonOurbigbookOptions)

  if (!nonOurbigbookOptions.had_error) {
    // check_db must come before any type of rendering and blow up
    // or else it can lead to infinite DB queries notably on duplicate ID scenarios.
    if (
      !nonOurbigbookOptions.had_error &&
      (
        ourbigbook_options.render ||
        path.relative(nonOurbigbookOptions.ourbigbook_json_dir, input_dir) === ''
      )
    ) {
      await check_db(nonOurbigbookOptions, { exitOnError: false })
      if (nonOurbigbookOptions.had_error) return
    }

    if (ourbigbook_options.output_format === ourbigbook.OUTPUT_FORMAT_HTML) {
      // File bytes can stay unchanged while their embedding articles change.
      // Fetch lightweight usage dependencies once, not once per preview.
      const usage = {}
      const sequelize = nonOurbigbookOptions.sequelize
      if (sequelize) {
        const { ARef, File, Id } = sequelize.models
        const refs = await ARef.findAll({
          attributes: ['to'],
          raw: true,
          include: [
            { model: File, as: 'aRefDefinedAt', attributes: ['last_parse'], required: true },
            { model: Id, as: 'fromId', attributes: ['idid'], required: true },
          ],
          order: [['to', 'ASC'], [{ model: Id, as: 'fromId' }, 'idid', 'ASC']],
        })
        for (const ref of refs) {
          const target = FILE_PREFIX + URL_SEP + ourbigbook.fileUsagePath(ref.to, nonOurbigbookOptions.localMediaPath)
          if (ref['fromId.idid'] === target) continue
          ;(usage[target] ||= []).push([ref['fromId.idid'], ref['aRefDefinedAt.last_parse']])
        }
      }
      nonOurbigbookOptions.fileUsageHashes = Object.fromEntries(
        Object.entries(usage).map(([id, refs]) => [id, hashToHex(JSON.stringify(refs))]))
      // Auto-generate a {file} _file page for each file in the project that does not have one already.
      // Auto-generate source, and convert it on the fly, a bit like for _dir conversion.
      const ourbigbook_paths_converted = nonOurbigbookOptions.ourbigbook_paths_converted
      // Not ideal, but we'll do it simple for now. This needs to be restored or a test fails.
      nonOurbigbookOptions.ourbigbook_paths_converted = ourbigbook_paths_converted
      await convert_directory_callback(
        input_dir,
        ourbigbook_options,
        nonOurbigbookOptions,
        convert_file_preview,
        localMedia?.files,
      )
      // Not ideal, but we'll do it simple for now. This needs to be restored or a test fails.
      nonOurbigbookOptions.ourbigbook_paths_converted = ourbigbook_paths_converted
    }

    if (
      nonOurbigbookOptions.cli.render &&
      !nonOurbigbookOptions.had_error
    ) {
      nonOurbigbookOptions.nonBigbPathsConverted = new Set()
      const newNonOurbigbookOptions = ourbigbook.cloneAndSet(nonOurbigbookOptions, 'is_render_after_extract', true)
      await convert_directory(
        input_dir,
        ourbigbook_options,
        newNonOurbigbookOptions,
      )
      nonOurbigbookOptions.had_error = newNonOurbigbookOptions.had_error
      if (localMedia && !nonOurbigbookOptions.had_error) {
        for (const { path: source, relpath } of localMedia.files) {
          const destination = path.join(nonOurbigbookOptions.outdir, RAW_PREFIX, relpath)
          if (nonOurbigbookOptions.cli.forceRender || !fs.existsSync(destination) ||
              fs.statSync(source).mtimeMs > fs.statSync(destination).mtimeMs) {
            fs.mkdirSync(path.dirname(destination), { recursive: true })
            fs.copyFileSync(source, destination)
          }
        }
        // Shared directories were rendered in the ordinary source walk above.
        for (const relpath of Object.keys(localMedia.directories)) {
          if (fs.existsSync(path.join(nonOurbigbookOptions.ourbigbook_json_dir, relpath))) continue
          const listingDir = path.join(nonOurbigbookOptions.outdir, DIR_PREFIX, relpath)
          if (fs.existsSync(listingDir) && !fs.statSync(listingDir).isDirectory()) {
            cli_error(`local media collides with generated output: ${relpath}`)
          }
          await convert_path_to_file(path.join(localMedia.root, relpath), ourbigbook_options,
            { ...nonOurbigbookOptions, virtualPath: relpath })
        }
      }
    }
  }
}

function filePreviewUsageMarker(relpath, nonOptions) {
  const hash = nonOptions.fileUsageHash || nonOptions.fileUsageHashes?.[FILE_PREFIX + URL_SEP + relpath] || hashToHex('[]')
  return `<!--ourbigbook-file-usage-v3:${hash}-->`
}

function outputHasMarker(outpath, marker) {
  if (!fs.existsSync(outpath)) return false
  const size = fs.statSync(outpath).size
  if (size < marker.length) return false
  // Read only the cache marker, even when the preview contains a large file.
  const fd = fs.openSync(outpath, 'r')
  try {
    const tail = Buffer.alloc(marker.length)
    fs.readSync(fd, tail, 0, tail.length, size - tail.length)
    return tail.toString() === marker
  } finally {
    fs.closeSync(fd)
  }
}

function skipFilePreview(onePath, outpath, relpath, nonOptions) {
  return !nonOptions.cli.forceRender && fs.existsSync(outpath) &&
    fs.statSync(onePath).mtime <= fs.statSync(outpath).mtime &&
    outputHasMarker(outpath, filePreviewUsageMarker(relpath, nonOptions))
}

async function convert_file_preview(onePath, ourbigbook_options, nonOurbigbookOptions) {
  const messagePrefix = 'file'
  if (
    // TODO move dir conversion here, remove this check:
    // https://docs.ourbigbook.com/todo/show-directory-listings-on-file-headers
    !fs.lstatSync(onePath).isDirectory()
  ) {
    const inputPathRelativeToOurbigbookJson = nonOurbigbookOptions.virtualPath ?? path.relative(nonOurbigbookOptions.ourbigbook_json_dir, onePath);
    const outputPathRelativeToOutdir = path.join(
      ourbigbook.FILE_PREFIX,
      inputPathRelativeToOurbigbookJson + '.' + ourbigbook.HTML_EXT
    )
    const outpath = path.join(nonOurbigbookOptions.outdir, outputPathRelativeToOutdir)
    const msgRet = convert_path_to_file_print_starting(ourbigbook_options, onePath, messagePrefix)
    if (
      nonOurbigbookOptions.publish &&
      ourbigbook_options.ourbigbook_json.generateSitemap &&
      ourbigbook_options.render
    ) {
      nonOurbigbookOptions.sitemap.push(outputPathRelativeToOutdir)
    }
    let skip
    if (
      skipFilePreview(onePath, outpath, inputPathRelativeToOurbigbookJson, nonOurbigbookOptions)
    ) {
      skip = true
    } else {
      const sequelize = nonOurbigbookOptions.sequelize
      const idid = ourbigbook.FILE_PREFIX + ourbigbook.URL_SEP + inputPathRelativeToOurbigbookJson
      if (
        !sequelize ||
        !(await sequelize.models.Id.findOne({
          attributes: ['id'],
          // Without splitting, an inline {file} section still needs a preview,
          // but an authored standalone file page must never be overwritten.
          where: { idid, ...(ourbigbook_options.split_headers ? {} : { toplevel_id: idid }) },
        }))
      ) {
        const src = `${ourbigbook.SHORTHAND_HEADER_CHAR} ${ourbigbook.ourbigbookEscapeNotStart(inputPathRelativeToOurbigbookJson)}\n{file}\n`
        const inputPath = ourbigbook.FILE_PREFIX + ourbigbook.URL_SEP + inputPathRelativeToOurbigbookJson + '.' + ourbigbook.OURBIGBOOK_EXT
        const newOptions = {
          ...ourbigbook_options,
          auto_generated_source: true,
          split_headers: false,
          hFileShowLarge: true,
        }
        const newNonOurbigbookOptions = { ...nonOurbigbookOptions }
        newNonOurbigbookOptions.input_path = inputPath
        const output = await convert_input(src, newOptions, newNonOurbigbookOptions);
        if (newNonOurbigbookOptions.had_error) {
          throw new Error(`src: ${src}`)
        }
        if (newOptions.render) {
          fs.mkdirSync(path.dirname(outpath), { recursive: true });
          fs.writeFileSync(outpath, output + filePreviewUsageMarker(inputPathRelativeToOurbigbookJson, nonOurbigbookOptions))
        }
      } else {
        skip = 'skipped ID already exists'
      }
    }
    convert_path_to_file_print_finish(ourbigbook_options, onePath, outpath, { skip, message_prefix: messagePrefix, t0: msgRet.t0 })
  }
}

/** Convert OurBigBook input from a string to output and return the output as a string.
 *
 * Wraps ourbigbook.convert with CLI usage convenience.
 *
 * @param {String} input
 * @param {Object} options - options to be passed to ourbigbook.convert
 * @param {Object} nonOurbigbookOptions - control options for this function,
 *                 not passed to ourbigbook.convert. Also contains some returns:
 *                 - {bool} had_error
 *                 - {Object} extra_returns
 * @return {String}
 */
async function convert_input(input, ourbigbook_options, nonOurbigbookOptions={}) {
  const new_options = { ...ourbigbook_options }
  if ('input_path' in nonOurbigbookOptions) {
    new_options.input_path = nonOurbigbookOptions.input_path
  }
  if ('title' in nonOurbigbookOptions) {
    new_options.title = nonOurbigbookOptions.title
  }
  let inputFormat = nonOurbigbookOptions.input_format || nonOurbigbookOptions.cli.inputFormat
  if (inputFormat === undefined && nonOurbigbookOptions.input_path) {
    const inputExt = path.parse(nonOurbigbookOptions.input_path).ext
    if (inputExt === '.md' || inputExt === '.markdown') {
      inputFormat = 'markdown'
    }
  }
  if (inputFormat === undefined) {
    inputFormat = 'bigb'
  }
  if (inputFormat === 'markdown' || inputFormat === 'md') {
    input = await ourbigbook.markdownToOurbigbook(input)
  } else if (inputFormat !== 'bigb') {
    cli_error(`unknown input format: ${inputFormat}`)
  }
  new_options.extra_returns = {}
  // If we don't where the output will go (the case for stdout) or
  // the user did not explicitly request full embedding, inline all CSS.
  // Otherwise, include and external CSS to make each page lighter.
  if (nonOurbigbookOptions.cli.embedResources) {
    new_options.template_vars.style = fs.readFileSync(
      ourbigbook_nodejs.DIST_CSS_PATH,
      ourbigbook_nodejs_webpack_safe.ENCODING
    )
    new_options.template_vars.post_body = `<script>${fs.readFileSync(
      ourbigbook_nodejs.DIST_JS_PATH, ourbigbook_nodejs_webpack_safe.ENCODING)}</script>\n`
  } else {
    let includes_str = ``;
    let scripts_str = ``;
    let includes = [];
    let scripts = [];
    let includes_local = [];
    let scripts_local = [];
    let template_includes_relative = [];
    let template_scripts_relative = [];
    if (nonOurbigbookOptions.publish) {
      template_includes_relative.push(
        path.relative(
          nonOurbigbookOptions.outdir,
          nonOurbigbookOptions.out_css_path
        )
      );
      template_scripts_relative.push(
        path.relative(
          nonOurbigbookOptions.outdir,
          nonOurbigbookOptions.out_js_path
        )
      );
    } else {
      includes_local.push(nonOurbigbookOptions.out_css_path);
      scripts_local.push(nonOurbigbookOptions.out_js_path);
    }
    if (
      ourbigbook_options.outfile !== undefined &&
      !is_installed_globally
    ) {
      for (const include of includes_local) {
        includes.push(path.relative(path.dirname(ourbigbook_options.outfile), include));
      }
      for (const script of scripts_local) {
        scripts.push(path.relative(path.dirname(ourbigbook_options.outfile), script));
      }
    } else {
      includes.push(...includes_local);
      scripts.push(...scripts_local);
    }

    for (const include of includes) {
      includes_str += `@import "${cssEscapeDoubleQuotedString(include)}";\n`;
    }
    for (const script of scripts) {
      scripts_str += `<script src="${script}"></script>\n`
    }
    new_options.template_vars.style = `\n${includes_str}`
    new_options.template_vars.post_body = `${scripts_str}`
    new_options.template_styles_relative = template_includes_relative;
    new_options.template_scripts_relative = template_scripts_relative;
  }
  // Finally, do the conversion!
  const output = await ourbigbook.convert(input, new_options, new_options.extra_returns);
  if (nonOurbigbookOptions.post_convert_callback) {
    await nonOurbigbookOptions.post_convert_callback(nonOurbigbookOptions.input_path, new_options.extra_returns)
  }
  if (nonOurbigbookOptions.log.ast) {
    console.error('ast:');
    console.error(JSON.stringify(new_options.extra_returns.ast, null, 2));
    console.error();
  }
  if (nonOurbigbookOptions.log['ast-simple']) {
    console.error('ast-simple:');
    console.error(new_options.extra_returns.ast.toString());
    console.error();
  }
  // Remove duplicate messages due to split header rendering. We could not collect
  // errors from that case at all maybe, but do we really want to run the risk of
  // missing errors?
  for (const error_string of ourbigbook_nodejs_webpack_safe.remove_duplicates_sorted_array(
      new_options.extra_returns.errors.map(e => e.toString()))) {
    console.error(error_string);
    nonOurbigbookOptions.build_errors?.push(error_string)
  }
  for (const warning_string of ourbigbook_nodejs_webpack_safe.remove_duplicates_sorted_array(
      new_options.extra_returns.warnings.map(warning => warning.toString()))) {
    console.error(warning_string);
  }
  nonOurbigbookOptions.extra_returns = new_options.extra_returns;
  if (new_options.extra_returns.errors.length > 0) {
    nonOurbigbookOptions.had_error = true;
  }
  ourbigbook.perfPrint(new_options.extra_returns.context, 'convert_input_end')
  return output;
}

function isBigbPath(input_path, cli) {
  const ext = path.extname(input_path)
  const markdownExplicit = cli.inputFormat === 'markdown' || cli.inputFormat === 'md'
  return ((ext === `.${OURBIGBOOK_EXT}` && !markdownExplicit) || ext === '.md' || ext === '.markdown') &&
    !fs.lstatSync(input_path).isDirectory()
}

function skipFileConversion(input_path, relpath, options, nonOptions) {
  const row = nonOptions.file_rows_dict[nonOptions.renderType]?.[relpath]
  if (!row) return false
  if (row.last_parse && new Date(row.last_parse).getTime() <= nonOptions.configMtime) {
    if (row.Render) row.Render.outdated = true
    return false
  }
  if (options.render) {
    return !!row.Render && !nonOptions.cli.forceRender && !row.Render.outdated && !nonOptions.cli.formatSource
  }
  const skip = !nonOptions.cli.forceRender && row.last_parse !== null && row.last_parse > fs.statSync(input_path).mtime
  if (!skip && row.Render) row.Render.outdated = true
  return skip
}

async function withDatabaseWrite(callback) {
  if (!isConversionWorker) return callback()
  await new Promise(resolve => {
    grantDatabaseWrite = resolve
    process.send({ acquireWrite: true })
  })
  try {
    return await callback()
  } finally {
    process.send({ releaseWrite: true })
  }
}

/** Convert filetypes that ourbigbook knows how to convert, and just copy those that we don't, e.g.:
 *
 * * .bigb to .html
 * * .scss to .css
 *
 * @param {string} input_path - path relative to the base_path, e.g. `./ourbigbook subdir` gives:
 *   base_path: "subdir" and input_path "index.bigb" amongst other files.
 *
 * The output file name is derived from the input file name with the output extension.
 */
async function convert_path_to_file(input_path, ourbigbook_options, nonOurbigbookOptions={}) {
  let msg_ret
  let output, first_output_path;
  let skip = false
  const is_directory = fs.lstatSync(input_path).isDirectory()
  let full_path = path.resolve(input_path);
  let input_path_parse = path.parse(input_path);
  let input_path_relative_to_ourbigbook_json;
  if (nonOurbigbookOptions.ourbigbook_json_dir !== undefined) {
    input_path_relative_to_ourbigbook_json = nonOurbigbookOptions.virtualPath ?? path.relative(nonOurbigbookOptions.ourbigbook_json_dir, input_path);
  }
  let new_options
  const isbigb = isBigbPath(input_path, nonOurbigbookOptions.cli)
  if (is_directory || isbigb) {
    nonOurbigbookOptions.ourbigbook_paths_converted.push(input_path_relative_to_ourbigbook_json)
    if (nonOurbigbookOptions.ourbigbook_paths_converted_only) {
      return
    }
    new_options = {
      ...ourbigbook_options
    }
  }
  let showFinish = false
  let message_prefix
  const ignore_convert_path = do_ignore_convert_path(
    input_path_relative_to_ourbigbook_json,
    nonOurbigbookOptions.ignore_convert_path_regexps,
    nonOurbigbookOptions.dont_ignore_convert_path_regexps
  )
  if (isbigb) {
    if (!ignore_convert_path) {
      showFinish = true
      msg_ret = convert_path_to_file_print_starting(ourbigbook_options, input_path)
      message_prefix = msg_ret.message_prefix
      let newNonOurbigbookOptions = { ...nonOurbigbookOptions }
      let input = fs.readFileSync(full_path, newNonOurbigbookOptions.encoding);
      skip = skipFileConversion(input_path, input_path_relative_to_ourbigbook_json,
        ourbigbook_options, nonOurbigbookOptions)
      if (!skip) {
        newNonOurbigbookOptions.input_path = input_path_relative_to_ourbigbook_json;

        // Convert.
        const new_options_main =  { ...new_options }
        let ourbigbook_json = new_options_main.ourbigbook_json
        if (ourbigbook_json === undefined) {
          ourbigbook_json = {}
        } else {
          ourbigbook_json = { ...ourbigbook_json }
        }
        new_options_main.ourbigbook_json = ourbigbook_json
        let lint = ourbigbook_json.lint
        if (lint === undefined) {
          lint = {}
        } else {
          lint = { ...lint }
        }
        ourbigbook_json.lint = lint
        if (lint.startsWithH1Header === undefined) {
          lint.startsWithH1Header = true
        }
        output = await convert_input(input, new_options_main, newNonOurbigbookOptions);
        if (newNonOurbigbookOptions.had_error) {
          nonOurbigbookOptions.had_error = true;
        }
        const extra_returns = newNonOurbigbookOptions.extra_returns
        if (
          nonOurbigbookOptions.cli.formatSource &&
          ourbigbook_options.render
        ) {
          if (!newNonOurbigbookOptions.had_error) {
            fs.writeFileSync(full_path, output);
          }
          first_output_path = full_path
        } else {
          // Write out the output the output files.
          for (const outpath in extra_returns.rendered_outputs) {
            const output_path = path.join(nonOurbigbookOptions.outdir, outpath);
            if (output_path === full_path) {
              cli_error(`output path equals input path: "${outpath}"`);
            }
            fs.mkdirSync(path.dirname(output_path), { recursive: true });
            if (
              ourbigbook_options.render &&
              nonOurbigbookOptions.publish &&
              ourbigbook_options.ourbigbook_json.generateSitemap
            ) {
              nonOurbigbookOptions.sitemap.push(outpath)
            }
            const renderedOutput = extra_returns.rendered_outputs[outpath]
            if (
              // We only want split on web.
              !nonOurbigbookOptions.web || renderedOutput.split
            ) {
              if (first_output_path === undefined) {
                first_output_path = output_path
              }
              fs.writeFileSync(output_path, renderedOutput.full)
            }
          }
        }
        if (
          new_options.split_headers &&
          ourbigbook_options.output_format === ourbigbook.OUTPUT_FORMAT_HTML
        ) {
          for (const header_ast of extra_returns.context.synonym_headers) {
            const new_options_redir = { ...nonOurbigbookOptions.options }
            new_options_redir.db_provider = extra_returns.context.db_provider;
            await generate_redirect(new_options_redir, header_ast.id, header_ast.synonym, nonOurbigbookOptions.outdir);
          }
        }

        const context = extra_returns.context;
        if (nonOurbigbookOptions.log.headers) {
          console.error(context.header_tree.toString());
        }

        // Update the Sqlite database with results from the conversion.
        ourbigbook.perfPrint(context, 'convert_path_pre_sqlite')
        if ('sequelize' in nonOurbigbookOptions && !nonOurbigbookOptions.options.embed_includes) {
          await withDatabaseWrite(() => ourbigbook_nodejs_webpack_safe.update_database_after_convert({
            extra_returns,
            db_provider: new_options.db_provider,
            had_error: nonOurbigbookOptions.had_error,
            is_render_after_extract: nonOurbigbookOptions.is_render_after_extract,
            nonOurbigbookOptions,
            renderType: nonOurbigbookOptions.renderType,
            path: input_path_relative_to_ourbigbook_json,
            render: ourbigbook_options.render,
            sequelize: nonOurbigbookOptions.sequelize,
          }))
        }
      }
    } else {
      console.log(`ignoreConvert: ${input_path_relative_to_ourbigbook_json}`)
    }
  }
  if (
    nonOurbigbookOptions.cli.formatSource
  ) {
    nonOurbigbookOptions.last_output_path = first_output_path
    // I should use callbacks instead of doing this. But lazy.
    return
  }

  // _raw and _dir auto-generation. --split-headers on explicit headers with {file}
  // still happens outside of this, this is just for auto-generation.
  const convertNonBigb = ourbigbook_options.output_format === ourbigbook.OUTPUT_FORMAT_HTML
  const editableMarkdown = ourbigbook_options.output_format === ourbigbook.OUTPUT_FORMAT_MARKDOWN
  const copyToRaw = ourbigbook_options.output_format !== ourbigbook.OUTPUT_FORMAT_OURBIGBOOK && !(editableMarkdown && isbigb)
  let output_path_noext
  const forceRender = nonOurbigbookOptions.input_path_is_file || nonOurbigbookOptions.cli.forceRender
  if (ourbigbook_options.output_format !== ourbigbook.OUTPUT_FORMAT_OURBIGBOOK) {
    output_path_noext = path.join(
      editableMarkdown ? '' : (is_directory ? DIR_PREFIX : RAW_PREFIX),
      is_directory ? input_path_relative_to_ourbigbook_json
        : path.join(path.dirname(input_path_relative_to_ourbigbook_json), path.parse(input_path_relative_to_ourbigbook_json).name),
    )
    if (is_directory) {
      if (
        nonOurbigbookOptions.publish &&
        ourbigbook_options.ourbigbook_json.generateSitemap &&
        ourbigbook_options.render
      ) {
        nonOurbigbookOptions.sitemap.push(output_path_noext + URL_SEP)
      }
      output_path_noext = path.join(output_path_noext, 'index')
    }
    if (ourbigbook_options.outfile === undefined) {
      output_path_noext = path.join(nonOurbigbookOptions.outdir, output_path_noext);
    } else {
      output_path_noext = ourbigbook_options.outfile;
    }

    if (ourbigbook_options.render) {
      fs.mkdirSync(path.dirname(output_path_noext), { recursive: true });
      if (!isbigb && convertNonBigb && !ignore_convert_path) {
        // Convert non-OurBigBook files and directories.
        let isSass = false
        let knownType = true
        if (is_directory) {
          first_output_path = path.join(output_path_noext + '.' + ourbigbook.HTML_EXT)
          message_prefix = 'dir'
        } else {
          if (input_path_parse.ext === SASS_EXT) {
            isSass = true
            first_output_path = output_path_noext + '.css'
            message_prefix = 'scss'
          } else {
            knownType = false
          }
        }
        if (knownType) {
          showFinish = true
        }
        const directoryEntries = is_directory ? mergedDirectoryEntries(
          input_path_relative_to_ourbigbook_json, nonOurbigbookOptions) : undefined
        const directoryMarker = is_directory
          ? `<!--ourbigbook-directory:${hashToHex(JSON.stringify(directoryEntries))}-->` : ''
        if (
          fs.existsSync(first_output_path) &&
          fs.statSync(input_path).mtime <= fs.statSync(first_output_path).mtime &&
          !forceRender && (!is_directory || outputHasMarker(first_output_path, directoryMarker))
        ) {
          skip = true
        } else {
          if (knownType) {
            msg_ret = convert_path_to_file_print_starting(ourbigbook_options, input_path, message_prefix)
          }
          if (is_directory) {
            // TODO get rid of this, move it entirely to the same code path as {file} generation:
            // https://docs.ourbigbook.com/todo/show-directory-listings-on-file-headers
            // Generate bigb source code for a directory conversion and render it on the fly.
            // TODO move to render https://docs.ourbigbook.com/todo/remove-synthetic-asts
            // Not asts, but source code generation here. Even worse! We did it like this to be
            // able to more easily reuse the ourbigbook.liquid.html template and style.
            //const title = `Directory: ${input_path_relative_to_ourbigbook_json}`
            //const arr = [`<!doctype html><html lang=en><head><meta charset=utf-8><title>${title}</title></head><body><h1>${title}</h1><ul>`]
            //function push_li(name, isdir) {
            //  const target = `${name}${isdir ? '/' : ''}`
            //  arr.push(`<li><a href=${target + (isdir ? 'index.html' : '')}>${target}</a></li>`)
            //}
            //fs.writeFileSync(output_path, arr.join('') + '</ul></body></html>')
            const dirs = []
            const files = []
            for (const [name, isdir] of directoryEntries) {
              ;(isdir ? dirs : files).push(name)
            }
            const dirArr = []
            const crumbArr = []
            let breadcrumbDir = input_path_relative_to_ourbigbook_json
            let upcount = 0
            if (breadcrumbDir === '.') {
              breadcrumbDir = ''
            }
            const new_options_dir = {
              ...new_options,
              auto_generated_source: true,
              rootRelpathPref: '../',
            }
            let toplevelId = DIR_PREFIX
            if (input_path_relative_to_ourbigbook_json === '') {
              new_options_dir.title = ourbigbook.FILE_ROOT_PLACEHOLDER
            } else {
              new_options_dir.title = input_path_relative_to_ourbigbook_json
              toplevelId += ourbigbook.Macro.HEADER_SCOPE_SEPARATOR + input_path_relative_to_ourbigbook_json
            }
            new_options_dir.toplevelId = toplevelId
            const newNonOurbigbookOptions = { ...nonOurbigbookOptions }
            // Needed the path to be able to find the relatively placed CSS under _raw.
            // notindex.bigb instead of index.bigb because this will be placed at subdir/index.html, unlike the .bigb
            // convention that places subdir/index.bigb at subdir.html rather than subdir/index.html so a different
            // number of up levels is needed.
            newNonOurbigbookOptions.input_path = path.join(DIR_PREFIX, input_path_relative_to_ourbigbook_json, `${INDEX_BASENAME_NOEXT}.${OURBIGBOOK_EXT}`);
            const htmlXExtension = newNonOurbigbookOptions.options.htmlXExtension
            const indexHtml = htmlXExtension ? 'index.html' : ''
            while (true) {
              const breadcrumbParse = path.parse(breadcrumbDir)
              let bname = breadcrumbParse.name
              if (breadcrumbDir === '') {
                // TODO https://github.com/ourbigbook/ourbigbook/issues/372
                bname = ourbigbook.FILE_ROOT_PLACEHOLDER
              }
              if (upcount === 0) {
                crumbArr.push(ourbigbook.ourbigbookEscapeNotStart(bname))
              } else {
                crumbArr.push(`\\a[${ourbigbook.ourbigbookEscapeNotStart(path.join(...Array(upcount).fill('..').concat([indexHtml])))}][${ourbigbook.ourbigbookEscapeNotStart(bname)}]{external}`)
              }
              if (breadcrumbDir === '') {
                break
              }
              breadcrumbDir = breadcrumbParse.dir
              upcount++
            }
            // Root.
            dirArr.push(...[...crumbArr].reverse().join(` ${ourbigbook.URL_SEP} `))
            dirArr.push(` ${ourbigbook.URL_SEP}`)
            if (files.length || dirs.length) {
              dirArr.push(`\n\n`)
            }
            function push_li(name, isdir) {
              const target = `${name}`
              let targetHref
              if (isdir) {
                targetHref = target
                if (indexHtml) {
                  targetHref += ourbigbook.URL_SEP + indexHtml
                }
              } else {
                targetHref = path.join(
                  path.relative(path.join(DIR_PREFIX, input_path_relative_to_ourbigbook_json), '.'),
                  FILE_PREFIX,
                  input_path_relative_to_ourbigbook_json,
                  target
                ) + (htmlXExtension ? '.' + ourbigbook.HTML_EXT : '')
              }
              dirArr.push(`* \\a[${ourbigbook.ourbigbookEscapeNotStart(targetHref)}][${ourbigbook.ourbigbookEscapeNotStart(target)}${isdir ? ourbigbook.URL_SEP : ''}]{external}\n`)
            }
            for (const name of [...dirs].sort()) {
              push_li(name, true)
            }
            for (const name of [...files].sort()) {
              push_li(name, false)
            }

            output = await convert_input(dirArr.join(''), new_options_dir, newNonOurbigbookOptions);
            if (newNonOurbigbookOptions.had_error) {
              throw new Error()
            }
            fs.writeFileSync(first_output_path, output + directoryMarker)
          } else {
            if (isSass) {
              fs.writeFileSync(
                first_output_path,
                require('sass').renderSync({
                  data: fs.readFileSync(input_path, nonOurbigbookOptions.encoding),
                  outputStyle: 'compressed',
                  includePaths: [
                    path.dirname(ourbigbook_nodejs.PACKAGE_PATH),
                  ],
                }).css
              );
            }
          }
        }
      }
    }
  }
  if (!is_directory && !isbigb && nonOurbigbookOptions.nonBigbPathsConverted !== undefined) {
    nonOurbigbookOptions.nonBigbPathsConverted.add(input_path)
  }
  if (showFinish) {
    convert_path_to_file_print_finish(
      ourbigbook_options,
      input_path,
      first_output_path,
      {
        message_prefix,
        skip,
        t0: msg_ret ? msg_ret.t0 : undefined
      }
    )
  }
  if (ourbigbook_options.output_format !== ourbigbook.OUTPUT_FORMAT_OURBIGBOOK) {
    if (!nonOurbigbookOptions.had_error && (isbigb || copyToRaw)) {
      // Copy the file into raw.
      if (
        copyToRaw &&
        !nonOurbigbookOptions.ourbigbook_paths_converted_only &&
        !is_directory &&
        ourbigbook_options.render
      ) {
        const output_path = output_path_noext + input_path_parse.ext;
        if (output_path !== path.resolve(input_path)) {
          let skip_str
          if (
            fs.existsSync(output_path) &&
            fs.statSync(input_path).mtime <= fs.statSync(output_path).mtime &&
            !forceRender
          ) {
            skip_str = ` (${MESSAGE_SKIP_BY_TIMESTAMP})`
          } else {
            skip_str = ''
          }
          console.log(`copy: ${path.relative(process.cwd(), input_path)} -> ${path.relative(process.cwd(), output_path)}${skip_str}`)
          if (
            nonOurbigbookOptions.publish &&
            ourbigbook_options.ourbigbook_json.generateSitemap &&
            ourbigbook_options.render
          ) {
            nonOurbigbookOptions.sitemap.push(path.relative(nonOurbigbookOptions.outdir, output_path))
          }
          if (!skip_str) {
            fs.copyFileSync(input_path, output_path)
          }
        }
      }
    }
  }

  if (ourbigbook_options.perf) {
    console.error(`perf convert_path_to_file_end ${now()}`);
  }
  nonOurbigbookOptions.last_output_path = first_output_path
  return output;
}

function convert_path_to_file_print_starting(ourbigbook_options, input_path, message_prefix) {
  if (message_prefix === undefined) {
    if (ourbigbook_options.render) {
      message_prefix = MESSAGE_PREFIX_RENDER;
    } else {
      message_prefix = MESSAGE_PREFIX_EXTRACT_IDS;
    }
  }
  const message = `${message_prefix}: ${path.relative(process.cwd(), input_path)}`;
  const t0 = now()
  console.log(message);
  return { message_prefix, t0 };
}

function convert_path_to_file_print_finish(ourbigbook_options, input_path, output_path, opts={}) {
  const { message_prefix, skip, t0 } = opts
  // Print conversion finished successfully info.
  let t1 = now();
  let output_path_str
  if (
    ourbigbook_options.render &&
    // Happens if:
    // - conversion to .tex
    output_path !== undefined
  ) {
    output_path_str = ` -> ${path.relative(process.cwd(), output_path)}`
  } else {
    output_path_str = ''
  }
  let skipMsg
  if (skip === true) {
    skipMsg = MESSAGE_SKIP_BY_TIMESTAMP
  } else if (skip) {
    skipMsg = skip
  }
  let doneStr
  if (skipMsg) {
    doneStr = `(${skipMsg})`
  } else {
    doneStr = finished_in_ms(t1 - t0)
  }
  console.log(`${message_prefix}: ${path.relative(process.cwd(), input_path)}${output_path_str} ${doneStr}`);
}

async function create_db(ourbigbook_options, nonOurbigbookOptions) {
  perfPrint('create_db_begin', ourbigbook_options)
  const sequelize = await ourbigbook_nodejs_webpack_safe.createSequelize(
    nonOurbigbookOptions.db_options,
    { force: nonOurbigbookOptions.cli.clearDb },
  )
  nonOurbigbookOptions.sequelize = sequelize;
  ourbigbook_options.db_provider = new ourbigbook_nodejs_webpack_safe.SqlDbProvider(sequelize);
  perfPrint('create_db_end', ourbigbook_options)
}

function do_ignore_convert_path(p, ignore_convert_path_regexps, dont_ignore_convert_path_regexps) {
  for (const re of ignore_convert_path_regexps) {
    if (re.test(p)) {
      for (const re2 of dont_ignore_convert_path_regexps) {
        if (re2.test(p)) {
          return false
        }
      }
      return true
    }
  }
  return false
}

function finished_in_ms(ms) {
  return `(finished in ${Math.floor(ms)} ms)`
}

async function generate_redirect(ourbigbook_options, redirect_src_id, redirect_target_id, outdir) {
  ourbigbook_options = { ...ourbigbook_options }
  ourbigbook_options.input_path = redirect_src_id;
  const outpath_basename = redirect_src_id + '.' + ourbigbook.HTML_EXT
  const outpath = path.join(outdir, outpath_basename);
  ourbigbook_options.outfile = outpath_basename;
  const redirect_href = await ourbigbook.convertXHref(redirect_target_id, ourbigbook_options);
  if (redirect_href === undefined) {
    cli_error(`redirection target ID "${redirect_target_id}" not found`);
  }
  generate_redirect_base(outpath, redirect_href)
}

function generate_redirect_base(outpath, redirect_href) {
  fs.mkdirSync(path.dirname(outpath), {recursive: true})
  // https://stackoverflow.com/questions/10178304/what-is-the-best-approach-for-redirection-of-old-pages-in-jekyll-and-github-page/36848440#36848440
  fs.writeFileSync(outpath,
`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Redirecting...</title>
<link rel="canonical" href="${redirect_href}"/>
<meta http-equiv="refresh" content="0;url=${redirect_href}" />
</head>
<body>
<h1>Redirecting...</h1>
<a href="${redirect_href}">Click here if you are not redirected.</a>
<script>location='${redirect_href}'</script>
</body>
</html>
`);
}

/** Return Set of branches in the repository. Hax. */
function git_branches(input_path) {
  const str = runCmd('git', ['branch', '-a']).replace(/\n$/, '')
  const arr = (str === '') ? [] : str.split('\n');
  return new Set(arr.map(s => s.substring(2)));
}

function git_has_commit(input_path) {
  try {
    runCmd('git', ['-C', input_path, 'log'], { showCmd: false, throwOnError: true })
    return true
  } catch(err) {
    return false
  }
}

/**
 * Check if path ourbigbook_json_dir is in a git repository and not ignored.
 * @return boolean
 */
function git_is_in_repo(ourbigbook_json_dir) {
  const extra_returns = {}
  runCmd('git', ['-C', ourbigbook_json_dir, 'check-ignore', ourbigbook_json_dir], {
    throwOnError: false, showCmd: false, extra_returns })
  // Exit statuses:
  // - 0: is ignored
  // - 1: is not ignored
  // - 128: not in git repository
  return extra_returns.out.status === 1
}

/**
 * @return Array[String] list of all non gitignored files and directories
 */
function git_ls_files(input_path) {
  const ret = runCmd(
    'git',
    ['-C', input_path, 'ls-files'],
    {
      showCmd: false,
      throwOnError: true
    }
  )
  ret.replace(/\n$/, '')
  if (ret === '') {
    return []
  } else {
    return ret.split('\n')
  }
}

/**
 * @return {String} full Git SHA of the source.
 */
function gitSha(input_path, srcBranch) {
  const args = ['-C', input_path, 'log', '-n1', '--pretty=%H'];
  if (srcBranch !== undefined) {
    args.push(srcBranch);
  }
  return chomp(runCmd('git', args, {showCmd: false, throwOnError: true}))
}

// A reusable, disposable checkout. Keep conversion caches, but never source edits.
function checkoutWebSnapshot(source, destination) {
  const opts = { throwOnError: true, showCmd: false }
  const commit = gitSha(source)
  if (!fs.existsSync(destination)) {
    fs.mkdirSync(path.dirname(destination), { recursive: true })
    runCmd('git', ['clone', '--no-checkout', '--', source, destination], opts)
  } else {
    const origin = chomp(runCmd('git', ['-C', destination, 'remote', 'get-url', 'origin'], opts))
    if (path.resolve(origin) !== path.resolve(source)) cli_error(`unexpected snapshot repository origin: ${destination}`)
    runCmd('git', ['-C', destination, 'fetch', 'origin', commit], opts)
  }
  runCmd('git', ['-C', destination, 'checkout', '--force', '--detach', commit], opts)
  runCmd('git', ['-C', destination, 'clean', '-ffd', '-x', '-e', '_out/'], opts)
  const updateSubmodules = (original, checkout) => {
    const modules = path.join(checkout, '.gitmodules')
    if (!fs.existsSync(modules)) return
    const entries = runCmd('git', ['config', '--file', modules, '--null', '--get-regexp', '^submodule\\..*\\.path$'], {
      ...opts, throwOnError: false,
    }).split('\0').filter(Boolean)
    for (const entry of entries) {
      const newline = entry.indexOf('\n')
      const relative = entry.slice(newline + 1)
      const local = path.join(original, relative)
      const nested = path.join(checkout, relative)
      const initialized = fs.existsSync(path.join(local, '.git'))
      if (initialized) {
        // Use local objects even when the pinned commit has not been pushed yet.
        // Clone directly: Git's submodule helper rejects paths starting with '-'.
        const pinned = chomp(runCmd('git', ['-C', checkout, 'rev-parse', `HEAD:${relative}`], opts))
        if (!fs.existsSync(path.join(nested, '.git'))) {
          runCmd('git', ['clone', '--no-checkout', '--', local, nested], opts)
        } else {
          runCmd('git', ['-C', nested, 'fetch', local, pinned], opts)
        }
        runCmd('git', ['-C', nested, 'checkout', '--force', '--detach', pinned], opts)
      } else {
        runCmd('git', ['-C', checkout, 'submodule', 'update', '--init', '--force', '--', relative], opts)
      }
      runCmd('git', ['-C', nested, 'clean', '-ffd', '-x'], opts)
      updateSubmodules(local, nested)
    }
  }
  updateSubmodules(source, destination)
  return commit
}

function git_toplevel(input_path) {
  return chomp(runCmd('git', ['rev-parse', '--show-toplevel'], {
    showCmd: false,
    throwOnError: true
  }))
}

function handleWebApiErr(err) {
  if (err.code === 'ECONNREFUSED') {
    cli_error('could not connect to server');
  } else {
    throw err
  }
}

// https://stackoverflow.com/questions/37521893/determine-if-a-path-is-subdirectory-of-another-in-node-js
function is_subpath(parent, child) {
  const relative = path.relative(parent, child);
  return relative && !relative.startsWith('..') && !path.isAbsolute(relative);
}

function perfPrint(name, ourbigbook_options) {
  if (ourbigbook_options === undefined || ourbigbook_options.log.perf) {
    console.error(`perf ${name} t=${now()}`);
  }
}

function relpathCwd(p) {
  let ret = path.relative(process.cwd(), p)
  if (ret === '')
    ret = '.'
  return ret
}

/** Render a template file from under template/ */
function renderTemplate(templateRelpath, outdir, env) {
  const template = fs.readFileSync(
    path.join(ourbigbook_nodejs.PACKAGE_PATH, 'template', templateRelpath),
    ourbigbook_nodejs_webpack_safe.ENCODING
  );
  const out = (new Liquid()).parseAndRenderSync(
    template,
    env,
    {
      strictFilters: true,
      strictVariables: true,
    }
  );
  fs.writeFileSync(path.join(outdir, templateRelpath), out);
}

function runCmd(cmd, args=[], options={}) {
  if (!('dry_run' in options)) {
    options.dry_run = false
  }
  if (!('env_extra' in options)) {
    options.env_extra = {}
  }
  if (!('extra_returns' in options)) {
    options.extra_returns = {}
  }
  if (!('showCmd' in options)) {
    options.showCmd = true
  }
  let { ignoreStdout } = options
  if (ignoreStdout === undefined) {
    ignoreStdout = false
  }
  let out
  const cmd_str = ([cmd].concat(args)).join(' ')
  if (options.showCmd) {
    console.log(cmd_str)
  }
  if (!options.dry_run) {
    const spawnOpts = {
      cwd: options.cwd,
      env: { ...process.env, ...options.env_extra },
    }
    if (ignoreStdout) {
      spawnOpts.stdio = 'ignore'
    }
    out = child_process.spawnSync(cmd, args, spawnOpts)
  }
  let ret
  if (options.dry_run) {
    ret = ''
  } else {
    if (out.status != 0 && options.throwOnError) {
      let msg = `Command failed with status: ${out.status}\ncmd: ${cmd_str}\n`
      if (!ignoreStdout) {
        if (out.stdout !== null) {
          msg += `stdout: \n${out.stdout.toString(ourbigbook_nodejs_webpack_safe.ENCODING)}\n`
        }
        if (out.stderr !== null) {
          msg += `stderr: \n${out.stderr.toString(ourbigbook_nodejs_webpack_safe.ENCODING)}\n`
        }
      }
      throw new Error(msg)
    }
    if (!ignoreStdout) {
      ret = out.stdout.toString(ourbigbook_nodejs_webpack_safe.ENCODING)
    }
  }
  options.extra_returns.out = out
  return ret
}

function viewOutput(output_path, cmdOpts) {
  const resolved_path = path.resolve(output_path)
  if (process.platform === 'darwin') {
    runCmd('open', [resolved_path], cmdOpts)
  } else if (process.platform === 'win32') {
    runCmd('cmd', ['/c', 'start', '', resolved_path], cmdOpts)
  } else {
    runCmd('xdg-open', [resolved_path], cmdOpts)
  }
}

/** Skip path from ourbigbook conversion. */
function ignore_path(
  ignore_basenames,
  ignore_paths,
  ignore_path_regexps,
  dont_ignore_path_regexps,
  _path
) {
  for (const re of dont_ignore_path_regexps) {
    if (re.test(_path)) {
      return false
    }
  }
  if (
    [RAW_PREFIX, DIR_PREFIX, ourbigbook_nodejs.PUBLISH_OBB_PREFIX].some(prefix => _path === prefix || _path.startsWith(prefix + path.sep)) ||
    ignore_paths.has(_path) ||
    ignore_basenames.has(path.basename(_path))
  )
    return true
  for (const re of ignore_path_regexps) {
    if (re.test(_path)) {
      return true
    }
  }
  return false
}

/** @alicearmstrong/mathematics.bigb -> mathematics  */
function pathNoUsernameNoext(inpath) {
  const nousername = inpath.split(ourbigbook.URL_SEP).slice(1).join(ourbigbook.URL_SEP)
  return nousername.substr(0, nousername.length - ourbigbook.OURBIGBOOK_EXT.length - 1)
}

/** mathematics -> @alicearmstrong/mathematics.bigb */
function pathUsernameAndExt(username, inpath) {
  if (inpath === '') {
    inpath = ourbigbook.INDEX_BASENAME_NOEXT
  }
  return `${ourbigbook.AT_MENTION_CHAR}${username}${ourbigbook.Macro.HEADER_SCOPE_SEPARATOR}${inpath}.${ourbigbook.OURBIGBOOK_EXT}`
}

// Paths that we have determined during ID extraction phase are not modified, so no need for a render stage.
function printStatus({
  cleanupDeleted,
  i,
  inpath,
  isUpload,
  render,
  t0,
  t1,
  title,
}={}) {
  let pref
  if (isUpload) {
    if (cleanupDeleted) {
      pref = 'unlist_upload'
    } else {
      pref = 'upload'
    }
  } else {
    if (render) {
      if (cleanupDeleted) {
        pref = 'delete'
      } else {
        pref = MESSAGE_PREFIX_RENDER
      }
    } else {
      pref = MESSAGE_PREFIX_EXTRACT_IDS
    }
  }
  msg = `web_${pref}: ${i}: ${title ? `${title} ` : ''}${inpath ? `(${inpath})` : ''}`
  if (t0 !== undefined) {
    msg += ` ${finished_in_ms(t1 - t0)}`
  }
  console.log(msg)
}

async function updateNestedSet(webApi, username, { foreground=false, watch=true }={}) {
  const queued = await queueNestedSetUpdate(webApi, username, { foreground, watch })
  if (watch) await waitNestedSetUpdate(webApi, username, queued)
}

async function queueNestedSetUpdate(webApi, username, { foreground=false, watch=true }={}) {
  const t1 = now()
  console.log(`nested_set`)
  const { data, status } = await webApi.articleUpdatedNestedSet(username, { body: { foreground } })
  if (status === 202) {
    const id = data.job.id
    console.log(`nested_set: job ${id} queued${watch ? '; waiting for worker' : ''}`)
    return { id, t1 }
  }
  assertApiStatus(status, data)
  return { t1 }
}

async function waitNestedSetUpdate(webApi, username, { id, t1 }) {
  if (id !== undefined) {
    let previousStatus
    let deadline = Date.now() + 21 * 60 * 1000
    while (true) {
      if (Date.now() > deadline) throw new Error(`Nested-set job ${id} timed out; check worker logs`)
      const result = await webApi.articleNestedSetJob(username, id, { timeout: 15000 })
      assertApiStatus(result.status, result.data)
      const job = result.data.job
      if (job.status === 'queued') deadline = Date.now() + 21 * 60 * 1000
      if (job.status === 'failed') throw new Error(job.error || `Nested-set job ${id} failed`)
      if (job.status === 'completed') break
      if (!['queued', 'pending', 'running'].includes(job.status)) throw new Error(`Unknown job status: ${job.status}`)
      if (job.status !== previousStatus) {
        console.log(`nested_set: job ${id} ${job.status}`)
        previousStatus = job.status
      }
      await new Promise(resolve => setTimeout(resolve, 1000))
    }
  }
  console.log(`nested_set: ${finished_in_ms(now() - t1)}`)
}

class WebBulkBusyError extends Error {}

function checkWebBulkBusy(status, data) {
  if (status === 409 && [data && data.errors].flat().includes('Another bulk job is active for this user')) {
    throw new WebBulkBusyError('Another bulk job is active for this user')
  }
}


async function queueWebArticles(webApi, articles, { phase='render', start=true, batchIndex, batchCount, onQueued, buildId, buildIndex }={}) {
  const requestId = require('crypto').randomBytes(16).toString('hex')
  const { data, status } = await webApi.articlesBulk(articles, requestId, { timeout: 20000 }, { phase, start, batchIndex, batchCount, buildId, buildIndex })
  if (status !== 202) {
    checkWebBulkBusy(status, data)
    if ([404, 405].includes(status)) console.error('Background rendering requires an updated server. Use --web-individual-upload for the legacy render path.')
    assertApiStatus(status, data)
    throw new Error(`Expected HTTP 202 when queueing renders, got ${status}`)
  }
  const id = data.job.id
  if (!start) console.log(`web_${phase}_stage: job ${batchIndex + 1}/${batchCount} (${articles.length} articles), first article: ${articles[0].path}`)
  if (onQueued) await onQueued()
  if (start) await waitWebArticles(webApi, id, phase, { batchIndex, batchCount, firstArticle: articles[0].path })
  return id
}

async function waitWebArticles(webApi, id, phase, { allowFailure=false, allowMissing=false, batchIndex, batchCount, firstArticle }={}) {
  const startedAt = now()
  let previousProgress
  let deadline = Date.now() + 21 * 60 * 1000
  while (true) {
    if (Date.now() > deadline) throw new Error(`Render job ${id} timed out; rerun --web to resume`)
    const result = await webApi.articlesBulkJob(id, { timeout: 15000 })
    if (allowMissing && result.status === 404) return
    assertApiStatus(result.status, result.data)
    const job = result.data.job
    if (batchIndex === undefined && job.batchIndex != null) batchIndex = job.batchIndex
    if (batchCount === undefined && job.batchCount != null) batchCount = job.batchCount
    if (['queued', 'waiting'].includes(job.status)) deadline = Date.now() + 21 * 60 * 1000
    // An existing job from another CLI invocation has no local batch plan.
    const completed = String(job.completed).padStart(String(job.total).length)
    const progress = `job ${batchIndex === undefined ? '?' : batchIndex + 1}/${batchCount === undefined ? '?' : batchCount}, article ${completed}/${job.total}, job id: ${id}, status: ${job.status}`
    if (progress !== previousProgress) {
      deadline = Date.now() + 21 * 60 * 1000
      const first = previousProgress === undefined ? `, first article: ${firstArticle || job.firstArticle || 'unknown'}` : ''
      const finished = job.status === 'completed' ? ` ${finished_in_ms(now() - startedAt)}` : ''
      console.log(`web_${phase}_run: ${progress}${first}${finished}`)
      previousProgress = progress
    }
    if (job.status === 'failed') {
      if (!allowFailure) throw new Error(job.error || `Render job ${id} failed`)
      console.error(`web_${phase}_run: ${progress}, error: ${job.error || 'unknown error'}`)
      return
    }
    if (job.status === 'completed') break
    if (!['waiting', 'queued', 'pending', 'running'].includes(job.status)) throw new Error(`Unknown render job status: ${job.status}`)
    await new Promise(resolve => setTimeout(resolve, 1000))
  }
}

async function waitWebBuild(webApi, id, { allowFailure=false }={}) {
  while (true) {
    const { data, status } = await webApi.articlesBuild(id, { timeout: 15000 })
    assertApiStatus(status, data)
    const build = data.build
    if (build.status === 'completed') return
    if (build.status === 'failed') {
      if (!allowFailure) throw new Error(build.error || 'Build failed')
      console.error(`web_upload: build failed: ${build.error || 'unknown error'}`)
      return
    }
    if (build.status !== 'running') throw new Error(`Unexpected build status: ${build.status}`)
    if (data.job) await waitWebArticles(webApi, data.job.id, data.job.phase, { allowFailure })
    await new Promise(resolve => setTimeout(resolve, 1000))
  }
}

// Watch the user's current build, including staging by another CLI. No writes.
async function watchWebBuild(webApi, username) {
  let previous
  while (true) {
    const { data, status } = await webApi.articlesCurrentBuild({ timeout: 15000 })
    assertApiStatus(status, data)
    const { build, job, tree } = data
    let message
    if (job) {
      await waitWebArticles(webApi, job.id, job.phase, { allowFailure: true, allowMissing: true })
      await new Promise(resolve => setTimeout(resolve, 1000))
      continue
    } else {
      message = `web_upload: @${username}: ${tree ? 'tree ' + tree.status : build ? build.status : 'no build'}`
    }
    if (message !== previous) { console.log(message); previous = message }
    if (!job && !tree && (!build || ['completed', 'failed'].includes(build.status))) {
      if (build?.status === 'failed') throw new Error(build.error || 'Build failed')
      return
    }
    await new Promise(resolve => setTimeout(resolve, 1000))
  }
}

/**
 * Walk directory recursively.
 *
 * https://stackoverflow.com/questions/5827612/node-js-fs-readdir-recursive-directory-search
 *
 * @param {Set} skip_basenames
 * @param {Set} ignore_paths
 */
function* walk_directory_recursively(
  file_or_dir,
  ignore_basenames,
  ignore_paths,
  ignore_path_regexps,
  dont_ignore_path_regexps,
  ourbigbook_json_dir
) {
  if (!ignore_path(
    ignore_basenames,
    ignore_paths,
    ignore_path_regexps,
    dont_ignore_path_regexps,
    path.relative(ourbigbook_json_dir, file_or_dir),
  )) {
    yield file_or_dir;
    if (fs.lstatSync(file_or_dir).isDirectory()) {
      const dirents = fs.readdirSync(file_or_dir, {withFileTypes: true});
      for (const dirent of dirents) {
        yield* walk_directory_recursively(
          path.join(file_or_dir, dirent.name),
          ignore_basenames,
          ignore_paths,
          ignore_path_regexps,
          dont_ignore_path_regexps,
          ourbigbook_json_dir,
        )
      }
    }
  }
}

async function webCreateOrUpdate({
  cleanupDeleted,
  fn,
  i,
  inpath,
  isUpload,
  render,
  title,
  webDry,
}) {
  printStatus({ i, cleanupDeleted, inpath, isUpload, render, title })
  const t0 = now()
  let data, status
  if (!webDry) {
    ;({ data, status } = await fn())
    assertApiStatus(status, data)
    //if (render && i % 100 == 0) {
    //  runCmd('bin/pg', ['bin/normalize', '-c', '-u', 'cirosantilli', 'nested-set'], {
    //    cwd: path.join(__dirname, 'web'),
    //    throwOnError: true,
    //  })
    //}
  }
  const t1 = now()
  printStatus({ i, title, inpath, isUpload, render, cleanupDeleted, t0, t1 })
  return { nestedSetNeedsUpdate: data ? data.nestedSetNeedsUpdate : true }
}

// Media has its own physical root but shares the site's logical file namespace.
function collectLocalMedia(nonOptions) {
  if (!nonOptions.localMediaPath) return
  const root = nonOptions.localMediaSnapshot || path.resolve(nonOptions.ourbigbook_json_dir, nonOptions.localMediaPath)
  if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
    cli_error(`local media directory does not exist: ${root}`)
  }
  const ignored = git_is_in_repo(root)
    ? new Set(runCmd('git', ['-C', root, 'ls-files', '--ignored', '--others', '--exclude-standard', '--directory', '-z'],
        { showCmd: false, throwOnError: true }).split('\0').filter(Boolean).map(p => p.replace(/\/$/, '')))
    : new Set()
  const directories = { '': [] }
  const files = []
  for (const file of walk_directory_recursively(root,
    new Set([...DEFAULT_IGNORE_BASENAMES_SET, '.gitignore', '.gitattributes', '.gitmodules']),
    ignored, [], [], root)) {
    const relpath = path.relative(root, file)
    if (!relpath) continue
    const stat = fs.lstatSync(file)
    if (stat.isSymbolicLink()) cli_error(`local media symlinks are not supported: ${relpath}`)
    const source = path.join(nonOptions.ourbigbook_json_dir, relpath)
    if (fs.existsSync(source) && !(stat.isDirectory() && fs.statSync(source).isDirectory())) {
      cli_error(`local media collides with source: ${relpath}`)
    }
    const dirname = path.dirname(relpath)
    directories[dirname === '.' ? '' : dirname].push([path.basename(relpath), stat.isDirectory()])
    if (stat.isDirectory()) directories[relpath] = []
    else if (stat.isFile()) files.push({ path: file, relpath })
  }
  return { root, directories, files }
}

function mergedDirectoryEntries(relpath, nonOptions) {
  const entries = new Map()
  const source = path.join(nonOptions.ourbigbook_json_dir, relpath)
  if (fs.existsSync(source)) {
    for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
      if (!ignore_path(DEFAULT_IGNORE_BASENAMES_SET, nonOptions.ignore_paths,
        nonOptions.ignore_path_regexps, nonOptions.dont_ignore_path_regexps,
        path.join(relpath, entry.name))) {
        entries.set(entry.name, entry.isDirectory())
      }
    }
  }
  for (const [name, isDirectory] of nonOptions.localMediaDirectories?.[relpath] || []) {
    entries.set(name, isDirectory)
  }
  return [...entries].sort(([a], [b]) => a.localeCompare(b))
}

function pathIsWithin(root, file) {
  const relative = path.relative(root, file)
  return relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative)
}

function resolvePublishedMediaPath(root, file, { localMediaPath, localMediaSnapshot }={}) {
  if (localMediaSnapshot) {
    const prefix = path.normalize(localMediaPath).replace(/\/$/, '')
    if (file === prefix || file.startsWith(prefix + path.sep)) {
      return path.join(localMediaSnapshot, path.relative(prefix, file))
    }
  }
  const source = path.join(root, file)
  if (localMediaPath && !fs.existsSync(source) && pathIsWithin(root, source)) {
    const media = path.join(localMediaSnapshot || path.resolve(root, localMediaPath), file)
    if (fs.existsSync(media)) return media
  }
  return source
}

function fileConversionOptions(ourbigbook_json_dir, mediaOptions) {
  const resolveFile = file => resolvePublishedMediaPath(ourbigbook_json_dir, file, mediaOptions)
  return {
    getAFileTypes: (aRefs) => {
      let ret = {}
      for (const a of aRefs) {
        try {
          ret[a] = fs.lstatSync(resolveFile(a)).isFile() ? FILE_TYPE_FILE : FILE_TYPE_DIRECTORY
        } catch (error) {
          // Missing targets are reported by check_db with their source locations.
          // Metadata lookup must not preempt that diagnostic with a Node stack.
          if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error
        }
      }
      return ret
    },
    fs_exists_sync: (my_path) => fs.existsSync(resolveFile(my_path)),
    read_include: read_include({
      exists: (inpath) => fs.existsSync(path.join(ourbigbook_json_dir, inpath)),
      read: (inpath) => fs.readFileSync(path.join(ourbigbook_json_dir, inpath), ourbigbook_nodejs_webpack_safe.ENCODING),
      path_sep: ourbigbook.Macro.HEADER_SCOPE_SEPARATOR,
    }),
    read_file: (readpath, context, opts={}) => {
      readpath = resolveFile(readpath)
      const mediaRoot = mediaOptions?.localMediaSnapshot || (mediaOptions?.localMediaPath &&
        path.resolve(ourbigbook_json_dir, mediaOptions.localMediaPath))
      if (
        // Let's prevent path transversal a bit by default.
        (pathIsWithin(ourbigbook_json_dir, readpath) || (mediaRoot && pathIsWithin(mediaRoot, readpath))) &&
        fs.existsSync(readpath)
      ) {
        if (fs.lstatSync(readpath).isFile()) {
          return fs.readFileSync(readpath, ourbigbook_nodejs_webpack_safe.ENCODING)
        }
      } else {
        return undefined
      }
    },
  }
}

async function createWebCache(outdir, { sync=true }={}) {
  const titleRegex = new RegExp(`${ourbigbook.SHORTHAND_HEADER_CHAR} (.*)`)
  const sequelizeWeb = new Sequelize({
    dialect: 'sqlite',
    storage: path.join(outdir, 'web.sqlite3'),
    logging: false,
  })
  const sequelizeWebArticle = sequelizeWeb.define('Article', {
    idid: { type: DataTypes.TEXT, unique: true },
    title: { type: DataTypes.TEXT },
    body: { type: DataTypes.TEXT },
    inpath: { type: DataTypes.TEXT },
    parentId: { type: DataTypes.TEXT },
    source: { type: DataTypes.TEXT },
    definedAt: { type: DataTypes.TEXT },
  })
  // Just to store the ID of the index.
  const sequelizeWebIndexId = sequelizeWeb.define('IndexId', {
    idid: { type: DataTypes.TEXT },
    // upsert helper.
    uniqueHack: { type: DataTypes.INTEGER, unique: true },
  })
  if (sync) await sequelizeWeb.sync()
  const postConvert = async (definedAt, extra_returns) => {
    if (extra_returns.errors.length === 0) {
      // Use the coordinator's write lock for this second SQLite database too.
      // Replace each source file's cached articles in one transaction.
      await withDatabaseWrite(() => sequelizeWeb.transaction(async transaction => {
        await sequelizeWebArticle.destroy({ where: { definedAt }, transaction })
        const rendered_outputs = extra_returns.rendered_outputs
        for (let inpath in rendered_outputs) {
          const rendered_outputs_entry = rendered_outputs[inpath]
          if (rendered_outputs_entry.split) {
            // To convert:
            //
            // linux-kernel-module-cheat-split.bigb
            //
            // to:
            //
            // linux-kernel-module-cheat.bigb
            //
            // on:
            //
            // = Linux kernel module cheat
            // {splitSuffix}
            //
            // otherwise the ID becomes linux-kernel-module-cheat and \x links fail.
            let source = rendered_outputs_entry.full;
            const lines = source.split('\n')
            let title
            if (lines.length) {
              const line0 = lines[0]
              const titleMatch = line0.match(titleRegex)
              if (titleMatch && titleMatch.length >= 2) {
                title = titleMatch[1]
              }
            }
            if (title === undefined) {
              cli_error(`every bigb must start with a "= Header" for --web upload, failed for: ${inpath}`)
            }
            const inpathParse = path.parse(inpath)
            const pathNoext = path.join(inpathParse.dir, inpathParse.name)
            if (rendered_outputs_entry.split_suffix) {
              inpath = pathNoext.slice(0, -(rendered_outputs_entry.split_suffix.length + 1)) + `.${ourbigbook.OURBIGBOOK_EXT}`
            }
            let addId
            let addSubdir
            let isToplevelIndex = false
            const header_ast = rendered_outputs_entry.header_ast
            if (ourbigbook.INDEX_FILE_BASENAMES_NOEXT.has(inpathParse.name)) {
              if (inpathParse.dir) {
                const dirPathParse = path.parse(inpathParse.dir)
                const titleId = ourbigbook.titleToId(title)
                if (titleId !== dirPathParse.name) {
                  // This would be ideal, allowing us to store all information about the article in the body itself.
                  // But it was hard to implement, since now the input path is an important input of conversion.
                  // So to start with we will just always provide the input path as a separate parameter.
                  // id= for toplevel was ignored as of writing, which is bad, should be either used or error.
                  //addId = dirPathParse.name
                }
                if (dirPathParse.dir) {
                  // Same as addId
                  //addSubdir = dirPathParse.dir
                }
              } else {
                // Hack source for subsequent hash calculation to match what we have on server, which
                // currently forces "Index" (TODO "index" et al. are likely also possible and would break this hack).
                // Ideally we should actually alter the file under _out/web/index.bigb
                // but that would be slightly more involved (a new option to convert?) so lazy.
                source = source.replace(titleRegex, `${ourbigbook.SHORTHAND_HEADER_CHAR} ${title}`)
                await sequelizeWebIndexId.upsert({ idid: header_ast.id, uniqueHack: 0 }, { transaction })
                isToplevelIndex = true
              }
              inpath = `${ourbigbook.INDEX_BASENAME_NOEXT}.${ourbigbook.OURBIGBOOK_EXT}`
            } else {
              const titleId = ourbigbook.titleToId(title)
              if (titleId !== inpathParse.name) {
                //addId = inpathParse.name
              }
              if (inpathParse.dir) {
                //addSubdir = inpathParse.dir
              }
            }
            let bodyStart
            if (lines[1] === '' && !addId && !addSubdir) {
              bodyStart = 2
            } else {
              bodyStart = 1
            }
            let body = ''
            if (addId) {
              // Restore this if we ever remove the separate path magic input.
              // Also id of toplevel header is currently ignored as of writing:
              //body += `{id=${addId}}\n`
            }
            if (addSubdir) {
              // Restore this if we ever remove the separate path magic input.
              //body += `{subdir=${addSubdir}}\n`
            }
            body += lines.slice(bodyStart).join('\n')
            const parent_ast = rendered_outputs_entry.header_ast.get_header_parent_asts(extra_returns.context)[0]
            const article = {
              body,
              inpath,
              definedAt,
              source,
              title,
            }
            if (parent_ast) {
              let parentId
              if (
                parent_ast.id !== ourbigbook.INDEX_BASENAME_NOEXT &&
                // Force every child of the topevel to add it as "@username" and instead of deducing it from title
                // as done on CLI. This means that giving the toplevel a custom ID and using that ID will fail to upload...
                // there is no solution to that. We should just force the toplevel to have no ID then on CLI for compatibility?
                !(
                  parent_ast.is_first_header_in_input_file &&
                  ourbigbook.INDEX_FILE_BASENAMES_NOEXT.has(path.parse(parent_ast.source_location.path).name)
                )
              ) {
                parentId = `${parent_ast.id}`
              } else {
                parentId = ''
              }
              article.parentId = parentId
            }
            let id_to_article_key
            if (isToplevelIndex) {
              id_to_article_key = ''
            } else {
              id_to_article_key = header_ast.id
            }
            article.idid = id_to_article_key
            await sequelizeWebArticle.upsert(article, { transaction })
          }
        }
      }))
    }
  }
  return { sequelize: sequelizeWeb, postConvert }
}

async function runDirectoryWorker() {
  // Do not leave workers behind if the coordinator is interrupted or killed.
  process.on('disconnect', () => process.exit())
  const sequelize = await ourbigbook_nodejs_webpack_safe.createSequelize(
    workerData.nonOptions.db_options, {}, { sync: false })
  const webCache = workerData.nonOptions.web
    ? await createWebCache(workerData.nonOptions.outdir, { sync: false }) : undefined
  // KaTeX macro tokens contain class instances that cannot cross IPC intact.
  const katex_macros = {}
  ourbigbook_nodejs_webpack_safe.preload_katex_from_file(ourbigbook_nodejs.DEFAULT_TEX_PATH, katex_macros)
  const texPath = path.join(workerData.nonOptions.filesystem_root, OURBIGBOOK_TEX_BASENAME)
  if (fs.existsSync(texPath)) ourbigbook_nodejs_webpack_safe.preload_katex_from_file(texPath, katex_macros)
  process.on('message', async task => {
    try {
      if (task.writeGranted) {
        grantDatabaseWrite()
        return
      }
      if (task.close) {
        if (webCache) await webCache.sequelize.close()
        await sequelize.close()
        process.disconnect()
        return
      }
      const options = {
        ...structuredClone(workerData.options),
        ...fileConversionOptions(workerData.nonOptions.filesystem_root, workerData.nonOptions),
        katex_macros,
        render: task.render,
        // Never carry extraction or another file's cached refs into rendering.
        db_provider: new ourbigbook_nodejs_webpack_safe.SqlDbProvider(sequelize),
      }
      const nonOptions = {
        ...structuredClone(workerData.nonOptions),
        virtualPath: task.virtualPath,
        fileUsageHash: task.fileUsageHash,
        file_rows_dict: {
          [workerData.nonOptions.renderType]: task.file_row ? { [task.relpath]: task.file_row } : {},
        },
        had_error: false,
        build_errors: [],
        is_render_after_extract: task.is_render_after_extract,
        options: ourbigbook.convertInitOptions(options),
        post_convert_callback: webCache?.postConvert,
        ourbigbook_paths_converted: [],
        sequelize,
        sitemap: [],
      }
      await (task.preview ? convert_file_preview : convert_path_to_file)(task.input_path, options, nonOptions)
      process.send({
        paths: nonOptions.ourbigbook_paths_converted,
        sitemap: nonOptions.sitemap,
        had_error: nonOptions.had_error,
        build_errors: nonOptions.build_errors,
        last_output_path: nonOptions.last_output_path,
      })
    } catch (error) {
      process.send({ error: error.stack || String(error) })
    }
  })
  process.send({ ready: true })
}

if (isConversionWorker) {
  process.once('message', data => {
    workerData = data
    runDirectoryWorker().catch(error => { throw error })
  })
} else {

// CLI options.
const cli_parser = commander.program
cli_parser.allowExcessArguments(false);

// Optional arguments.
cli_parser.option('--add-test-instrumentation', 'For testing only', false);
cli_parser.option('--body-only', 'output only the content inside the HTLM body element', false);
cli_parser.option('--check-db-only', `only check the database, don't do anything else: https://docs.ourbigbook.com#check-db`, false);
cli_parser.option('--china', 'https://docs.ourbigbook.com#china', false);
cli_parser.option('--clear-db', 'clear the database before running', false);
cli_parser.option('--dry-run', "don't run most external commands https://github.com/ourbigbook/ourbigbook#dry-run", false);
cli_parser.option('--dry-run-push', "don't run git push commands https://github.com/ourbigbook/ourbigbook#dry-run-push", false);
cli_parser.option('--embed-includes', 'https://docs.ourbigbook.com#embed-include', false);
cli_parser.option('--embed-resources', 'https://docs.ourbigbook.com#embed-resources', false);
// Originally added for testing, this allows the test filesystems to be put under the repository itself,
// otherwise they would pickup our toplevel ourbigbook.json.
cli_parser.option('--escape-literal', 'https://docs.ourbigbook.com#escape-literal', false);
cli_parser.option('--fakeroot <fakeroot>', 'Stop searching for ourbigbook.json at this directory rather than at the filesystem root');
cli_parser.option('--generate <name>', 'https://docs.ourbigbook.com#generate', false);
cli_parser.option('--help-macros', 'print the metadata of all macros to stdout in JSON format. https://docs.ourbigbook.com#help-macros', false);
cli_parser.option('-I, --input-format <input-format>', 'input format: bigb or markdown. Inferred from .md and .markdown extensions by default');
cli_parser.option('-j, --jobs <n>', 'parallel directory conversion workers; defaults to available CPUs: https://docs.ourbigbook.com#jobs', value => {
  if (!/^[1-9][0-9]*$/.test(value) || !Number.isSafeInteger(Number(value))) {
    throw new commander.InvalidArgumentError('jobs must be a positive integer')
  }
  return Number(value)
}, os.availableParallelism ? os.availableParallelism() : os.cpus().length || 1);
cli_parser.option('-l, --log <log...>', 'https://docs.ourbigbook.com#log');
cli_parser.option('--no-check-db', 'Skip the database sanity check that is normally done after conversions https://docs.ourbigbook.com#no-check-db');
cli_parser.option('--no-html-x-extension <bool>', 'https://docs.ourbigbook.com#no-html-x-extension', undefined);
cli_parser.option('--no-db', 'ignore the ID database, mostly for testing https://docs.ourbigbook.com#internal-cross-file-references-internals');
cli_parser.option('--no-render', "only extract IDs, don't render: https://docs.ourbigbook.com#no-render");
cli_parser.option('--no-web-render', "same as --no-render, but for --web upload step: https://docs.ourbigbook.com#no-web-render");
cli_parser.option('-F, --force-render', "don't skip render by timestamp: https://docs.ourbigbook.com#no-render-timestamp", false);
cli_parser.option('--outdir <outdir>', 'https://docs.ourbigbook.com#outdir');
cli_parser.option('-o, --outfile <outfile>', 'https://docs.ourbigbook.com#outfile');
cli_parser.option('-O, --output-format <output-format>', 'https://docs.ourbigbook.com#output-format', 'html');
cli_parser.option('-p --publish', 'https://docs.ourbigbook.com#publish', false);
cli_parser.option('--publish-no-convert', 'Attempt to publish without converting. Implies --publish: conversion https://docs.ourbigbook.com#publish-no-convert', false);
cli_parser.option('-P, --publish-commit <commit-message>', 'https://docs.ourbigbook.com#publish-commit');
cli_parser.option('--publish-target <target>', 'https://docs.ourbigbook.com#publish-target', 'github-pages');
cli_parser.option('--format-source', 'https://docs.ourbigbook.com#format-source');
cli_parser.option('-S, --split-headers', 'https://docs.ourbigbook.com#split-headers');
cli_parser.option('--stdout', 'also print output to stdout in addition to saving to a file https://docs.ourbigbook.com#stdout', false);
cli_parser.option('--template <template>', 'https://docs.ourbigbook.com#template');
cli_parser.option('--title-to-id', `read tiles from stdin line by line, output IDs to stdout only, don't do anything else: https://docs.ourbigbook.com#title-to-id`, false);
cli_parser.option('-w, --watch', 'https://docs.ourbigbook.com#watch', false);
cli_parser.option('-W, --web', 'sync to ourbigbook web https://docs.ourbigbook.com#web', false);
cli_parser.option('--web-ask-password', 'Ask the password in case it had some default https://docs.ourbigbook.com#web-ask-password');
cli_parser.option('--web-dry', 'web dry run, skip any --web operations that would interact with the server https://docs.ourbigbook.com#web-dry', false);
cli_parser.option('--web-force-id-extraction', "Force ID extraction on Web: https://docs.ourbigbook.com#web-force-id-extraction");
cli_parser.option('--web-force', 'Replace the current web build without asking for confirmation (implies --web)');
cli_parser.option('--web-cancel-build', 'Cancel the current web build without converting or uploading (implies --web)');
cli_parser.option('--web-watch', 'Only watch the current web build; do not convert or upload (implies --web)');
cli_parser.option('--web-no-watch', 'Exit after submitting the web build, without waiting for completion (implies --web)');
cli_parser.option('--web-force-render', "same as --force-render but for --web upload: https://docs.ourbigbook.com#web-force-render");
cli_parser.option('--web-id <id>', 'Upload only the selected ID. It must belong to a file being converted. https://docs.ourbigbook.com/#web-id');
cli_parser.option('--web-individual-upload', 'Upload articles individually on foreground https://docs.ourbigbook.com/#web-individual-upload');
cli_parser.option('--web-max-renders <n>', 'stop after <n> articles are rendered: https://docs.ourbigbook.com#web-max-renders', cliInt);
cli_parser.option('--web-nested-set', `only update the nested set index, don't do anything else. Implies --web: https://docs.ourbigbook.com#web-nested-set-option`, false);
cli_parser.option('--no-web-nested-set-bulk', `only update the nested set index after all articles have been uploaded: https://docs.ourbigbook.com#web-nested-set-bulk`);
cli_parser.option('--web-password <password>', 'Set password from CLI. Really bad idea for non-test users due e.g. to Bash history: https://docs.ourbigbook.com#web-user');
cli_parser.option('--web-start-id <id>', 'Start the Web article upload passes from this ID, inclusive: https://docs.ourbigbook.com#web-start-id');
cli_parser.option('--web-test', 'Convenient --web-* defaults local development: https://docs.ourbigbook.com#web-test', false);
cli_parser.option('--web-url <url>', 'Set a custom sync URL for --web: https://docs.ourbigbook.com#web-url');
cli_parser.option('--web-user <username>', 'Set username from CLI: https://docs.ourbigbook.com#web-user');
cli_parser.option('--unsafe-ace', 'https://docs.ourbigbook.com#unsafe-ace');
cli_parser.option('--unsafe-xss', 'https://docs.ourbigbook.com#unsafe-xss');
cli_parser.option('-V, --view-output', 'Open the main output file in a browser after a single-file conversion: https://docs.ourbigbook.com#view-output', false);

// Positional arguments.
cli_parser.argument('[input_path...]', 'file or directory to convert http://docs.ourbigbook.com#ourbigbook-executable. If the first path is a file, all others must also be files (and not directories) as an optimization limitation. And they must lie in the same OurBigBook project.');

// Parse CLI.
cli_parser.parse(process.argv);
let [inputPaths] = cli_parser.processedArgs
const cli = cli_parser.opts()

// main action.
;(async () => {
if (cli.helpMacros) {
  console.log(JSON.stringify(ourbigbook.macroList(), null, 2));
} else if (cli.china) {
  console.log(china_dictatorship.get_data());
} else {
  let input;
  let output;
  let publish = cli.publish || cli.publishCommit !== undefined || cli.publishNoConvert
  let htmlXExtension;
  let publishTargetIsWebsite
  let input_dir;
  const web = cli.web || cli.webTest || cli.webNestedSet || cli.webWatch || cli.webNoWatch || cli.webForce || cli.webCancelBuild
  if (cli.webNoWatch) {
    for (const [option, enabled] of [['--web-watch', cli.webWatch], ['--web-individual-upload', cli.webIndividualUpload]]) {
      if (enabled) cli_error(`--web-no-watch cannot be combined with ${option}`)
    }
  }
  if (cli.webStartId !== undefined) {
    if (!web) {
      cli_error('--web-start-id requires --web or --web-test')
    }
    if (cli.webId !== undefined) {
      cli_error('--web-start-id cannot be combined with --web-id')
    }
  }
  if (inputPaths.length === 0) {
    if (web || publish || cli.watch || cli.generate || cli.checkDbOnly || cli.webNestedSet) {
      inputPaths = ['.'];
    }
  } else {
    if (cli.generate) {
      cli_error('cannot give an input path with --generate');
    }
  }

  // Determine the ourbigbook.json file by walking up the directory tree.
  let input_path_is_file;
  let inputPath
  if (inputPaths.length === 0) {
    // Input from stdin.
    input_dir = undefined;
    input_path_is_file = false;
  } else {
    for (const inputPath of inputPaths) {
      if (!fs.existsSync(inputPath)) {
        cli_error('input path does not exist: ' + inputPath);
      }
      if (input_path_is_file && !fs.lstatSync(inputPath).isFile()) {
        cli_error(`the first input path is a file, but one of the other ones isn't: "{inputPath}"`);
      }
    }
    inputPathCwd = relpathCwd(inputPaths[0])
    inputPath = inputPaths[0]
    input_path_is_file = fs.lstatSync(inputPath).isFile();
    if (input_path_is_file) {
      input_dir = path.dirname(inputPath);
    } else {
      input_dir = inputPath;
    }
  }

  // Initialize ourbigbook.json and directories determined from it if present.
  let ourbigbook_json_dir
  let ourbigbook_json = {}
  if (inputPaths.length === 0) {
    ourbigbook_json_dir = '.'
  } else {
    let curdir = path.resolve(inputPath);
    if (input_path_is_file) {
      curdir = path.dirname(curdir)
    }
    ourbigbook_json_dir = ourbigbook_nodejs_webpack_safe.findOurbigbookJsonDir(
      curdir,
      { fakeroot: cli.fakeroot === undefined ? undefined : path.resolve(cli.fakeroot) },
    )
    if (ourbigbook_json_dir === undefined) {
      // No ourbigbook.json found.
      const cwd = process.cwd();
      if (is_subpath(cwd, inputPath)) {
        ourbigbook_json_dir = cwd
      } else {
        if (input_path_is_file) {
          ourbigbook_json_dir = path.dirname(inputPath)
        } else {
          ourbigbook_json_dir = inputPath
        }
      }
    } else if (!web || !git_is_in_repo(ourbigbook_json_dir)) {
      Object.assign(ourbigbook_json, JSON.parse(fs.readFileSync(
        path.join(ourbigbook_json_dir, ourbigbook.OURBIGBOOK_JSON_BASENAME), ourbigbook_nodejs_webpack_safe.ENCODING)))
    }
  }
  let webSourceRoot, webMediaSnapshot
  if (web && git_is_in_repo(ourbigbook_json_dir)) {
    const opts = { throwOnError: true, showCmd: false }
    const source = chomp(runCmd('git', ['-C', ourbigbook_json_dir, 'rev-parse', '--show-toplevel'], opts))
    webSourceRoot = path.resolve(ourbigbook_json_dir)
    const snapshot = path.join(webSourceRoot, ourbigbook_nodejs_webpack_safe.TMP_DIRNAME, 'publish')
    const project = path.join(snapshot, path.relative(source, webSourceRoot))
    const absoluteInputs = inputPaths.map(p => path.resolve(p))
    for (const p of absoluteInputs) {
      if (path.relative(source, p).startsWith('..' + path.sep)) cli_error(`web input is outside the Git repository: ${p}`)
    }
    if (cli.outdir !== undefined) cli.outdir = path.resolve(cli.outdir)
    if (cli.template !== undefined) cli.template = path.join(snapshot, path.relative(source, path.resolve(cli.template)))
    const commit = checkoutWebSnapshot(source, snapshot)
    console.log(`Web upload uses committed source: ${commit} (checkout: ${project})`)
    process.chdir(project)
    inputPaths = absoluteInputs.map(p => path.relative(project, path.join(snapshot, path.relative(source, p))) || '.')
    inputPath = inputPaths[0]
    inputPathCwd = relpathCwd(inputPath)
    input_dir = input_path_is_file ? path.dirname(inputPath) : inputPath
    ourbigbook_json_dir = project
    const config = path.join(project, ourbigbook.OURBIGBOOK_JSON_BASENAME)
    ourbigbook_json = fs.existsSync(config) ? JSON.parse(fs.readFileSync(config, 'utf8')) : {}
    const mediaPath = ourbigbook_json['media-providers']?.local?.path
    if (mediaPath) {
      const mediaSource = path.resolve(webSourceRoot, mediaPath)
      // Submodules inside the source checkout already use the superproject's pinned commit.
      if (!is_subpath(source, mediaSource)) {
        if (!git_is_in_repo(mediaSource)) cli_error(`local media provider must be a Git repository for committed web uploads: ${mediaSource}`)
        webMediaSnapshot = fs.mkdtempSync(path.join(os.tmpdir(), 'ourbigbook-web-media-'))
        process.on('exit', () => fs.rmSync(webMediaSnapshot, { recursive: true, force: true }))
        runCmd('git', ['clone', '--shared', '--no-checkout', '--', mediaSource, webMediaSnapshot], opts)
        runCmd('git', ['-C', webMediaSnapshot, 'checkout', '--detach', gitSha(mediaSource)], opts)
      }
    }
  }
  const publish_target = cli.publishTarget
  const publish_target_options = publish && ourbigbook_json.target
    ? ourbigbook_json.target[publish_target]
    : undefined
  if (
    publish_target_options
  ) {
    ourbigbook_json = lodash.merge(
      {},
      ourbigbook_json,
      publish_target_options
    )
  }
  if (web) {
    let jsonH = ourbigbook_json.h
    if (jsonH === undefined) {
      jsonH = {}
      ourbigbook_json.h = jsonH
    }
    jsonH.splitDefault = true
  }
  if (
    fs.existsSync(DEFAULT_TEMPLATE_BASENAME) &&
    !('template' in ourbigbook_json)
  ) {
    ourbigbook_json.template = DEFAULT_TEMPLATE_BASENAME
  }

  let markdownMaxBytes, split_headers, publish_uses_git;
  // Content will become publicly visible after publishing. For example:
  // - publish to github pages: yes
  // - publish a local file to then ZIP: no
  let publishIsPublic
  const publish_create_files = {}
  if (publish) {
    switch (publish_target) {
      case 'github-pages':
        htmlXExtension = false;
        split_headers = true;
        publish_uses_git = true;
        publishTargetIsWebsite = true
        publishIsPublic = true

        // Disable Jekyll processing, including its exclusion of underscore paths.
        publish_create_files['.nojekyll'] = ''

        const cname_path = path.join(ourbigbook_json_dir, 'CNAME')
        if (fs.existsSync(cname_path)) {
          publish_create_files['CNAME'] = fs.readFileSync(cname_path, ourbigbook_nodejs_webpack_safe.ENCODING)
        }
        break;
      case 'github-md':
        if (!publish_target_options || publish_target_options.generateSitemap === undefined) {
          ourbigbook_json.generateSitemap = false
        }
        cli.outputFormat = ourbigbook.OUTPUT_FORMAT_GITHUB_MARKDOWN
        htmlXExtension = true
        split_headers = true
        publish_uses_git = true
        publishTargetIsWebsite = false
        publishIsPublic = true
        markdownMaxBytes = ourbigbook.GITHUB_MARKDOWN_MAX_BYTES
        if (ourbigbook_json.githubMarkdownMaxBytes !== undefined) {
          if (
            !Number.isInteger(ourbigbook_json.githubMarkdownMaxBytes) ||
            ourbigbook_json.githubMarkdownMaxBytes <= 0
          ) {
            cli_error('githubMarkdownMaxBytes must be a positive integer')
          }
          // A project may choose a more conservative limit, but cannot disable
          // the GitHub rendering safeguard by raising it.
          markdownMaxBytes = Math.min(
            markdownMaxBytes,
            ourbigbook_json.githubMarkdownMaxBytes,
          )
        }
        break
      case 'local':
        htmlXExtension = true;
        publish_uses_git = false;
        publishTargetIsWebsite = false
        publishIsPublic = false
        break;
      default:
        cli_error(`unknown publish target: ${publish_target}`)
    }
  }
  if (split_headers === undefined) {
    if (cli.splitHeaders === true) {
      split_headers = cli.splitHeaders
    } else {
      split_headers = ourbigbook_json.splitHeaders
    }
  }
  if (htmlXExtension === undefined) {
    if (cli.htmlXExtension === false) {
      htmlXExtension = cli.htmlXExtension
    } else {
      htmlXExtension = ourbigbook_json.htmlXExtension
    }
  }

  // Options that will be passed directly to ourbigbook.convert().
  if (!(cli.outputFormat in ourbigbook.OUTPUT_FORMATS)) {
    cli_error(`unknown output format: ${cli.outputFormat}`)
  }
  const output_format = (cli.formatSource || web) ? ourbigbook.OUTPUT_FORMAT_OURBIGBOOK : cli.outputFormat
  const ourbigbook_options = {
    ...fileConversionOptions(ourbigbook_json_dir),
    add_test_instrumentation: cli.addTestInstrumentation,
    body_only: cli.bodyOnly,
    ourbigbook_json,
    embed_includes: cli.embedIncludes,
    htmlXExtension,
    markdownIndexBasename: publish_target === 'github-md' ? 'README' : undefined,
    markdownMaxBytes,
    output_format,
    outfile: cli.outfile,
    path_sep: path.sep,
    publish,
    render: cli.render,
    showSplitOnToc: ourbigbook_json.showSplitOnToc,
    split_headers: split_headers,
    template_vars: {
      publishTargetIsWebsite: false,
    },
    unsafeXss: cli.unsafeXss,
    webLocalConvert: web,
  }
  // Resolved options.
  const options = ourbigbook.convertInitOptions(ourbigbook_options)
  const localMediaPath = options.ourbigbook_json['media-providers'].local.path
  const localMediaDir = webMediaSnapshot || (localMediaPath ? path.resolve(ourbigbook_json_dir, localMediaPath) : undefined)

  ourbigbook_options.log = {};
  const nonOurbigbookOptions_log = {};
  if (cli.log !== undefined) {
    for (const log of cli.log) {
      if (ourbigbook.LOG_OPTIONS.has(log)) {
        ourbigbook_options.log[log] = true;
      } else if (LOG_OPTIONS.has(log)) {
        nonOurbigbookOptions_log[log] = true;
      } else {
        cli_error('unknown --log option: ' + log);
      }
    }
  }

  if (inputPath !== undefined) {
    let template_path;
    if (cli.template !== undefined) {
      template_path = cli.template;
    } else if ('template' in ourbigbook_json && ourbigbook_json.template !== null) {
      template_path = path.join(ourbigbook_json_dir, ourbigbook_json.template);
    }
    if (template_path === undefined) {
      ourbigbook_options.template = undefined;
    } else {
      ourbigbook_options.template = fs.readFileSync(template_path).toString();
    }
  }

  if (inputPath !== undefined) {
    try {
      ourbigbook_options.template_vars.git_sha = gitSha(input_dir);
    } catch(error) {
      // Not in a git repo.
    }
  }
  let outdir;
  if (cli.outdir === undefined) {
    if (cli.generate) {
      outdir = '.'
    } else {
      outdir = ourbigbook_json_dir;
    }
  } else {
    outdir = cli.outdir;
  }
  if (cli.generate) {
    let generate = cli.generate
    if (generate === 'subdir') {
      outdir = path.join(outdir, 'docs')
    }
    fs.mkdirSync(outdir, {recursive: true});

    // Generate package.json.
    const package_json = JSON.parse(fs.readFileSync(
      ourbigbook_nodejs.PACKAGE_PACKAGE_JSON_PATH).toString());
    const package_json_str = `{
  "dependencies": {
    "ourbigbook": "${package_json.version}"
  }
}
`;
    fs.writeFileSync(path.join(outdir, 'package.json'), package_json_str);

    // Generate .gitignore. Reuse our gitignore up to the first blank line.
    let gitignore_new = '';
    const gitignore = fs.readFileSync(
      ourbigbook_nodejs.GITIGNORE_PATH,
      ourbigbook_nodejs_webpack_safe.ENCODING
    );
    for (const line of gitignore.split('\n')) {
      if (line === '') {
        break;
      }
      gitignore_new += line + '\n';
    }
    fs.writeFileSync(path.join(outdir, '.gitignore'), gitignore_new);

    let title = 'Ourbigbook Template';
    let multifile
    if (generate === 'default') {
      renderTemplate(`not-index.${ourbigbook.OURBIGBOOK_EXT}`, outdir, {});
      multifile = true
    } else {
      title += ' ' + generate
      multifile = false
    }
    renderTemplate('README.md', outdir, {})
    renderTemplate(`${ourbigbook.INDEX_BASENAME_NOEXT}.${ourbigbook.OURBIGBOOK_EXT}`, outdir, {
      generate,
      multifile,
      title,
      version: package_json.version,
    });
    if (multifile) {
      fs.copyFileSync(path.join(ourbigbook_nodejs.PACKAGE_PATH, DEFAULT_TEMPLATE_BASENAME),
        path.join(outdir, DEFAULT_TEMPLATE_BASENAME));
      fs.copyFileSync(path.join(ourbigbook_nodejs.PACKAGE_PATH, 'main.scss'),
        path.join(outdir, 'main.scss'));
      fs.copyFileSync(ourbigbook_nodejs.LOGO_PATH, path.join(outdir, ourbigbook_nodejs.LOGO_BASENAME));
    }

    fs.writeFileSync(
      path.join(
        outdir,
        ourbigbook.OURBIGBOOK_JSON_BASENAME
      ),
      JSON.stringify({
        ignore: [
          'CONTRIBUTING\\.md',
          'README\\.md',
        ],
      }, null, 2) + '\n'
    )
    process.exit(0)
  }
  let tmpdir, renderType
  const outputOutOfTree = ourbigbook_json.outputOutOfTree !== false || web
  if (
    // Possible on intput from stdin.
    outdir !== undefined
  ) {
    tmpdir = path.join(outdir, ourbigbook_nodejs_webpack_safe.TMP_DIRNAME);
    if (
      cli.outdir === undefined &&
      outputOutOfTree
    ) {
      let subdir
      if (web) {
        subdir = ourbigbook.RENDER_TYPE_WEB
      } else {
        subdir = output_format
      }
      outdir = path.join(tmpdir, subdir)
    }
  }
  if (web) {
    renderType = ourbigbook.RENDER_TYPE_WEB
  } else {
    renderType  = output_format
  }
  // Options that are not directly passed to ourbigbook.convert
  // but rather used only by this ourbigbook executable.
  const nonOurbigbookOptions = {
    ourbigbook_json_dir,
    filesystem_root: ourbigbook_json_dir,
    localMediaPath,
    ourbigbook_paths_converted: [],
    ourbigbook_paths_converted_only: false,
    cli,
    db_options: {},
    dont_ignore_path_regexps: options.ourbigbook_json.dontIgnore.map(p => RegExp(`^${p}$`)),
    dont_ignore_convert_path_regexps: options.ourbigbook_json.dontIgnoreConvert.map(p => RegExp(
      `^${p}($|${lodash.escapeRegExp(path.sep)})`)),
    file_rows_dict: {},
    encoding: ourbigbook_nodejs_webpack_safe.ENCODING,
    external_css_and_js: false,
    filterFilesThatDontExist: (aRefs) => aRefs.filter(
      aRef => !fs.existsSync(path.join(ourbigbook_json_dir, aRef.to))),
    had_error: false,
    is_render_after_extract: false,
    ignore_path_regexps: options.ourbigbook_json.ignore.map(p => RegExp(
      `^${p}($|${lodash.escapeRegExp(path.sep)})`)),
    ignore_convert_path_regexps: options.ourbigbook_json.ignoreConvert.map(p => RegExp(
      `^${p}($|${lodash.escapeRegExp(path.sep)})`)),
    ignore_paths: new Set(),
    input_path_is_file,
    log: nonOurbigbookOptions_log,
    // undefined: don't collect. Set(): collect.
    nonBigbPathsConverted: undefined,
    // Resolved options.
    options,
    out_css_path: ourbigbook_nodejs.DIST_CSS_PATH,
    out_js_path: ourbigbook_nodejs.DIST_JS_PATH,
    outdir,
    post_convert_callback: undefined,
    publish,
    renderType,
    sitemap: [],
    web,
  };
  if (publish) {
    ourbigbook_options.logoPath = ourbigbook_nodejs.LOGO_ROOT_RELPATH
  } else {
    ourbigbook_options.logoPath = ourbigbook_nodejs.LOGO_PATH
  }

  // CLI options
  if (webMediaSnapshot) {
    Object.assign(nonOurbigbookOptions, { localMediaPath, localMediaSnapshot: webMediaSnapshot })
  }
  Object.assign(ourbigbook_options, fileConversionOptions(ourbigbook_json_dir, nonOurbigbookOptions))
  const cmdOpts = {
    dry_run: cli.dryRun,
    env_extra: {},
    throwOnError: true,
  }
  const cmdOptsNoDry = { ...cmdOpts }
  cmdOptsNoDry.dry_run = false
  // Commands that retrieve information and don't change state.
  const cmdOptsInfo = { ...cmdOptsNoDry }
  cmdOptsInfo.showCmd = false
  const cmdOptsInfoNothrow = { ...cmdOptsInfo }
  cmdOptsInfoNothrow.throwOnError = false
  // We've started using this variatnt for commands that might blow spawnSync stdout buffer size.
  // This is not ideal as it prevents obtaining the error messages from stdout/stderr for debug purposes.
  // A better solution might instead be to have an async readline variant:
  // https://stackoverflow.com/questions/63796633/spawnsync-bin-sh-enobufs/77420941#77420941
  const cmdOptsNoStdout = { ...cmdOpts }
  cmdOptsNoStdout.ignoreStdout = true

  const isInGitRepo = git_is_in_repo(ourbigbook_json_dir)
  const configPath = path.join(ourbigbook_json_dir, ourbigbook.OURBIGBOOK_JSON_BASENAME)
  nonOurbigbookOptions.configMtime = fs.existsSync(configPath) ? fs.statSync(configPath).mtimeMs : 0
  if (localMediaDir) {
    const mediaRelpath = path.relative(ourbigbook_json_dir, localMediaDir)
    nonOurbigbookOptions.ignore_paths.add(mediaRelpath)
    if (mediaRelpath && pathIsWithin(ourbigbook_json_dir, localMediaDir)) {
      // This also applies to explicitly selected paths, not just directory walks.
      nonOurbigbookOptions.ignore_convert_path_regexps.push(
        new RegExp(`^${lodash.escapeRegExp(mediaRelpath)}($|${lodash.escapeRegExp(path.sep)})`))
    }
  }
  if (isInGitRepo && inputPath !== undefined) {
    const inputRelpath  = path.relative(ourbigbook_json_dir, input_dir)
    nonOurbigbookOptions.ignore_paths = new Set([
      ...nonOurbigbookOptions.ignore_paths,
      ...runCmd(
        'git', ['-C', input_dir, 'ls-files', '--ignored', '--others', '--exclude-standard', '--directory'], cmdOptsInfo
        ).split('\n').slice(0, -1).map(s => s.replace(/\/$/, '')).map(s => path.join(inputRelpath, s))
    ])
  }
  // A custom output directory inside the source tree must not become input on
  // later passes, notably once it contains the generated -/ namespace.
  const outputRelpath = path.relative(ourbigbook_json_dir, outdir)
  if (outputRelpath && outputRelpath !== '..' && !outputRelpath.startsWith('..' + path.sep) && !path.isAbsolute(outputRelpath)) {
    nonOurbigbookOptions.ignore_paths.add(outputRelpath)
  } else if (outputRelpath === '') {
    nonOurbigbookOptions.ignore_paths.add('-')
  }
  ourbigbook_options.outdir = path.relative(outdir, ourbigbook_json_dir)
  if (!nonOurbigbookOptions_log.db) {
    // They do not like true, has to be false or function.
    // And setting undefined is also considered true.
    nonOurbigbookOptions.db_options.logging = false;
  }
  let input_git_toplevel;
  let subdir_relpath;
  let publish_tmpdir;

  // Load built-in math defines.
  const katex_macros = {}
  ourbigbook_nodejs_webpack_safe.preload_katex_from_file(ourbigbook_nodejs.DEFAULT_TEX_PATH, katex_macros)
  ourbigbook_options.katex_macros = katex_macros

  if (cli.titleToId) {
    const readline = require('readline');
    for await (const line of readline.createInterface({ input: process.stdin })) {
      console.log(ourbigbook.titleToId(line))
    }
    process.exit(0)
  }
  if (inputPath === undefined) {
    if (cli.viewOutput) {
      cli_error('--view-output can only be used on single-file conversions');
    }
    // Input from stdin.
    title = 'stdin';
    input = await readStdin();
    if (cli.escapeLiteral) {
      output = ourbigbook.ourbigbookEscape(input)
    } else {
      output = await convert_input(input, ourbigbook_options, nonOurbigbookOptions);
    }
  } else {
    if (!fs.existsSync(inputPath)) {
      cli_error(`input_path does not exist: "${inputPath}"`);
    }
    if (cli.viewOutput) {
      if (!input_path_is_file) {
        cli_error('--view-output can only be used on single-file conversions');
      }
      if (inputPaths.length !== 1) {
        cli_error('--view-output requires exactly one input file');
      }
      if (!cli.render) {
        cli_error('--view-output requires render output; it is incompatible with --no-render');
      }
    }
    let publishDir
    let publishDirCwd
    if (!input_path_is_file) {
      if (cli.outfile !== undefined) {
        cli_error(`--outfile given but multiple output files must be generated, maybe you want --outdir?`);
      }
      if (publish) {
        input_git_toplevel = git_toplevel(inputPath);
        subdir_relpath = path.relative(input_git_toplevel, inputPath);
        publishDir = path.join(tmpdir, 'publish');
        publishDirCwd = relpathCwd(publishDir)
        publish_git_dir = path.join(publishDir, '.git');
        if (fs.existsSync(publish_git_dir)) {
          // This cleanup has to be done before the database initialization.
          runCmd('git', ['-C', publishDirCwd, 'clean', '-x', '-d', '-f'], cmdOpts);
        }
        publish_tmpdir = path.join(publishDir, subdir_relpath, ourbigbook_nodejs_webpack_safe.TMP_DIRNAME);
      }
    }
    if (publish_tmpdir === undefined) {
      publish_tmpdir = tmpdir;
    }

    // ourbigbook.tex custom math defines.
    let tex_path = path.join(ourbigbook_json_dir, OURBIGBOOK_TEX_BASENAME);
    if (fs.existsSync(tex_path)) {
      ourbigbook_nodejs_webpack_safe.preload_katex_from_file(tex_path, katex_macros)
    }

    // Setup the ID database.
    if (cli.db) {
      nonOurbigbookOptions.db_options.storage = path.join(publish_tmpdir, ourbigbook_nodejs_front.SQLITE_DB_BASENAME)
    } else {
      nonOurbigbookOptions.db_options.storage = ourbigbook_nodejs_webpack_safe.SQLITE_MAGIC_MEMORY_NAME
    }
    if (cli.checkDbOnly) {
      await create_db(ourbigbook_options, nonOurbigbookOptions);
      await check_db(nonOurbigbookOptions)
    } else if (web) {
      let token
      let webUrl
      if (cli.webUrl) {
        webUrl = cli.webUrl
      } else if (cli.webTest) {
        webUrl = 'http://localhost:3000'
      } else {
        let host
        if (options.ourbigbook_json.web && options.ourbigbook_json.web.host) {
          host = options.ourbigbook_json.web.host
        } else {
          host = ourbigbook.OURBIGBOOK_JSON_DEFAULT.web.host
        }
        webUrl = `https://${host}`
      }
      console.log(`Publishing to: ${webUrl}`)
      const url = new URL(webUrl)
      const host = url.host
      await create_db(ourbigbook_options, nonOurbigbookOptions);

      // Get username, password and attempt login before anything else.
      let username, webApi
      if (cli.webUser) {
        username = cli.webUser
      } else {
        if (cli.webTest) {
          username = 'barack-obama'
        }
      }
      const cliWhere = { host }
      if (username) {
        cliWhere.username = username
      } else {
        cliWhere.defaultUsernameForHost = true
      }
      const host_row = await nonOurbigbookOptions.sequelize.models.Cli.findOne({ where: cliWhere })
      if (username === undefined) {
        if (host_row === null) {
          ;[err, username] = await read({ prompt: 'Username: ' })
        } else  {
          username = host_row.username
          console.log(`Using previous username: ${username}\n`);
        }
      }
      webApi = new WebApi({
        getToken: () => token,
        https: url.protocol === 'https:',
        port: url.port,
        hostname: url.hostname,
        retries: WEB_MAX_RETRIES,
        retryDelayMs: WEB_RETRY_DELAY_MS,
        validateStatus: () => true,
      })
      let tokenOk = true
      if (host_row) {
        token = host_row.token
        let data, status
        // Can fail with "jwt expired" if expered if you wait for a long time
        // after the previous login. So we test the token first thing.
        ;({ data, status } = await webApi.min())
        tokenOk = data.loggedIn
      }
      if (!host_row || !tokenOk) {
        let err, password

        // Password
        if (cli.webPassword) {
          password = cli.webPassword
        } else {
          if (cli.webTest && !cli.webAskPassword) {
            password = 'asdf'
          } else {
            ;[err, password] = await read({ prompt: 'Password: ', silent: true })
          }
        }

        if (!cli.webDry) {
          let data, status
          try {
            ;({ data, status } = await webApi.userLogin({ username, password }))
          } catch(err) {
            handleWebApiErr(err)
          }
          if (status === 422) {
            cli_error('invalid username or password');
          } else if (status !== 200) {
            cli_error(`error status: ${status}`);
          }
          token = data.user.token
        }

        await nonOurbigbookOptions.sequelize.transaction(async (transaction) => {
          await nonOurbigbookOptions.sequelize.models.Cli.update(
            {
              defaultUsernameForHost: false
            },
            {
              where: {
                host,
              },
              transaction,
            }
          )
          await nonOurbigbookOptions.sequelize.models.Cli.upsert(
            {
              host,
              username,
              token,
              // Use the latest one by default.
              defaultUsernameForHost: true
            },
            { transaction }
          )
        })
      }

      let currentBuildToken
      if (cli.webCancelBuild) {
        const current = await webApi.articlesCurrentBuild({ timeout: 15000 })
        assertApiStatus(current.status, current.data)
        const result = await webApi.articlesCancelBuild(current.data.build?.token || null, undefined, { timeout: 15000 })
        if (result.status !== 202) assertApiStatus(result.status, result.data)
        console.log(`web_upload: @${username}: ${result.data.message}`)
        process.exit(0)
      }
      if (cli.webWatch) {
        await watchWebBuild(webApi, username)
        process.exit(0)
      }
      if (!cli.webDry && !cli.webIndividualUpload && !cli.webNestedSet) {
        const { data, status } = await webApi.articlesCurrentBuild({ timeout: 15000 })
        assertApiStatus(status, data)
        const ongoing = data.job || data.tree || (data.build && !['completed', 'failed'].includes(data.build.status))
        if (ongoing && !cli.webForce) {
          let answer = ''
          if (process.stdin.isTTY) {
            const [error, response] = await read({ prompt: 'A web build exists. Discard it and start a new upload? [y/N] ' })
            if (error) throw error
            answer = response
          } else {
            console.log(`A web build exists; ${cli.webNoWatch ? 'leaving it running' : 'watching it'}. Use --web-force to replace it non-interactively.`)
          }
          if (!/^y(es)?$/i.test(answer.trim())) {
            if (!cli.webNoWatch) await watchWebBuild(webApi, username)
            process.exit(0)
          }
        }
        currentBuildToken = require('crypto').randomBytes(16).toString('hex')
        while (true) {
          const result = await webApi.articlesBuildReplace(currentBuildToken, data.build?.token || null, { timeout: 15000 })
          if (result.status !== 202) assertApiStatus(result.status, result.data)
          if (result.data.build.status !== 'cancelling') break
          console.log('web_upload: waiting for the current article/tree transaction to finish before replacement')
          await new Promise(resolve => setTimeout(resolve, 1000))
        }
      }

      if (cli.webNestedSet) {
        await updateNestedSet(webApi, username, { foreground: cli.webIndividualUpload, watch: !cli.webNoWatch })
        process.exit(0)
      }

      // Do a local conversion that splits mutiheader files into single header files for upload.
      ourbigbook_options.split_headers = true
      ourbigbook_options.render_include = false
      ourbigbook_options.forbid_multi_h1 = true
      // We create this quick and dirty separate database to store information for upload.
      // Technically much of this information is part of Article, but factoring that would be risky/hard,
      // it is not worth it.
      //
      // Adding this cache because I had an unminimizable error on the main document, and we have to save some time
      // or else I can't minimize it, this way we can skip the initial bigb split render conversion and go
      // straight to upload.
      const webCache = await createWebCache(nonOurbigbookOptions.outdir)
      const sequelizeWeb = webCache.sequelize
      const sequelizeWebArticle = sequelizeWeb.models.Article
      const sequelizeWebIndexId = sequelizeWeb.models.IndexId
      nonOurbigbookOptions.post_convert_callback = webCache.postConvert
      let treeToplevelId, treeToplevelFileId
      if (input_path_is_file) {
        await convert_path_to_file(inputPath, ourbigbook_options, nonOurbigbookOptions)
        treeToplevelFile = await nonOurbigbookOptions.sequelize.models.File.findOne({
          where: { path: inputPath } })
        treeToplevelFileId = treeToplevelFile.id
        treeToplevelId = treeToplevelFile.toplevel_id
      } else {
        // TODO non toplevel directory not supported yet.
        await convert_directory_extract_ids_and_render(
          inputPath,
          ourbigbook_options,
          nonOurbigbookOptions,
        )
        const index = (await sequelizeWebIndexId.findAll())[0]
        if (index === undefined) {
          cli_error('a toplevel index is mandatory for web uploads')
        }
        treeToplevelId = index.idid
      }
      if (nonOurbigbookOptions.had_error) {
        process.exit(1)
      }

      const upload = async () => {
        let header_tree = []
        if (input_path_is_file || cli.webId) {
          let toPush
          if (cli.webId) {
            toPush = cli.webId
          } else {
            toPush = treeToplevelId
          }
          header_tree.push({ to_id: toPush })
        } else {
          // Fake an index entry at the end so that the index will get rendered.
          // It is not otherwise present as it has no parents.
          header_tree.push({ to_id: '' })
        }
        if (!cli.webId) {
          header_tree = header_tree.concat(await ourbigbook_options.db_provider.fetch_header_tree_ids(
            [treeToplevelId],
            {
              definedAtFileId: treeToplevelFileId,
            }
          ))
        }
        // A single-file or single-ID upload may not fetch the surrounding header
        // tree. Fetch missing parent and previous-sibling metadata explicitly so
        // it still uses the authoritative cross-file Ref graph rather than the
        // lossy split-file web cache.
        if (input_path_is_file || cli.webId) {
          const { Ref } = nonOurbigbookOptions.sequelize.models
          for (const headerTreeEntry of header_tree) {
            if (headerTreeEntry.from_id === undefined && headerTreeEntry.to_id !== '') {
              const parentRef = await Ref.findOne({
                attributes: ['from_id', 'to_id_index'],
                raw: true,
                where: {
                  to_id: headerTreeEntry.to_id,
                  type: Ref.Types[ourbigbook.REFS_TABLE_PARENT],
                },
              })
              if (parentRef) {
                headerTreeEntry.from_id = parentRef.from_id
                if (parentRef.to_id_index > 0) {
                  const previousSiblingRef = await Ref.findOne({
                    attributes: ['to_id'],
                    order: [['to_id_index', 'DESC']],
                    raw: true,
                    where: {
                      from_id: parentRef.from_id,
                      to_id_index: { [Op.lt]: parentRef.to_id_index },
                      type: Ref.Types[ourbigbook.REFS_TABLE_PARENT],
                    },
                  })
                  if (previousSiblingRef) {
                    headerTreeEntry.previous_sibling_id = previousSiblingRef.to_id
                  }
                }
              }
            }
          }
        }
        let webStartIndex = 0
        if (cli.webStartId !== undefined) {
          webStartIndex = header_tree.findIndex(entry => entry.to_id === cli.webStartId)
          if (webStartIndex === -1) {
            cli_error(`--web-start-id ID not found in the upload tree: "${cli.webStartId}"`)
          }
        }
        const dorender = [false]
        if (!cli.webIndividualUpload) dorender.push('check')
        if (cli.webRender) {
          dorender.push(true)
        }
        let data, status, i = 0
        const webPathToArticle = {}
        if (!cli.webDry) {
          do {
            ;({ data, status } = await webApi.articlesHash({ author: username, offset: i }))
            assertApiStatus(status, data)
            const articles = data.articles
            for (const article of articles) {
              webPathToArticle[article.path] = article
            }
            i += articles.length
          } while (data.articles.length === ARTICLE_HASH_LIMIT_MAX)
        }
        const idToArticleMeta = {}
        const localArticles = await sequelizeWebArticle.findAll({ attributes: ['source', 'title', 'idid', 'inpath', 'parentId'] })
        for (const article of localArticles) {
          idToArticleMeta[article.idid] = article
        }
        // The split-file cache cannot represent a toplevel header's cross-file
        // Include parent. Derive all upload tree metadata from the authoritative
        // Ref graph returned in header_tree. Calculate it before applying
        // --web-start-id so resumed uploads can replay skipped prerequisites.
        const idToWebTreeMeta = {}
        const lastChildIdByParentId = new Map()
        for (const header_tree_entry of header_tree) {
          const articleMeta = idToArticleMeta[header_tree_entry.to_id]
          // Can fail for synonyms.
          if (articleMeta) {
            const parentId = header_tree_entry.from_id === undefined
              // The synthetic index entry, and a single file whose parent is not
              // present in the local database, have no Ref metadata to override.
              ? articleMeta.parentId
              : header_tree_entry.from_id
            let previousSiblingId = header_tree_entry.previous_sibling_id
            if (previousSiblingId === undefined && parentId !== null && parentId !== undefined) {
              previousSiblingId = lastChildIdByParentId.get(parentId)
            }
            if (parentId !== null && parentId !== undefined) {
              lastChildIdByParentId.set(parentId, header_tree_entry.to_id)
            }
            idToWebTreeMeta[header_tree_entry.to_id] = {
              parentId,
              previousSiblingId,
            }
            if (cli.addTestInstrumentation) {
              console.error(
                `web_upload_tree: ${JSON.stringify(header_tree_entry.to_id)} parent=${JSON.stringify(parentId)} previous=${JSON.stringify(previousSiblingId)}`
              )
            }
          }
        }
        // A suffix is not necessarily self-contained because fetch_header_tree_ids
        // returns breadth-first order. A child after the resume marker can therefore
        // have a parent before it. Replay skipped parents and previous siblings (and
        // their own prerequisites) so resuming also works when those server rows are
        // absent, while still avoiding unrelated completed subtrees.
        const webResumeDependencyIds = new Set()
        if (webStartIndex > 0) {
          const headerTreeIdToIndex = new Map(header_tree.map(
            (entry, index) => [entry.to_id, index]
          ))
          const pendingIds = header_tree.slice(webStartIndex).map(entry => entry.to_id)
          for (let pendingIndex = 0; pendingIndex < pendingIds.length; pendingIndex++) {
            const webTreeMeta = idToWebTreeMeta[pendingIds[pendingIndex]]
            if (!webTreeMeta) {
              continue
            }
            for (const dependencyId of [webTreeMeta.parentId, webTreeMeta.previousSiblingId]) {
              const dependencyIndex = headerTreeIdToIndex.get(dependencyId)
              if (
                dependencyIndex !== undefined &&
                dependencyIndex < webStartIndex &&
                !webResumeDependencyIds.has(dependencyId)
              ) {
                webResumeDependencyIds.add(dependencyId)
                pendingIds.push(dependencyId)
              }
            }
          }
        }
        let nestedSetNeedsUpdate = false
        // Partial uploads must not delete unrelated server articles or files.
        const fullWebUpload = inputPath === '.' && !cli.webId
        if (fullWebUpload || localMediaDir) {
          // Handle uploads
          {
            const uploadPaths = new Map(fullWebUpload
              ? [...nonOurbigbookOptions.nonBigbPathsConverted].map(p => [p, p]) : [])
            if (localMediaDir) {
              const ignored = git_is_in_repo(localMediaDir)
                ? new Set(runCmd('git', ['-C', localMediaDir, 'ls-files', '--ignored', '--others', '--exclude-standard', '--directory', '-z'], cmdOptsInfo)
                  .split('\0').filter(Boolean).map(p => p.replace(/\/$/, '')))
                : new Set()
              // Walk this repository separately: source discovery deliberately skips -/.
              for (const file of walk_directory_recursively(localMediaDir,
                new Set([...DEFAULT_IGNORE_BASENAMES_SET, '.gitignore', '.gitattributes', '.gitmodules']),
                ignored, [], [], localMediaDir)) {
                const stat = fs.lstatSync(file)
                const destination = path.relative(localMediaDir, file)
                const sourcePath = path.join(ourbigbook_json_dir, destination)
                // Validate the source tree too, including partial uploads and
                // article sources that are not in the ordinary upload manifest.
                // Shared directories are fine; files must never overwrite either.
                if (uploadPaths.has(destination) ||
                  (fs.existsSync(sourcePath) &&
                    !(stat.isDirectory() && fs.lstatSync(sourcePath).isDirectory()))) {
                  cli_error(`media upload path collision: ${destination}`)
                }
                if (!stat.isFile()) continue
                uploadPaths.set(destination, file)
              }
            }
            const uploadHashes = {}
            const serverOnlyUploads = []
            let i = 0
            if (!cli.webDry) {
              do {
                ;({ data, status } = await webApi.uploadHash({ author: username, offset: i }))
                assertApiStatus(status, data)
                const uploads = data.uploads
                for (const upload of uploads) {
                  const pathNoUsername = upload.path.substring(username.length + URL_SEP.length)
                  uploadHashes[pathNoUsername] = upload.hash
                  if (fullWebUpload && !uploadPaths.has(pathNoUsername) && upload.list !== false) {
                    serverOnlyUploads.push(pathNoUsername)
                  }
                }
                i += uploads.length
              } while (data.uploads.length === ARTICLE_HASH_LIMIT_MAX)
            }
            i = 0
            for (const p of serverOnlyUploads) {
              // Preserve existing raw URLs and bytes; removal from listings is
              // allowed for owners, unlike the admin-only deletion endpoint.
              await webCreateOrUpdate({
                cleanupDeleted: true,
                fn: async () => webApi.uploadUpdate(`${username}${URL_SEP}${p}`, { list: false }),
                i: i++,
                isUpload: true,
                title: p,
                webDry: cli.webDry,
              })
            }
            for (const [p, file] of uploadPaths) {
              const content = fs.readFileSync(file)
              if (hashToHex(content) !== uploadHashes[p]) {
                await webCreateOrUpdate({
                  cleanupDeleted: false,
                  fn: async () => webApi.uploadCreateOrUpdate(`${username}${URL_SEP}${p}`, content),
                  i: i++,
                  isUpload: true,
                  title: p,
                  webDry: cli.webDry,
                })
              }
            }
          }

        }
        if (fullWebUpload) {
          // https://docs.ourbigbook.com/todo/make-articles-removed-locally-empty-on-web-upload
          const serverOnlyPaths = new Set(Object.keys(webPathToArticle))
          for (const header_tree_entry of header_tree) {
            serverOnlyPaths.delete(pathUsernameAndExt(username, header_tree_entry.to_id))
          }
          let i = 0
          for (const path of serverOnlyPaths) {
            if (webPathToArticle[path].cleanupIfDeleted) {
              const render = true
              const ret = await webCreateOrUpdate({
                cleanupDeleted: true,
                fn: async () => webApi.articleCreateOrUpdate(
                  {
                    bodySource: '',
                  },
                  {
                    path: pathNoUsernameNoext(path),
                    render,
                    list: false,
                    updateNestedSetIndex: !cli.webNestedSetBulk,
                  },
                ),
                i: i++,
                inpath: path,
                render,
                webDry: cli.webDry,
              })
              if (ret.nestedSetNeedsUpdate) {
                nestedSetNeedsUpdate = true
              }
            }
          }
        }
        let buildId = currentBuildToken
        const buildJobs = []
        for (const render of dorender) {
          let i = 0
          let renderBatch = []
          let batchSourceIds = []
          const phase = !render ? 'extract' : render === 'check' ? 'check' : 'render'
          const phaseBatches = []
          const flushRenderBatch = async () => {
            if (renderBatch.length) {
              if (cli.webDry) console.log(`web_${phase}: would queue ${renderBatch.length} articles`)
              else {
                // Plan exact count- and byte-limited batches before staging so
                // progress has a real denominator. Keep source bodies on disk.
                phaseBatches.push({
                  targets: renderBatch.map(({ article, ...target }) => target),
                  sourceIds: batchSourceIds,
                })
              }
              renderBatch = []
              batchSourceIds = []
            }
          }
          // This ordering ensures parents come before children.
          for (const [headerTreeIndex, header_tree_entry] of header_tree.entries()) {
            const id = header_tree_entry.to_id
            const articleMeta = idToArticleMeta[id]
            const webTreeMeta = idToWebTreeMeta[id]
            if (
              // Can fail for synonyms.
              articleMeta && webTreeMeta
            ) {
              // Prerequisites only need their ID extraction replayed. Rendering
              // still begins exactly at the requested marker.
              if (
                headerTreeIndex < webStartIndex &&
                (render || !webResumeDependencyIds.has(id))
              ) {
                continue
              }
              const inpathParse = path.parse(articleMeta.inpath)
              const articlePath = path.join(inpathParse.dir, inpathParse.name)
              const webPathToArticleEntry = webPathToArticle[pathUsernameAndExt(username, articlePath)]
              if (webPathToArticleEntry === undefined ) {
                webPathToArticleEntry
              }
              const articleHashProps = {
                source: articleMeta.source,
                list: true,
              }
              const parentId = addUsername(webTreeMeta.parentId, username)
              if (parentId !== null) {
                articleHashProps.parentId = parentId
              }
              const previousSiblingId = addUsername(webTreeMeta.previousSiblingId, username)
              if (previousSiblingId !== null) {
                articleHashProps.previousSiblingId = previousSiblingId
              }
              // A completed preflight survives worker/client restarts even when
              // rendering is still outstanding. Render itself always validates
              // against the current database; this only skips the separate pass.
              if (
                render === 'check' &&
                !cli.webForceIdExtraction &&
                webPathToArticleEntry &&
                webPathToArticleEntry.hash === articleHash(articleHashProps) &&
                webPathToArticleEntry.checkedHash === webPathToArticleEntry.hash
              ) continue
              if (
                webPathToArticleEntry === undefined ||
                webPathToArticleEntry.hash !== articleHash(articleHashProps) ||
                (
                  render &&
                  (
                    webPathToArticleEntry.renderOutdated ||
                    cli.webForceRender ||
                    (render === 'check' && cli.webForceIdExtraction)
                  )
                ) ||
                (
                  !render &&
                  (
                    cli.webForceIdExtraction ||
                    webResumeDependencyIds.has(id)
                  )
                )
              ) {
                // OK, we are going to render this article, so fetch it fully now including the source.
                const article = await sequelizeWebArticle.findOne({ where: { idid: id } })
                let data, status
                const articleArgs = {}
                if (!render) {
                  articleArgs.titleSource = article.title
                  articleArgs.bodySource = article.body
                }
                const extraArgs = {
                  path: articlePath,
                  render: Boolean(render),
                }
                if (parentId) {
                  extraArgs.parentId = parentId
                }
                if (previousSiblingId) {
                  extraArgs.previousSiblingId = previousSiblingId
                }
                extraArgs.updateNestedSetIndex = !cli.webNestedSetBulk
                extraArgs.list = true
                if (!cli.webIndividualUpload) {
                  const target = { ...extraArgs, ...(render ? { hash: articleHash(articleHashProps) } : { article: articleArgs }) }
                  if (renderBatch.length === ARTICLE_RENDER_BATCH_LIMIT || Buffer.byteLength(JSON.stringify([...renderBatch, target])) > 4 * 1024 * 1024) await flushRenderBatch()
                  renderBatch.push(target)
                  if (!render) batchSourceIds.push(id)
                  i++
                } else {
                  const ret = await webCreateOrUpdate({
                    fn: async () => webApi.articleCreateOrUpdate(articleArgs, extraArgs),
                    i: i++,
                    inpath: article.inpath,
                    render,
                    title: article.title,
                    webDry: cli.webDry,
                  })
                  if (render && ret.nestedSetNeedsUpdate) {
                    nestedSetNeedsUpdate = true
                  }
                }
                if (
                  render &&
                  cli.webMaxRenders !== undefined &&
                  i === cli.webMaxRenders
                ) {
                  break
                }
              }
            }
          }
          await flushRenderBatch()
          for (const [batchIndex, batch] of phaseBatches.entries()) {
            if (!buildId) {
              buildId = require('crypto').randomBytes(16).toString('hex')
              const { data, status } = await webApi.articlesBuildReplace(buildId, null, { timeout: 20000 })
              checkWebBulkBusy(status, data)
              if ([404, 405].includes(status)) console.error('Durable background uploads require an updated server. Use --web-individual-upload for the legacy path.')
              if (status !== 202) assertApiStatus(status, data)
            }
            const sources = !render && new Map((await sequelizeWebArticle.findAll({
              attributes: ['idid', 'title', 'body'], where: { idid: { [Op.in]: batch.sourceIds } },
            })).map(article => [article.idid, article]))
            const targets = render ? batch.targets : batch.targets.map((target, index) => {
              const article = sources.get(batch.sourceIds[index])
              return { ...target, article: { titleSource: article.title, bodySource: article.body } }
            })
            const id = await queueWebArticles(webApi, targets, {
              phase, start: false, batchIndex, batchCount: phaseBatches.length, buildId, buildIndex: buildJobs.length,
            })
            buildJobs.push({ id, phase, batchIndex, batchCount: phaseBatches.length, firstArticle: targets[0].path })
          }
        }
        if (buildId) {
          let rebuildTree = Boolean(cli.webNestedSetBulk && (buildJobs.length || nestedSetNeedsUpdate))
          if (cli.webNestedSetBulk && !rebuildTree) {
            const result = await webApi.user(username)
            assertApiStatus(result.status, result.data)
            rebuildTree = Boolean(result.data.nestedSetNeedsUpdate)
          }
          const { data, status } = await webApi.articlesCurrentBuildCommit(buildId, buildJobs.length, rebuildTree, { timeout: 20000 })
          checkWebBulkBusy(status, data)
          if (status !== 202) assertApiStatus(status, data)
          console.log(`Web upload has submission complete (${buildJobs.length} jobs). The server can now finish by itself even if you close the CLI.`)
          if (!cli.webNoWatch) {
            for (const job of buildJobs) await waitWebArticles(webApi, job.id, job.phase, job)
            await waitWebBuild(webApi, buildId)
          }
        }
        if (!buildId && cli.webNestedSetBulk && !cli.webDry) {
          if (!nestedSetNeedsUpdate) {
            ;({ data, status } = await webApi.user(username))
            assertApiStatus(status, data)
            if (data.nestedSetNeedsUpdate) {
              nestedSetNeedsUpdate = true
            }
          }
          if (nestedSetNeedsUpdate) {
            await updateNestedSet(webApi, username, { foreground: cli.webIndividualUpload })
          }
        }
      }
      await upload()
      await sequelizeWeb.close()
    } else if (cli.watch) {
      if (cli.stdout) {
        cli_error('--stdout and --watch are incompatible');
      }
      if (publish) {
        cli_error('--publish and --watch are incompatible');
      }
      if (cli.viewOutput) {
        cli_error('--view-output and --watch are incompatible');
      }
      await create_db(ourbigbook_options, nonOurbigbookOptions);
      if (!input_path_is_file) {
        await reconcile_db_and_filesystem(inputPath, ourbigbook_options, nonOurbigbookOptions);
        await convert_directory_extract_ids(inputPath, ourbigbook_options, nonOurbigbookOptions);
      }
      const watcher = require('chokidar').watch(inputPath, {ignored: DEFAULT_IGNORE_BASENAMES})
      const convert = async (subpath) => {
        await convert_path_to_file(subpath, ourbigbook_options, nonOurbigbookOptions);
        await check_db(nonOurbigbookOptions)
        nonOurbigbookOptions.ourbigbook_paths_converted = []
      }
      watcher.on('change', convert).on('add', convert)
    } else {
      if (input_path_is_file) {
        if (publish) {
          cli_error('--publish must take a directory as input, not a file');
        }
        await create_db(ourbigbook_options, nonOurbigbookOptions);
        for (const inputPath of inputPaths) {
          if (ignore_path(
            DEFAULT_IGNORE_BASENAMES_SET,
            nonOurbigbookOptions.ignore_paths,
            nonOurbigbookOptions.ignore_path_regexps,
            nonOurbigbookOptions.dont_ignore_path_regexps,
            inputPath
          )) {
            console.error(`skipping conversion of "${inputPath}" because it is ignored`)
          } else {
            output = await convert_path_to_file(inputPath, ourbigbook_options, nonOurbigbookOptions);
          }
        }
        if (!nonOurbigbookOptions.had_error && cli.render) {
          await check_db(nonOurbigbookOptions)
        }
        if (
          cli.viewOutput &&
          !nonOurbigbookOptions.had_error
        ) {
          const output_path = nonOurbigbookOptions.last_output_path
          if (output_path === undefined) {
            cli_error('could not determine output file for --view-output')
          }
          viewOutput(output_path, cmdOpts)
        }
      } else {
        if (cli.stdout) {
          cli_error('--publish cannot be used in directory conversion');
        }
        let actualInputDir;
        let publishBranch;
        let publishOutPublishDir;
        let publishOutPublishDirCwd;
        let publishOutPublishDistDir;
        let publishRemoteUrl;
        let srcBranch;

        if (publish) {
          if (localMediaDir && publish_uses_git) {
            const mediaGit = args => chomp(runCmd('git', ['-C', localMediaDir, ...args], cmdOptsInfo))
            if (fs.realpathSync(mediaGit(['rev-parse', '--show-toplevel'])) !== fs.realpathSync(localMediaDir)) {
              cli_error('local media path must be the root of its own Git repository for GitHub publishing')
            }
            const remote = require('git-url-parse')(mediaGit(['remote', 'get-url', 'origin']))
            if (remote.source !== 'github.com') cli_error('local media repository origin must be on github.com for GitHub publishing')
            const commit = mediaGit(['rev-parse', 'HEAD'])
            let branch = mediaGit(['rev-parse', '--abbrev-ref', 'HEAD'])
            // A submodule can pin an older, already published commit. Do not try
            // to rewind its remote branch to that commit.
            const alreadyPublished = branch === 'HEAD' && mediaGit([
              'for-each-ref', '--contains', commit, '--format=%(refname)', 'refs/remotes/origin',
            ]) !== ''
            if (branch === 'HEAD' && !alreadyPublished) {
              branch = mediaGit(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']).replace(/^origin\//, '')
            }
            ourbigbook_options.localMediaPublish = true
            const snapshot = fs.mkdtempSync(path.join(require('os').tmpdir(), 'ourbigbook-publish-media-'))
            process.on('exit', () => fs.rmSync(snapshot, { recursive: true, force: true }))
            runCmd('git', ['clone', '--shared', '--no-checkout', '--', localMediaDir, snapshot], cmdOptsNoDry)
            runCmd('git', ['-C', snapshot, 'checkout', '--detach', commit], cmdOptsNoDry)
            Object.assign(nonOurbigbookOptions, { localMediaPath, localMediaSnapshot: snapshot })
            Object.assign(ourbigbook_options, fileConversionOptions(ourbigbook_json_dir, nonOurbigbookOptions))
            // Publish media first, including before cloning a source repository with submodules.
            if (!cli.dryRunPush && !alreadyPublished) runCmd('git', ['-C', localMediaDir, 'push', 'origin', `HEAD:refs/heads/${branch}`], cmdOpts)
          }
          // Clone the source to ensure that only git tracked changes get built and published.
          ourbigbook_options.template_vars.publishTargetIsWebsite = publishTargetIsWebsite
          if (!isInGitRepo) {
            cli_error('--publish must point to a path inside a git repository');
          }
          if (publish_uses_git) {
            // TODO ideally we should use the default remote for the given current branch, but there doesn't seem
            // to be a super easy way for now, so we just hardcode origin to start with.
            // https://stackoverflow.com/questions/171550/find-out-which-remote-branch-a-local-branch-is-tracking
            const opts = {}
            const originUrl = chomp(runCmd('git', ['-C', inputPathCwd, 'config', '--get', 'remote.origin.url'], cmdOptsInfoNothrow))
            if (cmdOptsInfoNothrow.extra_returns.out.status != 0) {
              cli_error('a "origin" git remote repository is required to publish, configure it with something like "git remote add origin git@github.com:username/reponame.git"')
            }
            if (options.ourbigbook_json.publishRemoteUrl) {
              publishRemoteUrl = options.ourbigbook_json.publishRemoteUrl
            } else {
              publishRemoteUrl = originUrl
            }
            if (!publishRemoteUrl) {
              publishRemoteUrl = 'git@github.com:ourbigbook/ourbigbook.git';
            }
            srcBranch = chomp(runCmd('git', ['-C', inputPathCwd, 'rev-parse', '--abbrev-ref', 'HEAD'], cmdOptsInfo))
            const parsed_remote_url = require("git-url-parse")(publishRemoteUrl);
            if (parsed_remote_url.source !== 'github.com') {
              cli_error('only know how  to publish to origin == github.com currently, please send a patch');
            }
            let remote_url_path_components = parsed_remote_url.pathname.split(path.sep);
            if (options.ourbigbook_json.publishBranch) {
              publishBranch = options.ourbigbook_json.publishBranch
            } else if (publish_target === 'github-md') {
              publishBranch = 'master'
            } else if (remote_url_path_components[2].startsWith(remote_url_path_components[1] + '.github.io')) {
              publishBranch = 'master';
            } else {
              publishBranch = 'gh-pages';
            }
            if (
              publishRemoteUrl === originUrl &&
              srcBranch === publishBranch
            ) {
              cli_error(`source and publish branches are the same: ${publishBranch}`);
            }
          }
          fs.mkdirSync(publishDir, { recursive: true });
          if (cli.publishCommit !== undefined) {
            runCmd('git', ['-C', inputPathCwd, 'add', '-u'], cmdOpts);
            runCmd( 'git', ['-C', inputPathCwd, 'commit', '-m', cli.publishCommit], cmdOpts);
          }
          sourceCommit = gitSha(inputPath, srcBranch);
          if (fs.existsSync(publish_git_dir)) {
            runCmd('git', ['-C', publishDirCwd, 'checkout', '--', '.'], cmdOptsNoDry);
            runCmd('git', ['-C', publishDirCwd, 'fetch'], cmdOptsNoDry);
            runCmd('git', ['-C', publishDirCwd, 'checkout', sourceCommit], cmdOptsNoDry);
            runCmd('git', ['-C', publishDirCwd, 'submodule', 'update', '--init'], cmdOptsNoDry);
            runCmd('git', ['-C', publishDirCwd, 'clean', '-xdf'], cmdOptsNoDry);
          } else {
            runCmd('git', ['clone', '--recursive', '--depth', '1', input_git_toplevel, publishDirCwd],
              ourbigbook.cloneAndSet(cmdOpts, 'dry_run', false));
          }

          // Set some variables especially for publishing.
          actualInputDir = path.join(publishDir, subdir_relpath);
          nonOurbigbookOptions.ourbigbook_json_dir = actualInputDir;
          publishOutPublishDir = path.join(publish_tmpdir, publish_target);
          publishOutPublishDirCwd = relpathCwd(publishOutPublishDir)
          publish_out_publish_obb_dir = path.join(publishOutPublishDir, ourbigbook_nodejs.PUBLISH_OBB_PREFIX)
          publishOutPublishDistDir = path.join(publishOutPublishDir, ourbigbook_nodejs.PUBLISH_ASSET_DIST_PREFIX)
          nonOurbigbookOptions.out_css_path = path.join(publishOutPublishDistDir, ourbigbook_nodejs.DIST_CSS_BASENAME);
          nonOurbigbookOptions.out_js_path = path.join(publishOutPublishDistDir, ourbigbook_nodejs.DIST_JS_BASENAME);
          nonOurbigbookOptions.external_css_and_js = true;

          // Remove all files from the publish diretory in case some were removed from the original source.
          if (!cli.publishNoConvert) {
            if (publish_uses_git) {
              if (fs.existsSync(path.join(publishOutPublishDir, '.git'))) {
                // Quiet avoids overflowing spawnSync's output buffer on large sites.
                // Empty indexes are OK; actual cleanup failures must abort the build.
                // Dry runs also rebuild locally, so they need the same clean output.
                runCmd('git', ['-C', publishOutPublishDirCwd, 'rm', '--quiet', '-r', '-f', '--ignore-unmatch', '--', '.'], cmdOptsNoDry)
                // An interrupted publish can also leave untracked/ignored output.
                runCmd('git', ['-C', publishOutPublishDirCwd, 'clean', '--quiet', '-fdx'], cmdOptsNoDry)
              }
            } else {
              fs.rmSync(publishOutPublishDir, { recursive: true, force: true })
            }

            // Clean database to ensure a clean conversion. TODO: this is dangerous, if some day we
            // start adding more conversion state outside of db.sqlite3. Better would be to remove the
            // entire _out/publish/_out. Te slight downside of that is that:
            // - it deletes other publish targets
            // - it forces re-fetch of git history on the gh-pages branch
            fs.rmSync(path.join(publish_tmpdir, ourbigbook_nodejs_front.SQLITE_DB_BASENAME), { force: true })

            fs.mkdirSync(publishOutPublishDir, { recursive: true })
          }
        } else {
          actualInputDir = inputPath;
          publishOutPublishDir = outdir;
          publishOutPublishDirCwd = relpathCwd(publishOutPublishDir)
        }
        nonOurbigbookOptions.outdir = publishOutPublishDir;

        if (!cli.publishNoConvert) {
          await create_db(ourbigbook_options, nonOurbigbookOptions);

          // Do the actual conversion.
          await convert_directory_extract_ids_and_render(actualInputDir, ourbigbook_options, nonOurbigbookOptions)
          if (nonOurbigbookOptions.had_error) {
            process.exit(1);
          }

          // Generate redirects from ourbigbook.json.
          if (output_format === ourbigbook.OUTPUT_FORMAT_HTML) {
            for (let [from, to] of options.ourbigbook_json.redirects) {
              if (
                // TODO https://docs.ourbigbook.com/respect-ourbigbook-json-htmlxextension-on-ourbigbook-json-redirects
                ourbigbook_options.htmlXExtension === false ? false : true &&
                !ourbigbook.protocolIsKnown(to)
              ) {
                to +=  '.' + ourbigbook.HTML_EXT
              }
              generate_redirect_base(
                path.join(nonOurbigbookOptions.outdir, from + '.' + ourbigbook.HTML_EXT),
                to
              )
            }
          }
        }

        // Publish the converted output if build succeeded.
        if (
          publish &&
          !nonOurbigbookOptions.had_error
        ) {
          // Push the original source.
          if (publishIsPublic && !cli.dryRunPush) {
            runCmd('git', ['-C', inputPathCwd, 'push'], cmdOpts);
          }

          if (publish_uses_git) {
            runCmd('git', ['-C', publishOutPublishDirCwd, 'init'], cmdOpts);
            const coreSshCommand = chomp(runCmd('git', ['-C', inputPath, 'config', '--get', 'core.sshCommand'], cmdOptsInfoNothrow))
            if (coreSshCommand) {
              runCmd('git', ['-C', publishOutPublishDirCwd, 'config', 'core.sshCommand', coreSshCommand], cmdOpts)
            }
            // https://stackoverflow.com/questions/42871542/how-to-create-a-git-repository-with-the-default-branch-name-other-than-master
            runCmd('git', ['-C', publishOutPublishDirCwd, 'checkout', '-B', publishBranch], cmdOptsNoStdout);
            try {
              // Fails if remote already exists.
              runCmd('git', ['-C', publishOutPublishDirCwd, 'remote', 'add', 'origin', publishRemoteUrl], cmdOpts);
            } catch(error) {
              runCmd('git', ['-C', publishOutPublishDirCwd, 'remote', 'set-url', 'origin', publishRemoteUrl], cmdOpts);
            }
            // Ensure that we are up-to-date with the upstream gh-pages if one exists.
            runCmd('git', ['-C', publishOutPublishDirCwd, 'fetch', 'origin'], cmdOpts);
            runCmd(
              'git',
              ['-C', publishOutPublishDirCwd, 'reset', '--quiet', `origin/${publishBranch}`],
              // Fails on the first commit in an empty repository.
              ourbigbook.cloneAndSet(cmdOpts, 'throwOnError', false)
            );
          }

          // Generate special files needed for a given publish target.
          for (const p in publish_create_files) {
            const outpath = path.join(publishOutPublishDir, p)
            fs.mkdirSync(path.dirname(outpath), { recursive: true });
            fs.writeFileSync(outpath, publish_create_files[p])
          }

          if (options.ourbigbook_json.prepublish) {
            if (!cli.dryRun && !cli.dryRunPush && !cli.unsafeAce) {
              cli_error('prepublish in ourbigbook.json requires running with --unsafe-ace');
            }
            const prepublish_path = options.ourbigbook_json.prepublish
            if (!fs.existsSync(prepublish_path)) {
              cli_error(`${ourbigbook.OURBIGBOOK_JSON_BASENAME} prepublish file not found: ${prepublish_path}`);
            }
            try {
              runCmd('./' + path.relative(process.cwd(), path.resolve(prepublish_path)), [relpathCwd(publishOutPublishDir)]);
            } catch(error) {
              cli_error(`${ourbigbook.OURBIGBOOK_JSON_BASENAME} prepublish command exited non-zero, aborting`);
            }
          }

          if (output_format === ourbigbook.OUTPUT_FORMAT_HTML) {
            fs.mkdirSync(publish_out_publish_obb_dir, { recursive: true })
            if (
              options.ourbigbook_json.generateSitemap &&
              options.ourbigbook_json.publishRootUrl
            ) {
              let sitemap = nonOurbigbookOptions.sitemap
              if (!nonOurbigbookOptions.options.htmlXExtension) {
                for (let i = 0; i < sitemap.length; i++) {
                  let p = sitemap[i]
                  let pSplit = p.split(URL_SEP)
                  if (pSplit[pSplit.length - 1] === 'index.html') {
                    p = pSplit.slice(0, -1).join(URL_SEP)
                  }
                  if (p.endsWith('.' + ourbigbook.HTML_EXT)) {
                    p = p.slice(0, - (ourbigbook.HTML_EXT.length + 1))
                  }
                  sitemap[i] = p
                }
              }
              const sitemapPath = path.join(publish_out_publish_obb_dir, 'sitemap.txt')
              console.log(`sitemap: ${relpathCwd(sitemapPath)}`)
              fs.writeFileSync(
                sitemapPath,
                sitemap
                  // Not ideal to remove duplicates here, but there were some
                  // duplicate _file cases that would require some thought.
                  .sort()
                  .filter((elem, index, arr) =>
                    index === arr.length - 1 || arr[index + 1] !== elem
                  ).map(p =>
                    `${options.ourbigbook_json.publishRootUrl}${p ? URL_SEP : ''}${p}`
                  ).join('\n')
              )
            }

            // Copy runtime assets from -/obb/ into the output repository.
            const dir = fs.opendirSync(ourbigbook_nodejs.DIST_PATH)
            let dirent
            while ((dirent = dir.readSync()) !== null) {
              require('fs-extra').copySync(
                path.join(ourbigbook_nodejs.DIST_PATH, dirent.name),
                path.join(publishOutPublishDistDir, dirent.name)
              )
            }
            if (options.ourbigbook_json.web && options.ourbigbook_json.web.linkFromStaticHeaderMetaToWeb) {
              fs.copyFileSync(
                ourbigbook_nodejs.LOGO_PATH,
                path.join(publishOutPublishDir, ourbigbook_nodejs.LOGO_ROOT_RELPATH)
              )
            }
            dir.closeSync()
          }

          if (publish_uses_git) {
            if (nonOurbigbookOptions.localMediaSnapshot) {
              // Merge only the committed snapshot, never dirty/untracked files or Git metadata.
              const copyMedia = (validateOnly, relative='') => {
                for (const entry of fs.readdirSync(path.join(nonOurbigbookOptions.localMediaSnapshot, relative), { withFileTypes: true })) {
                  if (['.git', '.gitignore', '.gitattributes', '.gitmodules'].includes(entry.name)) continue
                  const mediaPath = path.join(relative, entry.name)
                  const destination = path.join(publishOutPublishDir, mediaPath)
                  if (entry.isSymbolicLink()) cli_error(`cannot publish local media symbolic link: ${mediaPath}`)
                  if (entry.isDirectory()) {
                    if (fs.existsSync(destination) && !fs.lstatSync(destination).isDirectory()) {
                      cli_error(`local media collides with generated output: ${mediaPath}`)
                    }
                    if (!validateOnly) fs.mkdirSync(destination, { recursive: true })
                    copyMedia(validateOnly, mediaPath)
                  } else {
                    if (fs.existsSync(destination)) cli_error(`local media collides with generated output: ${mediaPath}`)
                    if (!validateOnly) fs.copyFileSync(path.join(nonOurbigbookOptions.localMediaSnapshot, mediaPath), destination, fs.constants.COPYFILE_EXCL)
                  }
                }
              }
              // Detect every collision before copying any media into the output.
              copyMedia(true)
              copyMedia(false)
            }
            // Commit and push.
            runCmd('git', ['-C', publishOutPublishDirCwd, 'add', '.'], cmdOpts);
            const args = ['-C', publishOutPublishDirCwd, 'commit', '-m', sourceCommit]
            if (git_has_commit(publishOutPublishDir)) {
              args.push('--amend')
            }
            const commitCmdOptions = { ...cmdOptsNoStdout }
            const name = chomp(runCmd('git', ['-C', inputPath, 'config', '--get', 'user.name'], cmdOpts))
            const email = chomp(runCmd('git', ['-C', inputPath, 'config', '--get', 'user.email'], cmdOpts))
            if (name && email) {
              commitCmdOptions.env_extra = {
                ...commitCmdOptions.env_extra,
                ...{
                  GIT_COMMITTER_EMAIL: email,
                  GIT_COMMITTER_NAME: name,
                  GIT_AUTHOR_EMAIL: email,
                  GIT_AUTHOR_NAME: name,
                },
              }
              args.push(...['--author', `${name} <${email}>`])
            }
            if (options.ourbigbook_json.publishCommitDate) {
              args.push(...['--date', options.ourbigbook_json.publishCommitDate])
              Object.assign(commitCmdOptions.env_extra, { GIT_COMMITTER_DATE: options.ourbigbook_json.publishCommitDate })
            }
            runCmd('git', args, commitCmdOptions);
            if (!cli.dryRunPush) {
              runCmd('git', ['-C', publishOutPublishDirCwd, 'push', '-f', 'origin', `${publishBranch}:${publishBranch}`], cmdOpts);
              // Mark the commit with the `published` branch to make it easier to find what was last published.
              runCmd('git', ['-C', inputPathCwd, 'checkout', '-B', 'published'], cmdOpts);
              runCmd('git', ['-C', inputPathCwd, 'push', '-f', '--follow-tags'], cmdOpts);
              runCmd('git', ['-C', inputPathCwd, 'checkout', '-'], cmdOpts);
            }
          }
        }
      }
    }
  }
  if (
    // Happens on empty input from stdin (Ctrl + D withotu typing anything)
    output !== undefined &&
    (
      inputPath === undefined ||
      cli.stdout
    )
  ) {
    process.stdout.write(output);
  }
  perfPrint('exit', ourbigbook_options)
  if (!cli.watch) {
    if (nonOurbigbookOptions.sequelize) await nonOurbigbookOptions.sequelize.close()
    process.exit(nonOurbigbookOptions.had_error ? 1 : 0)
  }
}
})().catch((e) => {
  console.error(e);
  process.exit(1);
})
}

Used by (6)