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')

// 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 { DataTypes, Op, Sequelize } = require('sequelize')

const ourbigbook = require('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 { ARTICLE_HASH_LIMIT_MAX, articleHash, WebApi, read_include } = 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 = [
  '.git',
  ourbigbook_nodejs_webpack_safe.TMP_DIRNAME,
  ourbigbook.RAW_PREFIX,
  ourbigbook.DIR_PREFIX,
];
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 PUBLISH_TARGETS = {
  'github-pages': 'github-pages',
  local: 'local',
}
const WEB_MAX_RETRIES = 5

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) {
  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,
        {
          options: nonOurbigbookOptions.options,
          perf: false,
        }
      )
      if (error_messages.length > 0) {
        console.error(error_messages.map(m => 'error: ' + m).join('\n'))
        cli_error()
      }
    }
    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) {
  nonOurbigbookOptions.ourbigbook_paths_converted = []
  for (const onePath of 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,
  )) {
    await cb(onePath, ourbigbook_options, nonOurbigbookOptions)
    if (nonOurbigbookOptions.had_error) {
      break
    }
  }
}

/**
 * @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) {
  await reconcile_db_and_filesystem(input_dir, ourbigbook_options, nonOurbigbookOptions)
  await convert_directory_extract_ids(input_dir, ourbigbook_options, nonOurbigbookOptions)

  if (!nonOurbigbookOptions.had_error) {
    if (ourbigbook_options.split_headers) {
      // Auto-generate a {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
      await convert_directory_callback(input_dir, ourbigbook_options, nonOurbigbookOptions, async (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 = path.relative(nonOurbigbookOptions.ourbigbook_json_dir, onePath);
          const outpath = path.join(nonOurbigbookOptions.outdir, ourbigbook.FILE_PREFIX, inputPathRelativeToOurbigbookJson + '.' + ourbigbook.HTML_EXT)
          const msgRet = convert_path_to_file_print_starting(ourbigbook_options, onePath, messagePrefix)
          let skip
          if (
            fs.existsSync(outpath) &&
            fs.statSync(onePath).mtime <= fs.statSync(outpath).mtime &&
            !nonOurbigbookOptions.cli.forceRender
          ) {
            skip = true
          } else {
            const sequelize = nonOurbigbookOptions.sequelize
            if (!sequelize || (await ourbigbook_nodejs_webpack_safe.get_noscopes_base_fetch_rows(sequelize, [ourbigbook.FILE_PREFIX + ourbigbook.URL_SEP + inputPathRelativeToOurbigbookJson])).length === 0) {
              const pathSplit = inputPathRelativeToOurbigbookJson.split(ourbigbook.URL_SEP)
              const src = `${ourbigbook.INSANE_HEADER_CHAR} ${ourbigbook.ourbigbookEscapeNotStart(pathSplit[pathSplit.length - 1])}\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)
              }
            } else {
              skip = 'skipped ID already exists'
            }
          }
          convert_path_to_file_print_finish(ourbigbook_options, onePath, outpath, { skip, message_prefix: messagePrefix, t0: msgRet.t0 })
        }
      })
      // 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.had_error &&
      (
        ourbigbook_options.render ||
        path.relative(nonOurbigbookOptions.ourbigbook_json_dir, input_dir) === ''
      )
    ) {
      await check_db(nonOurbigbookOptions)
    }
    if (
      nonOurbigbookOptions.cli.render &&
      !nonOurbigbookOptions.had_error
    ) {
      const newNonOurbigbookOptions = ourbigbook.cloneAndSet(nonOurbigbookOptions, 'is_render_after_extract', true)
      await convert_directory(
        input_dir,
        ourbigbook_options,
        newNonOurbigbookOptions,
      )
      nonOurbigbookOptions.had_error = newNonOurbigbookOptions.had_error
    }
  }
}

/** Convert 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
  }
  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 "${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.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;
}

/** 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 = path.relative(nonOurbigbookOptions.ourbigbook_json_dir, input_path);
  }
  let new_options
  const isbigb = input_path_parse.ext === `.${ourbigbook.OURBIGBOOK_EXT}` && !is_directory
  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);
      if (input_path_relative_to_ourbigbook_json) {
        const file_row_type = nonOurbigbookOptions.file_rows_dict[nonOurbigbookOptions.renderType]
        if (
          // Can not be present on single file (non directory) conversion. In that case we always convert.
          file_row_type
        ) {
          const file_row = file_row_type[input_path_relative_to_ourbigbook_json]
          if (
            // File has previously been rendered.
            file_row !== undefined
          ) {
            const file_row_render = file_row.Render
            if (
              ourbigbook_options.render
            ) {
              if (file_row_render) {
                skip = !nonOurbigbookOptions.cli.forceRender &&
                  !file_row_render.outdated &&
                  // TODO add a Render magic format for this use case. Or maybe
                  // start tracking output paths as well in Render to also cover -o.
                  // But lazy now, just never use timestamp for --format-source.
                  !nonOurbigbookOptions.cli.formatSource
              }
            } else {
              skip = file_row.last_parse !== null && file_row.last_parse > fs.statSync(input_path).mtime
              if (!skip && file_row_render) {
                // We are going to update the parse, so mark as outdated here.
                // This way we don't need to fetch from DB again.
                file_row_render.outdated = true
              }
            }
          }
        }
      }
      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}"`);
            }
            if (first_output_path === undefined) {
              first_output_path = output_path
            }
            fs.mkdirSync(path.dirname(output_path), { recursive: true });
            fs.writeFileSync(output_path, extra_returns.rendered_outputs[outpath].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 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
  ) {
    // I should use callbacks instead of doing this. But lazy.
    return
  }

  let output_path_noext = path.join(
    is_directory ? ourbigbook.DIR_PREFIX : ourbigbook.RAW_PREFIX,
    path.relative(
      nonOurbigbookOptions.ourbigbook_json_dir,
      path.join(
        input_path_parse.dir,
        input_path_parse.name
      )
    ),
  )
  if (is_directory) {
    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;
  }
  const forceRender = nonOurbigbookOptions.input_path_is_file || nonOurbigbookOptions.cli.forceRender

  const convertNonBigb = ourbigbook_options.output_format === ourbigbook.OUTPUT_FORMAT_HTML
  if (ourbigbook_options.render) {
    fs.mkdirSync(path.dirname(output_path_noext), { recursive: true });
    if (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
      }
      if (
        fs.existsSync(first_output_path) &&
        fs.statSync(input_path).mtime <= fs.statSync(first_output_path).mtime &&
        !forceRender
      ) {
        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 dirents = fs.readdirSync(full_path, { withFileTypes: true });
          const dirs = []
          const files = []
          for (const dirent of dirents) {
            const name = dirent.name
            if (!ignore_path(
              DEFAULT_IGNORE_BASENAMES_SET,
              nonOurbigbookOptions.ignore_paths,
              nonOurbigbookOptions.ignore_path_regexps,
              nonOurbigbookOptions.dont_ignore_path_regexps,
              path.join(input_path, name)
            )) {
              if (dirent.isDirectory()) {
                dirs.push(name)
              } else {
                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 }
          const pref = new_options_dir.split_headers ? ourbigbook.FILE_PREFIX : ourbigbook.RAW_PREFIX 
          new_options_dir.auto_generated_source = true
          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
          }
          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(pref, input_path_relative_to_ourbigbook_json, 'notindex.bigb');
          const htmlXExtension = newNonOurbigbookOptions.options.htmlXExtension
          const indexHtml = htmlXExtension ? 'index.html' : ''
          while (true) {
            const breadcrumbParse = path.parse(breadcrumbDir)
            let bname = breadcrumbParse.name
            if (breadcrumbDir === '') {
              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(input_path_relative_to_ourbigbook_json, '..'),
                pref,
                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)
        } 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 (!nonOurbigbookOptions.had_error && (isbigb || convertNonBigb)) {
    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 (convertNonBigb) {
      // Copy the raw file over into _raw.
      if (!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 (!skip_str) {
            fs.copyFileSync(input_path, output_path)
          }
        }
      }
    }
  }

  if (ourbigbook_options.perf) {
    console.error(`perf convert_path_to_file_end ${now()}`);
  }
  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: 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}))
}

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
}

/** 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 (
    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,
  render,
  t0,
  t1,
  title,
}={}) {
  let pref
  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.error(msg)
}

async function tryCreateOrUpdate(webApi, articleArgs, extraArgs) {
  let tries = 0
  let ret
  while (true) {
    let retry = false
    try {
      return webApi.articleCreateOrUpdate(
        articleArgs,
        extraArgs,
      )
    } catch(err) {
      if (tries < WEB_MAX_RETRIES || (
        // Happens if the server is restarted in the middle of a conversion after it was killed by nodemon.
        tries === 0 && err.code === 'ECONNRESET' &&
        // Here we are waiting for the server to get up again.
        tries > 0 && err.code === 'ECONNREFUSED'
      )) {
        retry = true
        console.error(`connection error, retry: ${tries}`)
        console.error(err)
      } else {
        handleWebApiErr(err)
      }
    }
    if (!retry) {
      break
    }
    // Give the server some time to restart after update.
    await new Promise(r => setTimeout(r, 500))
    tries++
  }
}

async function updateNestedSet(webApi, username) {
  const t1 = now()
  console.log(`nested_set`)
  await webApi.articleUpdatedNestedSet(username)
  console.log(`nested_set: ${finished_in_ms(now() - t1)}`)
}

/**
 * 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({
  title,
  articleArgs,
  cleanupDeleted,
  extraArgs,
  inpath,
  i,
  updateNestedSetIndex,
  webApi,
  webDry,
}) {
  const render = extraArgs.render
  printStatus({ i, cleanupDeleted, inpath, render })
  const t0 = now()
  if (!webDry) {
    // Retry the transaction a few times. This designed to work well during development when nodemon
    // restarts the server after a file update that adds some logging, often for perf. This way
    // you can just turn logs on and off during a large conversion and it will just keep running fine.
    ;({ data, status } = await tryCreateOrUpdate(
      webApi,
      articleArgs,
      Object.assign(
        {
          list: true,
        },
        extraArgs,
        {
          updateNestedSetIndex,
        }
      )
    ))
    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, render, cleanupDeleted, t0, t1 })
  return { nestedSetNeedsUpdate: data. nestedSetNeedsUpdate}
}

// 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('--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('-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', PUBLISH_TARGETS['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-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-max-renders <n>', 'stop after <n> articles are rendered: https://docs.ourbigbook.com#web-max-renders', ourbigbook_nodejs.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('--web-nested-set-bulk', `only update the nested set index after all articles have been uploaded: https://docs.ourbigbook.com#web-nested-set-bulk`, true);
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-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');

// 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 input_dir;
  const web = cli.web || cli.webTest || cli.nestedSet
  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 {
    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;
    }
    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}"`);
      }
    }
  }

  // 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 {
      Object.assign(ourbigbook_json, JSON.parse(fs.readFileSync(
        path.join(ourbigbook_json_dir, ourbigbook.OURBIGBOOK_JSON_BASENAME), ourbigbook_nodejs_webpack_safe.ENCODING)))
    }
  }
  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 split_headers, publish_uses_git;
  const publish_create_files = {}
  const publish_target = cli.publishTarget
  if (publish) {
    switch (publish_target) {
      case PUBLISH_TARGETS['github-pages']:
        htmlXExtension = false;
        split_headers = true;
        publish_uses_git = true;

        // Otherwise _* paths are not added to the website, notably _raw/* and _file/*.
        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 PUBLISH_TARGETS.local:
        htmlXExtension = true;
        publish_uses_git = 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 = {
    add_test_instrumentation: cli.addTestInstrumentation,
    body_only: cli.bodyOnly,
    ourbigbook_json,
    embed_includes: cli.embedIncludes,
    fs_exists_sync: (my_path) => fs.existsSync(path.join(ourbigbook_json_dir, my_path)),
    htmlXExtension,
    output_format,
    outfile: cli.outfile,
    path_sep: path.sep,
    publish,
    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) => {
      readpath = path.join(path.join(ourbigbook_json_dir, readpath))
      if (
        // Let's prevent path transversal a bit by default.
        path.resolve(readpath).startsWith(path.resolve(ourbigbook_json_dir)) &&
        fs.existsSync(readpath)
      ) {
        if (fs.lstatSync(readpath).isFile()) {
          return {
            type: 'file',
            content: fs.readFileSync(readpath, ourbigbook_nodejs_webpack_safe.ENCODING),
          }
        } else {
          return {
            type: 'directory',
          }
        }
      } else {
        return undefined
      }
    },
    render: cli.render,
    split_headers: split_headers,
    template_vars: {
      publishTargetIsWebsite: false,
    },
    unsafeXss: cli.unsafeXss,
  }
  // Resolved options.
  const options = ourbigbook.convertInitOptions(ourbigbook_options)

  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({}, 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,
    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}($|${path.sep})`)),
    file_rows_dict: {},
    encoding: ourbigbook_nodejs_webpack_safe.ENCODING,
    external_css_and_js: false,
    had_error: false,
    is_render_after_extract: false,
    ignore_path_regexps: options.ourbigbook_json.ignore.map(p => RegExp(`^${p}($|${path.sep})`)),
    ignore_convert_path_regexps: options.ourbigbook_json.ignoreConvert.map(p => RegExp(`^${p}($|${path.sep})`)),
    ignore_paths: new Set(),
    input_path_is_file,
    log: nonOurbigbookOptions_log,
    // 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,
  };
  if (publish) {
    ourbigbook_options.logoPath = ourbigbook_nodejs.LOGO_ROOT_RELPATH
  } else {
    ourbigbook_options.logoPath = ourbigbook_nodejs.LOGO_PATH
  }

  // CLI options
  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)
  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))
    ])
  }
  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) {
    // Input from stdin.
    title = 'stdin';
    input = await readStdin();
    output = await convert_input(input, ourbigbook_options, nonOurbigbookOptions);
  } else {
    if (!fs.existsSync(inputPath)) {
      cli_error(`input_path does not exist: "${inputPath}"`);
    }
    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}`
      }
      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,
        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 }
          )
        })
      }

      if (cli.webNestedSet) {
        await updateNestedSet(webApi, username)
        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
      const titleRegex = new RegExp(`${ourbigbook.INSANE_HEADER_CHAR} (.*)`)
      // 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 sequelizeWeb = new Sequelize({
        dialect: 'sqlite',
        storage: path.join(nonOurbigbookOptions.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 },
      })
      await sequelizeWeb.sync()
      nonOurbigbookOptions.post_convert_callback = async (definedAt, extra_returns) => {
        if (extra_returns.errors.length === 0) {
          await sequelizeWebArticle.destroy({ where: { definedAt }})
          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 {
                  title = ''
                  // 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.INSANE_HEADER_CHAR} ${title}`)
                  await sequelizeWebIndexId.upsert({ idid: header_ast.id, uniqueHack: 0 })
                  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) {
                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)
            }
          }
        }
      }
      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)
      }

      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],
          {
            to_id_index_order: 'ASC',
            definedAtFileId: treeToplevelFileId,
          }
        ))
      }
      const dorender = [false]
      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
      }
      let nestedSetNeedsUpdate = false
      if (
        // These are needed otherwise we were deleting every header that was not selected when doing
        // ourbigbook --web myinput.bigb or ourbigbook --web-id myid
        inputPath === '.' &&
        !cli.webId
      ) {
        // 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 ret = await webCreateOrUpdate({
              inpath: path,
              articleArgs: {
                bodySource: '',
              },
              cleanupDeleted: true,
              extraArgs: {
                path: pathNoUsernameNoext(path),
                render: true,
                list: false,
              },
              i: i++,
              updateNestedSetIndex: !cli.webNestedSetBulk,
              webApi,
              webDry: cli.webDry,
            })
            if (ret.nestedSetNeedsUpdate) {
              nestedSetNeedsUpdate = true
            }
          }
        }
      }
      for (const render of dorender) {
        let i = 0
        // This ordering ensures parents come before children.
        for (const header_tree_entry of header_tree) {
          const id = header_tree_entry.to_id
          const articleMeta = idToArticleMeta[id]
          if (
            // Can fail for synonyms.
            articleMeta
          ) {
            if (
              // Do only once no norender pass or else lastChildArticleProcessed loops around and goes wrong on the render pass.
              !render
            ){
              // Calculate previousSiblingId. This works because header_tree is guaranteed to be in-order transversed.
              const parentArticle = idToArticleMeta[articleMeta.parentId]
              if (parentArticle) {
                const lastChildArticleProcessed = parentArticle.lastChildArticleProcessed
                if (lastChildArticleProcessed) {
                  articleMeta.previousSiblingId = lastChildArticleProcessed.idid
                }
                parentArticle.lastChildArticleProcessed = articleMeta
              }
            }
            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,
            }
            const parentId = addUsername(articleMeta.parentId, username)
            if (parentId !== null) {
              articleHashProps.parentId = parentId
            }
            const previousSiblingId = addUsername(articleMeta.previousSiblingId, username)
            if (previousSiblingId !== null) {
              articleHashProps.previousSiblingId = previousSiblingId
            }
            if (
              webPathToArticleEntry === undefined ||
              webPathToArticleEntry.hash !== articleHash(articleHashProps) ||
              (
                render &&
                (
                  webPathToArticleEntry.renderOutdated ||
                  cli.webForceRender
                )
              ) ||
              (
                !render &&
                cli.webForceIdExtraction
              )
            ) {
              // 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,
              }
              if (parentId) {
                extraArgs.parentId = parentId
              }
              if (previousSiblingId) {
                extraArgs.previousSiblingId = previousSiblingId
              }
              const ret = await webCreateOrUpdate({
                title: article.title,
                inpath: article.inpath,
                articleArgs,
                extraArgs,
                i: i++,
                updateNestedSetIndex: !cli.webNestedSetBulk,
                webApi,
                webDry: cli.webDry,
              })
              if (render && ret.nestedSetNeedsUpdate) {
                nestedSetNeedsUpdate = true
              }
              if (
                render &&
                cli.webMaxRenders !== undefined &&
                i === cli.webMaxRenders
              ) {
                break
              }
            }
          }
        }
      }
      if (cli.webNestedSetBulk) {
        if (!nestedSetNeedsUpdate) {
          ;({ data, status } = await webApi.user(username))
          assertApiStatus(status, data)
          if (data.nestedSetNeedsUpdate) {
            nestedSetNeedsUpdate = true
          }
        }
        if (nestedSetNeedsUpdate) {
          await updateNestedSet(webApi, username)
        }
      }
    } else if (cli.watch) {
      if (cli.stdout) {
        cli_error('--stdout and --watch are incompatible');
      }
      if (publish) {
        cli_error('--publish 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)
        }
      } 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) {
          // Clone the source to ensure that only git tracked changes get built and published.
          ourbigbook_options.template_vars.publishTargetIsWebsite = true
          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 (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'))) {
                // git rm -rf . blows up on an empty directory.
                // This check blows up for a very large directory. We could instead just get one line,
                // but requires modifying the runCmd function: https://stackoverflow.com/questions/63796633/spawnsync-bin-sh-enobufs/77420941#77420941
                //if (git_ls_files(publishOutPublishDir).length > 0) {
                // So instead I'm lazy and just throwOnError false here.
                runCmd('git', ['-C', publishOutPublishDirCwd, 'rm', '-r', '-f', '.'],
                  ourbigbook.cloneAndSet(cmdOpts, 'throwOnError', false))
              }
            } 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.
          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 (!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', `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 ('prepublish' in options.ourbigbook_json) {
            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`);
            }
          }

          // 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)
            )
          }
          fs.mkdirSync(publish_out_publish_obb_dir, { recursive: true })
          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) {
            // 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) {
    process.exit(nonOurbigbookOptions.had_error ? 1 : 0)
  }
}
})().catch((e) => {
  console.error(e);
  process.exit(1);
})