Large text files are not previewed, as they would take up too much useless vertical space and disk memory/bandwidth.
index.js
"use strict";
let dolog = false
const globals = {};
if (typeof performance === 'undefined') {
// Fuck, I can't find how to make this browser/node portable more nicely.
// https://github.com/nodejs/node/issues/28635
// https://github.com/browserify/perf-hooks-browserify
//
// eval('require') because react-scripts build from web/
// calls webpack, which for some reason cannot find it.
globals.performance = eval('require')('perf_hooks').performance;
} else {
globals.performance = performance;
}
const katex = require('katex');
const lodash = require('lodash');
const path = require('path');
const pluralize = require('pluralize');
// consts used by classes.
const HTML_PARENT_MARKER = '<span class="fa-solid-900">\u{f062}</span>';
exports.HTML_PARENT_MARKER = HTML_PARENT_MARKER;
const NOSPLIT_MARKER_TEXT = 'nosplit'
exports.NOSPLIT_MARKER_TEXT = NOSPLIT_MARKER_TEXT;
const SPLIT_MARKER_TEXT = 'split'
exports.SPLIT_MARKER_TEXT = SPLIT_MARKER_TEXT;
const TOC_MARKER_SYMBOL = '<span class="fa-solid-900">\u{f03a}</span>'
const TOC_MARKER = `${TOC_MARKER_SYMBOL} toc`
class AstNode {
/**
* Abstract syntax tree node. This is the base node type that
* represents the parsed output.
*
* @param {AstType} node_type -
* @param {String} macro_name - - if node_type === AstType.PLAINTEXT or AstType.ERROR: fixed to
* AstType.PLAINTEXT_MACRO_NAME
* - elif node_type === AstType.PARAGRAPH: fixed to undefined
* - else: arbitrary regular macro
* @param {Object[String, AstArgument]} args - dict of arg names to arguments.
* where arguments are arrays of AstNode
* @param {SourceLocation} source_location - the best representation of where the macro is starts in the document
* used primarily to present useful debug messages
* @param {Object} options
* {String} text - the text content of an AstType.PLAINTEXT, undefined for other types
*/
constructor(node_type, macro_name, args, source_location, options={}) {
if (!('count_words' in options)) {
options.count_words = true;
}
if (!('first_toplevel_child' in options)) {
options.first_toplevel_child = false;
}
if (!('from_include' in options)) {
options.from_include = false;
}
if (!('from_ourbigbook_example' in options)) {
options.from_ourbigbook_example = false;
}
if (!('force_no_index' in options)) {
options.force_no_index = false;
}
if (!(Macro.ID_ARGUMENT_NAME in options)) {
options.id = undefined;
}
if (!('header_parent_ids' in options)) {
options.header_parent_ids = [];
}
if (!('header_tree_node_word_count' in options)) {
options.header_tree_node_word_count = 0;
}
if (!('is_first_header_in_input_file' in options)) {
// Either the first header on a regular toplevel input,
// or the first header inside an included include.
options.is_first_header_in_input_file = false;
}
if (!('numbered' in options)) {
options.numbered = true;
}
if (!('parent_ast' in options)) {
// AstNode. This is different from header_tree_node because
// it points to the parent ast node, i.e. the ast_node that
// contains this inside one of its arguments.
//
// header_tree_node on the other hand points to the header tree.
// The header tree is currently not even connected via arguments.
options.parent_ast = undefined;
}
if (!('split_default' in options)) {
options.split_default = false;
}
if (!('split_default_children' in options)) {
// This header is not split_default, but its children are.
options.split_default_children = false;
}
if (!('synonym' in options)) {
options.synonym = undefined;
}
if (!('text' in options)) {
options.text = undefined;
}
if (!('xss_safe' in options)) {
// If true, force this particular instance of the macro to be safe.
// even if the macro is unsafe by default.
// This is used for autogenerated elements like incoming links, which.
// are guaranteed to be safe through other means.
options.xss_safe = false;
}
// Generic fields.
this.node_type = node_type;
this.macro_name = macro_name;
// For elements that have an id.
// {String} or undefined.
// Includes scope. Ideally, we should remove that requirement to not duplicate.
// the information. But it will require a large hard refactor... lazy.
this.id = options.id;
// Current running scope. This is inherited from the scope of the ancestor headers.
// An element with {scope} set does not get this option set (except if due to ancestors),
// only its children do.
this.scope = options.scope;
this.subdir = options.subdir;
// For elements that are of AstType.PLAINTEXT.
this.text = options.text
this.source_location = source_location;
this.args = args;
// The effetctive path of the the file counting from the toplevel directory,
// coming either from the file=XXX or title if that is empty.
// Examples:
// - _file/path/to/myfile.txt.bigb: .file = path/to/myfile.txt, without the _file prefix
this.file = undefined
this.first_toplevel_child = options.first_toplevel_child;
this.is_first_header_in_input_file = options.is_first_header_in_input_file;
// This is the Nth macro of this type that appears in the document.
this.macro_count = undefined;
// A unique global index for this macro.
this.macro_count_global = undefined;
// This is the Nth macro of this type that is visible,
// and therefore increments counts such as Figure 1), Figure 2), etc.
// All indexed IDs (those that can be linked to) are also visible, but
// it is possible to force non-indexed IDs to count as well with
// captionNumberVisible.
this.macro_count_visible = undefined;
// Is this header numbered? Note that this is not set based on the numbered= argument:
// that argument determines this .numbered of descendants only. This is analogous to scope.
this.numbered = options.numbered;
// {AstNode} that contains this as an argument.
this.parent_ast = options.parent_ast;
// AstArgument
this.parent_argument = undefined;
this.split_default = options.split_default;
this.split_default_children = options.split_default_children;
// {HeaderTreeNode} that points to the element.
// This is used for both headers and non headers:
// the only difference is that non-headers are not connected as
// children of their parent. But they still know who the parent is.
// This was originally required for header scope resolution.
this.header_tree_node = options.header_tree_node;
// For DB serialization since we don't current serialize header_tree_node.
this.header_tree_node_word_count = options.header_tree_node_word_count
// When fetching Nodes from the database, we only get their ID,
// so we can't construct a full proper header_tree_node from that alone.
// So we just store the IDs here and not on header_tree_node as that
// alone is already useful.
this.header_parent_ids = options.header_parent_ids;
this.validation_error = undefined;
this.validation_output = {};
// The ID of this element has been indexed.
this.index_id = undefined;
this.force_no_index = options.force_no_index;
// The ToC will go before this header.
this.toc_header = false;
// This ast is a descendant of a header. Applications:
// - header descendants don't count for word counts
this.in_header = false;
// {String} the id of the target of this synonym header.
// undefined if not a synonym.
this.synonym = options.synonym;
// This was added to the tree from an include.
this.from_include = options.from_include;
// This was added to the tree from a ourbigbook_example.
this.from_ourbigbook_example = options.from_ourbigbook_example;
// Includes under this header.
this.includes = [];
this.xss_safe = options.xss_safe;
// Array of AstNode of synonym headers of this one that have title2 set.
this.title2s = []
this.toplevel_id = undefined
// Has this node already been validated? This cannot happen twice e.g. because of booleans:
// booleans currently just work by adding a dummy argument with a default. But then we lose
// the fact if the thing was given or not. This matters for example for numbered, which
// inherits from parent if the arg is not given on parent.
this.validated = false
this.count_words = options.count_words
if (this.node_type === AstType.PLAINTEXT) {
this.word_count = this.text.split(/\s+/).filter(i => i).length;
} else {
this.word_count = 0;
}
for (const argname in args) {
const arg = args[argname]
this.setup_argument(argname, arg);
for (const ast of arg) {
ast.parent_ast = this
}
}
}
/**
* Convert this AST node to an output string.
*
* @param {Object} context
* If a call will change this object, it must first make a copy,
* otherwise future calls to non descendants would also be affected by the change.
*
* - {Object} options - global options passed in from toplevel. Never modified by calls
* - {bool} html_is_attr - are we inside an HTML attribute, which implies implies different
* escape rules, e.g. " and ' must be escaped.
* - {bool} html_escape - if false, disable HTML escaping entirely. This is needed for
* content that is passed for an external tool for processing, for example
* Math equations to KaTeX, In that case, the arguments need to be passed as is,
* otherwise e.g. `1 < 2` would escape the `<` to `<` and KaTeX would receive bad input.
* - {HeaderTreeNode} header_tree - HeaderTreeNode graph containing AstNode headers
* - {Object} ids - map of document IDs to their description:
* - 'prefix': prefix to add for a full reference, e.g. `Figure 1`, `Section 2`, etc.
* - {AstArgument} 'title': the title of the element linked to
* - {bool} in_caption_number_visible
* - {Set[AstNode]} x_parents: set of all parent x elements.
* - {String} root_relpath_shift - relative path introduced due to a scope in split header mode
* @param {Object} context
* @return {String}
*/
render(context) {
if (context === undefined) {
context = {};
}
if (!('errors' in context)) {
context.errors = [];
}
if (!('html_escape' in context)) {
context.html_escape = true;
}
if (!('html_is_attr' in context)) {
context.html_is_attr = false;
}
if (!('db_provider' in context)) {
context.db_provider = {};
}
if (!('id_conversion' in context)) {
context.id_conversion = false;
}
if (!('ignore_errors' in context)) {
context.ignore_errors = false;
}
if (!('in_literal' in context)) {
context.in_literal = false;
}
if (!('in_x_text' in context)) {
context.in_x_text = false;
}
if (!('katex_macros' in context)) {
context.katex_macros = {};
}
if (!('arg_depth' in context)) {
context.arg_depth = 0;
}
//if (!('last_render' in context)) {
//}
if (!('macros' in context)) {
throw new Error('context does not have a mandatory .macros property');
}
if (!('source_location' in context)) {
// Set a source location to this element and the entire subtree.
// This allows for less verbose manual construction of trees with
// a single dummy source_location.
context.source_location = undefined;
}
if (!('validateAst' in context)) {
// Do validateAst to this element and the entire subtree.
// This allows for less verbose manual construction of trees with
// a single dummy source_location.
context.validateAst = false;
}
if (!('x_parents' in context)) {
context.x_parents = new Set();
}
if (
(
this.from_include &&
context.in_split_headers &&
!this.from_ourbigbook_example
) ||
(
context.options.output_format === OUTPUT_FORMAT_OURBIGBOOK &&
this.from_ourbigbook_example &&
!context.id_conversion
)
) {
return '';
}
if (context.source_location !== undefined) {
this.source_location = context.source_location;
for (const argname in this.args) {
this.args[argname].source_location = context.source_location;
}
}
if (context.validateAst && !this.validated) {
validateAst(this, context);
}
if (this.node_type === AstType.PARAGRAPH) {
// Possible for AstType === PARAGRAPH which can happen for
// insane paragraph inside header or ID during post processing.
// Not the nicest solution, but prevents the crash, so so be it.
// https://github.com/ourbigbook/ourbigbook/issues/143
return ' '
}
const macro = context.macros[this.macro_name];
let out, render_pre, render_post
if (this.validation_error === undefined) {
let output_format;
if (context.id_conversion) {
output_format = OUTPUT_FORMAT_ID;
} else {
output_format = context.options.output_format;
}
let convert_function
if (!macro.options.xss_safe && (this.xss_safe || context.options.xss_safe)) {
let xss_safe_alt = macro.options.xss_safe_alt
if (xss_safe_alt === undefined) {
xss_safe_alt = XSS_SAFE_ALT_DEFAULT
}
convert_function = xss_safe_alt[output_format]
} else {
convert_function = macro.convert_funcs[output_format];
}
if (convert_function === undefined) {
const message = `output format ${context.options.output_format} not defined for macro ${this.macro_name}`;
renderError(context, message, this.source_location);
out = errorMessageInOutput(message, context);
} else {
const opts = {
extra_returns: {}
}
out = convert_function(this, context, opts);
render_pre = opts.extra_returns.render_pre
render_post = opts.extra_returns.render_post
}
} else {
renderError(
context,
this.validation_error[0],
this.validation_error[1],
this.validation_error[2]
);
out = errorMessageInOutput(this.validation_error[0], context);
}
// Add a div to all direct children of toplevel to implement
// the on hover links to self and left margin.
{
const parent_ast = this.parent_ast;
if (
parent_ast !== undefined &&
parent_ast.macro_name === Macro.TOPLEVEL_MACRO_NAME &&
this.id !== undefined &&
macro.toplevel_link
) {
out = OUTPUT_FORMATS[context.options.output_format].toplevelChildModifier(this, context, out);
if (render_pre) {
out = render_pre + out
}
if (render_post) {
out = out + render_post
}
}
}
context.last_render = out
if (
context.toplevel_output_path &&
this.macro_name === Macro.HEADER_MACRO_NAME &&
this.is_first_header_in_input_file &&
context.extra_returns.rendered_outputs[context.toplevel_output_path] !== undefined &&
context.extra_returns.rendered_outputs[context.toplevel_output_path].h1RenderLength === undefined
) {
// TODO This is a bit of a hack used for web where we want separate headers.
// Ideally we should just render h1 and body separately neatly before merging the
// two sources, e.g. at renderAstList. But lazy. Let's build on top of some technical debt.
context.extra_returns.rendered_outputs[context.toplevel_output_path].h1RenderLength = out.length
}
return out;
}
add_argument(argname, arg) {
this.args[argname] = arg
this.setup_argument(argname, arg)
}
/** Get all ancestors Asts of this Ast ordered from nearest to furthest.
* @return {List[AstNode]}.
*/
ancestors(context) {
const ancestors = [];
let cur_ast = this
let ancestor_id_set = new Set()
while (true) {
cur_ast = cur_ast.get_header_parent_asts(context)[0];
if (cur_ast === undefined) {
break
}
if (ancestor_id_set.has(cur_ast.id)) {
// This fixes https://github.com/ourbigbook/ourbigbook/issues/204 so long as we are
// rendering. Doing something before render would be ideal however, likely on the check_db step.
const message = `parent IDs lead to infinite ancestor loop: ${ancestors.map(a => a.id).join(' -> ')} -> ${cur_ast.id}`;
renderError(context, message, this.source_location);
break
} else {
ancestor_id_set.add(cur_ast.id)
}
ancestors.push(cur_ast);
}
return ancestors
}
// Return the full scope of a given node. This includes the concatenation of both:
// * any scopes of any parents
// * the ID of the node if it has a scope set for itself
// If none of those provide scopes, return undefined.
calculate_scope() {
let parent_scope;
if (this.scope !== undefined) {
parent_scope = this.scope;
}
if (this.subdir) {
if (parent_scope) {
parent_scope += Macro.HEADER_SCOPE_SEPARATOR
} else {
parent_scope = ''
}
parent_scope += this.subdir
}
let self_scope;
if (
this.validation_output.scope !== undefined &&
this.validation_output.scope.boolean
) {
self_scope = this.id;
if (parent_scope !== undefined) {
self_scope = self_scope.substr(parent_scope.length + 1);
}
} else {
self_scope = '';
}
let ret = '';
if (parent_scope !== undefined) {
ret += parent_scope;
}
if (
parent_scope !== undefined &&
self_scope !== ''
) {
ret += Macro.HEADER_SCOPE_SEPARATOR;
}
if (self_scope !== '') {
ret += self_scope;
}
if (ret === '') {
return undefined;
}
return ret;
}
/* Get parent ID, but only consider IDs that come through header_tree_node. */
get_local_header_parent_id() {
if (
this.header_tree_node !== undefined &&
this.header_tree_node.parent_ast !== undefined &&
this.header_tree_node.parent_ast.ast !== undefined
) {
return this.header_tree_node.parent_ast.ast.id
}
return undefined
}
/** Works with both actual this.header_tree_node and
* this.header_parent_ids when coming from a database. */
get_header_parent_ids(context) {
const ret = new Set()
const local_parent_id = this.get_local_header_parent_id()
// Refs defined in the current .bigb file + include_path_set
if (local_parent_id !== undefined) {
ret.add(local_parent_id)
}
// Refs not defined from outside in the current .bigb file + include_path_set
// but which were explicitly requested, e.g. we request it for all headers
// to look for external include parents
if (context.options.db_provider) {
const parents_from_db = context.options.db_provider.get_refs_to_as_ids(
REFS_TABLE_PARENT,
this.id,
)
for (const parent_from_db of parents_from_db) {
ret.add(parent_from_db)
}
}
// Refs not defined from outside in the current .bigb file + include_path_set,
// but which were automatically fetched by JOIN our fetch all IDs query:
// we fetch all parent and children via JOIN for every ID we fetch.
// This is needed notably for toplevel scope removal.
for (const header_parent_id of this.header_parent_ids) {
ret.add(header_parent_id)
}
return ret
}
/* Like get_header_parent_ids, but returns the parent AST. */
get_header_parent_asts(context) {
const ret = []
// We replace all parents of the current toplevel with the given one.
// This is what we want for Web.
if (
this.id === context.toplevel_ast.id &&
context.options.parent_id
) {
const ast = context.db_provider.get(context.options.parent_id, context)
if (
// Can fail if user passes a parentId that does not exist on Web.
ast !== undefined
) {
ret.push(ast)
}
} else {
const header_parent_ids = this.get_header_parent_ids(context);
for (const header_parent_id of header_parent_ids) {
if (header_parent_id !== undefined) {
ret.push(context.db_provider.get(header_parent_id, context));
}
}
}
return ret
}
is_header_local_descendant_of(ancestor, context) {
let cur_ast = this;
const ancestor_id = ancestor.id;
while (true) {
let cur_id = cur_ast.get_local_header_parent_id()
if (cur_id === undefined) {
return false;
}
cur_ast = context.db_provider.get(cur_id, context)
if (cur_ast.id === ancestor_id) {
return true;
}
}
}
is_last_in_argument() {
return this.parent_argument_index === this.parent_argument.length() - 1
}
/** Manual implementation. There must be a better way, but I can't find it... */
static fromJSON(ast_json, context) {
// Post order depth first convert the AST JSON tree.
let new_ast
let ast_head = JSON.parse(ast_json)
const toplevel_arg = []
const todo_visit = [[toplevel_arg, ast_head]];
while (todo_visit.length !== 0) {
const [parent_arg, ast_json] = todo_visit[todo_visit.length - 1];
let finishedSubtrees = false
let done = false
for (const argname in ast_json.args) {
const asts = ast_json.args[argname].asts
if (ast_head === asts[asts.length - 1]) {
finishedSubtrees = true
done = true
break
}
if (done) {
break
}
}
let isLeaf = true
for (const arg_name in ast_json.args) {
const arg_json = ast_json.args[arg_name];
if (arg_json.asts.length > 0) {
isLeaf = false
break
}
}
if (finishedSubtrees || isLeaf) {
todo_visit.pop()
// Visit.
const new_args = {}
for (const arg_name in ast_json.args) {
const arg_json = ast_json.args[arg_name];
const new_arg = new AstArgument(arg_json.asts, arg_json.source_location);
new_args[arg_name] = new_arg
}
new_ast = new AstNode(
AstType[ast_json.node_type],
ast_json.macro_name,
new_args,
ast_json.source_location,
{
text: ast_json.text,
first_toplevel_child: ast_json.first_toplevel_child,
header_tree_node_word_count: ast_json.header_tree_node_word_count,
is_first_header_in_input_file: ast_json.is_first_header_in_input_file,
scope: ast_json.scope,
subdir: ast_json.subdir,
split_default: ast_json.split_default,
// TODO: Remove synonym from JSON, use Ref.type = synonym instead
synonym: ast_json.synonym,
word_count: ast_json.word_count,
}
);
if (context !== undefined) {
validateAst(new_ast, context)
}
parent_arg.push(new_ast)
ast_head = new_ast
} else {
for (const arg_name in ast_json.args) {
const arg_json = ast_json.args[arg_name];
for (const ast_child_json of arg_json.asts.slice().reverse()) {
todo_visit.push([arg_json.asts, ast_child_json]);
}
arg_json.asts.length = 0
}
}
}
return new_ast
}
/** Calculate the output path for this Ast. */
output_path(context, options={}) {
let ast = this
let id
let input_path = ast.source_location.path
if (input_path === undefined || !context.options.db_provider) {
return {}
} else {
if ('effective_id' in options) {
// Used for synonyms.
id = options.effective_id
ast = context.db_provider.get(id, context);
} else {
id = ast.id
}
const ast_undefined = ast === undefined
if (!ast_undefined && ast.macro_name !== Macro.HEADER_MACRO_NAME) {
id = ast.get_header_parent_ids(context).values().next().value;
}
let ast_input_path_toplevel_id
const get_file_ret = context.options.db_provider.get_file(input_path);
if (get_file_ret) {
ast_input_path_toplevel_id = get_file_ret.toplevel_id
} else {
// The only way this can happen is if we are in the current file, and it hasn't
// been added to the file db yet.
ast_input_path_toplevel_id = context.toplevel_id
}
let split_suffix;
if (!ast_undefined && ast.args.splitSuffix !== undefined) {
split_suffix = renderArg(ast.args.splitSuffix, context);
}
const args = {
ast_id: id,
ast_input_path: input_path,
ast_undefined,
context_to_split_headers: context.to_split_headers,
ast_input_path_toplevel_id,
path_sep: context.options.path_sep,
splitDefaultNotToplevel: context.options.ourbigbook_json.h.splitDefaultNotToplevel,
split_suffix,
toSplitHeadersOverride: options.toSplitHeadersOverride,
}
if (!ast_undefined) {
args.ast_is_first_header_in_input_file = ast.is_first_header_in_input_file
args.ast_split_default = ast.split_default
args.ast_toplevel_id = ast.toplevel_id
}
const ret = outputPathBase(args);
if (ret === undefined) {
return {}
} else {
const { dirname, basename, split_suffix: split_suffix_used } = ret
return {
path: pathJoin(dirname, basename + '.' + OUTPUT_FORMATS[context.options.output_format].ext, context.options.path_sep),
dirname,
basename,
split_suffix: split_suffix_used,
}
}
}
}
setup_argument(argname, arg) {
// TODO the second post process pass is destroying this information.
arg.parent_ast = this;
arg.argument_name = argname;
}
// Recursively set source_location on this subtree for elements that
// don't have it yet. Convenient for manually created trees.
set_source_location(source_location) {
const todo_visit_asts = [this]
const todo_visit_args = []
while (todo_visit_asts.length > 0) {
const ast = todo_visit_asts.pop();
if (ast.source_location === undefined) {
ast.source_location = source_location
}
for (const argname in ast.args) {
todo_visit_args.push(ast.args[argname])
}
while (todo_visit_args.length > 0) {
const arg = todo_visit_args.pop();
if (arg.source_location === undefined) {
arg.source_location = source_location
}
for (const ast of arg) {
todo_visit_asts.push(ast)
}
}
}
}
// Set attrs to this AstNode and all its descdendants.
set_recursively(attrs) {
const todo_visit_asts = [this]
const todo_visit_args = []
while (todo_visit_asts.length > 0) {
const ast = todo_visit_asts.pop();
Object.assign(ast, attrs)
for (const argname in ast.args) {
todo_visit_args.push(ast.args[argname])
}
while (todo_visit_args.length > 0) {
const arg = todo_visit_args.pop();
for (const ast of arg) {
todo_visit_asts.push(ast)
}
}
}
}
// A simplified recursive view of the most important fields of this AstNode and children,
// with one AstNode or AstArgument per line. Indispensable for debugging, since the toJSON
// is huge.
toString() {
let ret = []
let indent_marker = ' '
const todo_visit = [{type: 'ast', value: this, indent: 0}]
while (todo_visit.length > 0) {
const thing = todo_visit.pop();
const indent = thing.indent + 1
const indent_str = indent_marker.repeat(thing.indent)
if (thing.type === 'ast') {
const ast = thing.value
let plaintext
if (ast.node_type === AstType.PLAINTEXT) {
plaintext = ` ${JSON.stringify(ast.text)}`
} else {
plaintext = ''
}
let idstr
if (ast.id) {
idstr = ` id="${ast.id}"`
} else {
idstr = ''
}
const pref = `${indent_str}ast `
let typestr
if (ast.node_type === AstType.PLAINTEXT || ast.node_type === AstType.MACRO) {
typestr = `${ast.macro_name}${plaintext}${idstr}`
} else {
typestr = `${ast.node_type.toString()}`
}
ret.push(`${pref}${typestr}`)
if (ast.node_type === AstType.MACRO) {
const args = ast.args
const argnames = Object.keys(args).sort().reverse()
for (const argname of argnames) {
todo_visit.push({type: 'arg', value: args[argname], argname, indent })
}
}
} else {
const arg = thing.value
if (arg) {
ret.push(`${indent_str}arg ${thing.argname}`)
for (const ast of arg.slice().reverse()) {
todo_visit.push({type: 'ast', value: ast, indent })
}
}
}
}
return ret.join('\n')
}
toJSON() {
const ret = {
macro_name: this.macro_name,
node_type: symbolToString(this.node_type),
scope: this.scope,
source_location: this.source_location,
subdir: this.subdir,
text: this.text,
first_toplevel_child: this.first_toplevel_child,
is_first_header_in_input_file: this.is_first_header_in_input_file,
split_default: this.split_default,
// TODO: Remove synonym from JSON, use Ref.type = synonym instead
synonym: this.synonym,
word_count: this.word_count,
}
const args_given = { ...this.args }
for (const argname in this.args) {
if (
argname in this.validation_output &&
!this.validation_output[argname].given
) {
delete args_given[argname]
}
}
ret.args = args_given
if (this.header_tree_node !== undefined) {
ret.header_tree_node_word_count = this.header_tree_node.word_count
}
return ret;
}
}
exports.AstNode = AstNode;
class AstArgument {
/** @param {List[AstNode]} nodes
* @ param {SourceLocation} source_location
*/
constructor(asts, source_location) {
if (asts === undefined) {
this.asts = []
} else {
this.asts = asts
}
this.source_location = source_location;
// AstNode
this.parent_ast = undefined;
// String
this.argument_name = undefined;
// boolean
//this.has_paragraph = undefined;
let i = 0;
for (const ast of this.asts) {
ast.parent_argument = this;
ast.parent_argument_index = i;
i++;
};
}
concat(...other) {
return this.asts.concat(...other)
}
get(i) {
return this.asts[i]
}
length() {
return this.asts.length
}
map(fn) {
return this.asts.map(fn)
}
set(i, val) {
this.asts[i] = val
}
slice(start, end) {
return new AstArgument(this.asts.slice(start, end), this.source_location)
}
splice(start, deleteCount, ...items) {
return this.asts.splice(start, deleteCount, ...items)
}
reverse() {
this.asts.reverse()
return this
}
push(...new_asts) {
const old_length = this.asts.length;
const ret = this.asts.push(...new_asts);
let i = 0;
for (const ast of new_asts) {
ast.parent_argument = this;
ast.parent_argument = this;
ast.parent_argument_index = old_length + i;
i++;
}
return ret;
}
reset() {
this.asts = []
}
*[Symbol.iterator] () {
for (const v of this.asts) {
yield(v)
}
}
toJSON() {
return {
asts: this.asts,
source_location: this.source_location,
}
}
}
class ErrorMessage {
/**
* @param {number} severity 1: most severe, 2: next, 3...
*/
constructor(message, source_location, severity=1) {
this.message = message;
this.source_location = source_location;
this.severity = severity;
}
toString(path) {
let ret = 'error: ';
let had_line_or_col = false;
if (this.source_location.path !== undefined) {
ret += `${this.source_location.path}:`;
}
if (this.source_location.line !== undefined) {
ret += `${this.source_location.line}`;
had_line_or_col = true;
}
if (this.source_location.column !== undefined) {
if (this.source_location.line !== undefined) {
ret += `:`;
}
ret += `${this.source_location.column}`;
had_line_or_col = true;
}
if (had_line_or_col)
ret += ': ';
ret += this.message;
return ret
}
}
function isAbsoluteXref(id, context) {
return id[0] === Macro.HEADER_SCOPE_SEPARATOR ||
(context.options.x_leading_at_to_web && id[0] === AT_MENTION_CHAR)
}
function resolveAbsoluteXref(id, context) {
if (context.options.ref_prefix) {
return context.options.ref_prefix + id
} else {
return id.substr(1)
}
}
/** Set a context.option that may come from ourbigbook.json.
* This method can also be used from ourbigbook to resolve the values of options,
* in the case of options that have effects on both Library and CLI. */
function resolveOption(options, opt) {
let ret = options[opt]
if (ret !== undefined) {
return options[opt]
}
const ourbigbook_json = options.ourbigbook_json
ret = ourbigbook_json[opt]
if (ret !== undefined) {
return ourbigbook_json[opt]
}
return OURBIGBOOK_JSON_DEFAULT[opt]
}
exports.resolveOption = resolveOption
/**
* Interface to retrieving the nodes of IDs defined in external files.
*
* We need the abstraction because IDs will come from widely different locations
* between browser and local Node.js operation:
*
* - browser: HTTP requests
* - local: sqlite database
*/
class DbProvider {
/**
* @return remove all IDs from this ID provider for the given path.
* For example, on a local ID database cache, this would clear
* all IDs from the cache.
*/
clear(input_path_noext_renamed) { throw new Error('unimplemented'); }
/**
* @param {String} id
* @param {String} current_scope: scope node of the location
* from which ID get is being done. This affects the final
* ID obtained due to scope resolution.
* @return {Union[AstNode,undefined]}.
* undefined: ID not found
* Otherwise, the ast node for the given ID
*/
get(id, context, current_scope) {
if (isAbsoluteXref(id, context)) {
return this.get_noscope(resolveAbsoluteXref(id, context), context);
} else {
if (current_scope !== undefined) {
current_scope += Macro.HEADER_SCOPE_SEPARATOR
for (let i = current_scope.length - 1; i > 0; i--) {
if (current_scope[i] === Macro.HEADER_SCOPE_SEPARATOR) {
let resolved_scope_id = this.get_noscope(
current_scope.substring(0, i + 1) + id, context);
if (resolved_scope_id !== undefined) {
return resolved_scope_id;
}
}
}
}
// Not found with ID resolution, so just try to get the exact ID.
return this.get_noscope(id, context);
}
}
/** Like get, but do not resolve scope. */
get_noscope(id) {
return this.get_noscope_base(id);
}
get_noscope_raw(ids) { throw new Error('unimplemented'); }
get_noscope_base(id) {
return this.get_noscopes_base(new Set([id]))[0]
}
get_noscopes_base(ids, ignore_paths_set) { throw new Error('unimplemented'); }
/** Array[{id: String, defined_at: String}]
*/
get_refs_to(type, to_id, reversed=false) { throw new Error('unimplemented'); }
/**
* Unlike get_refs_to_as_ids, this function deduplicates possible scopes of an ID.
* selecting only the correct one for each.
*
* @param {String} id
* @return {Array[AstNode]}: all header nodes that have the given ID
* as a parent includer.
*/
get_refs_to_as_asts(type, to_id, context, opts={}) {
const { current_scope, reversed } = opts
let ref_ids = this.get_refs_to_as_ids(type, to_id, reversed)
let ret = {};
for (const ref_id of ref_ids) {
const from_ast = this.get(ref_id, context, current_scope);
if (from_ast === undefined) {
if (!context.ignore_errors) {
throw new Error(`could not find reference in database: ${ref_id}`);
}
} else {
ret[from_ast.id] = from_ast;
}
}
return Object.entries(ret).map(kv => kv[1]);
}
/** @return Set[string] the IDs that reference the given AST
*
* The return contains multiple possible refs considering unresolved scopes.
* The final correct scope resolution is not calculated by this function.
**/
get_refs_to_as_ids(type, to_id, reversed=false) {
let other_key
const entries = this.get_refs_to(type, to_id, reversed)
return new Set(entries.map(e => e.id))
}
get_file(path) { throw new Error('unimplemented'); }
async fetch_files(path, context) { throw new Error('unimplemented'); }
}
exports.DbProvider = DbProvider;
/** DbProvider that first tries db_provider_1 and then db_provider_2.
*
* The initial use case for this is to transparently use either IDs defined
* in the current document, or IDs defined externally.
*/
class ChainedDbProvider extends DbProvider {
constructor(db_provider_1, db_provider_2) {
super();
this.db_provider_1 = db_provider_1;
this.db_provider_2 = db_provider_2;
}
get_noscope_base(id) {
let ret = this.db_provider_1.get_noscope_base(id);
if (ret !== undefined) {
return ret;
}
ret = this.db_provider_2.get_noscope_base(id);
if (ret !== undefined) {
return ret;
}
return undefined;
}
get_refs_to(type, to_id, reverse=false) {
return this.db_provider_1.get_refs_to(type, to_id, reverse).concat(
this.db_provider_2.get_refs_to(type, to_id, reverse))
}
get_file(path) {
return this.db_provider_1.get_file(path) || this.db_provider_2.get_file(path)
}
}
/** ID provider from a dict.
* The initial use case is to represent locally defined IDs, and inject
* them into ChainedDbProvider together with externally defined IDs.
*/
class DictDbProvider extends DbProvider {
constructor(dict, refs_to) {
super();
this.dict = dict;
this.refs_to = refs_to;
}
get_noscope_base(id) {
return this.dict[id];
}
get_refs_to(type, to_id, reverse=false) {
const ret = []
const from_ids_reverse = this.refs_to[reverse]
if (from_ids_reverse !== undefined) {
const from_ids_type = from_ids_reverse[to_id];
if (from_ids_type !== undefined) {
const from_ids = from_ids_type[type];
if (from_ids !== undefined) {
for (const from_id in from_ids) {
const defined_ats = from_ids[from_id].defined_at
for (const defined_at in defined_ats) {
ret.push({ id: from_id, defined_at })
}
}
}
}
}
return ret
}
get_file(path) {
return null
}
}
/** Represents possible arguments of each Macro */
class MacroArgument {
/**
* @param {String} name
*/
constructor(options) {
options = { ...options }
if (!('elide_link_only' in options)) {
// If the only thing contained in this argument is a single
// Macro.LINK_MACRO_NAME macro, AST post processing instead extracts
// the href of that macro, and transforms it into a text node with that href.
//
// Goal: to allow the use to write both \a[http://example.com] and
// \p[http://example.com] and get what a sane person expects, see also:
// https://docs.ourbigbook.com#insane-link-parsing-rules
options.elide_link_only = false;
}
if (!('boolean' in options)) {
// https://docs.ourbigbook.com#boolean-argument
options.boolean = false;
}
if (!('count_words' in options)) {
options.count_words = false;
}
if (!('default' in options)) {
// https://docs.ourbigbook.com#boolean-named-arguments
options.default = undefined;
}
if (!('mandatory' in options)) {
// https://docs.ourbigbook.com#mandatory-positional-arguments
options.mandatory = false;
}
if (!('multiple' in options)) {
// https://docs.ourbigbook.com#multiple-argument
options.multiple = false;
}
if (!('ourbigbook_output_prefer_literal' in options)) {
options.ourbigbook_output_prefer_literal = false
}
if (!('positive_nonzero_integer' in options)) {
options.positive_nonzero_integer = false;
}
if (!('remove_whitespace_children' in options)) {
// https://docs.ourbigbook.com#remove-whitespace-children
options.remove_whitespace_children = false;
}
this.boolean = options.boolean;
this.count_words = options.count_words;
this.multiple = options.multiple;
this.default = options.default;
this.elide_link_only = options.elide_link_only;
this.mandatory = options.mandatory;
this.name = options.name;
this.positive_nonzero_integer = options.positive_nonzero_integer;
this.remove_whitespace_children = options.remove_whitespace_children;
this.ourbigbook_output_prefer_literal = options.ourbigbook_output_prefer_literal;
}
}
class Macro {
/**
* Encapsulates properties of macros, including how to convert
* them to various output formats.
*
* @param {String} name
* @param {Array[MacroArgument]} args
* @param {Function} convert
* @param {Object} options
* {boolean} phrasing - is this phrasing content?
* (HTML5 elements that can go in paragraphs). This matters to:
* - determine where `\n\n` paragraphs will split
* - phrasing content does not get IDs
* {String} auto_parent - automatically surround consecutive sequences of macros with
* the same parent auto_parent into a node with auto_parent type. E.g.,
* to group list items into ul.
* {Set[String]} auto_parent_skip - don't do auto parent generation if the parent is one of these types.
* {Function[AstNode, Object] -> String} get_number - return the number that shows on on full references
* as a string, e.g. "123" in "Figure 123." or "1.2.3" in "Section 1.2.3.".
* A return of undefined means that the number is not available, e.g. this is current limitation
* of cross references to other files (could be implemented).
* {Function[AstNode, Object] -> Bool} macro_counts_ignore - if true, then an ID should not be automatically given
* to this node. This is usually the case for nodes that are not visible in the final output,
* otherwise that would confuse readers.
*/
constructor(name, positional_args, options={}) {
if (!('auto_parent' in options)) {
// https://docs.ourbigbook.com#auto_parent
}
if (!('auto_parent_skip' in options)) {
options.auto_parent_skip = new Set();
}
if (!('captionNumberVisible' in options)) {
options.captionNumberVisible = function(ast) { return false; }
}
if (!('caption_prefix' in options)) {
options.caption_prefix = capitalizeFirstLetter(name);
}
if (!('default_x_style_full' in options)) {
options.default_x_style_full = true;
}
if (!('get_number' in options)) {
options.get_number = function(ast, context) { return ast.macro_count_visible; }
}
if (!('get_title_arg' in options)) {
options.get_title_arg = function(ast, context) {
return ast.args[Macro.TITLE_ARGUMENT_NAME];
}
}
if (!('id_prefix' in options)) {
options.id_prefix = titleToId(name);
}
if (!('image_video_content_func' in options)) {
options.image_video_content_func = function() { throw new Error('unimplemented'); };
}
if (!('macro_counts_ignore' in options)) {
// Applications:
// * if an AST node with an ID but no-rendered HTML ID, this breaks editor scroll sync
// Returning true makes it not have an ID at all.
options.macro_counts_ignore = function(ast) {
return false;
}
}
if (!('named_args' in options)) {
options.named_args = [];
}
if (!('phrasing' in options)) {
options.phrasing = false;
}
if (!('show_disambiguate' in options)) {
options.show_disambiguate = false;
}
if (!('source_func' in options)) {
options.source_func = function() { throw new Error('unimplemented'); };
}
if (!('toplevel_link' in options)) {
options.toplevel_link = true;
}
if (!('xss_safe' in options)) {
options.xss_safe = true;
}
if (!('xss_safe_alt' in options)) {
}
this.name = name;
this.positional_args = positional_args;
{
let named_args = {};
for (const arg of options.named_args) {
named_args[arg.name] = arg;
}
this.named_args = named_args;
}
this.auto_parent = options.auto_parent;
this.auto_parent_skip = options.auto_parent_skip;
this.convert_funcs = {}
this.id_prefix = options.id_prefix;
this.options = options;
this.remove_whitespace_children = options.remove_whitespace_children;
this.toplevel_link = options.toplevel_link;
this.name_to_arg = {};
for (const arg of this.positional_args) {
let name = arg.name;
this.check_name(name);
this.name_to_arg[name] = arg;
}
for (const name in this.named_args) {
this.check_name(name);
this.name_to_arg[name] = this.named_args[name];
}
// Add arguments common to all macros.
for (const argname of Macro.COMMON_ARGNAMES) {
this.named_args[argname] = new MacroArgument({
name: argname,
})
this.name_to_arg[argname] = this.named_args[argname];
}
}
add_convert_function(output_format, my_function, macro_name) {
this.convert_funcs[output_format] = my_function;
// This produces incredibly superior render backtraces as you can immediately spot which
// type of macro is being rendered without entering the line numbers.
Object.defineProperty(my_function, 'name', { value: `render_func_${macro_name}` })
}
check_name(name) {
if (Macro.COMMON_ARGNAMES_SET.has(name)) {
throw new Error(`name "${name}" is reserved and automatically added`);
}
if (name in this.name_to_arg) {
throw new Error('name already taken: ' + name);
}
}
toJSON() {
const options = this.options;
const ordered_options = {};
Object.keys(options).sort().forEach(function(key) {
ordered_options[key] = options[key];
});
return {
name: this.name,
options: ordered_options,
positional_args: this.positional_args,
named_args: this.named_args,
}
}
}
exports.Macro = Macro;
// Macro names defined here are those that have magic properties, e.g.
// headers are used by the 'toc'.
Macro.OURBIGBOOK_EXAMPLE_MACRO_NAME = 'OurBigBookExample';
Macro.CODE_MACRO_NAME = 'c';
// Add arguments common to all macros.
Macro.DISAMBIGUATE_ARGUMENT_NAME = 'disambiguate';
Macro.ID_ARGUMENT_NAME = 'id';
// Undocumented argument used only for testing.
// Adds data-X to one of the rendered HTML elements.
// to facilitate XPath selection.
Macro.TEST_DATA_ARGUMENT_NAME = 'ourbigbookTestData';
Macro.TEST_DATA_HTML_PROP = 'data-ourbigbook-test';
Macro.SYNONYM_ARGUMENT_NAME = 'synonym';
Macro.COMMON_ARGNAMES = [
Macro.ID_ARGUMENT_NAME,
Macro.DISAMBIGUATE_ARGUMENT_NAME,
Macro.TEST_DATA_ARGUMENT_NAME,
];
Macro.COMMON_ARGNAMES_SET = new Set(Macro.COMMON_ARGNAMES)
Macro.CONTENT_ARGUMENT_NAME = 'content';
Macro.DESCRIPTION_ARGUMENT_NAME = 'description';
Macro.HEADER_MACRO_NAME = 'H';
Macro.HEADER_CHILD_ARGNAME = 'child';
Macro.HEADER_TAG_ARGNAME = 'tag';
Macro.X_MACRO_NAME = 'x';
Macro.HEADER_SCOPE_SEPARATOR = '/';
Macro.INCLUDE_MACRO_NAME = 'Include';
Macro.LINK_MACRO_NAME = 'a';
Macro.LIST_ITEM_MACRO_NAME = 'L';
Macro.MATH_MACRO_NAME = 'm';
Macro.PARAGRAPH_MACRO_NAME = 'P';
Macro.PLAINTEXT_MACRO_NAME = 'plaintext';
Macro.TABLE_MACRO_NAME = 'Table';
Macro.TD_MACRO_NAME = 'Td';
Macro.TH_MACRO_NAME = 'Th';
Macro.TR_MACRO_NAME = 'Tr';
Macro.TITLE_ARGUMENT_NAME = 'title';
Macro.TITLE2_ARGUMENT_NAME = 'title2';
// We set a fixed magic ID to the ToC because:
// - when doing --split-headers, the easy approach is to add the ToC node
// after normal ID indexing has happened, which means that we can't link
// to the ToC as other normal links. And if we could, we would have to worry
// about how to avoid ID duplication
// - only a single ToC ever renders per document. So we can just have a fixed
// magic one.
Macro.RESERVED_ID_PREFIX = '_'
const FILE_PREFIX = Macro.RESERVED_ID_PREFIX + 'file'
exports.FILE_PREFIX = FILE_PREFIX
Macro.FILE_ID_PREFIX = FILE_PREFIX + Macro.HEADER_SCOPE_SEPARATOR
const RAW_PREFIX = Macro.RESERVED_ID_PREFIX + 'raw'
exports.RAW_PREFIX = RAW_PREFIX
const DIR_PREFIX = Macro.RESERVED_ID_PREFIX + 'dir'
exports.DIR_PREFIX = DIR_PREFIX
Macro.TOC_ID = Macro.RESERVED_ID_PREFIX + 'toc';
Macro.TOC_PREFIX = Macro.TOC_ID + '/'
Macro.TOPLEVEL_MACRO_NAME = 'Toplevel';
/** Helper to create plaintext nodes, since so many of the fields are fixed in that case. */
class PlaintextAstNode extends AstNode {
constructor(text, source_location) {
super(AstType.PLAINTEXT, Macro.PLAINTEXT_MACRO_NAME,
{}, source_location, { text });
}
}
class SourceLocation {
constructor(line, column, path) {
this.line = line;
this.column = column;
this.path = path;
}
clone() {
// https://stackoverflow.com/questions/41474986/how-to-clone-a-javascript-es6-class-instance
// Saves about 0.1s out of 2.1s on https://github.com/cirosantilli/cirosantilli.github.io/blob/ed5e39dad5c9ce099b554409d05be0c5c32e5209/ciro-santilli.bigb.
// Noticed on CDT profiling. 5% in one line? I'll take it.
return new SourceLocation(this.line, this.column, this.path)
//return lodash.clone(this);
}
isEqual(other) {
return lodash.isEqual(this, other);
}
}
exports.SourceLocation = SourceLocation;
class Token {
/**
* @param {String} type
* @param {SourceLocation} source_location
* @param {String} value - Default: undefined
*/
constructor(type, source_location, value) {
this.type = type;
this.source_location = source_location;
this.value = value;
}
toJSON() {
return {
type: this.type.toString(),
source_location: this.source_location,
value: this.value
}
}
}
class Tokenizer {
/**
* @param {String} input_string
*/
constructor(
input_string,
extra_returns={},
show_tokenize=false,
start_line=1,
input_path=undefined
) {
this.chars = Array.from(input_string);
this.cur_c = this.chars[0];
this.source_location = new SourceLocation(start_line, 1, input_path);
this.extra_returns = extra_returns;
this.extra_returns.errors = [];
this.i = 0;
this.in_insane_header = false;
this.in_escape_insane_link = false;
this.list_level = 0;
this.tokens = [];
this.show_tokenize = show_tokenize;
this.log_debug('Tokenizer');
this.log_debug(`this.chars ${this.chars}`);
this.log_debug(`this.chars.length ${this.chars.length}`);
this.log_debug('');
}
/** Advance the current character and set cur_c to the next one.
*
* Maintain the newline count up to date for debug messages.
*
* The current index must only be incremented through this function
* and never directly.
*
* @param {Number} how many to consume
* @return {boolean} true iff we are not reading past the end of the input
*/
consume(n=1) {
for (let done = 0; done < n; done++) {
this.log_debug('consume');
this.log_debug('this.i: ' + this.i);
this.log_debug('this.cur_c: ' + this.cur_c);
this.log_debug();
if (this.chars[this.i] === '\n') {
this.source_location.line += 1;
this.source_location.column = 1;
} else {
this.source_location.column += 1;
}
this.i += 1;
if (this.i >= this.chars.length) {
this.cur_c = undefined;
return false;
}
this.cur_c = this.chars[this.i];
}
return true;
}
consume_list_indent() {
if (this.i > 0 && this.chars[this.i - 1] === '\n') {
let new_list_level = 0;
while (
arrayContainsArrayAt(this.chars, this.i, INSANE_LIST_INDENT) &&
new_list_level < this.list_level
) {
for (const c in INSANE_LIST_INDENT) {
this.consume();
}
new_list_level += 1;
}
for (let i = 0; i < this.list_level - new_list_level; i++) {
this.push_token(TokenType.POSITIONAL_ARGUMENT_END);
}
this.list_level = new_list_level;
}
}
consume_plaintext_char() {
return this.plaintext_append_or_create(this.cur_c);
}
/**
* @return {boolean} EOF reached?
*/
consume_optional_newline(literal) {
this.log_debug('consume_optional_newline');
this.log_debug();
if (
!this.is_end() &&
this.cur_c === '\n' &&
(
literal ||
// Insane constructs that start with a newline prevent the skip.
(
// Pararaph.
this.peek() !== '\n' &&
// Insane start.
this.tokenize_insane_start(this.i + 1) === undefined
)
)
) {
this.log_debug();
return this.consume();
}
return true;
}
consume_optional_newline_after_argument() {
if (
!this.is_end() &&
this.cur_c === '\n' &&
!this.in_insane_header
) {
const full_indent = INSANE_LIST_INDENT.repeat(this.list_level);
if (
arrayContainsArrayAt(this.chars, this.i + 1, full_indent + START_POSITIONAL_ARGUMENT_CHAR) ||
arrayContainsArrayAt(this.chars, this.i + 1, full_indent + START_NAMED_ARGUMENT_CHAR)
) {
this.consume(full_indent.length + 1);
}
}
}
consume_optional_newline_before_close() {
if (this.tokens.length > 0) {
const last_token = this.tokens[this.tokens.length - 1]
if (last_token.type === TokenType.PLAINTEXT) {
const txt = last_token.value
if (txt[txt.length - 1] === '\n') {
if (last_token.value.length === 1) {
this.tokens.pop()
} else {
last_token.value = txt.substring(0, txt.length - 1);
}
}
}
}
}
error(message, source_location) {
let new_source_location;
if (source_location === undefined) {
new_source_location = new SourceLocation();
} else {
new_source_location = source_location.clone();
}
if (new_source_location.path === undefined)
new_source_location.path = this.source_location.path;
if (new_source_location.line === undefined)
new_source_location.line = this.source_location.line;
if (new_source_location.column === undefined)
new_source_location.column = this.source_location.column;
this.extra_returns.errors.push(
new ErrorMessage(message, new_source_location));
}
is_end() {
return this.i === this.chars.length;
}
log_debug(message='') {
if (this.show_tokenize) {
console.error('tokenize: ' + message);
}
}
peek() {
return this.chars[this.i + 1];
}
plaintext_append_or_create(s) {
let new_plaintext = true;
if (this.tokens.length > 0) {
let last_token = this.tokens[this.tokens.length - 1];
if (last_token.type === TokenType.PLAINTEXT) {
last_token.value += s;
new_plaintext = false;
}
}
if (new_plaintext) {
this.push_token(TokenType.PLAINTEXT, s);
}
return this.consume();
}
push_token(token, value, source_location) {
this.log_debug('push_token');
this.log_debug('token: ' + token.toString());
this.log_debug('value: ' + value);
this.log_debug();
let new_source_location;
if (source_location === undefined) {
new_source_location = new SourceLocation();
} else {
new_source_location = source_location.clone();
}
if (new_source_location.line === undefined)
new_source_location.line = this.source_location.line;
if (new_source_location.column === undefined)
new_source_location.column = this.source_location.column;
if (new_source_location.path === undefined)
new_source_location.path = this.source_location.path;
this.tokens.push(new Token(token, new_source_location, value));
}
/**
* @return {Array[Token]}
*/
tokenize() {
// Ignore the last newline of the file.
// It is good practice to always have a newline
// at the end of files, but it doesn't really mean
// that the user wants the last element to contain one.
if (this.chars[this.chars.length - 1] === '\n') {
this.chars.pop();
}
let unterminated_literal = false;
let start_source_location;
while (!this.is_end()) {
this.log_debug('tokenize loop');
this.log_debug('this.i: ' + this.i);
this.log_debug('this.source_location: ' + this.source_location);
this.log_debug('this.cur_c: ' + this.cur_c);
if (this.in_insane_header && this.cur_c === '\n') {
this.in_insane_header = false;
this.push_token(TokenType.POSITIONAL_ARGUMENT_END);
this.consume_optional_newline_after_argument()
}
this.consume_list_indent();
start_source_location = this.source_location.clone();
if (this.cur_c === ESCAPE_CHAR) {
this.consume();
if (this.is_end()) {
this.error(`trailing unescaped ${ESCAPE_CHAR}`, start_source_location);
} else if (!charIsIdentifier(this.cur_c)) {
this.consume_plaintext_char();
} else {
// Insane link.
for (const known_url_protocol of KNOWN_URL_PROTOCOLS) {
if (arrayContainsArrayAt(this.chars, this.i, known_url_protocol)) {
this.in_escape_insane_link = true;
break;
}
}
// Macro.
if (!this.in_escape_insane_link) {
let macro_name = this.tokenize_func(charIsIdentifier);
this.consume_optional_newline_after_argument();
this.push_token(
TokenType.MACRO_NAME,
macro_name,
start_source_location,
);
}
}
} else if (this.cur_c === START_NAMED_ARGUMENT_CHAR) {
let source_location = this.source_location.clone();
// Tokenize past the last open char.
let open_length = this.tokenize_func(
c => c === START_NAMED_ARGUMENT_CHAR
).length;
this.push_token(TokenType.NAMED_ARGUMENT_START,
START_NAMED_ARGUMENT_CHAR.repeat(open_length), source_location);
source_location = this.source_location.clone();
if (this.cur_c === undefined) {
// { at the end of file. Test: "named argument: open bracket at end of file fails gracefully".
unterminated_literal = true;
} else {
let arg_name = this.tokenize_func(charIsIdentifier);
this.push_token(TokenType.NAMED_ARGUMENT_NAME, arg_name, source_location);
if (this.cur_c === NAMED_ARGUMENT_EQUAL_CHAR) {
// Consume the = sign.
this.consume();
} else if (this.cur_c === END_NAMED_ARGUMENT_CHAR) {
// Boolean argument.
} else {
this.error(`expected character: '${NAMED_ARGUMENT_EQUAL_CHAR}' or '${END_NAMED_ARGUMENT_CHAR}', got '${this.cur_c}'`);
}
if (open_length === 1) {
this.consume_optional_newline();
} else {
// Literal argument.
let close_string = closingChar(
START_NAMED_ARGUMENT_CHAR).repeat(open_length);
if (!this.tokenize_literal(START_NAMED_ARGUMENT_CHAR, close_string)) {
unterminated_literal = true;
}
this.push_token(TokenType.NAMED_ARGUMENT_END, close_string);
this.consume_optional_newline_after_argument()
}
}
} else if (this.cur_c === END_NAMED_ARGUMENT_CHAR) {
this.consume_optional_newline_before_close();
this.push_token(TokenType.NAMED_ARGUMENT_END, END_NAMED_ARGUMENT_CHAR);
this.consume();
this.consume_optional_newline_after_argument()
} else if (this.cur_c === START_POSITIONAL_ARGUMENT_CHAR) {
let source_location = this.source_location.clone();
// Tokenize past the last open char.
let open_length = this.tokenize_func(
c => c === START_POSITIONAL_ARGUMENT_CHAR
).length;
this.push_token(TokenType.POSITIONAL_ARGUMENT_START,
START_POSITIONAL_ARGUMENT_CHAR.repeat(open_length), source_location);
if (open_length === 1) {
this.consume_optional_newline();
} else {
// Literal argument.
let close_string = closingChar(
START_POSITIONAL_ARGUMENT_CHAR).repeat(open_length);
if (!this.tokenize_literal(START_POSITIONAL_ARGUMENT_CHAR, close_string)) {
unterminated_literal = true;
}
this.push_token(TokenType.POSITIONAL_ARGUMENT_END);
this.consume_optional_newline_after_argument()
}
} else if (this.cur_c === END_POSITIONAL_ARGUMENT_CHAR) {
this.consume_optional_newline_before_close();
this.push_token(TokenType.POSITIONAL_ARGUMENT_END);
this.consume();
this.consume_optional_newline_after_argument();
} else if (this.cur_c in MAGIC_CHAR_ARGS) {
const source_location = this.source_location.clone();
// Insane shortcuts e.g. $$ math, `` code and <> magic \x.
const open_char = this.cur_c;
const open_length = this.tokenize_func(c => c === open_char).length;
let close_char
let isTopic = false
if (open_char === INSANE_X_START) {
close_char = INSANE_X_END
if (this.cur_c === INSANE_TOPIC_CHAR) {
this.consume(INSANE_TOPIC_CHAR.length)
isTopic = true
}
} else {
close_char = open_char
}
const close_string = close_char.repeat(open_length);
let macro_name = MAGIC_CHAR_ARGS[open_char];
if (open_length > 1) {
macro_name = macro_name.toUpperCase();
}
this.push_token(TokenType.MACRO_NAME, macro_name);
this.push_token(TokenType.POSITIONAL_ARGUMENT_START);
if (!this.tokenize_literal(open_char, close_string, true)) {
unterminated_literal = true;
}
this.push_token(TokenType.POSITIONAL_ARGUMENT_END);
if (open_char === INSANE_X_START) {
this.push_token(TokenType.NAMED_ARGUMENT_START, START_NAMED_ARGUMENT_CHAR, source_location);
this.push_token(TokenType.NAMED_ARGUMENT_NAME, isTopic ? 'topic' : 'magic', source_location);
this.push_token(TokenType.NAMED_ARGUMENT_END, END_NAMED_ARGUMENT_CHAR, source_location);
}
this.consume_optional_newline_after_argument()
} else if (this.cur_c === '\n' && this.peek() === '\n') {
this.consume();
this.consume();
// We must close list level changes before the paragraph, e.g. in:
//
// ``
// * aa
// * bb
//
// cc
// ``
//
// the paragraph goes after `ul`, it does not stick to `bb`
this.consume_list_indent();
this.push_token(TokenType.PARAGRAPH);
if (this.cur_c === '\n') {
this.error('paragraph with more than two newlines, use just two');
}
} else {
let done = false;
// Insane link.
if (this.in_escape_insane_link) {
this.in_escape_insane_link = false;
} else {
let is_insane_link = false;
for (const known_url_protocol of KNOWN_URL_PROTOCOLS) {
if (
arrayContainsArrayAt(this.chars, this.i, known_url_protocol)
) {
const pos_char_after = this.i + known_url_protocol.length;
if (
pos_char_after < this.chars.length &&
!INSANE_LINK_END_CHARS.has(this.chars[pos_char_after])
) {
is_insane_link = true;
break;
}
}
}
if (is_insane_link) {
this.push_token(TokenType.MACRO_NAME, Macro.LINK_MACRO_NAME);
this.push_token(TokenType.POSITIONAL_ARGUMENT_START);
while (this.consume_plaintext_char()) {
if (INSANE_LINK_END_CHARS.has(this.cur_c)) {
break
}
if (this.cur_c === ESCAPE_CHAR) {
this.consume()
}
}
this.push_token(TokenType.POSITIONAL_ARGUMENT_END)
this.consume_optional_newline_after_argument()
done = true;
}
}
// Insane topic link.
let is_insane_topic_link = false;
if (this.cur_c === INSANE_TOPIC_CHAR) {
const source_location = this.source_location.clone()
this.push_token(TokenType.MACRO_NAME, Macro.X_MACRO_NAME, source_location)
this.push_token(TokenType.POSITIONAL_ARGUMENT_START, START_POSITIONAL_ARGUMENT_CHAR, source_location)
this.consume(INSANE_TOPIC_CHAR.length)
while (this.consume_plaintext_char()) {
if (INSANE_LINK_END_CHARS.has(this.cur_c)) {
break
}
if (this.cur_c === ESCAPE_CHAR) {
this.consume()
}
}
this.push_token(TokenType.POSITIONAL_ARGUMENT_END, END_POSITIONAL_ARGUMENT_CHAR, source_location)
this.push_token(TokenType.NAMED_ARGUMENT_START, START_NAMED_ARGUMENT_CHAR, source_location)
this.push_token(TokenType.NAMED_ARGUMENT_NAME, 'topic', source_location)
this.push_token(TokenType.NAMED_ARGUMENT_END, END_NAMED_ARGUMENT_CHAR, source_location)
done = true
}
// Insane lists and tables.
if (
!done && (
this.i === 0 ||
this.cur_c === '\n' ||
(
this.tokens.length > 0 &&
this.tokens[this.tokens.length - 1].type === TokenType.PARAGRAPH
) ||
// Immediately at the start of an argument.
this.tokens.length > 0 && (
this.tokens[this.tokens.length - 1].type === TokenType.NAMED_ARGUMENT_NAME ||
this.tokens[this.tokens.length - 1].type === TokenType.POSITIONAL_ARGUMENT_START
)
)
) {
let i = this.i;
if (this.cur_c === '\n') {
i += 1;
}
let new_list_level = 0;
while (arrayContainsArrayAt(this.chars, i, INSANE_LIST_INDENT)) {
i += INSANE_LIST_INDENT.length;
new_list_level += 1;
}
let insane_start_return = this.tokenize_insane_start(i);
if (insane_start_return !== undefined) {
const [insane_start, insane_start_length] = insane_start_return;
if (new_list_level <= this.list_level + 1) {
if (this.cur_c === '\n') {
this.consume();
}
this.consume_list_indent();
this.push_token(TokenType.MACRO_NAME, INSANE_STARTS_TO_MACRO_NAME[insane_start]);
this.push_token(TokenType.POSITIONAL_ARGUMENT_START);
this.list_level += 1;
done = true;
for (let i = 0; i < insane_start_length; i++) {
this.consume();
}
}
}
}
// Insane headers.
if (!done && (
this.i === 0 ||
this.chars[this.i - 1] === '\n'
)) {
let i = this.i;
let new_header_level = 0;
while (this.chars[i] === INSANE_HEADER_CHAR) {
i += 1;
new_header_level += 1;
}
if (new_header_level > 0 && this.chars[i] === ' ') {
this.push_token(TokenType.MACRO_NAME, Macro.HEADER_MACRO_NAME);
this.push_token(TokenType.POSITIONAL_ARGUMENT_START, INSANE_HEADER_CHAR.repeat(new_header_level));
this.push_token(TokenType.PLAINTEXT, new_header_level.toString());
this.push_token(TokenType.POSITIONAL_ARGUMENT_END);
this.push_token(TokenType.POSITIONAL_ARGUMENT_START);
for (let i = 0; i <= new_header_level; i++)
this.consume();
this.in_insane_header = true;
done = true;
}
}
// Character is nothing else, so finally it is a regular plaintext character.
if (!done) {
this.consume_plaintext_char();
}
}
}
if (unterminated_literal) {
this.error(`unterminated literal argument`, start_source_location);
}
// Close any open headers at the end of the document.
if (this.in_insane_header) {
this.push_token(TokenType.POSITIONAL_ARGUMENT_END);
}
// Close any open list levels at the end of the document.
for (let i = 0; i < this.list_level; i++) {
this.push_token(TokenType.POSITIONAL_ARGUMENT_END);
}
this.push_token(TokenType.PARAGRAPH);
this.push_token(TokenType.INPUT_END);
return this.tokens;
}
// Create a token with all consecutive chars that are accepted
// by the given function.
tokenize_func(f) {
this.log_debug('tokenize_func');
this.log_debug('this.i: ' + this.i);
this.log_debug('this.cur_c: ' + this.cur_c);
this.log_debug('');
let value = '';
while (f(this.cur_c)) {
value += this.cur_c;
this.consume();
if (this.is_end())
break;
}
return value;
}
/**
* Determine if we are at the start of an insane indented sequence
* like an insane list '* ' or table '| '
*
* @return {Union[[String,Number],undefined]} -
* - [insane_start, length] if any is found. For an empty table or list without space,
* length is insane_start.length - 1. Otherwise it equals insane_start.length.
* - undefined if none found.
*/
tokenize_insane_start(i) {
for (const insane_start in INSANE_STARTS_TO_MACRO_NAME) {
if (
arrayContainsArrayAt(this.chars, i, insane_start)
) {
// Full insane start match.
return [insane_start, insane_start.length];
}
// Empty table or list without space.
let insane_start_nospace = insane_start.substring(0, insane_start.length - 1);
if (
arrayContainsArrayAt(this.chars, i, insane_start_nospace) &&
(
i === this.chars.length - 1 ||
this.chars[i + insane_start.length - 1] === '\n'
)
) {
return [insane_start, insane_start.length - 1];
}
}
return undefined;
}
/**
* Start inside the literal argument after the opening,
* and consume until its end.
*
* @return {boolean} - true if OK, false if unexpected EOF
*/
tokenize_literal(open_char, close_string, openEqualsClose = false) {
this.log_debug('tokenize_literal');
this.log_debug(`this.i: ${this.i}`);
this.log_debug(`open_char: ${open_char}`);
this.log_debug(`close_string ${close_string}`);
this.log_debug('');
if (this.is_end())
return false;
// Remove leading escapes.
let i = this.i;
while (this.chars[i] === ESCAPE_CHAR) {
i++;
if (this.is_end())
return false;
}
if (this.chars[i] === open_char && !openEqualsClose) {
// Skip one of the escape chars if they are followed by an open.
if (!this.consume())
return false;
} else {
if (!this.consume_optional_newline(true))
return false;
}
// Now consume the following unescaped part.
let start_i = this.i;
let start_source_location = this.source_location.clone();
while (
this.chars.slice(this.i, this.i + close_string.length).join('')
!== close_string
) {
if (!this.consume())
return false;
}
// Handle trailing escape.
let append;
let end_i;
if (
this.chars[this.i - 1] === ESCAPE_CHAR &&
this.chars.slice(this.i + 1, this.i + close_string.length + 1).join('') === close_string
) {
// Ignore the trailing backslash.
end_i = this.i - 1;
// Consume the escaped closing char.
if (!this.consume())
return false;
append = closingChar(open_char);
} else {
end_i = this.i;
append = '';
}
// Remove insane list indents.
let plaintext = '';
{
let i = start_i;
while (true) {
if (this.chars[i - 1] === '\n') {
if (this.chars[i] === '\n') {
} else if (arrayContainsArrayAt(this.chars, i, INSANE_LIST_INDENT.repeat(this.list_level))) {
i += INSANE_LIST_INDENT.length * this.list_level;
} else {
this.error(`literal argument with indent smaller than current insane list`, start_source_location);
}
}
if (i < end_i) {
plaintext += this.chars[i];
} else {
break;
}
i++;
}
}
// Create the token.
this.push_token(
TokenType.PLAINTEXT,
plaintext + append,
start_source_location,
);
this.consume_optional_newline_before_close();
// Skip over the closing string.
for (let i = 0; i < close_string.length; i++)
this.consume();
return true;
}
}
class HeaderTreeNode {
/**
* Structure:
*
* toplevel -> value -> toplevel header ast
* -> child[0] -> value -> h2 1 header ast
* -> parent_ast -> toplevel
* -> child[1] -> value -> h2 2 header ast
* -> parent_ast -> toplevel
*
* P inside h2 1:
* -> parent_ast -> child[0]
* a inside P inside h2 1:
* -> parent_ast -> child[0]
*
* And every non-header element also gets a parent link to its header without child down:
*
* @param {AstNode} value
* @param {HeaderTreeNode} parent_ast
*/
constructor(ast, parent_ast, options={}) {
this.ast = ast;
this.parent_ast = parent_ast;
this.children = [];
this.index = undefined;
this.descendant_count = 0;
this.descendant_word_count = 0;
if (ast !== undefined) {
this.word_count = ast.word_count;
if (!ast.in_header) {
let cur_node = this.parent_ast;
if (cur_node !== undefined && cur_node.parent_ast !== undefined) {
cur_node.update_ancestor_counts(this.word_count + ast.header_tree_node_word_count)
}
}
} else {
this.word_count = 0;
}
}
// TODO how is this different from passing parent_ast on constructor? Forgot.
// Maybe something along the lines of "this allows you to separate creation and chaining".
add_child(child) {
child.index = this.children.length;
this.children.push(child);
let cur_node = this;
while (cur_node !== undefined) {
cur_node.descendant_count += 1;
cur_node = cur_node.parent_ast;
}
}
/** @return {Number} How deep this node is relative to
* the to of the root of the tree. */
get_level() {
let level = 0;
let cur_node = this.parent_ast;
while (cur_node !== undefined) {
level++;
cur_node = cur_node.parent_ast;
}
return level;
}
/** E.g. get number 1.4.2.5 of a Section.
*
* @return {String}
*/
get_nested_number(header_tree_top_level) {
let indexes = [];
let cur_node = this;
while (
// Possible in skipped header levels.
cur_node !== undefined &&
cur_node.ast !== undefined &&
cur_node.ast.numbered &&
cur_node.get_level() !== header_tree_top_level
) {
indexes.push(cur_node.index + 1);
cur_node = cur_node.parent_ast;
}
return indexes.reverse().join('.');
}
toString() {
const ret = [];
let todo_visit;
// False for toplevel of the tree.
if (this.ast === undefined) {
todo_visit = this.children.slice().reverse();
} else {
todo_visit = [this];
}
while (todo_visit.length > 0) {
const cur_node = todo_visit.pop();
const level = cur_node.get_level();
ret.push(`${INSANE_HEADER_CHAR.repeat(level)} h${level} ${cur_node.get_nested_number(1)} ${cur_node.ast.id}`);
todo_visit.push(...cur_node.children.slice().reverse());
}
return ret.join('\n');
}
update_ancestor_counts(add) {
let cur_node = this.parent_ast
while (cur_node !== undefined) {
cur_node.descendant_word_count += add;
cur_node = cur_node.parent_ast;
}
}
}
exports.HeaderTreeNode = HeaderTreeNode
/** Add an entry to the data structures that keep the map of incoming
* and outgoing \x and \x {child} links. */
function addToRefsTo(toid, context, fromid, relation_type, opts={}) {
addToRefsToOneWay(false, toid, context, fromid, relation_type, opts)
addToRefsToOneWay(true, fromid, context, toid, relation_type, opts)
}
function addToRefsToOneWay(reverse, toid, context, fromid, relation_type, opts={}) {
let { child_index, source_location, inflected } = opts
if (inflected === undefined) {
inflected = false
}
let from_to_dict_false = context.refs_to[reverse];
let from_ids;
if (toid in from_to_dict_false) {
from_ids = from_to_dict_false[toid];
} else {
from_ids = {};
from_to_dict_false[toid] = from_ids;
}
let from_ids_relation_type;
if (relation_type in from_ids) {
from_ids_relation_type = from_ids[relation_type]
} else {
from_ids_relation_type = {}
from_ids[relation_type] = from_ids_relation_type
}
let from_ids_relation_type_fromid = from_ids_relation_type[fromid]
if (from_ids_relation_type_fromid === undefined) {
from_ids_relation_type_fromid = { defined_at: {}, child_index }
from_ids_relation_type[fromid] = from_ids_relation_type_fromid
}
let from_ids_relation_type_fromid_defined_at = from_ids_relation_type_fromid.defined_at[context.options.input_path]
if (from_ids_relation_type_fromid_defined_at === undefined) {
from_ids_relation_type_fromid_defined_at = []
from_ids_relation_type_fromid.defined_at[context.options.input_path] = from_ids_relation_type_fromid_defined_at
}
from_ids_relation_type_fromid_defined_at.push({
line: source_location.line,
column: source_location.column,
inflected,
})
}
/**
* Determine if big_array contains small_array starting at index position
* inside the big array.
*
* @return {boolean} true iff if the big array contains the small one
*/
function arrayContainsArrayAt(big_array, position, small_array) {
for (let i = 0; i < small_array.length; i++) {
if (big_array[position + i] !== small_array[i]) {
return false;
}
}
return true;
}
// https://stackoverflow.com/questions/7837456/how-to-compare-arrays-in-javascript
function arrayEquals(arr1, arr2) {
if (arr1.length !== arr2.length)
return false;
for (let i = 0; i < arr1.length; i++) {
if (arr1[i] !== arr2[i])
return false;
}
return true;
}
function basename(str, sep) {
return pathSplit(str, sep)[1];
}
/// https://stackoverflow.com/questions/22697936/binary-search-in-javascript/29018745#29018745
function binarySearch(ar, el, compare_fn) {
var m = 0;
var n = ar.length - 1;
while (m <= n) {
var k = (n + m) >> 1;
var cmp = compare_fn(el, ar[k]);
if (cmp > 0) {
m = k + 1;
} else if (cmp < 0) {
n = k - 1;
} else {
return k;
}
}
return -m - 1;
}
function binarySearchInsert(ar, el, compare_fn) {
let index = binarySearch(ar, el, compare_fn);
if (index < 0) {
ar.splice(-(index + 1), 0, el);
}
return ar;
}
function binarySearchLineToIdArrayFn(elem0, elem1) {
return elem0[0] - elem1[0];
}
/** Calculate node ID and add it to the ID index. */
function calculateId(
ast,
context,
non_indexed_ids,
indexed_ids,
macro_counts,
macro_count_global,
macro_counts_visible,
state,
is_header,
line_to_id_array
) {
const macro_name = ast.macro_name;
const macro = context.macros[macro_name];
// Linear count of each macro type for macros that have IDs.
if (!macro.options.macro_counts_ignore(ast)) {
if (!(macro_name in macro_counts)) {
macro_counts[macro_name] = 0;
}
const macro_count = macro_counts[macro_name] + 1;
macro_counts[macro_name] = macro_count;
ast.macro_count = macro_count;
}
let index_id = true;
let skip_scope = false
let id
let file_header = ast.macro_name === Macro.HEADER_MACRO_NAME &&
ast.validation_output.file.given
let file_id_text_append
const new_context = cloneAndSet(context, 'id_conversion', true);
const title_arg = macro.options.get_title_arg(ast, context);
const title_text = renderArgNoescape(title_arg, new_context)
if (file_header) {
const file_render = renderArg(ast.args.file, new_context)
if (file_render) {
file_id_text_append = file_render
} else {
file_id_text_append = title_text
}
ast.file = file_id_text_append
}
if (
// This can happen be false for included headers, and this is notably important
// for the toplevel header which gets its ID from the filename.
ast.id === undefined
) {
const macro_id_arg = ast.args[Macro.ID_ARGUMENT_NAME];
if (macro_id_arg === undefined) {
let id_text = '';
const id_prefix = context.macros[ast.macro_name].id_prefix;
if (title_arg !== undefined) {
if (id_prefix !== '') {
id_text += id_prefix + ID_SEPARATOR
}
if (file_header) {
id_text = Macro.FILE_ID_PREFIX + id_text + file_id_text_append
} else {
id_text += titleToId(title_text, new_context.options.ourbigbook_json.id, new_context);
}
const disambiguate_arg = ast.args[Macro.DISAMBIGUATE_ARGUMENT_NAME];
if (disambiguate_arg !== undefined) {
id_text += ID_SEPARATOR + titleToId(renderArgNoescape(disambiguate_arg, new_context), new_context.options.ourbigbook_json.id);
}
id = id_text;
} else {
id_text += '_'
}
if (id === undefined) {
index_id = false;
if (!macro.options.phrasing) {
const parent_header_tree_node = ast.header_tree_node.parent_ast
if (parent_header_tree_node && new_context.options.prefixNonIndexedIdsWithParentId) {
skip_scope = true
id_text = parent_header_tree_node.ast.id + Macro.HEADER_SCOPE_SEPARATOR + id_text;
}
id_text += macro_count_global;
macro_count_global++
id = id_text;
// IDs of type p-1, p-2, q-1, q-2, etc.
//if (ast.macro_count !== undefined) {
// id_text += ast.macro_count;
// id = id_text;
//}
}
}
} else {
id = renderArgNoescape(macro_id_arg, new_context);
}
if (
index_id &&
id !== undefined &&
id.startsWith(Macro.RESERVED_ID_PREFIX) &&
!file_header
) {
let message = `IDs that start with "${Macro.RESERVED_ID_PREFIX}" are reserved: "${id}"`;
parseError(state, message, ast.source_location);
}
if (id !== undefined && ast.scope !== undefined && !skip_scope) {
id = ast.scope + Macro.HEADER_SCOPE_SEPARATOR + id
}
}
if (id !== undefined) {
ast.id = id
}
if (ast.id && ast.subdir && !skip_scope) {
ast.id = ast.subdir + Macro.HEADER_SCOPE_SEPARATOR + ast.id
}
if (file_header && ast.scope) {
// TODO we should use the input directory here, not scope. {file} should ignore scope most likely
// and care only about the input directory.
let scopeSplit = ast.scope.split(Macro.HEADER_SCOPE_SEPARATOR)
if (scopeSplit[0] === FILE_PREFIX) {
scopeSplit = scopeSplit.slice(1)
}
ast.file = (scopeSplit.length ? scopeSplit.join(Macro.HEADER_SCOPE_SEPARATOR) + Macro.HEADER_SCOPE_SEPARATOR : '') + ast.file
}
if (id === '') {
parseError(state, 'ID cannot be empty', ast.source_location);
}
ast.index_id = index_id;
if (ast.id !== undefined && !ast.force_no_index) {
let non_indexed_ast = non_indexed_ids[ast.id];
if (non_indexed_ast === undefined) {
non_indexed_ids[ast.id] = ast;
if (index_id) {
indexed_ids[ast.id] = ast;
const local_id = ast.get_local_header_parent_id()
if (local_id !== undefined) {
addToRefsTo(
ast.id,
context,
local_id,
REFS_TABLE_PARENT,
{
child_index: ast.header_tree_node.index,
source_location: ast.source_location,
}
)
}
}
} else {
const message = duplicateIdErrorMessage(
ast.id,
non_indexed_ast.source_location.path,
non_indexed_ast.source_location.line,
non_indexed_ast.source_location.column
)
parseError(state, message, ast.source_location);
}
if (captionNumberVisible(ast, context)) {
if (!(macro_name in macro_counts_visible)) {
macro_counts_visible[macro_name] = 0;
}
const macro_count = macro_counts_visible[macro_name] + 1;
macro_counts_visible[macro_name] = macro_count;
ast.macro_count_visible = macro_count;
}
binarySearchInsert(line_to_id_array,
[ast.source_location.line, ast.synonym || ast.id], binarySearchLineToIdArrayFn);
}
return { title_text, macro_count_global }
}
/* Calculate the length of the scope of a child header given its parent ast. */
function calculateScopeLength(parent_ast) {
if (parent_ast !== undefined) {
let scope = parent_ast.calculate_scope();
if (scope !== undefined) {
return scope.length + 1;
}
}
return 0;
}
function capitalizeFirstLetter(string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
exports.capitalizeFirstLetter = capitalizeFirstLetter
function captionNumberVisible(ast, context) {
return ast.index_id || context.macros[ast.macro_name].options.captionNumberVisible(ast, context);
}
function charIsAlphanumeric(c) {
let code = c.codePointAt(0);
return (
// 0-9
(code > 47 && code < 58) ||
// A-Z
(code > 64 && code < 91) ||
// a-z
(code > 96 && code < 123)
)
}
// Valid macro name / argument characters.
// Compatible with JavaScript-like function names / variables.
// https://docs.ourbigbook.com#macro-identifier
function charIsIdentifier(c) {
return charIsAlphanumeric(c)
};
/** Shallow clone an object, and set a given value on the cloned one. */
function cloneAndSet(obj, key, value) {
let new_obj = {...obj};
new_obj[key] = value;
return new_obj;
}
exports.cloneAndSet = cloneAndSet;
function cloneObject(obj) {
// https://stackoverflow.com/questions/41474986/how-to-clone-a-javascript-es6-class-instance
return Object.assign(Object.create(Object.getPrototypeOf(obj)), obj);
}
function closingChar(c) {
if (c === START_POSITIONAL_ARGUMENT_CHAR)
return END_POSITIONAL_ARGUMENT_CHAR;
if (c === START_NAMED_ARGUMENT_CHAR)
return END_NAMED_ARGUMENT_CHAR;
throw new Error('char does not have a close: ' + c);
}
function closingToken(token) {
if (token === TokenType.POSITIONAL_ARGUMENT_START)
return TokenType.POSITIONAL_ARGUMENT_END;
if (token === TokenType.NAMED_ARGUMENT_START)
return TokenType.NAMED_ARGUMENT_END;
throw new Error('token does not have a close: ' + token);
}
/**
* Main ourbigbook input to HTML/LaTeX/etc. output JavaScript API.
*
* The CLI interface basically just feeds this.
*
* @param {Object} options
* {DbProvider} external_ids
* {Function[String] -> [string,string]} read_include(id) -> [file_name, content]
* {Number} h_include_level_offset - add this offset to the levels of every header
* {boolean} render - if false, parse the input, but don't render it,
* and return undefined.
* The initial use case for this is to allow a faster and error-less
* first pass when building an entire directory with internal cross file
* references to extract IDs of each file.
* @param {Object} extra_returns
* {Object} key: output_path
* value: Object:
* title: rendered toplevel inner title
* full: full render, including title and body
* This is needed primarily because --html-spit-header
* generates multiple output files for a single input file.
* @return {String}
*/
async function convert(
input_string,
options,
extra_returns={},
) {
let context = convertInitContext(options, extra_returns);
// Tokenize.
let sub_extra_returns;
sub_extra_returns = {};
perfPrint(context, 'tokenize_pre')
input_string = input_string.replace(/\n$/, '')
const tokenizer = new Tokenizer(
input_string,
sub_extra_returns,
context.options.log.tokenize,
context.options.start_line,
context.options.input_path,
)
let tokens = tokenizer.tokenize();
if (input_string[input_string.length - 1] === '\n') {
context.errors.push(new ErrorMessage(`the document cannot end in two or more newlines`, tokenizer.source_location))
}
perfPrint(context, 'tokenize_post')
if (context.options.log['tokens-inside']) {
console.error('tokens:');
for (let i = 0; i < tokens.length; i++) {
console.error(`${i}: ${JSON.stringify(tokens[i], null, 2)}`);
}
console.error();
}
extra_returns.tokens = tokens;
extra_returns.errors.push(...sub_extra_returns.errors);
sub_extra_returns = {};
// Setup context.media_provider_default based on `default-for`.
{
const media_providers = context.options.ourbigbook_json['media-providers'];
context.media_provider_default = {};
for (const media_provider_name in media_providers) {
const media_provider = media_providers[media_provider_name];
if ('default-for' in media_provider) {
for (const default_for of media_provider['default-for']) {
if (default_for[0] == default_for[0].toUpperCase()) {
context.errors.push(new ErrorMessage(`default-for names must start with a lower case letter`, new SourceLocation(1, 1)));
} else {
if (default_for === 'all') {
for (const macro_name of MACRO_WITH_MEDIA_PROVIDER) {
context.media_provider_default[default_for] = media_provider_name;
context.media_provider_default[capitalizeFirstLetter(default_for)] = media_provider_name;
}
} else {
if (MACRO_WITH_MEDIA_PROVIDER.has(default_for)) {
if (context.media_provider_default[default_for] === undefined) {
context.media_provider_default[default_for] = media_provider_name;
context.media_provider_default[capitalizeFirstLetter(default_for)] = media_provider_name;
} else {
context.errors.push(new ErrorMessage(`multiple media providers set for macro "${default_for}"`, new SourceLocation(1, 1)));
}
} else {
context.errors.push(new ErrorMessage(`macro "${default_for}" does not accept media providers`, new SourceLocation(1, 1)));
}
}
}
}
}
}
for (const macro_name of MACRO_WITH_MEDIA_PROVIDER) {
if (context.media_provider_default[macro_name] === undefined) {
context.media_provider_default[macro_name] = 'local';
context.media_provider_default[capitalizeFirstLetter(macro_name)] = 'local';
}
}
}
const toplevel_ast = await parse(tokens, context.options, context, sub_extra_returns);
if (context.options.log['ast-inside']) {
console.error('ast:');
console.error(JSON.stringify(toplevel_ast, null, 2));
console.error();
}
extra_returns.ast = toplevel_ast;
extra_returns.context = context;
extra_returns.ids = sub_extra_returns.ids;
Object.assign(extra_returns.debug_perf, sub_extra_returns.debug_perf);
extra_returns.errors.push(...sub_extra_returns.errors);
let output;
if (context.options.render) {
// Gather toplevel ids and their asts.
const toplevel_ids = {}
const content = toplevel_ast.args.content;
let first_toplevel_id
let undefined_asts = []
for (const child_ast of content) {
if (child_ast.toplevel_id === undefined) {
/** Some undefined topleve_ids are unescapable e.g. input from stdin.
*
* But there is on case that we could improve:
*
* ``
* aa
*
* = Bb
* ``
*
* aa is undefined toplevel_id. We are just semi hacking it and grouping all
* undefined toplevel_id with the first defined one.
* */
undefined_asts.push(child_ast)
} else {
let toplevel_ids_entry = toplevel_ids[child_ast.toplevel_id]
if (first_toplevel_id === undefined) {
first_toplevel_id = child_ast.toplevel_id
}
if (toplevel_ids_entry === undefined) {
if (undefined_asts.length) {
toplevel_ids_entry = undefined_asts
undefined_asts = []
} else {
toplevel_ids_entry = []
}
toplevel_ids[child_ast.toplevel_id] = toplevel_ids_entry
}
toplevel_ids_entry.push(child_ast)
}
}
// Nosplit renders. \H toplevel is split here.
for (const toplevel_id in toplevel_ids) {
// TODO this prevents toplevel arguments (currently only {title}) from working. Never used them though.
// The Toplevel element is a bit useless given this setup.
const ret = renderAstList({
asts: toplevel_ids[toplevel_id],
context,
first_toplevel: toplevel_id === first_toplevel_id,
split: false,
});
if (toplevel_id === first_toplevel_id) {
output = ret
}
}
if (undefined_asts.length) {
// There are no headers.
output = renderAstList({
asts: undefined_asts,
context,
split: false,
});
}
perfPrint(context, 'split_render_pre')
// Split header conversion.
if (context.options.split_headers) {
// Reset katex_macros to the ourbigbook.json defaults, otherwise
// macros will get redefiend in the split render, as they are redefined on every render.
context.katex_macros = { ...context.options.katex_macros }
const content = toplevel_ast.args.content;
// Gather up each header (must be a direct child of toplevel)
// and all elements that belong to that header (up to the next header).
let asts = [];
let header_count = 0
for (const child_ast of content) {
const macro_name = child_ast.macro_name;
if (
macro_name === Macro.HEADER_MACRO_NAME &&
// Just ignore extra added include headers, these
// were overwritting index-split.html output.
!child_ast.from_include &&
!child_ast.validation_output.synonym.boolean
) {
renderAstList({ asts, context, header_count, split: true });
asts = [];
header_count++
}
asts.push(child_ast);
}
renderAstList({ asts, context, header_count, split: true });
// Because the following conversion would redefine them.
}
perfPrint(context, 'render_post')
extra_returns.errors.push(...context.errors);
}
// Sort errors that might have been produced on different conversion
// stages by line.
extra_returns.errors = extra_returns.errors.sort((a, b)=>{
if (a.source_location.path !== undefined && b.source_location.path !== undefined) {
let ret = a.source_location.path.localeCompare(b.source_location.path);
if (ret !== 0)
return ret;
}
if (a.severity < b.severity)
return -1;
if (a.severity > b.severity)
return 1;
if (a.source_location.line < b.source_location.line)
return -1;
if (a.source_location.line > b.source_location.line)
return 1;
if (a.source_location.column < b.source_location.column)
return -1;
if (a.source_location.column > b.source_location.column)
return 1;
return 0;
});
if (output !== undefined) {
if (output[output.length - 1] !== '\n') {
output += '\n';
}
}
perfPrint(context, 'end_convert')
return output;
}
exports.convert = convert;
/** Convert an argument to an XSS-safe output string.
*
* An argument contains a list of nodes, loop over that list of nodes,
* converting them to strings and concatenate all strings.
*
* @param {AstArgument} arg
* @return {String} empty string if arg is undefined
*/
function renderArg(arg, context) {
let converted_arg = [];
if (arg !== undefined) {
if (context.options.output_format === OUTPUT_FORMAT_OURBIGBOOK) {
for (const ast of arg) {
if (ast.macro_name === Macro.PARAGRAPH_MACRO_NAME) {
arg.has_paragraph = true
}
if (context.macros[ast.macro_name].options.phrasing) {
arg.not_all_block = true
}
}
}
for (const ast of arg) {
converted_arg.push(ast.render(context));
}
}
return converted_arg.join('');
}
/* Similar to renderArg, but used for IDs.
*
* Because IDs are used programmatically in ourbigbook, we don't escape
* HTML characters at this point.
*
* @param {AstArgument} arg
* @return {String}
*/
function renderArgNoescape(arg, context={}) {
return renderArg(arg, cloneAndSet(context, 'html_escape', false));
}
/* Convert a list of asts.
*
* The simplest type of conversion is to convert
* a single Toplevel element all the way down.
*
* This function adds a dummy Toplevel to a list of AstNodes, and
* sets up some other related stuff to convert that list.
*
* Applications for this include:
* - --split-headers
* - \H toplevel argument
*
* @param {List[Ast]} asts
* @param {boolean} first_toplevel
* @param {Number} header_count
* @param {boolean} split
*/
function renderAstList({ asts, context, first_toplevel, header_count, split }) {
if (
// Can fail if:
// * the first thing in the document is a header
// * the document has no headers
asts.length > 0 &&
!(
context.options.ourbigbook_json.h.splitDefaultNotToplevel &&
header_count === 1
)
) {
context = { ...context }
const options = { ...context.options }
context.options = options;
const first_ast = cloneObject(asts[0]);
if (!first_toplevel) {
// When not in simple header mode, we always have a value-less node, with
// children with values. Now things are a bit more complicated, because we
// want to keep the header tree intact, but at the same time also uniquely point
// to one of the headers. So let's fake a tree node that has only one child we care
// about. And the child does not have this fake parent to be able to see actual parents.
context.header_tree = new HeaderTreeNode();
// Clone this, because we are going to modify it, and it would affect
// non-split headers and final outputs afterwards.
first_ast.header_tree_node = cloneObject(first_ast.header_tree_node);
context.header_tree.add_child(first_ast.header_tree_node);
context.header_tree_top_level = first_ast.header_tree_node.get_level();
}
const output_path_ret = first_ast.output_path(
cloneAndSet(context, 'to_split_headers', split)
)
const { path: output_path, split_suffix } = output_path_ret
if (options.log['split-headers']) {
console.error(`split-headers${split ? '' : ' nosplit'}: ` + output_path);
}
context.toplevel_output_path = output_path;
if (output_path !== undefined) {
const [toplevel_output_path_dir, toplevel_output_path_basename] =
pathSplit(output_path, context.options.path_sep);
context.toplevel_output_path_dir = toplevel_output_path_dir;
} else {
context.toplevel_output_path_dir = '';
}
context.toplevel_ast = first_ast;
const ast_toplevel = new AstNode(
AstType.MACRO,
Macro.TOPLEVEL_MACRO_NAME,
{
'content': new AstArgument(asts, first_ast.source_location)
},
first_ast.source_location,
);
context.toplevel_id = first_ast.id;
context.in_split_headers = split;
let rendered_outputs_entry = {}
if (output_path !== undefined) {
// root_relpath
options.template_vars = { ...options.template_vars }
const new_root_relpath = getRootRelpath(output_path, context)
context.root_relpath_shift = path.relative(
options.template_vars.root_relpath,
new_root_relpath
)
options.template_vars.root_relpath = new_root_relpath
options.template_vars.raw_relpath = path.join(new_root_relpath, RAW_PREFIX)
options.template_vars.file_relpath = path.join(new_root_relpath, FILE_PREFIX)
options.template_vars.dir_relpath = path.join(new_root_relpath, DIR_PREFIX)
options.template_vars.file_relpath = path.join(new_root_relpath, FILE_PREFIX)
context.extra_returns.rendered_outputs[output_path] = rendered_outputs_entry
}
// Do the conversion.
context.toc_was_rendered = false
const ret = ast_toplevel.render(context)
if (output_path !== undefined) {
rendered_outputs_entry.full = ret
rendered_outputs_entry.split = split
rendered_outputs_entry.header_ast = first_ast
rendered_outputs_entry.split_suffix = split_suffix
if (
options.renderH2 &&
first_ast.macro_name === Macro.HEADER_MACRO_NAME
) {
options.h_show_split_header_link = false
const parent_ast = first_ast.get_header_parent_asts(context)[0];
context.header_tree = new HeaderTreeNode();
if (parent_ast) {
context.toplevel_ast = parent_ast;
const output_path_ret = parent_ast.output_path(
cloneAndSet(context, 'to_split_headers', split)
)
const { path: output_path, split_suffix } = output_path_ret
// Hax. It has to be both undefined, and different than the correct output_path.
// I think this is needed because is a slightly different rendering than what
// is ever done outside of web (it is an h2, and its h1 is on another page)
// so it is likely either this hack or another flag.
context.toplevel_output_path = output_path + 'asdf';
}
const header_tree_h1 = new HeaderTreeNode(parent_ast, context.header_tree);
context.header_tree.add_child(header_tree_h1);
first_ast.header_tree_node = cloneObject(first_ast.header_tree_node);
first_ast.header_tree_node.parent_ast = header_tree_h1
first_ast.first_toplevel_child = false
//header_tree_h1.add_child(first_ast.header_tree_node)
context.skipOutputEntry = true
context.forceHeadersHaveTocLink = true
context.toplevel_id = header_tree_h1.id
first_ast.toplevel_id = header_tree_h1.id
rendered_outputs_entry.h2Render = first_ast.render(context)
}
}
return ret
}
}
/**
* @param {Object} options:
* - {Number} start_line
* - {Array} errors
* @return {AstArgument}*/
async function parseInclude(
input_string,
convert_options,
cur_header_level,
input_path,
href,
options={}
) {
convert_options = { ...convert_options }
convert_options.from_include = true;
convert_options.h_parse_level_offset = cur_header_level;
convert_options.input_path = input_path;
convert_options.render = false;
convert_options.toplevel_id = href;
convert_options.header_tree_stack = new Map(convert_options.header_tree_stack);
convert_options.header_tree_id_stack = new Map(convert_options.header_tree_id_stack);
if (options.start_line !== undefined) {
convert_options.start_line = options.start_line;
}
const convert_extra_returns = {};
await convert(
input_string,
convert_options,
convert_extra_returns,
);
if (options.errors !== undefined) {
options.errors.push(...convert_extra_returns.errors);
}
return convert_extra_returns.ast.args.content;
}
// Convert an argument as an ID, notably:
// - no HTML escapes
// - plaintext conversion
function convertIdArg(arg, context) {
return renderArgNoescape(arg,
// This was added because it was blowing up in the edge case of
// \x[\m[1]] and others during parse to setup the x DB
// because we hadn't validate validated elements
// there yet. Not sure we could, no patience.
// This fix relies on the expectation that id_conversion will
// not rely on validateAst. Maybe that is reasonable.
cloneAndSet(context, 'id_conversion', true))
}
function convertInitContext(options={}, extra_returns={}) {
options = { ...options }
if (!('add_test_instrumentation' in options)) { options.add_test_instrumentation = false; }
if (!('add_test_instrumentation' in options)) { options.add_test_instrumentation = false; }
if (!('body_only' in options)) { options.body_only = false; }
if (!('db_provider' in options)) { options.db_provider = undefined; }
if (!('fixedScopeRemoval' in options)) {
// Rather than removing scopes from children page in a toplevel page that has a scope,
// remove fixed n chars from every single ID. This is used on Web to remove @ from links
// with dynamic article tree.
options.fixedScopeRemoval = undefined;
}
if (!('renderH2' in options)) { options.renderH2 = false; }
if (!('ourbigbook_json' in options)) { options.ourbigbook_json = {}; }
const ourbigbook_json = options.ourbigbook_json;
{
if (!('media-providers' in ourbigbook_json)) { ourbigbook_json['media-providers'] = {}; }
{
const media_providers = ourbigbook_json['media-providers'];
for (const media_provider_type of MEDIA_PROVIDER_TYPES) {
if (!(media_provider_type in media_providers)) {
media_providers[media_provider_type] = {};
}
const media_provider = media_providers[media_provider_type];
if (!('title-from-src' in media_provider)) {
media_provider['title-from-src'] = false;
}
}
if (media_providers.local && !('path' in media_providers.local)) {
media_providers.local.path = '';
}
if (media_providers.github && !('remote' in media_providers.github)) {
media_providers.github.remote = 'TODO determine from git remote origin if any';
}
for (const media_provider_name in media_providers) {
const media_provider = media_providers[media_provider_name];
if (!('title-from-src' in media_provider)) {
media_provider['title-from-src'] = false;
}
}
}
if (!('h' in ourbigbook_json)) { ourbigbook_json.h = {}; }
if (!('htmlXExtension' in ourbigbook_json)) { ourbigbook_json.htmlXExtension = undefined; }
if (!('numbered' in ourbigbook_json.h)) { ourbigbook_json.h.numbered = true; }
if (!('openLinksOnNewTabs' in ourbigbook_json)) { ourbigbook_json.openLinksOnNewTabs = false; }
if (!('splitDefault' in ourbigbook_json.h)) { ourbigbook_json.h.splitDefault = false; }
if (!('splitDefaultNotToplevel' in ourbigbook_json.h)) {
ourbigbook_json.h.splitDefaultNotToplevel = false;
}
{
if (!('lint' in ourbigbook_json)) { ourbigbook_json.lint = {}; }
const lint = ourbigbook_json.lint
if (!('h-tag' in lint)) { lint['h-tag'] = undefined; }
if (!('h-parent' in lint)) { lint['h-parent'] = undefined; }
}
{
if (!('id' in ourbigbook_json)) { ourbigbook_json.id = {}; }
const id = ourbigbook_json.id
if (!('normalize' in id)) { id.normalize = {}; }
const normalize = id.normalize
if (!('latin' in normalize)) {
normalize.latin = OURBIGBOOK_JSON_DEFAULT.id.normalize.latin
}
if (!('punctuation' in normalize)) {
normalize.punctuation = OURBIGBOOK_JSON_DEFAULT.id.normalize.punctuation
}
}
if (!('web' in ourbigbook_json)) { ourbigbook_json.web = {}; }
{
const web = ourbigbook_json.web
if (!('hostCapitalized' in web)) {
if ('host' in web) {
web.hostCapitalized = web.host
} else {
web.hostCapitalized = OURBIGBOOK_JSON_DEFAULT.web.hostCapitalized
}
}
if (!('host' in web)) {
web.host = OURBIGBOOK_JSON_DEFAULT.web.host
}
if (!('linkFromStaticHeaderMetaToWeb' in web)) { web.linkFromStaticHeaderMetaToWeb = false; }
if (!('username' in web)) { web.username = undefined; }
if (web.linkFromStaticHeaderMetaToWeb && web.username === undefined) {
throw new Error(`web.username must be given when web.linkFromStaticHeaderMetaToWeb = true"`)
}
}
if (!('xPrefix' in ourbigbook_json)) { ourbigbook_json.xPrefix = undefined; }
}
if (!('embed_includes' in options)) { options.embed_includes = false; }
// Check if file exists.
if (!('fs_exists_sync' in options)) { options.fs_exists_sync }
if (!('forbid_include' in options)) {
// If given, must be a string, and is an error given \\Include is used.
options.forbid_include = undefined;
}
if (!('forbid_multi_h1' in options)) {
// Only allow 1 h1 per input source.
options.forbid_multi_h1 = false
}
if (!('forbid_multiheader' in options)) {
// Input can only contain a single header.
// If given, must be the string explaining the error.
options.forbid_multiheader = undefined;
}
if (!('from_include' in options)) { options.from_include = false; }
if (!('from_ourbigbook_example' in options)) { options.from_ourbigbook_example = false; }
if (!('auto_generated_source' in options)) {
// true if the input was auto-generated rather than coming
// from a hand written .bigb input file as usual. Initial application:
// don't show "source code of this page" on templates.
options.auto_generated_source = false;
}
if (!('html_embed' in options)) { options.html_embed = false; }
options.htmlXExtension = resolveOption(options, 'htmlXExtension')
if (options.htmlXExtension === undefined) {
// Add HTML extension to x links. And therefore also:
// * output files with the `.html` extension
// * output `/index.html` vs just `/`
if (ourbigbook_json.htmlXExtension === undefined) {
options.htmlXExtension = true;
} else {
options.htmlXExtension = ourbigbook_json.htmlXExtension;
}
}
if (!('hFileShowLarge' in options)) { options.hFileShowLarge = false }
if (!('h_parse_level_offset' in options)) {
// When parsing, start the first header at this offset instead of h1.
// This is used when doing includes, since the included header is at.
// an offset relative to where it is included from.
options.h_parse_level_offset = 0;
}
if (!('h_show_split_header_link' in options)) {
options.h_show_split_header_link = true;
}
if (!('h_web_ancestors' in options)) {
// If true, reserve an empty metadata line for web ancestors, which are dynamically loaded.
options.h_web_ancestors = false;
}
if (!('h_web_metadata' in options)) {
// If true, reserve an empty metadata line for web injected elements
// such as like count and date modified.
options.h_web_metadata = false;
}
if (!('input_path' in options)) { options.input_path = undefined; }
if (!('internalLinkMetadata' in options)) { options.internalLinkMetadata = false }
if (!('katex_macros' in options)) { options.katex_macros = {}; }
if (!('logoPath' in options)) { options.logoPath = undefined; }
if (!('prefixNonIndexedIdsWithParentId' in options)) {
// E.g. the first paragraph of a header would have ID `_1` without this.
// This this option it becomes instead `header-id/_1`.
options.prefixNonIndexedIdsWithParentId = false;
}
if (!('log' in options)) { options.log = {}; }
if (!('outfile' in options)) {
// Override the default calculated output file for the main input.
options.outfile = undefined;
}
if (!('outdir' in options)) {
// Path of the output directory relative to the toplevel directory.
// E.g. out/html
options.outdir = undefined;
}
if (!('output_format' in options)) { options.output_format = OUTPUT_FORMAT_HTML; }
if (!('path_sep' in options)) { options.path_sep = undefined; }
if (!('parent_id' in options)) {
// Marks the given ID as a parent of the toplevel header of this conversion.
// More precisely, this creates a de-facto virtual Ref from that ID to the one
// generated by this conversion.
// This was introduced for Web, where we have an explicit parentId provided
// at initial conversion time. Without this options, we would need a separate
// conversion to determine the toplevel ID of this file.
options.parent_id = undefined;
}
if (!('publish' in options)) {
// If true, this means that this is a ourbigbook --publish run rather
// than a regular developmt compilation.
options.publish = false
}
if (options.publish && 'publishOptions' in ourbigbook_json) {
lodash.merge(ourbigbook_json, ourbigbook_json.publishOptions)
}
if (!('read_include' in options)) { options.read_include = () => undefined; }
if (!('read_file' in options)) { options.read_file = () => undefined; }
if (!('ref_prefix' in options)) {
// TODO implement.
// This option started as a hack as a easier to implement workaround for:
// https://github.com/ourbigbook/ourbigbook/issues/229
// to allow tagged and incoming links to work at all on OurBigBook Web.
// That specific usage should be removed.
//
// However, we later found another usage for it, which should not be removed:
// it is necessary to resolve absolute references like \x[/top-id] correctly to
// \x[@username/top-id] in Web.
options.ref_prefix = '';
}
if (!('render' in options)) { options.render = true; }
if (!('render_include' in options)) {
// If false, \\Include are removed from the rendered output.
// Their side effects such as determining the header tree are still used.
options.render_include = true;
}
if (!('render_metadata' in options)) {
// Render article "metadata" such as: ToC, tagged, incoming links, ancestors.
// This is notable disabled on Web, where metadata is fetched on the fly at page load.
options.render_metadata = true;
}
if (!('start_line' in options)) { options.start_line = 1; }
if (!('show_descendant_count' in options)) { options.show_descendant_count = true; }
if (!('split_headers' in options)) {
options.split_headers = false;
}
if (!('template' in options)) { options.template = undefined; }
if (!('template_scripts_relative' in options)) {
// Like template_styles_relative but for sripts.
options.template_scripts_relative = [];
}
if (!('template_styles_relative' in options)) {
// CSS styles relative to ourbigbook.json. Must be resolved by ourbigbook.convert.
// because of split headers. The relative path expanded result gets prepended
// to `options.template_vars.style`.
options.template_styles_relative = [];
}
if ('template_vars' in options) {
options.template_vars = { ...options.template_vars }
} else {
options.template_vars = {};
}
if (!('head' in options.template_vars)) { options.template_vars.head = ''; }
if (!('root_relpath' in options.template_vars)) { options.template_vars.root_relpath = ''; }
if (!('post_body' in options.template_vars)) { options.template_vars.post_body = ''; }
if (!('style' in options.template_vars)) { options.template_vars.style = ''; }
if (!(Macro.TITLE_ARGUMENT_NAME in options)) {
//options[Macro.TITLE_ARGUMENT_NAME] = undefined
}
if (!('x_absolute' in options)) {
// Make all internal links absolute from website root.
// This is the only way that we can have a single rendering that works on both
// /go/topic/<topic> and /username/<topic>. It will also remove the need for
// the ../ hack we were using to make the same links work from both index /username
// and /username/<topic>.
options.x_absolute = false;
}
if (!('tocIdPrefix' in options)) { options.tocIdPrefix = ''; }
if (!('webMode' in options)) {
// Previously we put some changes under more specific options, e.g.
// h_web_ancestors and h_web_metadata, but that was likely overgeneralization,
// let's just dump every web variant under here from now on unless there is a
// specific reason not to!
//
// This option is not named just "web" because of the future desire to merge
// ourbigbook.json directly into options, and we would like a web sub-Object
// which would conflict with this boolean.
options.webMode = false
}
if (!('x_external_prefix' in options)) {
// Used in web to offset the relative paths of issues and editor preview, e.g.
// go/issues/1/username/article
options.x_external_prefix = '';
}
if (!('x_leading_at_to_web' in options)) {
// If \x href starts with @ as in \x[@username] link to OBB Web
// https://ourbigbook.com/username instead of treating it as a regular ID.
options.x_leading_at_to_web = true
}
if (!('x_remove_leading_at' in options)) {
// If true, make \x[@username/someid] link to username/someid without the leading @.
// This is used in Web, where our URLs don't really have the @ sign on them,
// but the username IDs do have the @ sign implicitly added to them.
options.x_remove_leading_at = false;
}
// Internalish options that may get modified by sub-includes/OurBigBookExample in order
// to forward state back up. Maybe we should move them to a subdict to make this clearer
// (moving to extra_returns feels bad because they are also input), but lazy.
//
// Non-indexed-ids: auto-generated numeric ID's like p-1, p-2, etc.
// It is not possible to link to them from inside the document, since links
// break across versions.
if (!('include_path_set' in options)) { options.include_path_set = new Set(); }
if (options.non_indexed_ids === undefined) {
options.non_indexed_ids = {};
}
if (options.indexed_ids === undefined) {
options.indexed_ids = {};
}
if (options.header_tree_stack === undefined) {
options.header_tree_stack = new Map();
}
if (options.header_tree_id_stack === undefined) {
options.header_tree_id_stack = new Map();
}
if (options.is_first_global_header === undefined) {
options.is_first_global_header = true;
}
if (options.refs_to_h === undefined) {
options.refs_to_h = [];
}
if (options.refs_to_x === undefined) {
options.refs_to_x = [];
}
if (options.include_hrefs === undefined) {
options.include_hrefs = {};
}
// Handle scope and IDs that are based on the input path:
//
// - toplevel_has_scope
//
// Set for index files in subdirectories. Is equivalent to
// adding a {scope} to the toplevel header.
//
// - toplevel_parent_scope
//
// Set for files in subdirectories. Means that the (faked non existent)
// parent toplevel header has {scope} set.
//
// - toplevel_id
//
// If true, force the toplevel header to have this ID.
// Otherwise, derive the ID from the title.
// https://docs.ourbigbook.com#the-id-of-the-first-header-is-derived-from-the-filename
//
// TODO hard setting this option here is bad, maybe we should put it in context instead.
options.toplevel_id = undefined;
let root_relpath_shift
let input_dir, basename
if (options.input_path !== undefined) {
;[input_dir, basename] = pathSplit(options.input_path, options.path_sep)
const [basename_noext, ext] = pathSplitext(basename)
if (INDEX_FILE_BASENAMES_NOEXT.has(basename_noext)) {
if (input_dir === '') {
// https://docs.ourbigbook.com#the-toplevel-index-file
options.toplevel_id = undefined;
} else {
// https://docs.ourbigbook.com#the-id-of-the-first-header-is-derived-from-the-filename
options.toplevel_id = input_dir;
options.toplevel_has_scope = true
root_relpath_shift = input_dir
}
options.isindex = true
} else {
const [input_path_noext, ext] = pathSplitext(options.input_path)
options.toplevel_id = input_path_noext;
options.isindex = false
}
if (input_dir === '') {
options.toplevel_parent_scope = undefined;
} else {
options.toplevel_parent_scope = input_dir
}
} else {
input_dir = ''
}
if (root_relpath_shift === undefined) {
root_relpath_shift = ''
}
if (options.unsafe_xss === undefined) {
const unsafe_xss = ourbigbook_json['unsafe-xss'];
if (unsafe_xss !== undefined) {
options.unsafe_xss = unsafe_xss;
} else {
options.unsafe_xss = false;
}
}
extra_returns.debug_perf = {};
extra_returns.errors = [];
extra_returns.rendered_outputs = {};
const context = {
katex_macros: { ...options.katex_macros },
in_split_headers: false,
in_parse: false,
errors: [],
extra_returns,
forceHeaderHasToc: false,
include_path_set: new Set(options.include_path_set),
input_dir,
in_header: false,
macros: macroListToMacros(),
options,
// Shifts in local \a links due to either:
// - scope + split headers e.g. scope/notindex.html
// - subdirectories
root_relpath_shift,
perf_prev: 0,
// List[String]
// This HTML is added before the next header is rendered, or at the end of conversion after
// the tailing toc if there are no header following. It is then automatically cleared.
renderBeforeNextHeader: [],
skipOutputEntry: false,
// Set of all the headers that have synonym set on them.
// Originally used to generate redirects to the heade they point to.
synonym_headers: new Set(),
toc_was_rendered: false,
toplevel_id: options.toplevel_id,
// Map from each toplevel_id to a list of AstNodes with that toplevel_id.
// Updated during render if it is a map, ignored if it is undefined.
toplevel_ids: undefined,
// Output path for the current rendering.
// Gets modified by split headers to point to the split header output path of each split header.
toplevel_output_path: options.outfile,
// undefined: follow defaults set otherwise
// true: force to split headers, e.g. split links
// false: force to nosplit headers, e.g. nosplit links
to_split_headers: undefined,
webUrl: `https://${ourbigbook_json.web.host}/`,
}
perfPrint(context, 'start_convert')
return context;
}
exports.convertInitContext = convertInitContext
/** Similar to convertXHref, used for external callers that don't have the context.
* TODO: possibly broken, was returning empty at some point. */
function renderAstFromOpts(astJson, options) {
const context = convertInitContext(options);
context.db_provider = options.db_provider;
return AstNode.fromJSON(astJson, context).render(context)
}
exports.renderAstFromOpts = renderAstFromOpts
/* Like xHref, but called with options as convert,
* so that we don't have to fake a complex context. */
function convertXHref(target_id, options) {
const context = convertInitContext(options)
context.db_provider = options.db_provider
const target_ast = context.db_provider.get(target_id, context)
if (target_ast === undefined) {
return undefined
} else {
return xHref(target_ast, context)
}
}
exports.convertXHref = convertXHref
function dirname(str, sep) {
return pathSplit(str, sep)[0]
}
function duplicateIdErrorMessage(id, path, line, column) {
let message = `duplicate ID "${id}", previous one defined at `;
if (path !== undefined) {
message += `file "${path}" `;
}
message += `line ${line} column ${column}`;
return message
}
exports.duplicateIdErrorMessage = duplicateIdErrorMessage
/** Error message to be rendered inside the generated output itself.
*
* If context is given, escape the message correctly for this context.
*
* @return {String}
*/
function errorMessageInOutput(msg, context) {
let escaped_msg;
if (context === undefined) {
escaped_msg = msg;
} else {
escaped_msg = htmlEscapeContext(context, msg);
}
return `[OURBIGBOOK_ERROR: ${escaped_msg}]`
}
/** Escape all ourbigbook constructs that must be escaped, except
* for those that only need to be escaped if they are at the start of a line. */
function escapeNotStart(text) {
return text.replace(MUST_ESCAPE_CHARS_REGEX_CHAR_CLASS_REGEX, `${ESCAPE_CHAR}$1`)
}
exports.escapeNotStart = escapeNotStart
// https://stackoverflow.com/questions/9461621/format-a-number-as-2-5k-if-a-thousand-or-more-otherwise-900
const FORMAT_NUMBER_APPROX_MAP = [
{ value: 1, symbol: "" },
{ value: 1E3, symbol: "k" },
{ value: 1E6, symbol: "M" },
{ value: 1E9, symbol: "G" },
{ value: 1E12, symbol: "T" },
{ value: 1E15, symbol: "P" },
{ value: 1E18, symbol: "E" }
];
function formatNumberApprox(num, digits) {
if (digits === undefined) {
digits = 0;
}
const rx = /\.0+$|(\.[0-9]*[1-9])0+$/;
let i;
for (i = FORMAT_NUMBER_APPROX_MAP.length - 1; i > 0; i--) {
if (num >= FORMAT_NUMBER_APPROX_MAP[i].value) {
break;
}
}
return (num / FORMAT_NUMBER_APPROX_MAP[i].value).toFixed(
digits).replace(rx, "$1") + FORMAT_NUMBER_APPROX_MAP[i].symbol;
}
exports.formatNumberApprox = formatNumberApprox
// Get all possible IDs due to walking up scope resolution
// into the given ids array.
//
// This very slightly duplicates the resolution code in DbProvider.get,
// but it was not trivial to factor them out, so just going for this now.
function getAllPossibleScopeResolutions(current_scope, id, context) {
let ids = []
if (isAbsoluteXref(id, context)) {
ids.push(resolveAbsoluteXref(id, context))
} else {
if (current_scope !== undefined) {
current_scope += Macro.HEADER_SCOPE_SEPARATOR
for (let i = current_scope.length - 1; i > 0; i--) {
if (current_scope[i] === Macro.HEADER_SCOPE_SEPARATOR) {
ids.push(current_scope.substring(0, i + 1) + id)
}
}
}
ids.push(id)
}
return ids
}
function getDescendantCount(tree_node) {
return [
tree_node.descendant_count,
tree_node.word_count,
tree_node.word_count + tree_node.descendant_word_count
];
}
function getDescendantCountHtml(context, tree_node, options) {
if (!context.options.show_descendant_count) {
return undefined
}
if (options.long_style === undefined) {
options.long_style = false;
}
if (options.show_descendant_count === undefined) {
options.show_descendant_count = true;
}
const [descendant_count, word_count, descendant_word_count] = getDescendantCount(tree_node);
let ret;
let word_count_html = ''
function addEntry(cls, long_style, longName, add_test_instrumentation, clsInstr, count) {
// Ideally this would go in a before/after content, but we can't because:
// - we already have one before for the icon
// - can't have multiple befores with different styles: https://stackoverflow.com/questions/11998593/can-i-have-multiple-before-pseudo-elements-for-the-same-element
// - can't be an after because the count comes after
const longStr = long_style ? `${longName}: ` : ''
// Word Count Recursive.
word_count_html += `<span class="${cls}"> ${longStr}`
if (add_test_instrumentation) {
word_count_html += `<span class="${clsInstr}">`
}
word_count_html += `${formatNumberApprox(count)}`
if (add_test_instrumentation) {
word_count_html += '</span>'
}
word_count_html += '</span>'
}
if (descendant_word_count > 0 && (context.options.add_test_instrumentation || options.show_descendant_count)) {
addEntry('wcntr', options.long_style, 'words', context.options.add_test_instrumentation, 'word-count-descendant', descendant_word_count)
}
if (tree_node.word_count > 0 && context.options.add_test_instrumentation || !options.show_descendant_count) {
addEntry('wcnt', options.long_style, 'words', context.options.add_test_instrumentation, 'word-count', word_count)
}
if (descendant_count > 0 && (context.options.add_test_instrumentation || options.show_descendant_count)) {
addEntry('dcnt', options.long_style, 'articles', context.options.add_test_instrumentation, 'descendant-count', descendant_count)
}
if (word_count_html !== '') {
ret = `<span class="metrics">${word_count_html}</span>`;
}
return ret;
}
/**
*
* @return - inputDirectory: input directory with prefix _file removed if present
*/
function checkAndUpdateLocalLink({
context,
href,
external,
media_provider_type,
source_location,
}) {
const was_protocol_given = protocolIsGiven(href)
let inputDirectory
const input_path = context.options.input_path
if (input_path !== undefined) {
inputDirectory = dirname(
input_path,
context.options.path_sep
)
} else {
inputDirectory = '.'
}
const is_absolute = href[0] === URL_SEP
const is_external = (external !== undefined && external) || (
external === undefined && was_protocol_given
)
// Check existence.
let error = ''
if (!is_external) {
if (href.length !== 0) {
let check_path;
if (is_absolute) {
check_path = href.slice(1)
} else {
check_path = path.join(inputDirectory, href)
}
if (
context.options.fs_exists_sync &&
!context.options.fs_exists_sync(check_path)
) {
error = `link to inexistent local file: ${href}`;
renderError(context, error, source_location);
error = errorMessageInOutput(error, context)
} else {
const readFileRet = context.options.read_file(check_path, context)
if (
// Fails on web before we implement files on web.
readFileRet !== undefined
) {
const { type } = context.options.read_file(check_path, context)
if (type === 'directory') {
if (context.options.htmlXExtension) {
href = path.join(href, 'index.html')
}
}
// Modify external paths to account for scope + --split-headers
let pref = context.root_relpath_shift
if (media_provider_type === 'local') {
if (type === 'directory') {
pref = path.join(pref, DIR_PREFIX)
} else {
pref = path.join(pref, RAW_PREFIX)
}
}
if (!is_absolute) {
pref = path.join(pref, inputDirectory)
}
href = path.join(pref, href)
}
}
}
}
return { href, error, inputDirectory }
}
// Get description and other closely related attributes.
function getDescription(description_arg, context) {
let description = renderArg(description_arg, context);
let force_separator
if (description === '') {
force_separator = false
} else {
force_separator = true;
}
let multiline_caption
if (description_arg) {
for (const ast of description_arg) {
if (!(
context.macros[ast.macro_name].options.phrasing ||
ast.node_type === AstType.PLAINTEXT
)) {
multiline_caption = ' multiline-caption'
break
}
}
}
if (multiline_caption === undefined) {
multiline_caption = ''
}
return { description, force_separator, multiline_caption }
}
function getLinkHtml({
ast,
attrs,
content,
context,
external,
href,
source_location,
extraReturns,
}) {
if (extraReturns === undefined) {
extraReturns = {}
}
if (context.x_parents.size === 0) {
if (attrs === undefined) {
attrs = ''
}
let error
Object.assign(extraReturns, checkAndUpdateLocalLink({
context,
external,
href,
// The only one available for now. One day we could add: \a[some/path]{provider=github}
media_provider_type: 'local',
source_location,
}))
;({ href, error } = extraReturns)
let testData
if (ast) {
testData = getTestData(ast, context)
} else {
testData = ''
}
return `<a${htmlAttr('href', href)}${attrs}${testData}>${content}${error}</a>`;
} else {
// Don't create a link if we are a child of another link, as that is invalid HTML.
return content;
}
}
/** Get the AST from the parent argument of headers or includes. */
function getParentArgumentAst(ast, context, include_options) {
let parent_id;
let parent_ast;
parent_id = magicTitleToId(convertIdArg(ast.args.parent, context), context);
if (isAbsoluteXref(parent_id, context)) {
parent_ast = context.db_provider.get_noscope(resolveAbsoluteXref(parent_id, context), context);
} else {
// We can't use context.db_provider.get here because we don't know who
// the parent node is, because scope can affect that choice.
// https://docs.ourbigbook.com#id-based-header-levels-and-scope-resolution
let sorted_keys = [...include_options.header_tree_stack.keys()].sort((a, b) => a - b);
let largest_level = sorted_keys[sorted_keys.length - 1];
for (let level = largest_level; level > 0; level--) {
let ast = include_options.header_tree_stack.get(level).ast;
if (idIsSuffix(parent_id, ast.id)) {
parent_ast = ast;
break;
}
}
}
return [parent_id, parent_ast];
}
function getRootRelpath(output_path, context) {
// TODO htmlEmbed was split into embedIncludes and embedResources.
// This was likely meant to be embedIncludes, but I don't have a filing test if this is commented out
// so not sure.
const [output_path_dir, output_path_basename] =
pathSplit(output_path, context.options.path_sep);
let root_relpath = path.relative(output_path_dir, '.')
if (root_relpath !== '') {
root_relpath += URL_SEP;
}
return root_relpath
}
function getTestData(ast, context) {
let test_data_arg = ast.args[Macro.TEST_DATA_ARGUMENT_NAME]
if (test_data_arg === undefined) {
return ''
} else {
return htmlAttr(Macro.TEST_DATA_HTML_PROP, renderArg(test_data_arg, context))
}
}
function getTitleAndDescription({ title, description, source, inner }) {
let sep
if (inner === undefined || isPunctuation(inner[inner.length - 1])) {
sep = ''
} else {
sep = '.'
}
if (source === undefined) {
source = ''
}
if (source && inner !== undefined) {
source = ' ' + source
}
if (inner !== undefined || source !== '') {
description = ' ' + description
}
return `${title}${sep}${source}${description}`
}
function githubProviderPrefix(context) {
const github = context.options.ourbigbook_json['media-providers'].github
if (github) {
return `https://raw.githubusercontent.com/${github.remote}/master`;
}
}
function checkHasToc(context) {
let root_node = context.header_tree;
if (root_node.children.length === 1) {
root_node = root_node.children[0];
}
return root_node.children.length > 0
}
// Ensure that all children and tag targets exist. This is for error checking only.
// https://docs.ourbigbook.com#h-child-argment
function headerCheckChildTagExists(ast, context, childrenOrTags, type) {
let ret = ''
for (let child of childrenOrTags) {
const target_id = magicTitleArgToId(child.args.content, context)
const target_ast = context.db_provider.get(target_id, context, ast.header_tree_node.ast.scope)
if (target_ast === undefined) {
let message = `unknown ${type} id: "${target_id}"`
renderError(context, message, child.source_location)
ret += errorMessageInOutput(message, context)
}
}
return ret
}
/** Make ancestor links for HTML heaader breadcrumb metadata line.
*
* @param {{href: string, content: string}[]} entries
* @return {string}
* */
function htmlAncestorLinks(entries, nAncestors) {
const ret = []
let i = 0
if (nAncestors > ANCESTORS_MAX) {
ret.push(`<a ${htmlAttr('href', `#${ANCESTORS_ID}`)}}> ...</a>`)
i++
}
for (const entry of entries) {
ret.push(`<a${entry.href}> ${entry.content}</a>`)
i++
}
return ret.join('')
}
exports.htmlAncestorLinks = htmlAncestorLinks
/** Convert a key value already fully HTML escaped strings
* to an HTML attribute. The callers MUST escape any untrusted chars.
* e.g. with htmlAttrValue.
*
* @param {String} key
* @param {AstArgument} arg
* @return {String} - of form ' a="b"' (with a leading space)
*/
function htmlAttr(key, value) {
return ` ${key}="${value}"`;
}
/** Convert an argument to an HTML attribute value.
*
* @param {AstArgument} arg
* @param {Object} context
* @return {String}
*/
function htmlAttrValue(arg, context) {
return renderArg(arg, cloneAndSet(context, 'html_is_attr', true));
}
function htmlClassAttr(classes) {
return htmlAttr('class', classes.join(' '))
}
function htmlCode(content, attrs) {
return htmlElem('pre', htmlElem('code', content), attrs);
}
/** Helper to convert multiple parameters directly to HTML attributes.
*
* The ID is automatically included.
*
* @param {AstNode} ast
* @param {Object} options
* @param {Array[String]} arg_names - which argument names should be added as properties.
* Only arguments that were given in the text input are used.
* @param {Object[String, AstNode]} custom_args - attributes that were not just passed in
* directly from the input text, but may rather have been calculated from the node.
*/
function htmlRenderAttrs(
ast, context, arg_names=[], custom_args={}
) {
// Determine the arguments.
let args = [];
for (const arg_name in custom_args) {
args.push([arg_name, custom_args[arg_name]]);
}
for (const arg_name of arg_names) {
if (arg_name in ast.args) {
args.push([arg_name, ast.args[arg_name]]);
}
}
// Build the output string.
let ret = '';
for (const name_arg_pair of args) {
const [arg_name, arg] = name_arg_pair;
ret += htmlAttr(arg_name, htmlAttrValue(arg, context));
}
return ret;
}
/**
* Same interface as htmlRenderAttrs, but automatically add the ID to the list
* of arguments.
*/
function htmlRenderAttrsId(
ast, context, arg_names=[], custom_args={}
) {
let id = ast.id;
if (id) {
custom_args[Macro.ID_ARGUMENT_NAME] = [
new PlaintextAstNode(
removeToplevelScope(id, context.toplevel_ast, context),
ast.source_location,
),
];
}
return htmlRenderAttrs(ast, context, arg_names, custom_args);
}
/** Helper for the most common HTML function type that does "nothing magic":
* only has "id" as a possible attribute, and uses ast.args.content as the
* main element child.
*/
function htmlRenderSimpleElem(elem_name, options={}) {
if (!('attrs' in options)) {
options.attrs = {};
}
if (!('wrap' in options)) {
options.wrap = false;
}
return function(ast, context) {
let attrs = htmlRenderAttrsId(ast, context);
let extra_attrs_string = '';
for (const key in options.attrs) {
extra_attrs_string += htmlAttr(key, options.attrs[key]);
}
let content = renderArg(ast.args.content, context);
let res = ''
const show_caption = ast.index_id || (ast.validation_output.description && ast.validation_output.description.given)
let elem_attrs
if (show_caption) {
const { description, force_separator, multiline_caption } = getDescription(ast.args.description, context)
const { full: title, inner } = xTextBase(ast, context, {
href_prefix: htmlSelfLink(ast, context),
force_separator
})
const title_and_description = getTitleAndDescription({ title, description, inner })
res += `<div${multiline_caption ? ` class="${multiline_caption.substring(1)}"` : ''}${attrs}>`;
res += `<div class="caption">${title_and_description}</div>`;
elem_attrs = ''
} else {
elem_attrs = `${extra_attrs_string}${attrs}`
}
res += `<${elem_name}${elem_attrs}${getTestData(ast, context)}>${content}</${elem_name}>`;
if (show_caption) {
res += `</div>`;
}
if (options.wrap) {
res = htmlElem('div', res);
}
return res;
};
}
function htmlElem(tag, content, attrs) {
let ret = '<' + tag;
for (const attr_id in attrs) {
ret += ' ' + attr_id + '="' + htmlEscapeAttr(attrs[attr_id]) + '"'
}
return ret + '>' + content + '</' + tag + '>';
}
function htmlEscapeAttr(str) {
return htmlEscapeContent(str)
.replace(/"/g, '"')
.replace(/'/g, ''')
;
}
function htmlEscapeContent(str) {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
;
}
exports.htmlEscapeContent = htmlEscapeContent
/** Escape string depending on the current context. */
function htmlEscapeContext(context, str) {
if (context.html_escape) {
if (context.html_is_attr) {
return htmlEscapeAttr(str);
} else {
return htmlEscapeContent(str);
}
} else {
return str;
}
}
function htmlImg({
alt,
ast,
context,
external,
inline,
media_provider_type,
rendered_attrs,
relpath_prefix,
src,
}) {
let error
;({
href: src,
error
} = checkAndUpdateLocalLink({
context,
external,
href: src,
media_provider_type,
source_location: ast.args.src.source_location,
}))
let border_attr
if (ast.validation_output.border.boolean) {
border_attr = htmlAttr('class', 'border')
} else {
border_attr = ''
}
if (relpath_prefix !== undefined) {
src = path.join(relpath_prefix, src)
}
const href = ast.validation_output.link.given ? renderArg(ast.args.link, context) : src
let html = `<a${htmlAttr('href', href)}><img${htmlAttr('src', htmlEscapeAttr(src))}${htmlAttr('loading', 'lazy')}${rendered_attrs}${alt}${border_attr}></a>`;
if (!inline) {
html = `<div class="float-wrap">${html}</div>`
}
return {
html: `${html}${error}`,
src,
};
}
function htmlIsWhitespaceTextNode(ast) {
return ast.node_type === AstType.PLAINTEXT && htmlIsWhitespace(ast.text);
}
// https://stackoverflow.com/questions/2161337/can-we-use-any-other-tag-inside-ul-along-with-li/60885802#60885802
function htmlIsWhitespace(string) {
for (const c of string) {
if (!HTML_ASCII_WHITESPACE.has(c))
return false;
}
return true;
}
function htmlKatexConvert(ast, context) {
let katex_in = renderArg(ast.args.content, cloneAndSet(context, 'html_escape', false))
if (!katex_in.endsWith('\n')) {
katex_in += '\n'
}
try {
return katex.renderToString(
katex_in,
{
globalGroup: true,
macros: context.katex_macros,
// The default is to also add MathML output for blind people.
// However, it adds it with absolute positioning for some reason.
// And as a result, if you add a math formula to the bottom of the editor,
// it generates a toplevel scrollbar on Chromium 84 but not Firefox 79.
output: 'html',
strict: 'error',
throwOnError: true,
}
);
} catch(error) {
// TODO remove the crap KaTeX adds to the end of the string.
// It uses Unicode char hacks to add underlines... and there are two trailing
// chars after the final newline, so the error message is taking up two lines
let message = error.toString().replace(/\n\xcc\xb2$/, '');
renderError(context, message, ast.args.content.source_location);
return errorMessageInOutput(message, context);
}
}
function htmlSelfLink(ast, context) {
return xHrefAttr(
ast,
cloneAndSet(context, 'to_split_headers', context.in_split_headers)
);
}
function htmlTitleAndDescription(ast, context) {
let title_and_description = ``
let { description, force_separator, multiline_caption } = getDescription(ast.args.description, context)
let href
if (ast.id) {
href = htmlSelfLink(ast, context)
} else {
href = ''
}
if (ast.index_id || ast.validation_output.description.given) {
const { full: title, inner } = xTextBase(ast, context, {
href_prefix: href,
force_separator
})
title_and_description += `<div class="caption">${getTitleAndDescription({ title, description, inner })}</div>`
}
return { title_and_description, multiline_caption, href }
}
/** https://stackoverflow.com/questions/14313183/javascript-regex-how-do-i-check-if-the-string-is-ascii-only/14313213#14313213 */
function isAscii(str) {
return /^[\x00-\x7F]*$/.test(str);
}
function idConvertSimpleElem(argname) {
if (argname === undefined) {
argname = 'content'
}
return function(ast, context) {
let ret = renderArg(ast.args[argname], context);
if (!context.macros[ast.macro_name].options.phrasing) {
ret += '\n';
}
return ret;
};
}
/** bb, aaa/bbb -> false
* bbb, aaa/bbb -> true
* aaa/bbb, aaa/bbb -> true
*/
function idIsSuffix(suffix, full) {
let full_len = full.length;
let suffix_len = suffix.length;
return (
full.endsWith(suffix) &&
(
full_len == suffix_len ||
(
full_len > suffix_len &&
full[full_len - suffix_len - 1] === Macro.HEADER_SCOPE_SEPARATOR
)
)
);
}
// @return [href: string, content: string ], both XSS safe.
function linkGetHrefContent(ast, context) {
const href = renderArg(ast.args.href, cloneAndSet(context, 'html_is_attr', true))
let content = renderArg(ast.args.content, context);
if (content === '') {
content = renderArg(ast.args.href, context);
if (!context.id_conversion) {
content = content.replace(/^https?:\/\//, '')
}
}
return [href, content];
}
// If in split header mode, link to the nosplit version.
// If in the nosplit mode, link to the split version.
function linkToSplitOpposite(ast, context) {
if (context.options.ourbigbook_json.toSplitHeaders) {
return undefined
} else {
let content
let title
let class_
if (context.in_split_headers) {
class_ = 'nosplit'
} else {
class_ = 'split'
}
let other_context = cloneAndSet(context, 'to_split_headers', !context.in_split_headers);
let other_href = xHrefAttr(ast, other_context);
if (
// I'm not going to lie, I bruteforced this. Sue me.
context.options.ourbigbook_json.h.splitDefaultNotToplevel &&
(
context.options.ourbigbook_json.h.splitDefault ||
!context.in_split_headers
)
) {
// This is dirty. But I am dirty.
// But seriously, checking this more cleanly would require
// unpacking a bunch of stuff down below from the toplevel scope removal.
// Related: https://github.com/ourbigbook/ourbigbook/issues/271
const other_href_same = xHrefAttr(ast, context);
if (other_href === other_href_same) {
return undefined
}
}
return `<a${htmlAttr('class', class_)}${other_href}></a>`;
}
}
/**
* @return {Object} dict of macro name to macro
*/
function macroListToMacros() {
const macros = {};
for (const macro of macroList()) {
for (const format_id in OUTPUT_FORMATS) {
const convert_func = OUTPUT_FORMATS[format_id].convert_funcs[macro.name]
if (convert_func === undefined) {
throw new Error(`undefined convert function for format "${format_id}" macro "${macro.name}"`)
}
macro.add_convert_function(format_id, convert_func, macro.name);
}
macros[macro.name] = macro;
}
return macros;
}
/** At some point we will generalize this to on-the-fly macro definitions. */
function macroList() {
return DEFAULT_MACRO_LIST;
}
exports.macroList = macroList
const OURBIGBOOK_EXT = 'bigb';
exports.OURBIGBOOK_EXT = OURBIGBOOK_EXT;
const MEDIA_PROVIDER_TYPES = new Set([
'github',
'local',
'unknown',
'wikimedia',
'youtube',
]);
const media_provider_type_wikimedia_re = new RegExp('^https?://upload.wikimedia.org/wikipedia/commons/');
const media_provider_type_youtube_re = new RegExp('^https?://(www\.)?(youtube.com|youtu.be)/');
const macro_image_video_block_convert_function_wikimedia_source_url = 'https://commons.wikimedia.org/wiki/File:';
const macro_image_video_block_convert_function_wikimedia_source_image_re = new RegExp('^\\d+px-');
const macro_image_video_block_convert_function_wikimedia_source_video_re = new RegExp('^([^.]+\.[^.]+).*');
function macroImageVideoBlockConvertFunction(ast, context) {
let rendered_attrs = htmlRenderAttrs(ast, context, ['height', 'width']);
let figure_attrs = htmlRenderAttrsId(ast, context);
let { description, force_separator, multiline_caption } = getDescription(ast.args.description, context)
let figure_class
if (multiline_caption) {
figure_class = htmlAttr('class', multiline_caption.slice(1))
} else {
figure_class = ''
}
let ret = `<figure${figure_attrs}${figure_class}>`
let href_prefix;
if (ast.id !== undefined) {
href_prefix = htmlSelfLink(ast, context);
} else {
href_prefix = undefined;
}
let {
error_message,
media_provider_type,
relpath_prefix,
source,
src,
is_url
} = macroImageVideoResolveParamsWithSource(ast, context);
if (error_message !== undefined) {
return error_message;
}
if (source !== '') {
force_separator = true;
source = `<a${htmlAttr('href', source)}>Source</a>.`;
}
let alt_val;
const has_caption = (ast.id !== undefined) && captionNumberVisible(ast, context);
if (ast.args.alt === undefined) {
if (has_caption) {
alt_val = undefined;
} else {
alt_val = src;
}
} else {
alt_val = renderArg(ast.args.alt, context);
}
let alt;
if (alt_val === undefined) {
alt = '';
} else {
alt = htmlAttr('alt', htmlEscapeAttr(alt_val));
}
ret += context.macros[ast.macro_name].options.image_video_content_func({
alt,
ast,
context,
is_url,
media_provider_type,
relpath_prefix,
rendered_attrs,
src,
});
if (has_caption) {
const { full: title, inner } = xTextBase(ast, context, { href_prefix, force_separator })
const title_and_description = getTitleAndDescription({ title, description, source, inner })
ret += `<figcaption>${title_and_description}</figcaption>`;
}
ret += '</figure>';
return ret;
}
/** Convert args such as tag= or \x[]{magic} to their final target ID. */
function magicTitleArgToId(arg, context) {
const id_context = cloneAndSet(context, 'id_conversion', true)
return magicTitleToId(renderArgNoescape(arg, id_context), id_context)
}
/**
* @param string target_id
*/
function magicTitleToId(target_id, context) {
if (target_id.startsWith(Macro.FILE_ID_PREFIX))
return target_id
let ret = titleToId(target_id, { keep_scope_sep: true, magic: true }, context)
if (target_id[0] === AT_MENTION_CHAR) {
ret = AT_MENTION_CHAR + ret
}
return ret
}
// https://stackoverflow.com/questions/44447847/enums-in-javascript-with-es6/49709701#49709701
function makeEnum(arr) {
let obj = {};
for (let val of arr){
obj[val] = Symbol(val);
}
return Object.freeze(obj);
}
function noext(str) {
return str.substring(0, str.lastIndexOf('.'));
}
const GREEK_MAP = {
'\u{03b1}': 'alpha',
'\u{0391}': 'Alpha',
'\u{03b2}': 'beta',
'\u{0392}': 'Beta',
'\u{03b3}': 'gamma',
'\u{0393}': 'Gamma',
'\u{03b4}': 'delta',
'\u{0394}': 'Delta',
'\u{03b5}': 'epsilon',
'\u{0395}': 'Epsilon',
'\u{03b6}': 'zeta',
'\u{0396}': 'Zeta',
'\u{03b7}': 'eta',
'\u{0397}': 'Eta',
'\u{03b8}': 'theta',
'\u{0398}': 'Theta',
'\u{03b9}': 'iota',
'\u{0399}': 'Iota',
'\u{03ba}': 'kappa',
'\u{039a}': 'Kappa',
'\u{03bb}': 'lambda',
'\u{039b}': 'Lambda',
'\u{03bc}': 'mu',
'\u{039c}': 'Mu',
'\u{03bd}': 'nu',
'\u{039d}': 'Nu',
'\u{03be}': 'xi',
'\u{039e}': 'Xi',
'\u{03bf}': 'omicron',
'\u{039f}': 'Omicron',
'\u{03c0}': 'pi',
'\u{03a0}': 'Pi',
'\u{03c1}': 'rho',
'\u{03a1}': 'Rho',
'\u{03c3}': 'sigma',
'\u{03a3}': 'Sigma',
'\u{03c4}': 'tau',
'\u{03a4}': 'Tau',
'\u{03c5}': 'upsilon',
'\u{03a5}': 'Upsilon',
'\u{03c6}': 'phi',
'\u{03a6}': 'Phi',
'\u{03c7}': 'chi',
'\u{03a7}': 'Chi',
'\u{03c8}': 'psi',
'\u{03a8}': 'Psi',
'\u{03c9}': 'omega',
'\u{03a9}': 'Omega',
}
// https://docs.ourbigbook.com#ascii-normalization
function normalizeLatinCharacter(c) {
c = lodash.deburr(c)
if (c in GREEK_MAP) {
return ID_SEPARATOR + GREEK_MAP[c] + ID_SEPARATOR
}
switch(c) {
// en-dash
case '\u{2013}':
// em-dash
case '\u{2014}':
return ID_SEPARATOR
}
return c
}
const NORMALIZE_PUNCTUATION_CHARACTER_MAP = {
'%': 'percent',
'&': 'and',
'+': 'plus',
'@': 'at',
'\u{2212}': 'minus',
}
function normalizePunctuationCharacter(c) {
if (c in NORMALIZE_PUNCTUATION_CHARACTER_MAP) {
return ID_SEPARATOR + NORMALIZE_PUNCTUATION_CHARACTER_MAP[c] + ID_SEPARATOR
} else {
return c
}
}
// https://stackoverflow.com/questions/17781472/how-to-get-a-subset-of-a-javascript-objects-properties/17781518#17781518
function objectSubset(source_object, keys) {
const new_object = {};
keys.forEach((obj, key) => { new_object[key] = source_object[key]; });
return new_object;
}
/* Calculate the output path given a billion parameters.
*
* This is the centerpiece of output path calculation. It is notably used in both:
* - calculating the output paths for a given input
* - calculating where \x links should point
* Since the exact same function is used for both, this guarantees that \x links always
* point to the correct file.
*
* We keep only simple types as inputs to this function (e.g. strings, and notably no AstNode and context)
* to this function so that it can be unit tested, or at least to make it clearer to us what the exact input is.
*
* Countless hours have been wasted writing and debugging this function. It is extremelly hard.
*
* Some of the things this function considers include:
* * README.bigb -> index.bigb renaming
* * split header stuff
**/
function outputPathBase(args={}) {
let {
ast_undefined,
ast_id,
ast_input_path,
ast_is_first_header_in_input_file,
ast_split_default,
ast_toplevel_id,
context_to_split_headers,
ast_input_path_toplevel_id,
path_sep,
splitDefaultNotToplevel,
split_suffix,
toSplitHeadersOverride,
} = args
if (ast_input_path === undefined) {
return undefined
}
const [dirname, basename] = pathSplit(ast_input_path, path_sep);
let renamed_basename_noext = noext(basename)
if (ast_input_path.split(path_sep)[0] !== FILE_PREFIX) {
renamed_basename_noext = renameBasename(renamed_basename_noext)
}
// We are the first header, or something that comes before it.
if (ast_undefined) {
const [dirname_ret, basename_ret] = indexPathFromDirname(dirname, renamed_basename_noext, path_sep)
return { dirname: dirname_ret, basename: basename_ret };
}
// Calculate the base basename_ret and dirname_ret.
let dirname_ret;
let basename_ret;
const to_split_headers = isToSplitHeadersBase(ast_split_default, context_to_split_headers, toSplitHeadersOverride);
if (
ast_is_first_header_in_input_file ||
(
!to_split_headers &&
ast_input_path_toplevel_id === ast_toplevel_id
)
) {
// For toplevel elements in split header mode, we have
// to take care of index and -split suffix.
if (renamed_basename_noext === INDEX_BASENAME_NOEXT) {
// basename_ret
if (
to_split_headers === ast_split_default ||
splitDefaultNotToplevel
) {
// The name is just index.html.
basename_ret = renamed_basename_noext;
} else {
// The name is split.html or nosplit.html.
basename_ret = '';
}
} else {
basename_ret = renamed_basename_noext;
}
dirname_ret = dirname;
} else {
if (to_split_headers && ast_id !== undefined) {
// Non-toplevel elements in split header mode are simple,
// the ID just gives the output path directly.
;[dirname_ret, basename_ret] = pathSplit(ast_id, URL_SEP);
} else {
if (dirname_ret === undefined) {
if (ast_toplevel_id !== undefined) {
;[dirname_ret, basename_ret] = pathSplit(ast_toplevel_id, URL_SEP);
} else {
;[dirname_ret, basename_ret] = [dirname, renamed_basename_noext]
}
}
}
}
;[dirname_ret, basename_ret] = indexPathFromDirname(dirname_ret, basename_ret, path_sep)
// Add -split, -nosplit or custom suffixes to basename_ret.
let suffix_to_add;
let suffix_added;
if (split_suffix === undefined || split_suffix === '') {
suffix_to_add = to_split_headers ? SPLIT_MARKER_TEXT : NOSPLIT_MARKER_TEXT;
} else {
suffix_to_add = split_suffix;
}
if (
!splitDefaultNotToplevel &&
(
(
to_split_headers &&
(
// To split.html
(
ast_id === ast_toplevel_id &&
!ast_split_default
) ||
// User explcitly gave {splitSuffix}
split_suffix !== undefined
)
) ||
// User gave {splitDefault}, so we link to nosplit.
(
!to_split_headers &&
ast_split_default
)
) &&
!toSplitHeadersOverride
) {
if (basename_ret !== '') {
basename_ret += '-';
}
basename_ret += suffix_to_add;
suffix_added = suffix_to_add;
}
return { dirname: dirname_ret, basename: basename_ret, split_suffix: suffix_added };
}
exports.outputPathBase = outputPathBase;
/** Parse tokens into the AST tree.
*
* @param {Array[Token]} tokens
* @return {Object} extra_returns
* - {Array[ErrorMessage]} errors
* - {Object} ids
* @return {AstNode}
*/
async function parse(tokens, options, context, extra_returns={}) {
perfPrint(context, 'parse_start')
context.in_parse = true
extra_returns.errors = [];
let state = {
extra_returns,
i: 0,
macros: context.macros,
options: options,
token: tokens[0],
tokens: tokens,
};
// Get toplevel arguments such as {title=}, see https://docs.ourbigbook.com#toplevel
const ast_toplevel_args = parseArgumentList(
state, Macro.TOPLEVEL_MACRO_NAME, AstType.MACRO);
if ('content' in ast_toplevel_args) {
parseError(state, `the toplevel arguments cannot contain an explicit content argument`, new SourceLocation(1, 1));
}
// Inject a maybe paragraph token after those arguments.
const paragraph_token = new Token(TokenType.PARAGRAPH, state.token.source_location);
tokens.splice(state.i, 0, paragraph_token);
state.token = paragraph_token;
// Parse the main part of the document as the content
// argument toplevel argument.
const ast_toplevel_content_arg = parseArgument(
state, state.token.source_location);
// Create the toplevel macro itself.
const ast_toplevel = new AstNode(
AstType.MACRO,
Macro.TOPLEVEL_MACRO_NAME,
Object.assign(ast_toplevel_args, {'content': ast_toplevel_content_arg}),
new SourceLocation(1, 1),
);
if (state.token.type !== TokenType.INPUT_END) {
parseError(state, `unexpected tokens at the end of input`);
}
if (context.options.log['ast-pp-simple']) {
console.error('ast-pp-simple: after parse');
console.error(ast_toplevel.toString());
console.error();
}
// Ast post process pass 1
//
// Post process the AST depth first minimally to support includes.
//
// This could in theory be done in a single pass with the next one,
// but that is much more hard to implement and maintain, because we
// have to stich togetegher internal structures to maintain the header
// tree across the includer and included documents.
//
// Another possibility would be to do it in the middle of the initial parse,
// but let's not complicate that further either, shall we?
context.headers_with_include = [];
context.header_tree = new HeaderTreeNode();
perfPrint(context, 'post_process_1')
let prev_non_synonym_header_ast;
let cur_header_level;
let first_header_level;
let first_header;
let header_tree_last_level;
let toplevel_parent_arg = new AstArgument([], new SourceLocation(1, 1));
let todo_visit = [[toplevel_parent_arg, ast_toplevel]];
// IDs that are indexed: you can link to those.
const line_to_id_array = [];
context.line_to_id = function(line) {
let index = binarySearch(line_to_id_array,
[line + 1, undefined], binarySearchLineToIdArrayFn);
if (index < 0) {
index = -(index + 1)
}
index -= 1
if (index === line_to_id_array.length) {
if (line_to_id_array.length > 0) {
index = line_to_id_array.length - 1;
} else {
return undefined;
}
}
return removeToplevelScope(line_to_id_array[index][1], context.toplevel_ast, context);
};
let macro_count_global = 0
const macro_counts = {};
const macro_counts_visible = {};
const headers_from_include = {}
let cur_header_tree_node;
let is_first_header = true;
extra_returns.ids = options.indexed_ids;
// Format:
// refs_to[false][to_id][type]{from_id: { defined_at: Set[defined_at], child_index: Number }
// refs_to[to][from_id][type]{to_id: { defined_at: Set[defined_at], child_index: Number }
context.refs_to = {
false: {},
true: {},
};
let local_db_provider = new DictDbProvider(
options.indexed_ids,
context.refs_to,
);
let db_provider;
if (options.db_provider !== undefined) {
db_provider = new ChainedDbProvider(
local_db_provider,
options.db_provider
);
} else {
db_provider = local_db_provider;
}
context.db_provider = db_provider;
options.include_path_set.add(options.input_path);
const title_ast_ancestors = []
const header_title_ast_ancestors = []
const header_ids = []
while (todo_visit.length > 0) {
const pop = todo_visit.pop();
if (pop === 'pop_title_ast_ancestors') {
title_ast_ancestors.pop()
continue
}
if (pop === 'pop_header_title_ast_ancestors') {
header_title_ast_ancestors.pop()
continue
}
const [parent_arg, ast] = pop
if (parent_arg.argument_name === Macro.TITLE_ARGUMENT_NAME) {
const parent_ast = parent_arg.parent_ast
title_ast_ancestors.push(parent_ast)
todo_visit.push('pop_title_ast_ancestors')
if (parent_ast.macro_name === Macro.HEADER_MACRO_NAME) {
header_title_ast_ancestors.push(parent_ast)
todo_visit.push('pop_header_title_ast_ancestors')
}
}
let parent_arg_push_after = []
let parent_arg_push_before = []
const macro_name = ast.macro_name;
ast.from_include = options.from_include;
ast.from_ourbigbook_example = options.from_ourbigbook_example;
ast.source_location.path = options.input_path;
if (macro_name === Macro.INCLUDE_MACRO_NAME) {
if (options.forbid_include) {
const error_ast = new PlaintextAstNode(options.forbid_include, ast.source_location);
error_ast.parent_ast = ast.parent_ast;
parent_arg.push(error_ast);
parseError(state, options.forbid_include, ast.source_location);
} else {
const href = renderArgNoescape(ast.args.href, context);
let input_dir, input_basename
if (options.input_path) {
;[input_dir, input_basename] = pathSplit(options.input_path, options.path_sep)
} else {
input_dir = '.'
}
// \Include parent argument handling.
let parent_ast;
let parent_id;
validateAst(ast, context);
if (ast.validation_output.parent.given) {
[parent_id, parent_ast] = getParentArgumentAst(ast, context, options)
if (parent_ast === undefined) {
const message = Macro.INCLUDE_MACRO_NAME + ' ' + HEADER_PARENT_ERROR_MESSAGE + parent_id;
const error_ast = new PlaintextAstNode(' ' + errorMessageInOutput(message), ast.source_location);
error_ast.parent_ast = ast.parent_ast;
parent_arg.push(error_ast);
parseError(state, message, ast.args.parent.source_location);
}
}
if (parent_ast === undefined) {
parent_ast = options.cur_header;
}
let parent_ast_header_level
let parent_ast_header_tree_node
let include_id = href
if (options.cur_header && options.cur_header.scope) {
include_id = options.cur_header.scope + Macro.HEADER_SCOPE_SEPARATOR + include_id
}
if (parent_ast === undefined) {
parent_ast_header_level = 0
parent_ast_header_tree_node = context.header_tree
} else {
// Possible on include without a parent header.
parent_ast_header_tree_node = parent_ast.header_tree_node;
parent_ast_header_level = parent_ast_header_tree_node.get_level();
addToRefsTo(
include_id,
context,
parent_ast.id,
REFS_TABLE_PARENT,
{
child_index: parent_ast_header_tree_node.children.length,
source_location: ast.source_location,
},
);
parent_ast.includes.push(href);
}
const peek_ast = todo_visit[todo_visit.length - 1][1];
if (peek_ast.node_type === AstType.PLAINTEXT && peek_ast.text === '\n') {
todo_visit.pop();
}
// https://github.com/ourbigbook/ourbigbook/issues/215
const read_include_ret = await (options.read_include(href, input_dir));
if (read_include_ret === undefined) {
if (
// On the local filesystem, this doesn't matter.
// But on the server it does, as we don't know about the other includes
// before they are processed.
context.options.render
) {
let message = `could not find include: "${href}"`;
parseError(
state,
message,
ast.source_location,
);
parent_arg.push(new PlaintextAstNode(message, ast.source_location));
}
} else {
const [include_path, include_content] = read_include_ret;
if (options.include_path_set.has(include_path)) {
let message = `circular include detected to: "${include_path}"`;
parseError(
state,
message,
ast.source_location,
);
parent_arg.push(new PlaintextAstNode(message, ast.source_location));
} else {
let new_child_nodes;
if (options.embed_includes) {
new_child_nodes = await parseInclude(
include_content,
options,
parent_ast_header_level,
include_path,
href,
{
errors: extra_returns.errors,
}
);
options.include_path_set.add(include_path);
} else {
const from_include = true
// Don't merge into a single file, render as a dummy header and an xref link instead.
const header_ast = new AstNode(
AstType.MACRO,
Macro.HEADER_MACRO_NAME,
{
'level': new AstArgument(
[
new PlaintextAstNode(
(parent_ast_header_level + 1).toString(),
)
],
),
[Macro.TITLE_ARGUMENT_NAME]: new AstArgument( [
// Will be patched in later in order to group all DB queries at the end of parse,
// as this requires getting an ID from DB.
new PlaintextAstNode('TODO patchme')
],
),
},
undefined,
{
force_no_index: true,
from_include,
id: include_id,
level: parent_ast_header_level + 1,
},
);
options.include_hrefs[include_id] = header_ast
headers_from_include[include_id] = header_ast
if (options.cur_header !== undefined) {
header_ast.scope = options.cur_header.scope
}
header_ast.header_tree_node = new HeaderTreeNode(header_ast, parent_ast_header_tree_node);
parent_ast_header_tree_node.add_child(header_ast.header_tree_node);
new_child_nodes = [
header_ast,
new AstNode(
AstType.PARAGRAPH,
),
new AstNode(
AstType.MACRO,
Macro.PARAGRAPH_MACRO_NAME,
{
'content': new AstArgument(
[
new AstNode(
AstType.MACRO,
Macro.X_MACRO_NAME,
{
'href': new AstArgument(
[
new PlaintextAstNode(href)
],
),
'content': new AstArgument(
[
new PlaintextAstNode(
'This section is present in another page, follow this link to view it.',
)
],
),
},
undefined,
),
],
),
},
undefined,
),
new AstNode(AstType.PARAGRAPH),
];
for (const child_node of new_child_nodes) {
child_node.set_source_location(ast.source_location)
child_node.set_recursively({
count_words: false,
from_include,
})
}
}
if (options.output_format === OUTPUT_FORMAT_OURBIGBOOK) {
if (options.render_include) {
parent_arg.push(ast)
}
} else {
// Push all included nodes, but don't recurse because:
// - all child includes will be resolved on the sub-render call
// - the current header level must not move, so that consecutive \Include
// calls won't nest into one another
for (const new_child_node of new_child_nodes) {
new_child_node.parent_ast = ast.parent_ast;
}
parent_arg.push(...new_child_nodes);
}
}
}
}
} else if (macro_name === Macro.OURBIGBOOK_EXAMPLE_MACRO_NAME) {
if (options.output_format === OUTPUT_FORMAT_OURBIGBOOK) {
parent_arg.push(ast)
}
// We need to add these asts on OUTPUT_FORMAT_OURBIGBOOK so that we can extract their IDs later on.
// We then just don't render them at render time.
const new_asts = [
new AstNode(
AstType.MACRO,
Macro.CODE_MACRO_NAME.toUpperCase(),
{ content: ast.args.content },
ast.source_location,
),
new AstNode(
AstType.MACRO,
Macro.PARAGRAPH_MACRO_NAME,
{
content: new AstArgument(
[
new PlaintextAstNode(
'which renders as:',
ast.source_location,
)
],
ast.source_location
),
},
ast.source_location,
),
new AstNode(
AstType.MACRO,
'Q',
{
content: await parseInclude(
renderArgNoescape(ast.args.content, cloneAndSet(context, 'id_conversion', true)),
options,
0,
options.input_path,
undefined,
{
start_line: ast.source_location.line + 1,
errors: extra_returns.errors,
}
),
},
ast.source_location,
),
]
for (const new_ast of new_asts) {
new_ast.set_recursively({
from_ourbigbook_example: true,
})
}
parent_arg.push(...new_asts);
} else {
// Not OurBigBookExample.
if (macro_name === Macro.HEADER_MACRO_NAME) {
if (is_first_header) {
ast.id = context.toplevel_id
if (options.toplevel_has_scope) {
// We also need to fake an argument here, because that will
// get serialized to the database, which is needed for
// toplevel scope removal from external links.
const scope_arg = new AstArgument([], ast.source_location);
ast.add_argument('scope', scope_arg);
}
if (options.toplevel_parent_scope !== undefined) {
ast.scope = options.toplevel_parent_scope;
}
ast.is_first_header_in_input_file = true;
}
ast.subdir = convertIdArg(ast.args.subdir, context)
// Required by calculateId.
validateAst(ast, context);
if (
!ast.is_first_header_in_input_file &&
!ast.validation_output.synonym.boolean &&
options.forbid_multiheader
) {
parseError(
state,
options.forbid_multiheader,
ast.source_location,
);
parent_arg.push(new PlaintextAstNode(options.forbid_multiheader, ast.source_location));
}
let is_synonym = ast.validation_output.synonym.boolean;
const header_level = ast.validation_output.level.positive_nonzero_integer
// splitDefault propagation to children.
if (ast.validation_output.splitDefault.given) {
ast.split_default = ast.validation_output.splitDefault.boolean;
} else if (options.cur_header !== undefined) {
ast.split_default = options.cur_header.split_default || options.cur_header.split_default_children;
} else {
if (options.ourbigbook_json.h.splitDefaultNotToplevel) {
ast.split_default = false
ast.split_default_children = options.ourbigbook_json.h.splitDefault
} else {
ast.split_default = options.ourbigbook_json.h.splitDefault
}
}
if (is_synonym) {
if (options.cur_header === undefined) {
const message = `the first header of an input file cannot be a synonym`;
parseError(state, message, ast.args.synonym.source_location);
// Hack it to behave like a non-synonym. This is the easiest way to avoid further errors.
is_synonym = false
} else {
if (header_level !== 1) {
const message = `synonym headers must be h1, got: ${header_level}`;
parseError(state, message, ast.args.level.source_location);
}
ast.synonym = options.cur_header.id;
if (ast.args[Macro.TITLE2_ARGUMENT_NAME] !== undefined) {
if (ast.args[Macro.TITLE2_ARGUMENT_NAME].asts.length > 1) {
parseError(state, `synonym headers can have at most one ${Macro.TITLE2_ARGUMENT_NAME} argument`, ast.args[Macro.TITLE2_ARGUMENT_NAME].source_location);
}
if (ast.args[Macro.TITLE2_ARGUMENT_NAME].asts[0].args[Macro.CONTENT_ARGUMENT_NAME].asts.length > 0) {
parseError(state, `the ${Macro.TITLE2_ARGUMENT_NAME} of synonym headers must be empty`, ast.args[Macro.TITLE2_ARGUMENT_NAME].source_location);
}
options.cur_header.title2s.push(ast);
}
context.synonym_headers.add(ast);
}
}
if (!is_synonym) {
prev_non_synonym_header_ast = ast;
options.cur_header = ast;
cur_header_level = header_level + options.h_parse_level_offset;
}
let parent_tree_node_error = false;
let parent_id;
if (ast.validation_output.parent.given) {
let parent_ast;
if (is_synonym) {
const message = `synonym and parent are incompatible`;
parseError(state, message, ast.args.level.source_location);
}
if (header_level !== 1) {
const message = `header with parent argument must have level equal 1`;
ast.args[Macro.TITLE_ARGUMENT_NAME].push(
new PlaintextAstNode(' ' + errorMessageInOutput(message), ast.source_location)
);
parseError(state, message, ast.args.level.source_location);
}
;[parent_id, parent_ast] = getParentArgumentAst(ast, context, options);
let parent_tree_node;
if (parent_ast !== undefined) {
parent_tree_node = options.header_tree_id_stack.get(parent_ast.id);
}
if (parent_tree_node === undefined) {
parent_tree_node_error = true;
} else {
cur_header_level = parent_tree_node.get_level() + 1;
}
}
if ('level' in ast.args) {
// Hack the level argument of the final AST to match for consistency.
ast.args.level = new AstArgument([
new PlaintextAstNode(cur_header_level.toString(), ast.args.level.source_location)],
ast.args.level.source_location);
}
if (
cur_header_level === 1 &&
!is_first_header &&
options.forbid_multi_h1 &&
!ast.validation_output.synonym.boolean
) {
const msg = 'only one level 1 header is allowed in this conversion'
parseError(state, msg, ast.source_location);
parent_arg.push(new PlaintextAstNode(msg, ast.source_location));
}
// lint['h-parent']
if (
context.options.ourbigbook_json.lint['h-parent'] &&
!ast.validation_output.synonym.boolean
) {
let message;
if (
context.options.ourbigbook_json.lint['h-parent'] === 'parent' &&
!ast.validation_output.parent.given &&
!is_first_header
) {
message = `no parent given with lint['h-parent'] = "parent"`;
} else if (
context.options.ourbigbook_json.lint['h-parent'] === 'number' &&
ast.validation_output.parent.given
) {
message = `parent given with lint['h-parent'] = "number"`;
}
if (message) {
parseError(state, message, ast.source_location);
parent_arg_push_after.push(new PlaintextAstNode(errorMessageInOutput(message), ast.source_location));
}
}
// lint['h-tag']
if (
context.options.ourbigbook_json.lint['h-tag'] !== undefined
) {
let message;
let arg;
if (
context.options.ourbigbook_json.lint['h-tag'] === 'child' &&
ast.validation_output[Macro.HEADER_TAG_ARGNAME].given
) {
message = `tag given with lint['h-tag'] = "child"`;
arg = ast.args[Macro.HEADER_TAG_ARGNAME]
} else if (
context.options.ourbigbook_json.lint['h-tag'] === 'tag' &&
ast.validation_output[Macro.HEADER_CHILD_ARGNAME].given
) {
message = `child given with lint['h-tag'] = "tag"`;
arg = ast.args[Macro.HEADER_CHILD_ARGNAME]
}
if (message) {
parseError(state, message, arg.source_location);
parent_arg_push_after.push(new PlaintextAstNode(errorMessageInOutput(message), arg.source_location));
}
}
is_first_header = false;
if (options.is_first_global_header) {
first_header = ast;
first_header_level = cur_header_level;
header_tree_last_level = cur_header_level - 1;
options.header_tree_stack.set(header_tree_last_level, context.header_tree);
options.is_first_global_header = false;
}
let header_level_skip_error;
if (is_synonym) {
ast.scope = options.cur_header.scope;
} else {
cur_header_tree_node = new HeaderTreeNode(ast, options.header_tree_stack.get(cur_header_level - 1));
if (cur_header_level - header_tree_last_level > 1) {
header_level_skip_error = header_tree_last_level;
}
if (cur_header_level < first_header_level) {
parseError(
state,
`header level ${cur_header_level} is smaller than the level of the first header of the document ${first_header_level}`,
ast.args.level.source_location,
);
}
const parent_tree_node = options.header_tree_stack.get(cur_header_level - 1);
if (parent_tree_node !== undefined) {
parent_tree_node.add_child(cur_header_tree_node);
const parent_ast = parent_tree_node.ast;
if (parent_ast !== undefined) {
let scope = parent_ast.calculate_scope();
// The ast might already have a scope here through less common means such as
// being in a subdirectory.
if (ast.scope) {
if (scope === undefined) {
scope = ''
} else {
scope += Macro.HEADER_SCOPE_SEPARATOR
}
scope += ast.scope
}
ast.scope = scope;
}
}
const old_tree_node = options.header_tree_stack.get(cur_header_level);
options.header_tree_stack.set(cur_header_level, cur_header_tree_node);
if (
// Possible on the first insert of a level.
old_tree_node !== undefined &&
// Possible if the level is not an integer.
old_tree_node.ast !== undefined
) {
options.header_tree_id_stack.delete(old_tree_node.ast.id);
}
header_tree_last_level = cur_header_level;
}
ast.header_tree_node = cur_header_tree_node
// Must come after the header tree step is mostly done, because scopes influence ID,
// and they also depend on the parent node.
;({ macro_count_global } = calculateId(ast, context, options.non_indexed_ids, options.indexed_ids,
macro_counts, macro_count_global, macro_counts_visible, state, true, line_to_id_array));
// Now stuff that must come after calculateId.
header_ids.push(ast.id)
// https://github.com/ourbigbook/ourbigbook/issues/100
if (parent_tree_node_error) {
const message = HEADER_PARENT_ERROR_MESSAGE + parent_id;
ast.args[Macro.TITLE_ARGUMENT_NAME].push(
new PlaintextAstNode(' ' + errorMessageInOutput(message), ast.source_location));
parseError(state, message, ast.args.parent.source_location);
}
if (is_synonym) {
addToRefsTo(
prev_non_synonym_header_ast.id,
context,
ast.id,
REFS_TABLE_SYNONYM,
{
source_location: ast.source_location,
}
)
} else {
if (header_level_skip_error !== undefined) {
const message = `skipped a header level from ${header_level_skip_error} to ${cur_header_level}`;
ast.args[Macro.TITLE_ARGUMENT_NAME].push(
new PlaintextAstNode(' ' + errorMessageInOutput(message), ast.source_location));
parseError(state, message, ast.args.level.source_location);
}
options.header_tree_id_stack.set(cur_header_tree_node.ast.id, cur_header_tree_node);
}
if (parent_arg_push_before.length) {
parent_arg_push_before = parent_arg_push_before.concat([new AstNode(
AstType.PARAGRAPH, undefined, undefined, ast.source_location)
])
}
// Push a paragraph separator after the header when adding nodes after.
if (parent_arg_push_after.length) {
parent_arg_push_after = [new AstNode(
AstType.PARAGRAPH, undefined, undefined, ast.source_location
)].concat(parent_arg_push_after)
}
// Add children/tags to the child database.
// https://docs.ourbigbook.com#h-child-argment
for (const [argname, child] of [
[Macro.HEADER_CHILD_ARGNAME, true],
[Macro.HEADER_TAG_ARGNAME, false],
]) {
const tags_or_children = ast.args[argname]
if (tags_or_children !== undefined) {
for (const tag_or_child of tags_or_children) {
const target_id = magicTitleArgToId(tag_or_child.args.content, context)
for (const target_id_with_scope of getAllPossibleScopeResolutions(ast.calculate_scope(), target_id, context)) {
options.refs_to_h.push({
ast,
child,
source_location: tag_or_child.source_location,
target_id: target_id_with_scope,
type: REFS_TABLE_X_CHILD,
})
}
}
}
}
} else if (macro_name === Macro.X_MACRO_NAME) {
if (header_title_ast_ancestors.length > 0 && ast.args.content === undefined) {
const message = 'x without content inside title of a header: https://docs.ourbigbook.com#x-within-title-restrictions'
ast.args.content = new AstArgument(
[ new PlaintextAstNode(' ' + errorMessageInOutput(message), ast.source_location) ],
ast.source_location
);
parseError(state, message, ast.source_location);
}
// refs database updates.
validateAst(ast, context)
let target_id = convertFileIdArg(ast, ast.args.href, context)
if (
// Otherwise the ref would be added to the DB and DB checks would fail.
!ast.validation_output.topic.boolean
) {
const fetch_plural = ast.validation_output.magic.boolean
if (fetch_plural) {
target_id = magicTitleToId(target_id, context)
}
const cur_scope = options.cur_header ? options.cur_header.calculate_scope() : ''
for (const target_id_with_scope of getAllPossibleScopeResolutions(cur_scope, target_id, context)) {
options.refs_to_x.push({
ast,
title_ast_ancestors: Object.assign([], title_ast_ancestors),
target_id: target_id_with_scope,
inflected: false,
})
}
if (fetch_plural) {
// In the case of magic, also fetch a singularized version from DB. We don't know
// which one is the correct one, so just fetch both and decide at render time.
const href_arg = ast.args.href
const last_ast = href_arg.get(href_arg.length() - 1);
if (
// Possible for unterminated insane link.
last_ast &&
last_ast.node_type === AstType.PLAINTEXT
) {
const old_text = last_ast.text
const new_text = pluralizeWrap(old_text, 1)
if (new_text !== old_text) {
last_ast.text = new_text
const target_id = magicTitleToId(convertIdArg(ast.args.href, context), context)
last_ast.text = old_text
for (const target_id_with_scope of getAllPossibleScopeResolutions(cur_scope, target_id, context)) {
options.refs_to_x.push({
ast,
title_ast_ancestors: Object.assign([], title_ast_ancestors),
target_id: target_id_with_scope,
inflected: true,
})
}
}
}
}
}
}
// Push this node into the parent argument list.
// This allows us to skip nodes, or push multiple nodes if needed.
parent_arg.push(...parent_arg_push_before);
parent_arg.push(ast);
parent_arg.push(...parent_arg_push_after);
// Recurse.
for (const arg_name in ast.args) {
const arg = ast.args[arg_name];
for (let i = arg.length() - 1; i >= 0; i--) {
todo_visit.push([arg, arg.get(i)]);
}
// We make the new argument be empty so that children can
// decide if they want to push themselves or not.
arg.reset()
}
}
}
if (context.options.log['ast-pp-simple']) {
console.error('ast-pp-simple: after pass 1');
console.error(ast_toplevel.toString());
console.error();
}
let fetch_header_tree_ids_rows
let fetch_ancestors_rows
const prefetch_file_ids = new Set()
if (!options.from_include) {
// Ast post process pass 2
//
// Post process the AST pre-order depth-first search after
// inclusions are resolved to support things like:
//
// - the insane but necessary paragraphs double newline syntax
// - automatic ul parent to li and table to tr
// - remove whitespace only text children from ul
// - extract all IDs into an ID index
//
// Normally only the toplevel includer will enter this code section.
perfPrint(context, 'post_process_2')
// Calculate header_tree_top_level.
//
// - if a header of this level is present in the document,
// there is only one of it. This implies for example that
// it does not get numerical prefixes like "1.2.3 My Header".
// when rendering, and it does not show up in the ToC.
context.header_tree_top_level = first_header_level;
if (context.header_tree.children.length === 1) {
const toplevel_header_node = context.header_tree.children[0];
const toplevel_header_ast = toplevel_header_node.ast;
if (toplevel_header_node.children.length > 0) {
toplevel_header_node.children[0].ast.toc_header = true;
}
context.toplevel_ast = toplevel_header_ast;
} else {
context.toplevel_ast = undefined;
if (context.header_tree.children.length > 0) {
context.header_tree.children[0].ast.toc_header = true;
}
}
// Not modified by split headers.
context.nosplit_toplevel_ast = context.toplevel_ast
let toplevel_parent_arg = new AstArgument([], new SourceLocation(1, 1));
{
const todo_visit = [[toplevel_parent_arg, ast_toplevel]];
while (todo_visit.length > 0) {
let [parent_arg, ast] = todo_visit.pop();
const macro_name = ast.macro_name;
const macro = context.macros[macro_name];
if (
macro_name === Macro.TOPLEVEL_MACRO_NAME &&
ast.parent_ast !== undefined
) {
// Prevent this from happening. When this was committed originally,
// it actually worked and output an `html` inside another `html`.
// Maybe we could do something with iframe, but who cares about that?
const message = `the "${Macro.TOPLEVEL_MACRO_NAME}" cannot be used explicitly`;
ast = new PlaintextAstNode(errorMessageInOutput(message), ast.source_location);
parseError(state, message, ast.source_location);
} else if (
ast.macro_name === Macro.HEADER_MACRO_NAME
) {
propagateNumbered(ast, context)
// Propagate toplevel_id for headers.
if (
// Fails for include dummies. No patience to find a better way now.
ast.validation_output.toplevel &&
ast.validation_output.toplevel.boolean
) {
ast.toplevel_id = ast.id
} else {
const parent_tree_node = ast.header_tree_node.parent_ast
if (
parent_tree_node === undefined ||
parent_tree_node.ast === undefined
) {
if (ast.is_first_header_in_input_file) {
ast.toplevel_id = ast.id
} else {
// Multiple toplevel h1, so after the first one we pick the same toplevel_id as the first one has.
if (parent_tree_node !== undefined) {
ast.toplevel_id = parent_tree_node.children[0].ast.toplevel_id
}
}
} else {
ast.toplevel_id = parent_tree_node.ast.toplevel_id
}
}
}
// Dump index of headers with includes.
if (ast.includes.length > 0) {
context.headers_with_include.push(ast);
}
if (
// This only happens in the following cases
// * \x are validated before for magic stuff
// * when you have include without embed,
// where we do a context.db_provider.get, and we set the attributes to it
// and the thing comes out of serialization validated.
macro_name !== Macro.HEADER_MACRO_NAME && !ast.validated
) {
validateAst(ast, context);
}
// Push this node into the parent argument list.
// This allows us to skip nodes, or push multiple nodes if needed.
parent_arg.push(ast);
// Loop over the child arguments. We do this rather than recurse into them
// to be able to easily remove or add nodes to the tree during this AST
// post-processing.
//
// Here we do sibling-type transformations that need to loop over multiple
// direct children in one go, such as:
//
// - auto add ul to li
// - remove whitespace only text children from ul
for (const arg_name in ast.args) {
// The following passes consecutively update arg.
let arg = ast.args[arg_name];
let macro_arg = macro.name_to_arg[arg_name];
// Handle elide_link_only.
if (
// Possible for error nodes.
macro_arg !== undefined &&
macro_arg.elide_link_only &&
arg.length() === 1 &&
arg.get(0).macro_name === Macro.LINK_MACRO_NAME
) {
const href_arg = arg.get(0).args.href;
href_arg.parent_ast = ast;
arg = href_arg;
}
// Child loop that adds table tr implicit parents to th and td.
// This needs to be done on a separate pass before the tr implicit table adding.
// It is however very similar to the other loop: the only difference is that we eat up
// a trailing paragraph if followed by another.
{
const new_arg = new AstArgument([], arg.source_location);
for (let i = 0; i < arg.length(); i++) {
let child_node = arg.get(i);
let new_child_nodes = [];
let new_child_nodes_set = false;
if (child_node.node_type === AstType.MACRO) {
const child_macro_name = child_node.macro_name;
if (
child_macro_name == Macro.TD_MACRO_NAME ||
child_macro_name == Macro.TH_MACRO_NAME
) {
const auto_parent_name = Macro.TR_MACRO_NAME;
const auto_parent_name_macro = state.macros[auto_parent_name];
if (
ast.macro_name !== auto_parent_name
) {
const start_auto_child_node = child_node;
const new_arg_auto_parent = new AstArgument([], child_node.source_location);
while (i < arg.length()) {
const arg_i = arg.get(i);
if (arg_i.node_type === AstType.MACRO) {
if (
arg_i.macro_name == Macro.TD_MACRO_NAME ||
arg_i.macro_name == Macro.TH_MACRO_NAME
) {
new_arg_auto_parent.push(arg_i);
} else {
break;
}
} else if (arg_i.node_type === AstType.PARAGRAPH) {
if (i + 1 < arg.length()) {
const arg_i_next_macro_name = arg.get(i + 1).macro_name;
if (
arg_i_next_macro_name == Macro.TD_MACRO_NAME ||
arg_i_next_macro_name == Macro.TH_MACRO_NAME
) {
// Ignore this paragraph, it is actually only a separator between two \tr.
i++;
}
}
break;
} else if (
auto_parent_name_macro.name_to_arg['content'].remove_whitespace_children &&
htmlIsWhitespaceTextNode(arg_i)
) {
// Ignore the whitespace node.
} else {
break;
}
i++;
}
new_child_nodes_set = true;
new_child_nodes = new AstArgument([new AstNode(
AstType.MACRO,
auto_parent_name,
{
'content': new_arg_auto_parent,
},
start_auto_child_node.source_location,
{
parent_ast: child_node.parent_ast
}
)], child_node.source_location);
// Because the for loop will advance past it.
i--;
}
}
}
if (!new_child_nodes_set) {
new_child_nodes = [child_node];
}
new_arg.push(...new_child_nodes);
}
arg = new_arg;
}
// Child loop that adds ul and table implicit parents.
{
const new_arg = new AstArgument([], arg.source_location);
for (let i = 0; i < arg.length(); i++) {
let child_node = arg.get(i);
let new_child_nodes = [];
let new_child_nodes_set = false;
if (
(arg_name in macro.name_to_arg) &&
macro.name_to_arg[arg_name].remove_whitespace_children &&
htmlIsWhitespaceTextNode(child_node)
) {
new_child_nodes_set = true;
} else if (child_node.node_type === AstType.MACRO) {
let child_macro_name = child_node.macro_name;
let child_macro = state.macros[child_macro_name];
if (child_macro.auto_parent !== undefined) {
// Add ul and table implicit parents.
const auto_parent_name = child_macro.auto_parent;
const auto_parent_name_macro = state.macros[auto_parent_name];
if (
ast.macro_name !== auto_parent_name &&
!child_macro.auto_parent_skip.has(ast.macro_name)
) {
const start_auto_child_node = child_node;
const new_arg_auto_parent = new AstArgument([], child_node.source_location);
while (i < arg.length()) {
const arg_i = arg.get(i);
if (arg_i.node_type === AstType.MACRO) {
if (state.macros[arg_i.macro_name].auto_parent === auto_parent_name) {
new_arg_auto_parent.push(arg_i);
} else {
break;
}
} else if (
auto_parent_name_macro.name_to_arg['content'].remove_whitespace_children &&
htmlIsWhitespaceTextNode(arg_i)
) {
// Ignore the whitespace node.
} else {
break;
}
i++;
}
new_child_nodes_set = true;
new_child_nodes = new AstArgument([new AstNode(
AstType.MACRO,
auto_parent_name,
{
'content': new_arg_auto_parent,
},
start_auto_child_node.source_location,
{
parent_ast: child_node.parent_ast
}
)], child_node.source_location);
// Because the for loop will advance past it.
i--;
}
}
}
if (!new_child_nodes_set) {
new_child_nodes = [child_node];
}
new_arg.push(...new_child_nodes);
}
arg = new_arg;
}
// Child loop that adds paragraphs.
{
let paragraph_indexes = [];
for (let i = 0; i < arg.length(); i++) {
const child_node = arg.get(i);
if (child_node.node_type === AstType.PARAGRAPH) {
paragraph_indexes.push(i);
}
}
if (paragraph_indexes.length > 0) {
const new_arg = new AstArgument([], arg.source_location);
if (paragraph_indexes[0] > 0) {
parseAddParagraph(state, ast, new_arg, arg, 0, paragraph_indexes[0], options);
}
let paragraph_start = paragraph_indexes[0] + 1;
for (let i = 1; i < paragraph_indexes.length; i++) {
const paragraph_index = paragraph_indexes[i];
parseAddParagraph(state, ast, new_arg, arg, paragraph_start, paragraph_index, options);
paragraph_start = paragraph_index + 1;
}
if (paragraph_start < arg.length()) {
parseAddParagraph(state, ast, new_arg, arg, paragraph_start, arg.length(), options);
}
arg = new_arg;
}
}
// Push children to continue the search. We make the new argument be empty
// so that children can decide if they want to push themselves or not.
{
const new_arg = new AstArgument([], arg.source_location);
const macro_arg_count_words = macro_arg !== undefined && macro_arg.count_words
for (let i = arg.length() - 1; i >= 0; i--) {
const child_ast = arg.get(i)
// Propagate count_words.
if (!child_ast.count_words || !macro_arg_count_words) {
child_ast.count_words = false
child_ast.word_count = 0
}
todo_visit.push([new_arg, child_ast]);
}
// Update the argument.
ast.args[arg_name] = new_arg;
}
}
}
}
if (context.options.log['ast-pp-simple']) {
console.error('ast-pp-simple: after pass 2');
console.error(ast_toplevel.toString());
console.error();
}
// Now do a pass that collects information that may be affected by
// the tree modifications of the previous step, e.g. ID generation.
perfPrint(context, 'post_process_3')
{
const todo_visit = [ast_toplevel];
let cur_header_tree_node
while (todo_visit.length > 0) {
const ast = todo_visit.pop();
const macro_name = ast.macro_name;
const macro = context.macros[macro_name];
let children_in_header;
if (macro_name === Macro.HEADER_MACRO_NAME) {
// TODO start with the toplevel.
cur_header_tree_node = ast.header_tree_node;
children_in_header = true;
} else {
ast.header_tree_node = new HeaderTreeNode(ast, cur_header_tree_node);
if (ast.in_header) {
children_in_header = true;
} else {
if (cur_header_tree_node !== undefined) {
cur_header_tree_node.word_count += ast.word_count;
}
children_in_header = false;
}
if (cur_header_tree_node !== undefined) {
ast.scope = cur_header_tree_node.ast.calculate_scope();
}
// Header IDs already previously calculated for parent= so we don't redo it in that case.
let ret = calculateId(ast, context, options.non_indexed_ids, options.indexed_ids, macro_counts,
macro_count_global, macro_counts_visible, state, false, line_to_id_array);
macro_count_global = ret.macro_count_global
// Propagate some header properties to non-header children.
// This allows us to save some extra DB fetches, at the cost of making the JSON slightly larger.
// and duplicating data a bit. But whaterver, simpler code with less JOINs.
if (ast.header_tree_node.parent_ast !== undefined && ast.header_tree_node.parent_ast.ast !== undefined) {
const header_ast = ast.header_tree_node.parent_ast.ast
ast.split_default = header_ast.split_default;
ast.is_first_header_in_input_file = header_ast.is_first_header_in_input_file;
// TODO calculate a value for this when there is not existing header_ast, e.g.:
//
// ``
// abc
//
// = First header
// ``
//
// currently leaves abc without toplevel_id. This would make it impossible to link from other files to it.
// Not fixing it now because this is forbidden in Web articles.
ast.toplevel_id = header_ast.toplevel_id
}
}
// Push children to continue the search. We make the new argument be empty
// so that children can decide if they want to push themselves or not.
for (const arg_name in ast.args) {
const arg = ast.args[arg_name];
for (let i = arg.length() - 1; i >= 0; i--) {
const ast = arg.get(i);
ast.in_header = children_in_header;
todo_visit.push(ast);
}
}
}
}
if (context.options.log['ast-pp-simple']) {
console.error('ast-pp-simple: after pass 3');
console.error(ast_toplevel.toString());
console.error();
}
// Now for some final operations that don't go over the entire Ast Tree.
perfPrint(context, 'post_process_4')
// Setup refs DB.
for (const ref of options.refs_to_h) {
const target_id_effective = xChildDbEffectiveId(
ref.target_id,
context,
ref.ast
)
if (ref.child) {
addToRefsTo(target_id_effective, context, ref.ast.id, ref.type, { source_location: ref.source_location });
} else {
addToRefsTo(ref.ast.id, context, target_id_effective, ref.type, { source_location: ref.source_location });
}
}
for (const ref of options.refs_to_x) {
const ast = ref.ast
const target_id_effective = xChildDbEffectiveId(
ref.target_id,
context,
ast
)
const parent_id = ast.get_local_header_parent_id();
if (
// Happens on some special elements e.g. the ToC.
parent_id !== undefined
) {
// TODO add test and enable this possible fix.
if (
// We don't want the "This section is present in another page" to count as a link.
!ast.from_include
) {
// Update xref database for incoming links.
const from_ids = addToRefsTo(
target_id_effective,
context,
parent_id,
REFS_TABLE_X,
{
source_location: ast.source_location,
inflected: ref.inflected,
}
)
}
// Update xref database for child/parent relationships.
{
let toid, fromid;
if (ast.validation_output.child.boolean) {
fromid = parent_id;
toid = target_id_effective;
} else if (ast.validation_output.parent.boolean) {
toid = parent_id;
fromid = target_id_effective;
}
if (toid !== undefined) {
addToRefsTo(toid, context, fromid, REFS_TABLE_X_CHILD, { source_location: ast.source_location });
}
}
}
for (const title_ast of ref.title_ast_ancestors) {
addToRefsTo(
target_id_effective,
context,
title_ast.id,
REFS_TABLE_X_TITLE_TITLE,
{ source_location: ast.source_location }
);
}
}
const first_toplevel_child = ast_toplevel.args.content.get(0);
if (first_toplevel_child !== undefined) {
first_toplevel_child.first_toplevel_child = true;
}
if (context.options.log['ast-pp-simple']) {
console.error('ast-pp-simple: after pass 4');
console.error(ast_toplevel.toString());
console.error();
}
if (context.options.render) {
perfPrint(context, 'db_queries')
let id_conflict_asts = []
if (options.db_provider !== undefined) {
const prefetch_ids = new Set()
for (const ref of options.refs_to_h) {
prefetch_ids.add(ref.target_id)
}
for (const ref of options.refs_to_x) {
// TODO as an easy optimization, just get all refs defined on the current source files.
// These are already resolved for scope and \x magic pluralization, so the final query
// would be much smaller than this.
const id = ref.target_id
prefetch_ids.add(id)
prefetch_file_ids.add(id)
const ast = ref.ast
if (
context.options.output_format === OUTPUT_FORMAT_OURBIGBOOK &&
ast.validation_output.p.boolean
) {
const id_plural = pluralizeWrap(id.replaceAll(ID_SEPARATOR, ' ')).replaceAll(' ', ID_SEPARATOR)
if (id !== id_plural) {
// We need to also fetch this to decide if we can use magic plural or not, in case
// there are separate IDs for both singular and plural. But we don't want to put it into
// refs_to_x, because we don't want it to end up in the Refs database.
prefetch_ids.add(id_plural)
}
}
}
if (options.parent_id) {
prefetch_ids.add(options.parent_id)
// TODO needed?
//prefetch_file_ids.add({ from_id: options.parent_id, to_id: toplevel_ast.id })
// If we managed to fake the ref here (which does not exist on DB yet),
// then we would get the ancestor list working as well.
// But it is not so easy because:
// row.from is stored in row format, not as Ast
}
if (options.forbid_include) {
for (const id of header_ids) {
// Hack the id_cache. This is needed to setup the tree. This is likely something like what is somewhat
// done in the \Include reconciliation below.
options.db_provider.id_cache[id] = context.db_provider.get(id, context)
}
} else {
for (const id in options.include_hrefs) {
// We need the target it to be able to render the dummy include title
// with link to the real content.
prefetch_ids.add(id)
}
}
// QUERRY EVERYTHING AT ONCE NOW!
let get_noscopes_base_fetch, get_refs_to_fetch, get_refs_to_fetch_reverse
;[
get_noscopes_base_fetch,
get_refs_to_fetch,
get_refs_to_fetch_reverse,
fetch_header_tree_ids_rows,
fetch_ancestors_rows,
] = await Promise.all([
options.db_provider.get_noscopes_base_fetch(
Array.from(prefetch_ids),
new Set(),
context,
),
// TODO merge the following two refs fetch into one single DB query. Lazy now.
// Started prototype at: https://github.com/ourbigbook/ourbigbook/tree/merge-ref-cache
// The annoying part is deciding what needs to go in which direction of the cache.
options.db_provider.get_refs_to_fetch(
[
// These are needed to render each header.
// Shows on parents.
REFS_TABLE_PARENT,
// Shows on tags.
REFS_TABLE_X_CHILD,
// This is needed for the Incoming links at the bottom of each output file.
REFS_TABLE_X,
],
header_ids,
{
context,
ignore_paths_set: context.options.include_path_set,
},
),
// This is needed to generate the "tagged" at the end of each output file.
options.db_provider.get_refs_to_fetch(
[
REFS_TABLE_X_CHILD,
],
header_ids,
{
context,
reversed: true,
ignore_paths_set: context.options.include_path_set,
}
),
context.options.render_metadata
? options.db_provider.fetch_header_tree_ids(
options.forbid_include
// Since \Include is not allowed, we must check every header.
// Parent relations are encoded directly on DB only. Used in Web.
? header_ids
: Object.keys(options.include_hrefs)
)
: []
,
options.db_provider.fetch_ancestors(context.toplevel_id, context),
])
};
// Reconcile the dummy include header with our actual knowledge from the DB, e.g.:
// * patch the ID of the include headers.
// Has to be deferred here after DB fetch obviously because we need he DB data.
// This is hair pulling stuff. There has to be a better way...
for (const href in options.include_hrefs) {
const header_ast = options.include_hrefs[href]
const target_ast = context.db_provider.get(href, context, header_ast.scope);
if (target_ast === undefined) {
let message = `ID in include not found on database: "${href}", ` +
`needed to calculate the cross reference title. Did you forget to convert all files beforehand?`;
header_ast.args[Macro.TITLE_ARGUMENT_NAME].get(0).text = errorMessageInOutput(message)
if (options.render) {
parseError(state, message, header_ast.source_location);
}
} else {
if (target_ast.is_first_header_in_input_file) {
// We want the rendered placeholder to use its parent numbered, so as to follow the includer's numbering scheme,
// but the descendants to follow what they would actually render in the output as so they will show correctly on ToC.
header_ast.add_argument('numbered', new AstArgument(
[
new PlaintextAstNode(context.options.ourbigbook_json.h.numbered ? '1' : '0', header_ast.source_location),
],
header_ast.source_location,
))
}
header_ast.splitDefault = target_ast.splitDefault
propagateNumbered(header_ast, context)
header_ast.set_source_location(target_ast.source_location)
header_ast.header_tree_node.update_ancestor_counts(target_ast.header_tree_node_word_count)
for (const argname in target_ast.args) {
if (
// We have to patch the level of the target ID (1) do our new dummy one in the current tree.
argname !== 'level' &&
argname !== 'wiki' &&
target_ast.validation_output[argname].given
) {
header_ast.args[argname] = target_ast.args[argname]
}
}
}
// This is a bit nasty and duplicates the header processing code,
// but it is a bit hard to factor them out since this is a magic include header,
// and all includes and headers must be parsed concurrently since includes get
// injected under the last header.
validateAst(header_ast, context);
if (target_ast !== undefined) {
// We modify the cache here to ensure that the header ID has the full header_tree_node, which
// then gets feched from \x{full} (notably ToC) in order to show the link number there.
//
// Yes, this erase IDs that come from other Includes, but we don´t have a use case for that
// right now, e.g. the placholder include header does not show parents.
target_ast.header_tree_node = header_ast.header_tree_node
target_ast.header_parent_ids = []
}
}
let build_header_tree_asts
if (options.db_provider !== undefined) {
build_header_tree_asts = context.options.db_provider.build_header_tree(
fetch_header_tree_ids_rows, { context })
context.options.db_provider.fetch_ancestors_build_tree(
fetch_ancestors_rows, context)
}
if (context.options.db_provider !== undefined) {
const prefetch_files = new Set()
// TODO I tried to do this as a JOIN from inside get_refs_to_fetch to replace these fetches,
// but then if I remove these, some tests start to fail. Understand why and remove this one day.
for (const prefetch_file_id of prefetch_file_ids) {
const prefetch_ast = context.db_provider.get_noscope(prefetch_file_id, context)
if (
// Possible in some error cases.
prefetch_ast !== undefined
) {
prefetch_files.add(prefetch_ast.source_location.path)
}
}
if (options.db_provider !== undefined) {
for (const ast of build_header_tree_asts) {
prefetch_files.add(ast.source_location.path)
}
}
if (prefetch_files.size) {
await context.options.db_provider.fetch_files(Array.from(prefetch_files), context)
}
}
for (const id in headers_from_include) {
const ast = headers_from_include[id]
// This to ensure that the ast we get from \x will have a consistent
// numbering with the local parent.
// This code will likely be removed if we do:
// https://github.com/ourbigbook/ourbigbook/issues/188
const cached_ast = context.db_provider.get(ast.id, context)
if (
// Possible in error cases and TODO apparently some non-error too.
cached_ast !== undefined
) {
cached_ast.numbered = ast.numbered
}
}
}
}
context.in_parse = false
perfPrint(context, 'parse_end')
return ast_toplevel;
}
// Maybe add a paragraph after a \n\n.
function parseAddParagraph(
state, ast, new_arg, arg, paragraph_start, paragraph_end, options
) {
parseLogDebug(state, 'function: parseAddParagraph');
parseLogDebug(state, 'paragraph_start: ' + paragraph_start);
parseLogDebug(state, 'paragraph_end: ' + paragraph_end);
parseLogDebug(state);
if (paragraph_start < paragraph_end) {
const macro = state.macros[arg.get(paragraph_start).macro_name];
const slice = arg.slice(paragraph_start, paragraph_end);
if (macro.options.phrasing || slice.length() > 1) {
// If the first element after the double newline is phrasing content,
// create a paragraph and put all elements until the next paragraph inside
// that paragraph.
new_arg.push(
new AstNode(
AstType.MACRO,
Macro.PARAGRAPH_MACRO_NAME,
{
'content': slice
},
arg.get(paragraph_start).source_location,
{
parent_ast: ast,
}
)
);
} else {
// Otherwise, don't create the paragraph, and keep all elements as they were.
new_arg.push(...slice);
}
}
}
// Consume one token.
function parseConsume(state) {
state.i += 1;
if (state.i < state.tokens.length) {
state.token = state.tokens[state.i];
} else {
throw new Error('programmer error');
}
parseLogDebug(state, 'function: parseConsume');
parseLogDebug(state, 'state.i = ' + state.i.toString())
parseLogDebug(state, 'state.token = ' + JSON.stringify(state.token));
parseLogDebug(state);
return state.token;
}
function parseLogDebug(state, msg='') {
if (state.options.log.parse) {
console.error('parse: ' + msg);
}
}
// Input: e.g. in `\Image[img.jpg]{height=123}` this parses the `[img.jpg]{height=123}`.
// Return value: dict with arguments.
function parseArgumentList(state, macro_name, macro_type) {
parseLogDebug(state, 'function: parseArgumentList');
parseLogDebug(state, 'state = ' + JSON.stringify(state.token));
parseLogDebug(state);
const args = {};
const macro = state.macros[macro_name];
let name_to_arg;
if (
// Happens in some error cases.
macro !== undefined
) {
name_to_arg = macro.name_to_arg
}
let positional_arg_count = 0;
while (
// End of stream.
state.token.type !== TokenType.INPUT_END &&
(
state.token.type === TokenType.POSITIONAL_ARGUMENT_START ||
state.token.type === TokenType.NAMED_ARGUMENT_START
)
) {
let arg_name;
let open_token = state.token;
// Consume the *_ARGUMENT_START token out.
parseConsume(state);
if (open_token.type === TokenType.POSITIONAL_ARGUMENT_START) {
if (macro_type === AstType.ERROR) {
arg_name = positional_arg_count.toString();
} else {
if (positional_arg_count >= macro.positional_args.length) {
parseError(state,
`unknown named macro argument "${arg_name}" of macro "${macro_name}"`,
open_token.source_location,
);
arg_name = positional_arg_count.toString();
} else {
arg_name = macro.positional_args[positional_arg_count].name;
}
positional_arg_count += 1;
}
} else {
// Named argument.
arg_name = state.token.value;
if (macro_type !== AstType.ERROR && !(arg_name in macro.named_args)) {
parseError(state,
`unknown named macro argument "${arg_name}" of macro "${macro_name}"`,
state.token.source_location
);
}
// Parse the argument name out.
parseConsume(state);
}
const arg_children = parseArgument(state, open_token.source_location);
if (state.token.type !== closingToken(open_token.type)) {
parseError(state, `unclosed argument "${open_token.value}"`, open_token.source_location);
}
if (
// Happens in some error cases, e.g. \\undefinedMacro[aa]
macro !== undefined
) {
const macro_arg = name_to_arg[arg_name];
const multiple = macro_arg !== undefined && macro_arg.multiple
if (arg_name in args) {
if (!multiple) {
// https://github.com/ourbigbook/ourbigbook/issues/101
parseError(state,
`named argument "${arg_name}" given multiple times`,
open_token.source_location,
)
}
} else {
if (multiple) {
args[arg_name] = new AstArgument([], open_token.source_location)
} else {
args[arg_name] = arg_children;
}
}
if (multiple) {
args[arg_name].push(new AstNode(
AstType.MACRO,
'Comment',
{ 'content': arg_children },
open_token.source_location,
))
}
}
if (state.token.type !== TokenType.INPUT_END) {
// Consume the *_ARGUMENT_END token out.
parseConsume(state);
}
}
return args;
}
/**
* Input: e.g. in `\Image[img.jpg]{height=123}` this parses the `img.jpg` and the `123`.
* @return AstArgument
*/
function parseArgument(state, open_argument_source_location) {
const arg_children = new AstArgument([], open_argument_source_location);
while (
state.token.type !== TokenType.INPUT_END &&
state.token.type !== TokenType.POSITIONAL_ARGUMENT_END &&
state.token.type !== TokenType.NAMED_ARGUMENT_END
) {
// The recursive case: the argument is a lists of macros, go into all of them.
arg_children.push(parseMacro(state));
}
return arg_children;
}
// Parse one macro. This is the centerpiece of the parsing!
// Input: e.g. in `\Image[img.jpg]{height=123}` this parses the entire string.
function parseMacro(state) {
parseLogDebug(state, 'function: parseMacro');
parseLogDebug(state, 'state = ' + JSON.stringify(state.token));
parseLogDebug(state);
if (state.token.type === TokenType.MACRO_NAME) {
const macro_name = state.token.value;
const macro_source_location = state.token.source_location;
let macro_type;
const unknown_macro_message = `unknown macro name: "${macro_name}"`;
if (macro_name in state.macros) {
macro_type = AstType.MACRO;
} else {
macro_type = AstType.ERROR;
parseError(state, unknown_macro_message);
}
// Consume the MACRO_NAME token out.
parseConsume(state);
const args = parseArgumentList(state, macro_name, macro_type);
if (macro_type === AstType.ERROR) {
return new AstNode(
macro_type,
Macro.PLAINTEXT_MACRO_NAME,
{},
state.token.source_location,
{text: errorMessageInOutput(unknown_macro_message)},
);
} else {
return new AstNode(macro_type, macro_name, args, macro_source_location);
}
} else if (state.token.type === TokenType.PLAINTEXT) {
// Non-recursive case.
let node = new PlaintextAstNode(
state.token.value,
state.token.source_location,
);
// Consume the PLAINTEXT node out.
parseConsume(state);
return node;
} else if (state.token.type === TokenType.PARAGRAPH) {
let node = new AstNode(
AstType.PARAGRAPH,
undefined,
undefined,
state.token.source_location,
);
// Consume the PLAINTEXT node out.
parseConsume(state);
return node;
} else {
let error_message
if (
state.token.type === TokenType.POSITIONAL_ARGUMENT_START ||
state.token.type === TokenType.NAMED_ARGUMENT_START
) {
error_message = `stray open argument character: '${state.token.value}', maybe you want to escape it with '\\'`;
} else {
// Generic error message.
error_message = `unexpected token ${state.token.type.toString()}`;
}
parseError(state, error_message);
let node = new PlaintextAstNode(
errorMessageInOutput(error_message),
state.token.source_location,
);
// Consume past whatever happened to avoid an infinite loop.
parseConsume(state);
return node;
}
state.i += 1;
}
function parseError(state, message, source_location) {
let new_source_location;
if (source_location === undefined) {
new_source_location = new SourceLocation();
} else {
new_source_location = source_location.clone();
}
new_source_location.path = state.options.input_path;
if (new_source_location.line === undefined)
new_source_location.line = state.token.source_location.line;
if (new_source_location.column === undefined)
new_source_location.column = state.token.source_location.column;
state.extra_returns.errors.push(new ErrorMessage(
message, new_source_location));
}
function pathJoin(dirname, basename, sep) {
let ret = dirname;
if (ret !== '') {
ret += sep;
}
return ret + basename;
}
function pathSplit(str, sep) {
const dir_sep_index = str.lastIndexOf(sep)
if (dir_sep_index == -1) {
return ['', str];
} else {
return [str.substring(0, dir_sep_index), str.substr(dir_sep_index + 1)];
}
}
exports.pathSplit = pathSplit
function pathSplitext(str) {
const sep_index = str.lastIndexOf('.')
if (sep_index == -1) {
return [str, ''];
} else {
return [str.substring(0, sep_index), str.substr(sep_index + 1)];
}
}
exports.pathSplitext = pathSplitext
function protocolGet(url) {
const match = /^([a-zA-Z]+):\/\//.exec(url)
if (match) {
return match[0]
} else {
return null
}
}
function protocolIsGiven(url) {
return protocolGet(url) !== null
}
function protocolIsKnown(src) {
for (const known_url_protocol of KNOWN_URL_PROTOCOLS) {
if (src.startsWith(known_url_protocol)) {
return true;
}
}
return false;
}
exports.protocolIsKnown = protocolIsKnown
// https://docs.ourbigbook.com#scope
function removeToplevelScope(id, toplevel_ast, context) {
const fixedScopeRemoval = context.options.fixedScopeRemoval
if (fixedScopeRemoval !== undefined) {
return id.slice(fixedScopeRemoval)
}
if (
toplevel_ast !== undefined &&
id === toplevel_ast.id
) {
if (toplevel_ast.scope !== undefined) {
return id.substr(toplevel_ast.scope.length + 1);
}
return id;
} else {
return id.substr(calculateScopeLength(toplevel_ast));
}
}
// https://docs.ourbigbook.com#index-files
const INDEX_BASENAME_NOEXT = 'index';
exports.INDEX_BASENAME_NOEXT = INDEX_BASENAME_NOEXT;
const README_BASENAME_NOEXT = 'README';
exports.README_BASENAME_NOEXT = README_BASENAME_NOEXT;
const INDEX_FILE_BASENAMES_NOEXT = new Set([
README_BASENAME_NOEXT,
INDEX_BASENAME_NOEXT,
]);
exports.INDEX_FILE_BASENAMES_NOEXT = INDEX_FILE_BASENAMES_NOEXT;
const IO_RENAME_MAP = {};
for (let i of INDEX_FILE_BASENAMES_NOEXT) {
IO_RENAME_MAP[i] = INDEX_BASENAME_NOEXT;
}
exports.IO_RENAME_MAP = IO_RENAME_MAP;
function renameBasename(original) {
if (original in IO_RENAME_MAP) {
return IO_RENAME_MAP[original];
} else {
return original;
}
}
function renderError(context, message, source_location, severity=1) {
if (!context.ignore_errors) {
context.errors.push(new ErrorMessage(message, source_location, severity));
}
}
function renderErrorXUndefined(ast, context, target_id, options={}) {
let { source_location } = options
let message = `cross reference to unknown id: "${target_id}" at render time`;
if (source_location === undefined) {
if (ast.args.href) {
source_location = ast.args.href.source_location
} else {
source_location = ast.source_location
}
}
renderError(context, message, source_location, 2);
return errorMessageInOutput(message, context)
}
/** Render the ToC from a list representation rather than tree.
*
* This function was introduced to factor out the static CLI ToC and the dynamic one from Web,
* the static one had a tree representation, but the dynamic one has a list, so we convert
* both to a single list representation and render it here.
*/
function renderTocFromEntryList({ add_test_instrumentation, entry_list, descendant_count_html, tocIdPrefix }) {
let top_level = 0;
if (tocIdPrefix === undefined) {
tocIdPrefix = ''
}
let ret = `<div class="toc-container" id="${tocIdPrefix}${Macro.TOC_ID}"><ul><li${htmlClassAttr([TOC_HAS_CHILD_CLASS, 'toplevel'])}><div class="title-div">`
ret += `${TOC_ARROW_HTML}<span class="not-arrow"><a class="title toc" href="#${tocIdPrefix}${Macro.TOC_ID}"> Table of contents</a>`
if (descendant_count_html) {
ret += `<span class="hover-metadata">${descendant_count_html}</span>`
}
ret += `</span></div>`
for (let i = 0; i < entry_list.length; i++) {
const entry = entry_list[i]
let {
content,
href,
level,
has_child,
id_prefix,
link_to_split,
parent_href,
parent_content,
target_id,
} = entry
if (id_prefix === undefined) {
id_prefix = ''
}
if (level > top_level) {
ret += `<ul>`;
} else if (level < top_level) {
ret += `</li></ul>`.repeat(top_level - level);
} else {
ret += `</li>`;
}
ret += '<li';
if (has_child) {
ret += htmlClassAttr([TOC_HAS_CHILD_CLASS]);
}
ret += '>'
const my_toc_id = tocId(target_id);
let id_to_toc = htmlAttr(Macro.ID_ARGUMENT_NAME, htmlEscapeAttr(my_toc_id));
let linear_count_str
if (add_test_instrumentation) {
linear_count_str = htmlAttr('data-test', i)
} else {
linear_count_str = ''
}
ret += `<div${id_to_toc}>${TOC_ARROW_HTML}<span class="not-arrow"><a${href}${linear_count_str}>${content}</a><span class="hover-metadata">`;
let toc_href = htmlAttr('href', '#' + htmlEscapeAttr(my_toc_id));
// c for current
ret += `<a${toc_href}${htmlAttr('class', 'c')}></a>`;
if (link_to_split) {
ret += `${link_to_split}`;
}
if (parent_href) {
// p for parent
ret += `<a${parent_href}${htmlAttr('class', 'u')}> ${parent_content}</a>`;
}
if (entry.descendant_count_html) {
ret += `${entry.descendant_count_html}`
}
ret += `</span></span></div>`
top_level = level;
}
ret += `</li></ul>`.repeat(top_level);
// Close the table of contents list.
ret += `</li></ul>`;
ret += `</div>`
return ret
}
exports.renderTocFromEntryList = renderTocFromEntryList
function renderToc(context) {
if (context.toc_was_rendered || !checkHasToc(context)) {
// Empty ToC. Don't render. Initial common case: leaf split header nodes.
return '';
}
context.toc_was_rendered = true
let entry_list = []
// Not rendering ID here because that function does scope culling. But TOC ID is a fixed value without scope for now.
// so that was removing the TOC id in subdirectories.
let todo_visit = [];
let root_node = context.header_tree;
if (root_node.children.length === 1) {
root_node = root_node.children[0];
}
let descendant_count_html = getDescendantCountHtml(context, root_node, { long_style: false, show_descendant_count: true });
for (let i = root_node.children.length - 1; i >= 0; i--) {
todo_visit.push([root_node.children[i], 1]);
}
while (todo_visit.length > 0) {
const entry = {}
const [tree_node, level] = todo_visit.pop();
entry.level = level
const has_child = tree_node.children.length > 0
entry.has_child = has_child
let target_ast = context.db_provider.get(tree_node.ast.id, context);
if (
// Can happen in test error cases:
// - cross reference from header title without ID to previous header is not allowed
// - include to file that does exists without embed includes before extracting IDs fails gracefully
target_ast !== undefined
) {
// I had this at one point, but it was too confusing that \x links linked to split, and ToC to nonsplit.
// If we want to keep split self contained, then we have to do it everywhere I think, not just ToC.
//// ToC entries always link to the same split/nosplit type, except for included sources.
//// This might be handled more generally through: https://github.com/ourbigbook/ourbigbook/issues/146
//// but for now we are just taking care of this specific and important ToC subcase.
//let cur_context;
//if (ast.source_location.path === target_ast.source_location.path) {
// cur_context = cloneAndSet(context, 'to_split_headers', context.in_split_headers);
//} else {
// cur_context = context;
//}
entry.content = xText(target_ast, context, {style_full: true, show_caption_prefix: false});
entry.href = xHrefAttr(target_ast, context);
entry.target_id = target_ast.id
if (context.options.split_headers) {
entry.link_to_split = linkToSplitOpposite(target_ast, context)
}
let parent_ast = target_ast.get_header_parent_asts(context)[0];
if (
// Possible on broken h1 level.
parent_ast !== undefined
) {
let parent_href_target;
if (
parent_ast.header_tree_node !== undefined &&
parent_ast.header_tree_node.get_level() === context.header_tree_top_level
) {
parent_href_target = context.options.tocIdPrefix + Macro.TOC_ID;
} else {
parent_href_target = tocId(parent_ast.id);
}
entry.parent_href = htmlAttr('href', '#' + parent_href_target);
entry.parent_content = renderArg(parent_ast.args[Macro.TITLE_ARGUMENT_NAME], context);
}
// The inner <div></div> inside arrow is so that:
// - outter div: takes up space to make clicking easy
// - inner div: minimal size to make the CSS arrow work, but too small for confortable clicking
entry.descendant_count_html = getDescendantCountHtml(context, tree_node, { long_style: false, show_descendant_count: true });
}
if (has_child) {
for (let i = tree_node.children.length - 1; i >= 0; i--) {
todo_visit.push([tree_node.children[i], level + 1]);
}
}
entry_list.push(entry)
}
return renderTocFromEntryList({
entry_list,
descendant_count_html,
add_test_instrumentation: context.options.add_test_instrumentation,
tocIdPrefix: context.options.tocIdPrefix,
})
}
function perfPrint(context, name) {
// Includes and CirodowExample also call convert to parse.
// For now we are ignoring those recursions. A more correct approach
// would be to track the time intervals of those subconverts, and add
// them up to the corresponding toplevel convert.
if (!context.options.from_include) {
const now = globals.performance.now();
const delta = now - context.perf_prev
context.extra_returns.debug_perf[name] = now
context.perf_prev = now
if (context.options.log.perf || context.options.log.mem) {
console.error(`perf ${name} t=${now} dt=${delta}`);
if (context.options.log.mem) {
global.gc()
console.error(process.memoryUsage());
}
}
}
}
exports.perfPrint = perfPrint
// https://github.com/plurals/pluralize/issues/127
function pluralizeWrap(s, n) {
let ret = pluralize(s, n)
if (n === undefined || n > 1 && s !== ret) {
const last = ret[ret.length - 1]
if (last === 'S') {
ret = ret.substring(0, ret.length - 1) + last.toLowerCase()
}
}
return ret
}
function propagateNumbered(ast, context) {
// numbered propagation to children.
// Note that the property only affects descendants, but not the node itself.
const parent_tree_node = ast.header_tree_node.parent_ast
if (
parent_tree_node === undefined ||
parent_tree_node.ast === undefined
) {
// Try getting parents from \Include.
// https://github.com/ourbigbook/ourbigbook/issues/188
//const parent_asts = ast.get_header_parent_asts(context)
//if (parent_asts.length > 0) {
// ast.numbered = parent_asts.some(ast => ast.numbered)
//} else {
ast.numbered = context.options.ourbigbook_json.h.numbered
} else {
const parent_ast = parent_tree_node.ast
if (parent_ast.validation_output.numbered.given) {
ast.numbered = parent_ast.validation_output.numbered.boolean
} else {
ast.numbered = parent_ast.numbered
}
}
}
exports.propagateNumbered = propagateNumbered
// Fuck JavaScript? Can't find a built-in way to get the symbol string without the "Symbol(" part.
// https://stackoverflow.com/questions/30301728/get-the-description-of-a-es6-symbol
function symbolToString(symbol) {
return symbol.toString().slice(7, -1);
}
function titleToId(title, options={}, context) {
if (options.normalize === undefined) {
options.normalize = {}
}
const new_chars = [];
let first = true
for (let c of title) {
if (
(
options.normalize !== undefined &&
options.normalize.latin !== undefined &&
options.normalize.latin
) ||
(
context !== undefined &&
context.options.ourbigbook_json.id.normalize.latin
)
) {
c = normalizeLatinCharacter(c)
}
if (
(
(
options.normalize !== undefined &&
options.normalize.punctuation !== undefined &&
options.normalize.punctuation
) ||
(
context !== undefined &&
context.options.ourbigbook_json.id.normalize.punctuation
)
)
&&
!(
first &&
c === AT_MENTION_CHAR &&
options.magic &&
(
context !== undefined &&
context.options.x_remove_leading_at
)
)
) {
c = normalizePunctuationCharacter(c)
}
c = c.toLowerCase();
const scope_sep = options.keep_scope_sep ? Macro.HEADER_SCOPE_SEPARATOR : ''
const ok_chars_regexp = new RegExp(`[a-z0-9-${scope_sep}]`)
if (
!isAscii(c) ||
ok_chars_regexp.test(c)
) {
new_chars.push(c);
} else {
new_chars.push(ID_SEPARATOR);
}
first = false
}
return new_chars.join('')
.replace(new RegExp(ID_SEPARATOR + '+', 'g'), ID_SEPARATOR)
.replace(new RegExp('^' + ID_SEPARATOR + '+'), '')
.replace(new RegExp(ID_SEPARATOR + '+$'), '')
;
}
exports.titleToId = titleToId;
/** Heuristic only. */
function idToTitle(id) {
return capitalizeFirstLetter(id).replace(ID_SEPARATOR, ' ')
}
exports.idToTitle = idToTitle
/** Factored out calculations of the ID that is given to each TOC entry.
*
* For after everything broke down due to toplevel scope.
*/
function tocId(id) {
return Macro.TOC_PREFIX + id;
}
function unconvertible(ast, context) {
const msg = `macro "${ast.macro_name}" must never render`
if (context.in_parse) {
renderError(context, msg, ast.source_location);
return errorMessageInOutput(msg, context)
} else {
throw new Error(msg);
}
}
function urlBasename(str) {
return basename(str, URL_SEP);
}
// Do some error checking and setup some stuff like boolean.
// We should likely do this in the AstNode constructor. The reason we didn't
// was likely to not need context at that point, and be nicer to serialization.
function validateAst(ast, context) {
if (ast.validated) {
throw new Error(`ast has already been validated:
${ast.toString()}`)
} else {
ast.validated = true
}
const macro_name = ast.macro_name;
const macro = context.macros[macro_name];
const name_to_arg = macro.name_to_arg;
// First pass sets defaults or missing arguments.
for (const argname in name_to_arg) {
ast.validation_output[argname] = {};
const macro_arg = name_to_arg[argname];
if (argname in ast.args) {
ast.validation_output[argname].given = true;
} else {
ast.validation_output[argname].given = false;
if (macro_arg.mandatory) {
ast.validation_error = [
`missing mandatory argument ${argname} of ${ast.macro_name}`,
ast.source_location,
];
}
if (macro_arg.default !== undefined) {
ast.args[argname] = new AstArgument([
new PlaintextAstNode(macro_arg.default, ast.source_location)]);
} else if (macro_arg.boolean) {
ast.args[argname] = new AstArgument([new PlaintextAstNode('0', ast.source_location)]);
}
}
}
// Second pass processes the values including defaults.
for (const argname in name_to_arg) {
const macro_arg = name_to_arg[argname];
if (argname in ast.args) {
const arg = ast.args[argname];
if (macro_arg.boolean) {
let arg_string;
if (arg.length() > 0) {
arg_string = renderArgNoescape(arg, cloneAndSet(context, 'id_conversion', true));
} else {
arg_string = '1';
}
if (arg_string === '0') {
ast.validation_output[argname].boolean = false;
} else if (arg_string === '1') {
ast.validation_output[argname].boolean = true;
} else {
ast.validation_output[argname].boolean = false;
ast.validation_error = [
`boolean argument "${argname}" of "${ast.macro_name}" has invalid value: "${arg_string}", only "0" and "1" are allowed`,
arg.source_location
];
break;
}
}
if (macro_arg.positive_nonzero_integer) {
const arg_string = renderArgNoescape(arg, context);
const int_value = parseInt(arg_string);
ast.validation_output[argname].positive_nonzero_integer = int_value;
if (!Number.isInteger(int_value) || !(int_value > 0)) {
ast.validation_error = [
`argument "${argname}" of macro "${ast.macro_name}" must be a positive non-zero integer, got: "${arg_string}"`,
arg.source_location
];
break;
}
}
}
}
}
exports.validateAst = validateAst
function xChildDbEffectiveId(target_id, context, ast) {
const target_ast = context.db_provider.get(target_id, context, ast.scope);
if (
target_ast === undefined
) {
// Return as is. This can happen during ID extraction when the
// target ID needs to be resolved across directory based scopes.
// In those cases, the target ID could be on another file, so we would need to
// read the database to decide. But reading from database during ID extraction
// is forbidden, so we do that on a separate pass.
// Related: https://github.com/ourbigbook/ourbigbook/issues/229
if (isAbsoluteXref(target_id, context)) {
target_id = resolveAbsoluteXref(target_id, context)
}
return target_id
} else {
return target_ast.id
}
}
function xGetTargetAstBase({
context,
do_magic_title_to_id,
do_singularize,
scope,
target_id,
}) {
if (
context.options.x_leading_at_to_web &&
target_id[0] === AT_MENTION_CHAR
) {
return [htmlAttr('href', context.webUrl + target_id.substr(1)), target_id];
}
let target_id_eff
if (do_magic_title_to_id) {
target_id_eff = magicTitleToId(target_id, context)
} else {
target_id_eff = target_id
}
let target_ast = context.db_provider.get(target_id_eff, context, scope);
if (do_singularize && !target_ast) {
target_id_eff = magicTitleToId(pluralizeWrap(target_id, 1), context)
target_ast = context.db_provider.get(target_id_eff, context, scope);
}
return { target_id: target_id_eff, target_ast }
}
function convertFileIdArg(ast, href_arg, context) {
let target_id = convertIdArg(href_arg, context);
if (
ast.validation_output.file.given
) {
target_id = Macro.FILE_ID_PREFIX + target_id
}
return target_id
}
function xGetTargetAst(ast, context) {
const href_arg = ast.args.href
const target_id = convertFileIdArg(ast, href_arg, context);
const ret = xGetTargetAstBase({
context,
do_magic_title_to_id: ast.validation_output.magic.boolean,
do_singularize: ast.validation_output.magic.boolean,
scope: ast.scope,
target_id,
})
ret.href_arg = href_arg
ret.target_id_raw = target_id
return ret
}
/**
* @param {AstNode} ast \x ast node
* @return {[String, String]} [href, content] pair for the x node.
*/
function xGetHrefContent(ast, context) {
const { href_arg, target_id, target_id_raw, target_ast } = xGetTargetAst(ast, context)
const content_arg = ast.args.content;
if (ast.validation_output.topic.boolean) {
let topicTitle = target_id_raw
let topicTitleSingular = topicTitle
if (!ast.validation_output.p.boolean) {
topicTitleSingular = pluralizeWrap(topicTitleSingular, 1)
}
const topicId = titleToId(topicTitleSingular, undefined, context)
if (content_arg !== undefined) {
topicTitle = renderArg(content_arg, context)
}
return [htmlAttr('href', `${context.options.webMode ? URL_SEP : context.webUrl}${WEB_TOPIC_PATH}${URL_SEP}${topicId}`), topicTitle, undefined];
}
// href
let href;
if (target_ast) {
href = xHrefAttr(target_ast, context);
} else {
const message = renderErrorXUndefined(ast, context, target_id)
return [href, message];
}
// content
let content;
if (content_arg === undefined) {
// No explicit content given, deduce content from target ID title.
if (context.x_parents.has(ast)) {
// Prevent render infinite loops.
let message = `x with infinite recursion`;
renderError(context, message, ast.source_location);
return [href, errorMessageInOutput(message, context)];
}
let x_text_options = {
caption_prefix_span: false,
capitalize: ast.validation_output.c.boolean,
from_x: true,
quote: true,
pluralize: ast.validation_output.p.given ? ast.validation_output.p.boolean : undefined,
};
if (ast.validation_output.magic.boolean) {
const first_ast = href_arg.get(0);
if (first_ast.node_type === AstType.PLAINTEXT) {
const sep_idx = first_ast.text.lastIndexOf(Macro.HEADER_SCOPE_SEPARATOR)
const idx = sep_idx === -1 ? 0 : sep_idx + 1
const c = first_ast.text[idx]
if (c !== c.toLowerCase()) {
x_text_options.capitalize = true
}
}
const last_ast = href_arg.get(href_arg.length() - 1);
if (last_ast.node_type === AstType.PLAINTEXT) {
const text = first_ast.text
if (text !== pluralizeWrap(text, 1)) {
x_text_options.pluralize = true
}
}
}
if (ast.validation_output.full.given) {
x_text_options.style_full = ast.validation_output.full.boolean;
}
const x_parents_new = new Set(context.x_parents);
x_parents_new.add(ast);
content = xText(target_ast, cloneAndSet(context, 'x_parents', x_parents_new), x_text_options);
if (content === ``) {
let message = `empty cross reference body: "${target_id}"`;
renderError(context, message, ast.source_location);
return errorMessageInOutput(message, context);
}
} else {
// Explicit content given, just use it then.
content = renderArg(content_arg, context);
}
return [href, content, target_ast];
}
/** Calculate the href value to a given target AstNode.
*
* This takes into account e.g. if the target node is in a different source file:
* https://docs.ourbigbook.com#internal-cross-file-references
*
* @param {AstNode} target_ast
* @return {String} the value of href (no quotes) that an \x cross reference to the given target_ast
*/
function xHref(target_ast, context) {
const [href_path, fragment] = xHrefParts(target_ast, context);
let ret = href_path;
if (fragment !== '')
ret += '#' + fragment;
return ret;
}
function indexPathFromDirname(dirname_ret, basename_ret, sep) {
const [dir_dir, dir_base] = pathSplit(dirname_ret, sep);
if (basename_ret === INDEX_BASENAME_NOEXT && dirname_ret) {
dirname_ret = dir_dir
basename_ret = dir_base
}
return [dirname_ret, basename_ret]
}
function isPunctuation(c) {
return c === '.' ||
c === '!' ||
c === '?' ||
c === ')'
}
// Get the path to the split header version
//
// to_split_headers is set explicitly when making
// links across split/non-split versions of the output.
//
// Otherwise, link to the same type of output as the current one
// as given in in_split_headers.
//
// This way, to_split_hedears in particular forces the link to be
// to the non-split mode, even if we are in split mode.
//
// Some desired sample outcomes:
//
// id='ourbigbook' -> ['', 'index-split']
// id='quick-start' -> ['', 'quick-start']
// id='not-readme' -> ['', 'not-readme-split']
// id='h2-in-not-the-readme' -> ['', 'h2-in-not-the-readme']
// id='subdir' -> ['subdir', 'index-split']
// id='subdir/subdir-h2' -> ['subdir', 'subdir-h2']
// id='subdir/notindex' -> ['subdir', 'notindex']
// id='subdir/notindex-h2' -> ['subdir', 'notindex-h2']
function isToSplitHeaders(ast, context) {
return isToSplitHeadersBase(
ast.split_default,
context.to_split_headers,
context.options.ourbigbook_json.toSplitHeaders,
)
}
function isToSplitHeadersBase(
ast_split_default,
context_to_split_headers,
to_split_headers_override,
) {
return (to_split_headers_override !== undefined && to_split_headers_override) ||
(context_to_split_headers === undefined && ast_split_default) ||
(context_to_split_headers !== undefined && context_to_split_headers);
}
/** This is the centerpiece of x href calculation!
*
* This code is crap. There are too many cases for my brain to handle.
* So I just write tests, and hack the code until the tests pass, but
* I'm not capable of factoring it nicely.
*
* @param {AstNode} target_ast
*/
function xHrefParts(target_ast, context) {
// Synonym handling.
let target_ast_effective_id, first_toplevel_child_effective
if (target_ast.synonym === undefined) {
target_ast_effective_id = target_ast.id
first_toplevel_child_effective = target_ast.first_toplevel_child
} else {
const synonym_target_ast = context.db_provider.get(target_ast.synonym, context);
target_ast_effective_id = synonym_target_ast.id
first_toplevel_child_effective = synonym_target_ast.first_toplevel_child
}
let to_split_headers = isToSplitHeaders(target_ast, context);
let to_current_toplevel =
target_ast_effective_id === context.toplevel_id &&
// Also requires outputting to the same type of split/nonsplit
// as the current one.
context.in_split_headers === to_split_headers
;
// href_path
let href_path,
toplevel_output_path_dirname = '',
toplevel_output_path_basename,
full_output_path,
target_output_path_dirname,
target_output_path_basename,
split_suffix
;
const target_input_path = target_ast.source_location.path;
if (
target_ast.source_location.path === undefined ||
context.toplevel_output_path === undefined ||
(
// Nosplit header link to a header that renders on the
// same page.
!context.in_split_headers &&
!(
context.to_split_headers !== undefined &&
context.to_split_headers
) &&
context.toplevel_id === target_ast.toplevel_id
) ||
(
// Split header link to image in current header.
context.in_split_headers &&
target_ast.macro_name !== Macro.HEADER_MACRO_NAME &&
target_ast.get_header_parent_ids(context).has(context.toplevel_id)
) ||
to_current_toplevel
) {
// The output path is the same as the current path. Stop.
// Everything else is basically handled by outputPathBase.
// That function doesn't have the concept of "where am I looking from".
// so we do that here. Maybe it would be better to change that, if if
// is hair tearing.
href_path = '';
} else {
;[toplevel_output_path_dirname, toplevel_output_path_basename] =
pathSplit(context.toplevel_output_path, context.options.path_sep);
;({
path: full_output_path,
dirname: target_output_path_dirname,
basename: target_output_path_basename,
split_suffix,
} = target_ast.output_path(
context,
{
effective_id: target_ast_effective_id,
toSplitHeadersOverride: context.options.ourbigbook_json.toSplitHeaders,
}
));
if (context.options.x_remove_leading_at) {
if (target_output_path_dirname) {
if (target_output_path_dirname[0] === AT_MENTION_CHAR) {
target_output_path_dirname = target_output_path_dirname.slice(1)
}
} else {
if (
target_output_path_basename &&
target_output_path_basename[0] === AT_MENTION_CHAR
) {
target_output_path_basename = target_output_path_basename.slice(1)
}
}
}
// The target path is the same as the current path being output.
if (full_output_path === context.toplevel_output_path) {
href_path = ''
} else {
let href_path_dirname_rel
if (context.options.x_absolute) {
href_path_dirname_rel = target_output_path_dirname
} else {
href_path_dirname_rel = path.relative(
toplevel_output_path_dirname, target_output_path_dirname);
}
if (
// Same output path.
href_path_dirname_rel === '' &&
target_output_path_basename === toplevel_output_path_basename
) {
target_output_path_basename = '';
} else {
if (context.options.htmlXExtension) {
target_output_path_basename += '.' + HTML_EXT;
} else if (target_output_path_basename === INDEX_BASENAME_NOEXT) {
if (href_path_dirname_rel === '') {
target_output_path_basename = '.';
} else {
target_output_path_basename = '';
}
}
}
href_path = pathJoin(href_path_dirname_rel,
target_output_path_basename, context.options.path_sep);
}
}
if (!context.options.include_path_set.has(target_input_path)) {
href_path = context.options.x_external_prefix + href_path
}
if (
context.options.ourbigbook_json.xPrefix !== undefined &&
href_path !== '' &&
split_suffix === undefined
) {
href_path = context.options.ourbigbook_json.xPrefix + pathJoin(
toplevel_output_path_dirname, href_path, context.options.path_sep)
}
if (href_path && context.options.x_absolute) {
href_path = '/' + href_path
}
// Fragment
if (
!context.in_split_headers &&
context.options.include_path_set.has(target_input_path) &&
!(context.to_split_headers !== undefined && context.to_split_headers)
) {
// We are not in split headers, and the output is in the current file.
// Therefore, don't use the split header target no matter what its
// splitDefault is, use the non-split one.
to_split_headers = false;
}
let fragment;
if (
(
target_ast.macro_name === Macro.HEADER_MACRO_NAME &&
(
// Linking to a toplevel ID.
first_toplevel_child_effective ||
// Linking towards a split header not included in the current output.
to_split_headers ||
to_current_toplevel ||
target_ast.id === target_ast.toplevel_id
)
)
) {
// An empty href means the beginning of the page.
fragment = '';
} else {
// This is the AST that will show up on the top of the rendered page.
// that contains target_ast. We need to know it for toplevel scope culling.
let toplevel_ast;
if (to_split_headers) {
// We know not a header target, as that would have been caught previously.
toplevel_ast = target_ast.get_header_parent_asts(context)[0];
} else {
if (target_ast.toplevel_id === undefined) {
toplevel_ast = context.nosplit_toplevel_ast
} else {
toplevel_ast = context.db_provider.get(target_ast.toplevel_id, context);
}
//const get_file_ret = context.options.db_provider.get_file(target_input_path);
//if (get_file_ret) {
//} else {
// // The only way this can happen is if we are in the current file, and it hasn't
// // been added to the file db yet.
//}
}
fragment = removeToplevelScope(target_ast_effective_id, toplevel_ast, context);
}
// return
return [htmlEscapeAttr(href_path), htmlEscapeAttr(fragment)];
}
/* href="" that links to a given node. */
function xHrefAttr(target_ast, context) {
return htmlAttr('href', xHref(target_ast, context));
}
/**
* Calculate the text (visible content) of a cross reference, or the text
* that the caption text that cross references can refer to, e.g.
* "Table 123. My favorite table". Both are done in a single function
* so that style_full references will show very siimlar to the caption
* they refer to.
*
* @param {Object} options
* @param {Object} href_prefix rendered string containing the href="..."
* part of a link to self to be applied e.g. to <>Figure 1<>, of undefined
* if this link should not be given.
* @return {string} full: '<a href="#barack-obama/equation-my-favorite-equation"><span class="caption-prefix">Equation 1</span></a>. My favorite equation'
* {string} inner: 'My favorite equation'
*/
function xTextBase(ast, context, options={}) {
context = cloneAndSet(context, 'in_x_text', true)
if (!('caption_prefix_span' in options)) {
options.caption_prefix_span = true;
}
if (!('quote' in options)) {
options.quote = false;
}
if (!('fixed_capitalization' in options)) {
options.fixed_capitalization = true;
}
if (!('href_prefix' in options)) {
options.href_prefix = undefined;
}
if (!('force_separator' in options)) {
options.force_separator = false;
}
if (!('from_x' in options)) {
options.from_x = false;
}
if (!('pluralize' in options)) {
// true: make plural
// false: make singular
// undefined: don't touch it
options.pluralize = undefined;
}
if (!('show_caption_prefix' in options)) {
options.show_caption_prefix = true;
}
const macro = context.macros[ast.macro_name];
let inner
let style_full;
if ('style_full' in options) {
style_full = options.style_full;
} else {
style_full = macro.options.default_x_style_full;
}
let ret = ``;
let number;
if (style_full) {
if (options.href_prefix !== undefined) {
ret += `<a${options.href_prefix}>`
}
if (options.show_caption_prefix) {
if (options.caption_prefix_span) {
ret += `<span class="caption-prefix">`;
}
ret += `${macro.options.caption_prefix} `;
}
if (
ast.numbered &&
(
// When in split headers, numbers are only added to headers that
// are descendants of the toplevel header, thus matching the current ToC.
// The numbers don't make much sense for other headers.
ast.macro_name !== Macro.HEADER_MACRO_NAME ||
(
// Possible in case of broken header parent=.
context.toplevel_ast !== undefined &&
ast.is_header_local_descendant_of(context.toplevel_ast, context)
)
)
) {
number = macro.options.get_number(ast, context);
if (number !== undefined) {
ret += number;
}
}
if (options.show_caption_prefix && options.caption_prefix_span) {
ret += `</span>`;
}
if (options.href_prefix !== undefined) {
ret += `</a>`
}
}
let title_arg = macro.options.get_title_arg(ast, context);
if (
(
(title_arg !== undefined && style_full) ||
options.force_separator
) &&
number !== undefined
) {
ret += htmlEscapeContext(context, `. `);
}
if (
title_arg !== undefined
) {
if (style_full && options.quote) {
ret += htmlEscapeContext(context, `"`);
}
// https://docs.ourbigbook.com#cross-reference-title-inflection
if (options.from_x) {
// {c}
let first_ast = title_arg.get(0);
if (
ast.macro_name === Macro.HEADER_MACRO_NAME &&
!ast.validation_output.c.boolean &&
!style_full &&
first_ast.node_type === AstType.PLAINTEXT
) {
// https://stackoverflow.com/questions/41474986/how-to-clone-a-javascript-es6-class-instance
title_arg = lodash.clone(title_arg)
title_arg.asts = lodash.clone(title_arg.asts)
// This is not amazing, as it can change some properties of the node,
// as we are not copying anything besides text and source_location.
// This almost had an impact on {toplevel} rendering, but it dien't matter
// so we didn't try to sanitize it further for now.
title_arg.set(0, new PlaintextAstNode(first_ast.text, first_ast.source_location))
let txt = title_arg.get(0).text;
let first_c = txt[0];
if (options.capitalize) {
first_c = first_c.toUpperCase();
} else {
first_c = first_c.toLowerCase();
}
title_arg.get(0).text = first_c + txt.substring(1);
}
// {p}
let last_ast = title_arg.get(title_arg.length() - 1);
if (
options.pluralize !== undefined &&
!style_full &&
last_ast.node_type === AstType.PLAINTEXT
) {
title_arg = lodash.clone(title_arg)
title_arg.asts = lodash.clone(title_arg.asts)
title_arg.set(title_arg.length() - 1, new PlaintextAstNode(last_ast.text, last_ast.source_location));
title_arg.get(title_arg.length() - 1).text = pluralizeWrap(last_ast.text, options.pluralize ? 2 : 1);
}
}
if (ast.file) {
inner = ast.file
} else {
inner = renderArg(title_arg, context);
}
ret += inner
if (style_full) {
const disambiguate_arg = ast.args[Macro.DISAMBIGUATE_ARGUMENT_NAME];
const title2_arg = ast.args[Macro.TITLE2_ARGUMENT_NAME];
const show_disambiguate = (disambiguate_arg !== undefined) && macro.options.show_disambiguate;
const title2_renders = [];
if (show_disambiguate) {
title2_renders.push(renderArg(disambiguate_arg, context));
}
if (title2_arg !== undefined) {
for (const arg of title2_arg.asts.map(ast => ast.args[Macro.CONTENT_ARGUMENT_NAME])) {
if (arg.asts.length) {
title2_renders.push(renderArg(arg, context));
}
}
}
for (const title2ast of ast.title2s) {
title2_renders.push(renderArg(title2ast.args.title, context));
}
if (title2_renders.length) {
ret += ` (${title2_renders.join(', ')})`
}
if (options.quote) {
ret += htmlEscapeContext(context, `"`);
}
}
}
return { full: ret, inner }
}
function xText(ast, context, options={}) {
return xTextBase(ast, context, options).full
}
// consts
// Dynamic website stuff.
const ANCESTORS_ID_UNRESERVED = 'ancestors'
const ANCESTORS_ID = `${Macro.RESERVED_ID_PREFIX}${ANCESTORS_ID_UNRESERVED}`
exports.ANCESTORS_ID = ANCESTORS_ID
const ANCESTORS_MAX = 6
exports.ANCESTORS_MAX = ANCESTORS_MAX
const AT_MENTION_CHAR = '@';
exports.AT_MENTION_CHAR = AT_MENTION_CHAR;
const FILE_ROOT_PLACEHOLDER = '(root)'
exports.FILE_ROOT_PLACEHOLDER = FILE_ROOT_PLACEHOLDER
const HTML_REF_MARKER = '<sup class="ref">[ref]</sup>'
const INSANE_TOPIC_CHAR = '#';
const WEB_API_PATH = 'api';
exports.WEB_API_PATH = WEB_API_PATH;
const WEB_TOPIC_PATH = 'go/topic';
const PARAGRAPH_SEP = '\n\n';
exports.PARAGRAPH_SEP = PARAGRAPH_SEP;
const REFS_TABLE_PARENT = 'PARENT';
exports.REFS_TABLE_PARENT = REFS_TABLE_PARENT;
const REFS_TABLE_X = 'X';
exports.REFS_TABLE_X = REFS_TABLE_X;
const REFS_TABLE_X_CHILD = 'X_CHILD';
exports.REFS_TABLE_X_CHILD = REFS_TABLE_X_CHILD;
// https://github.com/ourbigbook/ourbigbook/issues/198
const REFS_TABLE_X_TITLE_TITLE = 'X_TITLE_TITLE';
exports.REFS_TABLE_X_TITLE_TITLE = REFS_TABLE_X_TITLE_TITLE;
// Header is synonym of another one.
const REFS_TABLE_SYNONYM = 'SYNONYM';
exports.REFS_TABLE_SYNONYM = REFS_TABLE_SYNONYM;
const END_NAMED_ARGUMENT_CHAR = '}';
const END_POSITIONAL_ARGUMENT_CHAR = ']';
const ESCAPE_CHAR = '\\';
// Rationale: 1 line = 80 characters.
// We want to preview files that are up to about 25 lines.
// More than that is wasteful in visual vertical area and bandwidth.
// Ideally we could just link to the files like images, but iframe does
// not work well, e.g. files that would be downloaded like .yml are also
// downloaded from the iframe
const FILE_PREVIEW_MAX_SIZE = 2000;
exports.FILE_PREVIEW_MAX_SIZE = FILE_PREVIEW_MAX_SIZE
const HEADER_PARENT_ERROR_MESSAGE = 'header parent either is a previous ID of a level, a future ID, or an invalid ID: '
const HTML_ASCII_WHITESPACE = new Set([' ', '\r', '\n', '\f', '\t']);
const HTML_EXT = 'html';
exports.HTML_EXT = HTML_EXT;
const INCOMING_LINKS_MARKER = '<span title="Incoming links" class="fa-solid-900">\u{f060}</span>'
exports.INCOMING_LINKS_MARKER = INCOMING_LINKS_MARKER
const SYNONYM_LINKS_MARKER = '<span title="Synonyms" class="fa-solid-900">\u{f07e}</span>'
exports.SYNONYM_LINKS_MARKER = SYNONYM_LINKS_MARKER
const INCOMING_LINKS_ID_UNRESERVED = 'incoming-links'
exports.INCOMING_LINKS_ID_UNRESERVED = INCOMING_LINKS_ID_UNRESERVED
const SYNONYM_LINKS_ID_UNRESERVED = 'synonyms'
exports.SYNONYM_LINKS_ID_UNRESERVED = SYNONYM_LINKS_ID_UNRESERVED
const ID_SEPARATOR = '-';
exports.ID_SEPARATOR = ID_SEPARATOR
const INSANE_LIST_START = '* ';
const INSANE_TD_START = '| ';
const INSANE_TH_START = '|| ';
const INSANE_LIST_INDENT = ' ';
const INSANE_HEADER_CHAR = '=';
exports.INSANE_HEADER_CHAR = INSANE_HEADER_CHAR
const LOG_OPTIONS = new Set([
'ast-inside',
'ast-pp-simple',
'mem',
'parse',
'perf',
'split-headers',
'tokens-inside',
'tokenize',
]);
exports.LOG_OPTIONS = LOG_OPTIONS;
const IMAGE_EXTENSIONS = new Set([
'bmp',
'gif',
'jpeg',
'jpg',
'png',
'svg',
'tiff',
'webp',
])
const OURBIGBOOK_JSON_BASENAME = 'ourbigbook.json';
exports.OURBIGBOOK_JSON_BASENAME = OURBIGBOOK_JSON_BASENAME
const OURBIGBOOK_JSON_DEFAULT = {
htmlXExtension: true,
id: {
normalize: {
latin: true,
punctuation: true,
},
},
web: {
host: 'ourbigbook.com',
hostCapitalized: 'OurBigBook.com',
link: false,
username: undefined,
}
}
exports.OURBIGBOOK_JSON_DEFAULT = OURBIGBOOK_JSON_DEFAULT
const OUTPUT_FORMAT_OURBIGBOOK = 'bigb';
exports.OUTPUT_FORMAT_OURBIGBOOK = OUTPUT_FORMAT_OURBIGBOOK
const RENDER_TYPE_WEB = 'web'
exports.RENDER_TYPE_WEB = RENDER_TYPE_WEB
const OUTPUT_FORMAT_HTML = 'html';
exports.OUTPUT_FORMAT_HTML = OUTPUT_FORMAT_HTML
const OUTPUT_FORMAT_ID = 'id';
exports.OUTPUT_FORMAT_ID = OUTPUT_FORMAT_ID
const VIDEO_EXTENSIONS = new Set([
'avi',
'mkv',
'mov',
'mp4',
'ogv',
'webm',
])
const TAGGED_ID_UNRESERVED = 'tagged'
exports.TAGGED_ID_UNRESERVED = TAGGED_ID_UNRESERVED
const TAGS_MARKER = '<span title="Tags" class="fa-solid-900">\u{f02c}</span>'
exports.TAGS_MARKER = TAGS_MARKER
const TOC_ARROW_HTML = '<div class="arrow"><div></div></div>'
const TOC_HAS_CHILD_CLASS = 'has-child'
const UL_OL_OPTS = {
wrap: true,
}
const INSANE_X_START = '<';
const INSANE_X_END = '>';
const INSANE_CODE_CHAR = '`'
const INSANE_MATH_CHAR = '$'
const MAGIC_CHAR_ARGS = {
[INSANE_MATH_CHAR]: Macro.MATH_MACRO_NAME,
[INSANE_CODE_CHAR]: Macro.CODE_MACRO_NAME,
[INSANE_X_START]: Macro.X_MACRO_NAME,
}
const NAMED_ARGUMENT_EQUAL_CHAR = '=';
const START_NAMED_ARGUMENT_CHAR = '{';
exports.START_NAMED_ARGUMENT_CHAR = START_NAMED_ARGUMENT_CHAR;
const START_POSITIONAL_ARGUMENT_CHAR = '[';
const INSANE_LINK_END_CHARS = new Set([
' ',
'\n',
START_POSITIONAL_ARGUMENT_CHAR,
START_NAMED_ARGUMENT_CHAR,
END_POSITIONAL_ARGUMENT_CHAR,
END_NAMED_ARGUMENT_CHAR,
]);
const INSANE_STARTS_TO_MACRO_NAME = {
[INSANE_LIST_START]: Macro.LIST_ITEM_MACRO_NAME,
[INSANE_TD_START]: Macro.TD_MACRO_NAME,
[INSANE_TH_START]: Macro.TH_MACRO_NAME,
};
const MUST_ESCAPE_CHARS_REGEX_CHAR_CLASS = [
`\\${ESCAPE_CHAR}`,
START_POSITIONAL_ARGUMENT_CHAR,
`\\${END_POSITIONAL_ARGUMENT_CHAR}`,
START_NAMED_ARGUMENT_CHAR,
END_NAMED_ARGUMENT_CHAR,
INSANE_X_START,
INSANE_CODE_CHAR,
INSANE_MATH_CHAR,
INSANE_TOPIC_CHAR,
].join('')
const MUST_ESCAPE_CHARS_REGEX_CHAR_CLASS_REGEX = new RegExp(`([${MUST_ESCAPE_CHARS_REGEX_CHAR_CLASS}])`, 'g')
const MUST_ESCAPE_CHARS_AT_START_REGEX_CHAR_CLASS = [
'\\* ',
'=',
'\\|\\|',
'\\|',
].join('|')
const MUST_ESCAPE_CHARS_AT_START_REGEX_CHAR_CLASS_REGEX = new RegExp(`(^|\n)(${MUST_ESCAPE_CHARS_AT_START_REGEX_CHAR_CLASS})`, 'g')
const INSANE_STARTS_MACRO_NAMES = new Set(Object.values(INSANE_STARTS_TO_MACRO_NAME))
const AstType = makeEnum([
// An in-output error message.
'ERROR',
// The most regular and non-magic nodes.
// Most nodes are of this type.
'MACRO',
// A node that contains only text, and no subnodes.
'PLAINTEXT',
// Paragraphs are basically MACRO, but with some special
// magic because of the double newline madness treatment.
'PARAGRAPH',
]);
const TokenType = makeEnum([
'INPUT_END',
'MACRO_NAME',
'NAMED_ARGUMENT_END',
'NAMED_ARGUMENT_NAME',
'NAMED_ARGUMENT_START',
'PARAGRAPH',
'PLAINTEXT',
'POSITIONAL_ARGUMENT_END',
'POSITIONAL_ARGUMENT_START',
]);
const DEFAULT_MEDIA_HEIGHT = 315;
// Arguments for \Image, \image and \Video
const IMAGE_VIDEO_INLINE_BLOCK_NAMED_ARGUMENTS = [
new MacroArgument({
name: 'border',
boolean: true,
}),
new MacroArgument({
name: 'external',
boolean: true,
}),
new MacroArgument({
name: 'height',
default: DEFAULT_MEDIA_HEIGHT.toString(),
positive_nonzero_integer: true,
}),
new MacroArgument({
name: 'provider',
}),
new MacroArgument({
name: 'width',
positive_nonzero_integer: true,
}),
]
// Arguments for \Image and \image
const IMAGE_INLINE_BLOCK_NAMED_ARGUMENTS = [
new MacroArgument({
name: 'link',
elide_link_only: true,
}),
]
// Arguments for \Image and \Video
const IMAGE_VIDEO_BLOCK_NAMED_ARGUMENTS = IMAGE_VIDEO_INLINE_BLOCK_NAMED_ARGUMENTS.concat([
new MacroArgument({
name: Macro.TITLE_ARGUMENT_NAME,
count_words: true,
}),
new MacroArgument({
name: Macro.DESCRIPTION_ARGUMENT_NAME,
count_words: true,
}),
new MacroArgument({
name: 'source',
elide_link_only: true,
}),
new MacroArgument({
name: 'titleFromSrc',
boolean: true,
}),
]);
const XSS_SAFE_ALT_DEFAULT = {
[OUTPUT_FORMAT_HTML]: (ast, context) => {
return '<div>HTML snippet:</div>' + htmlCode(renderArg(ast.args[Macro.CONTENT_ARGUMENT_NAME], context))
}
}
/**
* Calculate a bunch of default parameters of the media from smart defaults if not given explicitly
*
* @return {Object}
* MediaProviderType {MediaProviderType} , e.g. type, src, source.
*/
function macroImageVideoResolveParams(ast, context) {
let error_message;
let media_provider_type;
let src = renderArgNoescape(ast.args.src, context);
let is_url;
// Provider explicitly given by user on macro.
if (ast.validation_output.provider.given) {
const provider_name = renderArgNoescape(ast.args.provider, cloneAndSet(context, 'id_conversion', true));
if (MEDIA_PROVIDER_TYPES.has(provider_name)) {
media_provider_type = provider_name;
} else {
error_message = `unknown media provider: "${htmlEscapeAttr(provider_name)}"`;
renderError(context, error_message, ast.args.provider.source_location);
media_provider_type = 'unknown';
}
}
// Otherwise, detect the media provider.
let media_provider_type_detected;
if (src.match(media_provider_type_wikimedia_re)) {
media_provider_type_detected = 'wikimedia';
} else if (src.match(media_provider_type_youtube_re)) {
media_provider_type_detected = 'youtube';
} else if (protocolIsKnown(src)) {
// Full URL to a website we know nothing about.
media_provider_type_detected = 'unknown';
}
if (media_provider_type_detected === undefined) {
if (media_provider_type === undefined) {
// Relative URL, use the default provider if any.
media_provider_type = context.media_provider_default[ast.macro_name];
}
is_url = false;
} else {
if (media_provider_type !== undefined && media_provider_type !== media_provider_type_detected) {
error_message = `detected media provider type "${media_provider_type_detected}", but user also explicitly gave "${media_provider_type}"`;
renderError(context, error_message, ast.args.provider.source_location);
}
if (media_provider_type === undefined) {
media_provider_type = media_provider_type_detected;
}
is_url = true;
}
// Fixup src depending for certain providers.
let relpath_prefix
if (media_provider_type === 'local') {
const path = context.options.ourbigbook_json['media-providers'].local.path;
if (path !== '') {
src = path + URL_SEP + src;
}
} else if (media_provider_type === 'github') {
const github_path = context.options.ourbigbook_json['media-providers'].github.path;
if (
github_path &&
context.options.fs_exists_sync(github_path) &&
!context.options.publish
) {
// Can't join it in here now or else existence check fails.
// But we need to keep this information around to be able to link from inside out/html/... relative path.
relpath_prefix = path.relative('.', context.options.outdir)
src = `${github_path}/${src}`
} else {
src = `${githubProviderPrefix(context)}/${src}`;
}
}
return {
error_message,
media_provider_type,
is_url,
relpath_prefix,
src,
}
}
function macroImageVideoResolveParamsWithSource(ast, context) {
const ret = macroImageVideoResolveParams(ast, context);
ret.source = context.macros[ast.macro_name].options.source_func(
ast, context, ret.src, ret.media_provider_type, ret.is_url);
return ret;
}
const MACRO_IMAGE_VIDEO_OPTIONS = {
captionNumberVisible: function (ast, context) {
return Macro.DESCRIPTION_ARGUMENT_NAME in ast.args ||
macroImageVideoResolveParamsWithSource(ast, context).source !== '';
},
get_title_arg: function(ast, context) {
// Title given explicitly.
if (ast.validation_output[Macro.TITLE_ARGUMENT_NAME].given) {
return ast.args[Macro.TITLE_ARGUMENT_NAME];
}
// Title from src.
const media_provider_type = (macroImageVideoResolveParams(ast, context)).media_provider_type;
if (
ast.validation_output.titleFromSrc.boolean ||
(
!ast.validation_output.titleFromSrc.given &&
context.options.ourbigbook_json['media-providers'][media_provider_type]['title-from-src']
)
) {
let basename_str;
let src = renderArg(ast.args.src, cloneAndSet(context, 'id_conversion', true));
if (media_provider_type === 'local') {
basename_str = urlBasename(src);
} else if (media_provider_type === 'wikimedia') {
basename_str = context.macros[ast.macro_name].options.image_video_basename(src);
} else {
basename_str = src;
}
const title_str = basename_str.replace(/_/g, ' ').replace(/\.[^.]+$/, '') + '.';
return new AstArgument([new PlaintextAstNode(
title_str, ast.source_location)], ast.source_location);
}
// We can't automatically generate one at all.
return undefined;
}
}
const MACRO_IMAGE_VIDEO_POSITIONAL_ARGUMENTS = [
new MacroArgument({
name: 'src',
elide_link_only: true,
mandatory: true,
}),
new MacroArgument({
name: 'alt',
}),
];
// https://docs.ourbigbook.com#known-url-protocols
const KNOWN_URL_PROTOCOL_NAMES = ['http', 'https'];
const KNOWN_URL_PROTOCOLS = new Set()
for (const name of KNOWN_URL_PROTOCOL_NAMES) {
KNOWN_URL_PROTOCOLS.add(name + '://');
}
const URL_SEP = '/';
exports.URL_SEP = URL_SEP;
const MACRO_WITH_MEDIA_PROVIDER = new Set(['image', 'video']);
const DEFAULT_MACRO_LIST = [
new Macro(
Macro.LINK_MACRO_NAME,
[
new MacroArgument({
name: 'href',
elide_link_only: true,
mandatory: true,
}),
new MacroArgument({
name: 'content',
count_words: true,
}),
],
{
named_args: [
new MacroArgument({
name: 'external',
boolean: true,
}),
new MacroArgument({
name: 'ref',
boolean: true,
}),
],
phrasing: true,
}
),
new Macro(
'b',
[
new MacroArgument({
name: 'content',
count_words: true,
}),
],
{
phrasing: true,
}
),
new Macro(
'br',
[],
{
phrasing: true,
}
),
new Macro(
// Block code.
Macro.CODE_MACRO_NAME.toUpperCase(),
[
new MacroArgument({
name: 'content',
count_words: true,
ourbigbook_output_prefer_literal: true,
}),
],
{
captionNumberVisible: function (ast, context) {
return Macro.DESCRIPTION_ARGUMENT_NAME in ast.args
},
caption_prefix: 'Code',
id_prefix: 'code',
named_args: [
new MacroArgument({
name: Macro.TITLE_ARGUMENT_NAME,
count_words: true,
}),
new MacroArgument({
name: Macro.DESCRIPTION_ARGUMENT_NAME,
count_words: true,
}),
],
},
),
new Macro(
// Inline code.
Macro.CODE_MACRO_NAME,
[
new MacroArgument({
name: 'content',
count_words: true,
ourbigbook_output_prefer_literal: true,
}),
],
{
phrasing: true,
}
),
new Macro(
Macro.OURBIGBOOK_EXAMPLE_MACRO_NAME,
[
new MacroArgument({
name: 'content',
ourbigbook_output_prefer_literal: true,
}),
],
{
macro_counts_ignore: function(ast) { return true; }
}
),
new Macro(
'Comment',
[
new MacroArgument({
name: Macro.CONTENT_ARGUMENT_NAME,
ourbigbook_output_prefer_literal: true,
}),
],
{
macro_counts_ignore: function(ast) { return true; }
}
),
new Macro(
'comment',
[
new MacroArgument({
name: Macro.CONTENT_ARGUMENT_NAME,
}),
],
{
phrasing: true,
}
),
new Macro(
Macro.HEADER_MACRO_NAME,
[
new MacroArgument({
name: 'level',
mandatory: true,
positive_nonzero_integer: true,
}),
new MacroArgument({
name: Macro.TITLE_ARGUMENT_NAME,
count_words: true,
}),
],
{
caption_prefix: 'Section',
default_x_style_full: false,
get_number: function(ast, context) {
let header_tree_node = ast.header_tree_node;
if (header_tree_node === undefined) {
return undefined;
} else {
return header_tree_node.get_nested_number(context.header_tree_top_level);
}
},
show_disambiguate: true,
id_prefix: '',
named_args: [
new MacroArgument({
name: 'c',
boolean: true,
}),
new MacroArgument({
name: Macro.HEADER_CHILD_ARGNAME,
multiple: true,
}),
new MacroArgument({
name: 'file',
}),
new MacroArgument({
name: 'numbered',
boolean: true,
}),
new MacroArgument({
name: 'parent',
}),
new MacroArgument({
name: 'scope',
boolean: true,
}),
new MacroArgument({
name: 'splitDefault',
boolean: true,
}),
new MacroArgument({
name: 'splitSuffix',
}),
new MacroArgument({
name: 'subdir',
}),
new MacroArgument({
name: Macro.SYNONYM_ARGUMENT_NAME,
boolean: true,
}),
new MacroArgument({
name: Macro.HEADER_TAG_ARGNAME,
multiple: true,
}),
new MacroArgument({
name: 'toplevel',
boolean: true,
}),
new MacroArgument({
name: Macro.TITLE2_ARGUMENT_NAME,
multiple: true,
}),
// Should I?
//new MacroArgument({
// name: 'tutorial',
//}),
new MacroArgument({
name: 'wiki',
}),
],
}
),
new Macro(
Macro.INCLUDE_MACRO_NAME,
[
new MacroArgument({
name: 'href',
mandatory: true,
}),
],
{
macro_counts_ignore: function(ast) { return true; },
named_args: [
new MacroArgument({
name: 'parent',
}),
],
phrasing: true,
}
),
new Macro(
Macro.LIST_ITEM_MACRO_NAME,
[
new MacroArgument({
name: 'content',
count_words: true,
}),
],
{
auto_parent: 'Ul',
auto_parent_skip: new Set(['Ol']),
}
),
new Macro(
// Block math.
Macro.MATH_MACRO_NAME.toUpperCase(),
[
new MacroArgument({
name: 'content',
count_words: true,
ourbigbook_output_prefer_literal: true,
}),
],
{
caption_prefix: 'Equation',
id_prefix: 'equation',
get_number: function(ast, context) {
// Override because unlike other elements such as images, equations
// always get numbers even if not indexed.
return ast.macro_count;
},
macro_counts_ignore: function(ast) {
return !ast.validation_output.show.boolean;
},
named_args: [
new MacroArgument({
name: Macro.TITLE_ARGUMENT_NAME,
}),
new MacroArgument({
name: Macro.DESCRIPTION_ARGUMENT_NAME,
}),
new MacroArgument({
boolean: true,
default: '1',
name: 'show',
}),
],
}
),
new Macro(
// Inline math.
Macro.MATH_MACRO_NAME,
[
new MacroArgument({
name: 'content',
count_words: true,
ourbigbook_output_prefer_literal: true,
}),
],
{
phrasing: true,
}
),
new Macro(
'i',
[
new MacroArgument({
name: 'content',
count_words: true,
}),
],
{
phrasing: true,
}
),
new Macro(
'Image',
MACRO_IMAGE_VIDEO_POSITIONAL_ARGUMENTS,
Object.assign(
{
caption_prefix: 'Figure',
image_video_content_func: function ({
alt,
ast,
context,
is_url,
media_provider_type,
rendered_attrs,
relpath_prefix,
src,
}) {
let img_html
;({ html: img_html, src } = htmlImg({
alt,
ast,
context,
external: ast.validation_output.external.given ? ast.validation_output.external.boolean : undefined,
media_provider_type,
rendered_attrs,
src,
relpath_prefix,
}))
return img_html
},
named_args: IMAGE_VIDEO_BLOCK_NAMED_ARGUMENTS.concat(IMAGE_INLINE_BLOCK_NAMED_ARGUMENTS),
source_func: function (ast, context, src, media_provider_type, is_url) {
if ('source' in ast.args) {
return renderArg(ast.args.source, context);
} else if (media_provider_type == 'wikimedia') {
return macro_image_video_block_convert_function_wikimedia_source_url +
context.macros[ast.macro_name].options.image_video_basename(src);
} else {
return '';
}
}
},
Object.assign(
{
image_video_basename: function(src) {
return urlBasename(htmlEscapeAttr(src)).replace(
macro_image_video_block_convert_function_wikimedia_source_image_re, '');
},
},
MACRO_IMAGE_VIDEO_OPTIONS,
),
),
),
new Macro(
'image',
MACRO_IMAGE_VIDEO_POSITIONAL_ARGUMENTS,
{
named_args: IMAGE_VIDEO_INLINE_BLOCK_NAMED_ARGUMENTS.concat(IMAGE_INLINE_BLOCK_NAMED_ARGUMENTS),
phrasing: true,
}
),
new Macro(
'JsCanvasDemo',
[
new MacroArgument({
name: 'content',
mandatory: true,
}),
],
{
xss_safe: false,
}
),
new Macro(
'Ol',
[
new MacroArgument({
name: 'content',
count_words: true,
remove_whitespace_children: true,
}),
],
),
new Macro(
Macro.PARAGRAPH_MACRO_NAME,
[
new MacroArgument({
name: 'content',
count_words: true,
}),
],
),
new Macro(
Macro.PLAINTEXT_MACRO_NAME,
[
new MacroArgument({
name: 'content',
count_words: true,
}),
],
{
phrasing: true,
}
),
new Macro(
'passthrough',
[
new MacroArgument({
name: 'content',
}),
],
{
phrasing: true,
xss_safe: false,
}
),
new Macro(
'Q',
[
new MacroArgument({
name: 'content',
count_words: true,
}),
],
{
caption_prefix: 'Quote',
id_prefix: 'quote',
named_args: [
new MacroArgument({
name: Macro.TITLE_ARGUMENT_NAME,
count_words: true,
}),
new MacroArgument({
name: Macro.DESCRIPTION_ARGUMENT_NAME,
count_words: true,
}),
],
}
),
new Macro(
'sub',
[
new MacroArgument({
name: 'content',
}),
],
{
phrasing: true,
}
),
new Macro(
'sup',
[
new MacroArgument({
name: 'content',
}),
],
{
phrasing: true,
}
),
new Macro(
Macro.TABLE_MACRO_NAME,
[
new MacroArgument({
name: 'content',
remove_whitespace_children: true,
count_words: true,
}),
],
{
captionNumberVisible: function (ast, context) {
return Macro.DESCRIPTION_ARGUMENT_NAME in ast.args
},
named_args: [
new MacroArgument({
name: Macro.DESCRIPTION_ARGUMENT_NAME,
count_words: true,
}),
new MacroArgument({
name: Macro.TITLE_ARGUMENT_NAME,
count_words: true,
}),
],
}
),
new Macro(
Macro.TD_MACRO_NAME,
[
new MacroArgument({
name: 'content',
count_words: true,
}),
]
),
new Macro(
Macro.TOPLEVEL_MACRO_NAME,
[
new MacroArgument({
count_words: true,
name: 'content',
}),
],
{
macro_counts_ignore: function(ast) { return true; },
named_args: [
new MacroArgument({
name: Macro.TITLE_ARGUMENT_NAME,
count_words: true,
}),
],
}
),
new Macro(
Macro.TH_MACRO_NAME,
[
new MacroArgument({
name: 'content',
count_words: true,
}),
],
),
new Macro(
Macro.TR_MACRO_NAME,
[
new MacroArgument({
name: 'content',
count_words: true,
remove_whitespace_children: true,
}),
],
{
auto_parent: Macro.TABLE_MACRO_NAME,
}
),
new Macro(
'Ul',
[
new MacroArgument({
name: 'content',
count_words: true,
remove_whitespace_children: true,
}),
],
),
new Macro(
Macro.X_MACRO_NAME,
[
new MacroArgument({
name: 'href',
mandatory: true,
}),
new MacroArgument({
name: 'content',
count_words: true,
}),
],
{
named_args: [
new MacroArgument({
name: 'c',
boolean: true,
}),
new MacroArgument({
// https://github.com/ourbigbook/ourbigbook/issues/92
name: 'child',
boolean: true,
}),
new MacroArgument({
name: 'file',
boolean: true,
}),
new MacroArgument({
name: 'full',
boolean: true,
}),
new MacroArgument({
name: 'magic',
boolean: true,
}),
new MacroArgument({
name: 'p',
boolean: true,
}),
new MacroArgument({
// https://github.com/ourbigbook/ourbigbook/issues/92
name: 'parent',
boolean: true,
}),
new MacroArgument({
name: 'ref',
boolean: true,
}),
new MacroArgument({
name: 'topic',
boolean: true,
}),
],
phrasing: true,
}
),
new Macro(
'Video',
MACRO_IMAGE_VIDEO_POSITIONAL_ARGUMENTS,
Object.assign(
{
caption_prefix: 'Video',
image_video_basename: function(src) {
return urlBasename(htmlEscapeAttr(src)).replace(
macro_image_video_block_convert_function_wikimedia_source_video_re, '$1');
},
image_video_content_func: function ({
alt,
ast,
context,
is_url,
media_provider_type,
rendered_attrs,
src,
}) {
if (media_provider_type === 'youtube') {
let url_start_time;
let video_id;
if (is_url) {
const url = new URL(src);
const url_params = url.searchParams;
if (url_params.has('t')) {
url_start_time = url_params.get('t');
}
if (url.hostname === 'youtube.com' || url.hostname === 'www.youtube.com') {
if (url_params.has('v')) {
video_id = url_params.get('v')
} else {
let message = `youtube URL without video ID "${src}"`;
renderError(context, message, ast.source_location);
return errorMessageInOutput(message, context);
}
} else {
// youtu.be/<ID> and path is "/<ID>" so get rid of "/".
video_id = url.pathname.substr(1);
}
} else {
video_id = src;
}
let start_time;
if ('start' in ast.args) {
start_time = ast.validation_output.start.positive_nonzero_integer;
} else if (url_start_time !== undefined) {
start_time = htmlEscapeAttr(url_start_time);
}
let start;
if (start_time !== undefined) {
start = `?start=${start_time}`;
} else {
start = '';
}
let height
if (ast.validation_output.height.given) {
height = ast.validation_output.height.positive_nonzero_integer
} else {
height = DEFAULT_MEDIA_HEIGHT
}
let width
if (ast.validation_output.width.given) {
width = ast.validation_output.width.positive_nonzero_integer
} else {
const DEFAULT_VIDEO_WIDTH = 560
width = Math.floor(DEFAULT_VIDEO_WIDTH * height / DEFAULT_MEDIA_HEIGHT)
}
return `<div class="float-wrap"><iframe width="${width}" height="${height}" loading="lazy" src="https://www.youtube.com/embed/${htmlEscapeAttr(video_id)}${start}" ` +
`allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe></div>`;
} else {
let error
;({ href: src, error } = checkAndUpdateLocalLink({
context,
external: ast.validation_output.external.given ? ast.validation_output.external.boolean : undefined,
href: src,
media_provider_type,
source_location: ast.args.src.source_location,
}))
let start;
if ('start' in ast.args) {
// https://stackoverflow.com/questions/5981427/start-html5-video-at-a-particular-position-when-loading
start = `#t=${ast.validation_output.start.positive_nonzero_integer}`;
} else {
start = '';
}
return `<video${htmlAttr('src', src + start)}${rendered_attrs} preload="none" controls${alt}></video>${error}`;
}
},
named_args: IMAGE_VIDEO_BLOCK_NAMED_ARGUMENTS.concat(
new MacroArgument({
name: 'start',
positive_nonzero_integer: true,
}),
),
source_func: function (ast, context, src, media_provider_type, is_url) {
if ('source' in ast.args) {
return renderArg(ast.args.source, context);
} else if (media_provider_type === 'youtube') {
if (is_url) {
return htmlEscapeAttr(src);
} else {
return `https://youtube.com/watch?v=${htmlEscapeAttr(src)}`;
}
} else if (media_provider_type === 'wikimedia') {
return macro_image_video_block_convert_function_wikimedia_source_url +
context.macros[ast.macro_name].options.image_video_basename(src);
} else {
return '';
}
}
},
MACRO_IMAGE_VIDEO_OPTIONS,
),
),
];
function createLinkList(context, ast, id, title, target_ids, body) {
let ret = '';
if (target_ids.size !== 0) {
// TODO factor this out more with real headers.
const target_asts = [];
const idWithPrefix = `${Macro.RESERVED_ID_PREFIX}${id}`
ret += htmlToplevelChildModifierById(`<h2 id="${idWithPrefix}"><a href="#${idWithPrefix}">${title}</a></h2>`, idWithPrefix)
for (const target_id of Array.from(target_ids).sort()) {
let target_ast = context.db_provider.get(target_id, context);
if (
// Possible when user sets an invalid ID on \x with child \x[invalid]{child}.
// The error is caught elsewhere.
target_ast !== undefined
) {
//let counts_str;
//if (target_ast.header_tree_node !== undefined) {
// counts_str = getDescendantCountHtml(target_ast.header_tree_node, false);
//} else {
// counts_str = '';
//}
target_asts.push(new AstNode(
AstType.MACRO,
Macro.LIST_ITEM_MACRO_NAME,
{
'content': new AstArgument(
[
new AstNode(
AstType.MACRO,
Macro.X_MACRO_NAME,
{
'href': new AstArgument(
[
new PlaintextAstNode(target_id),
],
),
'c': new AstArgument(),
},
),
//new AstNode(
// AstType.MACRO,
// 'passthrough',
// {
// 'content': new AstArgument(
// [
// new PlaintextAstNode(counts_str),
// ],
// ),
// },
// undefined,
// {
// xss_safe: true,
// }
//),
],
),
},
));
}
}
let ulArgs = {
'content': new AstArgument(target_asts)
}
if (context.options.add_test_instrumentation) {
ulArgs[Macro.TEST_DATA_ARGUMENT_NAME] = [new PlaintextAstNode(id)]
}
const incoming_ul_ast = new AstNode(AstType.MACRO, 'Ul', ulArgs)
const new_context = cloneAndSet(context, 'validateAst', true);
new_context.source_location = ast.source_location;
ret += htmlToplevelChildModifierById(incoming_ul_ast.render(new_context))
}
return ret
}
/** This factors out the wrapper div that every toplevel element must have to do the margin and self link.
*
* It can be called directly for autogenerated elements which might not have a corresponding AST.
*/
function htmlToplevelChildModifierByHref(out, href) {
let linkToSelf
let cls
if (href) {
linkToSelf = `<a${htmlAttr('href', href)}></a>`
cls = ''
} else {
linkToSelf = ''
cls = ' class="nolink"'
}
return out
//return `<div${cls}>${linkToSelf}${out}</div>`
}
function htmlToplevelChildModifierById(out, id) {
const href = id ? `#${id}` : ''
return htmlToplevelChildModifierByHref(out, href)
}
exports.htmlToplevelChildModifierById = htmlToplevelChildModifierById
class OutputFormat {
constructor(id, opts={}) {
this.id = id
this.ext = opts.ext
this.convert_funcs = opts.convert_funcs
if ('toplevelChildModifier' in opts) {
this.toplevelChildModifier = opts.toplevelChildModifier
} else {
this.toplevelChildModifier = (ast, context, out) => out
}
}
}
const OUTPUT_FORMATS_LIST = [
new OutputFormat(
OUTPUT_FORMAT_HTML,
{
ext: HTML_EXT,
toplevelChildModifier: function(ast, context, out) {
let href
if (ast) {
href = xHref(ast, context)
} else {
href = ''
}
return htmlToplevelChildModifierByHref(out, href)
},
convert_funcs: {
[Macro.LINK_MACRO_NAME]: function(ast, context) {
let [href, content] = linkGetHrefContent(ast, context);
if (ast.validation_output.ref.boolean) {
content = `${HTML_REF_MARKER}`;
}
const external = ast.validation_output.external.given ? ast.validation_output.external.boolean : undefined
let attrs = htmlRenderAttrsId(ast, context);
if (context.options.ourbigbook_json.openLinksOnNewTabs) {
attrs += ' target="_blank"'
}
return getLinkHtml({
ast,
attrs,
content,
context,
external,
href,
source_location: ast.args.href.source_location,
})
},
'b': htmlRenderSimpleElem('b'),
'br': function(ast, context) { return '<br>' },
[Macro.CODE_MACRO_NAME.toUpperCase()]: function(ast, context) {
const { title_and_description, multiline_caption } = htmlTitleAndDescription(ast, context)
let ret = `<div class="code${multiline_caption}"${htmlRenderAttrsId(ast, context)}>`
ret += htmlCode(renderArg(ast.args.content, context))
ret += title_and_description
ret += `</div>`
return ret
},
[Macro.CODE_MACRO_NAME]: htmlRenderSimpleElem('code'),
[Macro.OURBIGBOOK_EXAMPLE_MACRO_NAME]: unconvertible,
'Comment': function(ast, context) { return ''; },
'comment': function(ast, context) { return ''; },
[Macro.HEADER_MACRO_NAME]: function(ast, context, opts={}) {
if (opts.extra_returns === undefined) {
opts.extra_returns = {}
}
if (context.in_header) {
// Previously was doing an infinite loop when rendering the parent header.
// But not valid HTML, so I don't think it is worth allowing at all:
// https://stackoverflow.com/questions/17363465/is-nesting-a-h2-tag-inside-another-header-with-h1-tag-semantically-wrong/71130770#71130770
const message = `cannot have a header inside another`;
renderError(context, message, ast.source_location);
return errorMessageInOutput(message, context);
}
const context_old = context
context = cloneAndSet(context, 'in_header', true)
const children = ast.args[Macro.HEADER_CHILD_ARGNAME]
const tags = ast.args[Macro.HEADER_TAG_ARGNAME]
if (ast.validation_output.synonym.boolean) {
if (children !== undefined) {
const message = `"synonym" and "child" are incompatible`;
renderError(context, message, children.source_location);
return errorMessageInOutput(message, context);
}
if (tags !== undefined) {
const message = `"synonym" and "tags" are incompatible`;
renderError(context, message, tags.source_location);
return errorMessageInOutput(message, context);
}
return '';
}
let level_int = ast.header_tree_node.get_level();
if (typeof level_int !== 'number') {
throw new Error('header level is not an integer after validation');
}
let custom_args;
const level_int_output = level_int - context.header_tree_top_level + 1;
const is_top_level = level_int === context.header_tree_top_level
let level_int_capped;
if (level_int_output > 6) {
custom_args = {'data-level': new AstArgument([new PlaintextAstNode(
level_int_output.toString(), ast.source_location)], ast.source_location)};
level_int_capped = 6;
} else {
custom_args = {};
level_int_capped = level_int_output;
}
let attrs = htmlRenderAttrs(ast, context, [], custom_args)
let id_attr = htmlRenderAttrsId(ast, context);
let ret = '';
ret += context.renderBeforeNextHeader.map(s => htmlToplevelChildModifierById(s)).join('')
context_old.renderBeforeNextHeader = []
let hasToc = false
if (
level_int !== context.header_tree_top_level ||
context.header_tree.children.length > 1 &&
context.options.render_metadata
) {
let render_toc_ret = renderToc(context)
if (render_toc_ret !== '') {
opts.extra_returns.render_pre = htmlToplevelChildModifierById(render_toc_ret, Macro.TOC_ID)
hasToc = true
}
}
// Div that contains h + on hover span.
let first_header = (
// May fail in some error scenarios.
context.toplevel_ast !== undefined &&
ast.id === context.toplevel_ast.id
)
ret += `<div class="h${first_header ? ' top' : ''}"${id_attr}${hasToc && context.options.add_test_instrumentation ? ' data-has-toc="1"' : ''}>`;
// Self link.
let self_link_context
let self_link_ast
if (context.options.split_headers) {
if (ast.from_include && !context.options.embed_includes) {
self_link_ast = context.db_provider.get(ast.id, context)
self_link_context = context
} else {
self_link_context = cloneAndSet(context, 'to_split_headers', true)
self_link_ast = ast
}
} else {
self_link_context = context
self_link_ast = ast
}
ret += `<div class="notnav"><h${level_int_capped}${attrs}><a${xHrefAttr(self_link_ast, self_link_context)}>`;
let x_text_options = {
show_caption_prefix: false,
style_full: true,
};
const x_text_base_ret = xTextBase(ast, context, x_text_options);
if (context.toplevel_output_path) {
const rendered_outputs_entry = context.extra_returns.rendered_outputs[context.toplevel_output_path]
if (
// So that when we are rendering h2Render we don't overwrite the "real" output.
!context.skipOutputEntry &&
// Can fail due to splits, which could overwrite nonsplit values.
rendered_outputs_entry !== undefined &&
rendered_outputs_entry.title === undefined
) {
rendered_outputs_entry.title = x_text_base_ret.inner;
const title_arg = ast.args[Macro.TITLE_ARGUMENT_NAME]
rendered_outputs_entry.titleSource = renderArg(
title_arg,
cloneAndSet(context, 'options',
cloneAndSet(context.options, 'output_format', OUTPUT_FORMAT_OURBIGBOOK)
)
)
rendered_outputs_entry.titleSourceLocation = title_arg.source_location
}
}
ret += x_text_base_ret.full;
ret += `</a>`;
ret += `</h${level_int_capped}>`;
const web_meta = []
if (context.options.h_web_metadata) {
const web_html = `<div class="web${first_header ? ' top' : ''}"></div>`
if (first_header) {
web_meta.push(web_html)
} else {
ret += web_html
}
}
// On hover metadata.
let link_to_split;
let parent_links;
{
const items = []
if (
context.options.split_headers &&
context.options.h_show_split_header_link
) {
link_to_split = linkToSplitOpposite(ast, context);
if (link_to_split) {
items.push(`${link_to_split}`);
}
}
let descendant_count = getDescendantCountHtml(context, ast.header_tree_node, { long_style: true });
if (descendant_count !== undefined) {
items.push(`${descendant_count}`);
}
if (!first_header) {
ret += `<span class="hover-meta"> ${items.join('')}</span>`
}
}
// .notnav
ret += '</div>'
// Metadata that shows on separate lines below toplevel header.
let wiki_link;
if (ast.validation_output.wiki.given) {
let wiki = renderArg(ast.args.wiki, context);
if (wiki === '') {
wiki = (renderArg(ast.args[Macro.TITLE_ARGUMENT_NAME], context)).replace(/ /g, '_');
if (ast.validation_output[Macro.DISAMBIGUATE_ARGUMENT_NAME].given) {
wiki += '_(' + (renderArg(ast.args[Macro.DISAMBIGUATE_ARGUMENT_NAME], context)).replace(/ /g, '_') + ')'
}
}
wiki_link = `<a href="https://en.wikipedia.org/wiki/${htmlEscapeAttr(wiki)}" class="wiki"></a>`;
}
let ourbigbookLink
if (context.options.ourbigbook_json.web.linkFromStaticHeaderMetaToWeb) {
// Same procedure that can be done from ourbigbook.json to redirect every link out.
const newContext = { ...context }
const newOptions = { ...context.options }
const newOurbigbookJson = { ...context.options.ourbigbook_json }
newContext.options = newOptions
newOptions.ourbigbook_json = newOurbigbookJson
newContext.to_split_headers = true
newOptions.htmlXExtension = false
newOurbigbookJson.xPrefix = ``
let logoPath
if (newContext.options.publish) {
// Relative path.
logoPath = `${newContext.options.template_vars.root_relpath}${newContext.options.logoPath}`
} else {
// Absolute path to local resource.
logoPath = newContext.options.logoPath
}
let p
if (context.options.isindex && ast.is_first_header_in_input_file) {
p = ''
} else {
p = `${URL_SEP}${ast.id}`
}
ourbigbookLink = `<a href="${context.webUrl}${context.options.ourbigbook_json.web.username}${p}"><img src="${logoPath}" class="logo" /> ${newContext.options.ourbigbook_json.web.hostCapitalized}</a>`;
}
// file handling 1
// Calculate file_link_html
let fileLinkHtml, fileContent
const fileProtocolIsGiven = protocolIsGiven(ast.file)
const renderPostAstsContext = cloneAndSet(context, 'validateAst', true)
renderPostAstsContext.source_location = ast.source_location
if (ast.file) {
if (ast.file.match(media_provider_type_youtube_re)) {
} else {
const readFileRet = context.options.read_file(ast.file, context)
if (readFileRet) {
;({ content: fileContent } = readFileRet)
}
}
// This section is about.
const pathArg = []
if (fileProtocolIsGiven) {
pathArg.push(
new AstNode(AstType.MACRO,
Macro.LINK_MACRO_NAME,
{
href: new AstArgument([
new PlaintextAstNode(ast.file)
]),
}
),
)
} else {
let curp = ''
pathArg.push(
new PlaintextAstNode(' '),
new AstNode(AstType.MACRO,
Macro.LINK_MACRO_NAME,
{
content: new AstArgument([
new PlaintextAstNode(FILE_ROOT_PLACEHOLDER)
]),
href: new AstArgument([
new PlaintextAstNode(URL_SEP)
]),
}
),
)
for (const p of ast.file.split(URL_SEP)) {
pathArg.push(new PlaintextAstNode(' ' + URL_SEP + ' '))
if (curp !== '') {
curp += URL_SEP
}
curp += p
const astNodeArgs = {
content: new AstArgument([
new PlaintextAstNode(p)
]),
href: new AstArgument([
new PlaintextAstNode(Macro.HEADER_SCOPE_SEPARATOR + curp)
]),
}
if (context.options.add_test_instrumentation) {
astNodeArgs[Macro.TEST_DATA_ARGUMENT_NAME] = [new PlaintextAstNode(ast.id + Macro.RESERVED_ID_PREFIX + Macro.RESERVED_ID_PREFIX + curp)]
}
pathArg.push(new AstNode(AstType.MACRO, Macro.LINK_MACRO_NAME, astNodeArgs))
}
}
fileLinkHtml = new AstNode(AstType.MACRO, 'b', {
content: new AstArgument(pathArg)
}).render(renderPostAstsContext)
}
// Calculate tag_ids_html
const tag_ids_html_array = [];
let tag_ids_html
const showTags = !ast.from_include || context.options.embed_includes
if (showTags) {
const new_context = cloneAndSet(context, 'validateAst', true);
// This is needed because in case of an an undefined \\x with {parent},
// the undefined target would render as a link on the parent, leading
// to an error that happens on the header, which is before the actual
// root cause.
new_context.ignore_errors = true;
new_context.source_location = ast.source_location;
const target_tag_asts = context.db_provider.get_refs_to_as_asts(
REFS_TABLE_X_CHILD, ast.id, new_context, { current_scope: ast.scope });
for (const target_id of target_tag_asts.map(ast => ast.id).sort()) {
const x_ast = new AstNode(
AstType.MACRO,
Macro.X_MACRO_NAME,
{
'href': new AstArgument(
[
new PlaintextAstNode(target_id),
],
),
'c': new AstArgument(),
},
ast.source_location,
{
scope: ast.scope,
}
);
tag_ids_html_array.push(x_ast.render(new_context));
}
tag_ids_html = `<span class="tags"> tags: ${tag_ids_html_array.join(', ')}</span>`
}
let toc_link_html;
{
if (
context.forceHeadersHaveTocLink ||
(
!is_top_level &&
checkHasToc(context)
)
) {
let id = ast.id
const fixedScopeRemoval = context.options.fixedScopeRemoval
if (fixedScopeRemoval !== undefined) {
// This is hacky, maybe we shouldn't overload fixedScopeRemoval for this...
// it is used only on web so... lazy to think now.
id = id.slice(fixedScopeRemoval)
}
const toc_href = htmlAttr('href', '#' + htmlEscapeAttr(tocId(id)));
toc_link_html = `<a${toc_href} class="toc"></a>`
}
}
// Calculate header_meta and header_meta
let header_meta = [];
let header_meta_ancestors = [];
let header_meta_file = [];
if (fileLinkHtml !== undefined) {
header_meta_file.push(fileLinkHtml);
}
if (first_header) {
if (!context.options.h_web_metadata) {
const ancestors = ast.ancestors(context)
const nAncestors = ancestors.length
if (nAncestors) {
const nearestAncestors = ancestors.slice(0, ANCESTORS_MAX).reverse()
const entries = []
for (const ancestor of nearestAncestors) {
const href = xHrefAttr(ancestor, context)
const content = renderArg(ancestor.args[Macro.TITLE_ARGUMENT_NAME], context)
entries.push({ href, content })
}
header_meta_ancestors.push(htmlAncestorLinks(entries, nAncestors));
}
}
} else {
const parent_asts = ast.get_header_parent_asts(context)
parent_links = [];
for (const parent_ast of parent_asts) {
const parent_href = xHrefAttr(parent_ast, context);
const parent_content = renderArg(parent_ast.args[Macro.TITLE_ARGUMENT_NAME], context);
// .u for Up
parent_links.push(`<a${parent_href} class="u"> ${parent_content}</a>`);
}
parent_links = parent_links.join('');
if (parent_links) {
header_meta.push(parent_links);
}
}
if (first_header) {
if (checkHasToc(context)) {
header_meta.push(`<a${htmlAttr('href', `#${context.options.tocIdPrefix}${Macro.TOC_ID}`)} class="toc"></a>`);
}
} else {
if (toc_link_html) {
header_meta.push(toc_link_html);
}
}
if (wiki_link !== undefined) {
header_meta.push(wiki_link);
}
if (ourbigbookLink !== undefined) {
header_meta.push(ourbigbookLink);
}
if (tag_ids_html_array.length) {
header_meta.push(tag_ids_html);
}
if (first_header) {
if (link_to_split !== undefined) {
header_meta.push(link_to_split);
}
let descendant_count_html = getDescendantCountHtml(context, ast.header_tree_node, { long_style: true });
if (descendant_count_html !== undefined) {
header_meta.push(descendant_count_html);
}
}
const metas = [
[web_meta, ''],
[header_meta_ancestors, 'ancestors'],
[header_meta, ''],
[header_meta_file, 'file'],
]
const header_has_meta = metas.some((m) => m[0].length > 0)
if (header_has_meta) {
ret += `<nav class="h-nav h-nav-toplevel">`;
}
if (context.options.h_web_ancestors && first_header && !context.options.isindex) {
ret += `<div class="nav ancestors"></div>`
}
let i = 0
for (const [meta, cls] of metas) {
if (meta.length > 0) {
ret += `<div class="nav${cls ? ` ${cls}` : ''}">${meta.join('')}</div>`;
}
i++
}
if (header_has_meta) {
ret += `</nav>`;
}
ret += `</div>`;
if (showTags) {
if (children !== undefined) {
ret += headerCheckChildTagExists(ast, context, children, 'child')
}
if (tags !== undefined) {
ret += headerCheckChildTagExists(ast, context, tags, 'tag')
}
}
// Variables we want permanently modify the context.
context_old.toc_was_rendered = context.toc_was_rendered
// file handling 2
const renderPostAsts = []
if (ast.file) {
const absPref = fileProtocolIsGiven ? '' : URL_SEP
if (IMAGE_EXTENSIONS.has(pathSplitext(ast.file)[1])) {
renderPostAsts.push(new AstNode(
AstType.MACRO,
'Image',
{
'src': new AstArgument(
[
new PlaintextAstNode(absPref + ast.file)
],
),
},
))
} else if (
VIDEO_EXTENSIONS.has(pathSplitext(ast.file)[1]) ||
ast.file.match(media_provider_type_youtube_re)
) {
renderPostAsts.push(new AstNode(
AstType.MACRO,
'Video',
{
'src': new AstArgument(
[
new PlaintextAstNode(absPref + ast.file)
],
),
},
))
} else {
// Plaintext file. Possibly embed into HTML.
const protocol = protocolGet(ast.file)
if (protocol === null || protocol === 'file') {
if (fileContent !== undefined) {
// https://stackoverflow.com/questions/1677644/detect-non-printable-characters-in-javascript
const bold_file_ast = new AstNode(AstType.MACRO, 'b', {
content: new AstArgument([
new PlaintextAstNode(`${ast.file}`),
])
})
let no_preview_msg
if (/[\x00]/.test(fileContent)) {
no_preview_msg = ` it is a binary file (contains \\x00) of unsupported type (e.g. not an image).`
} else if (
fileContent.length > FILE_PREVIEW_MAX_SIZE &&
!context.in_split_headers &&
!context.options.hFileShowLarge
) {
no_preview_msg = `it is too large (> ${FILE_PREVIEW_MAX_SIZE} bytes)`
}
if (no_preview_msg) {
context_old.renderBeforeNextHeader.push(new AstNode(AstType.MACRO, Macro.PARAGRAPH_MACRO_NAME, {
content: new AstArgument([
bold_file_ast,
new PlaintextAstNode(` was not rendered because ${no_preview_msg}`),
])
}).render(renderPostAstsContext))
} else {
context_old.renderBeforeNextHeader.push(new AstNode(AstType.MACRO, Macro.PARAGRAPH_MACRO_NAME, {
content: new AstArgument([
bold_file_ast
])
}).render(renderPostAstsContext))
context_old.renderBeforeNextHeader.push(new AstNode(
AstType.MACRO,
Macro.CODE_MACRO_NAME.toUpperCase(), {
content: new AstArgument([ new PlaintextAstNode(fileContent)]),
},
).render(renderPostAstsContext))
}
}
}
}
}
if (renderPostAsts.length) {
opts.extra_returns.render_post = renderPostAsts.map(a => htmlToplevelChildModifierById(a.render(renderPostAstsContext))).join('')
}
return ret;
},
[Macro.INCLUDE_MACRO_NAME]: unconvertible,
[Macro.LIST_ITEM_MACRO_NAME]: htmlRenderSimpleElem('li'),
[Macro.MATH_MACRO_NAME.toUpperCase()]: function(ast, context) {
let katex_output = htmlKatexConvert(ast, context)
let ret = ``
if (ast.validation_output.show.boolean) {
const { href, multiline_caption, title_and_description } = htmlTitleAndDescription(ast, context)
ret += `<div class="math${multiline_caption}"${htmlRenderAttrsId(ast, context)}>`
ret += `<div class="equation">`
ret += `<div>${katex_output}</div>`
ret += `<div class="number"><a${href}>(${context.macros[ast.macro_name].options.get_number(ast, context)})</a></div>`
ret += `</div>`
ret += title_and_description
ret += `</div>`
}
return ret
},
[Macro.MATH_MACRO_NAME]: function(ast, context) {
// KaTeX already adds a <span> for us.
return htmlKatexConvert(ast, context);
},
'i': htmlRenderSimpleElem('i'),
'Image': macroImageVideoBlockConvertFunction,
'image': function(ast, context) {
let alt_arg;
if (ast.args.alt === undefined) {
alt_arg = ast.args.src;
} else {
alt_arg = ast.args.alt;
}
let alt = htmlAttr('alt', htmlEscapeAttr(renderArg(alt_arg, context)));
let rendered_attrs = htmlRenderAttrsId(ast, context, ['height', 'width']);
let { error_message, media_provider_type, src } = macroImageVideoResolveParams(ast, context);
const external = ast.validation_output.external.given ? ast.validation_output.external.boolean : undefined
let { html: imgHtml } = htmlImg({ alt, ast, context, external, inline: true, media_provider_type, rendered_attrs, src })
if (error_message) {
imgHtml += errorMessageInOutput(error_message, context)
}
return imgHtml
},
'JsCanvasDemo': function(ast, context) {
return htmlCode(
renderArg(ast.args.content, context),
{ 'class': 'ourbigbook-js-canvas-demo' }
);
},
'Ol': htmlRenderSimpleElem('ol', UL_OL_OPTS),
[Macro.PARAGRAPH_MACRO_NAME]: htmlRenderSimpleElem(
'div',
{
attrs: {'class': 'p'},
}
),
[Macro.PLAINTEXT_MACRO_NAME]: function(ast, context) {
return htmlEscapeContext(context, ast.text);
},
'passthrough': function(ast, context) {
return renderArgNoescape(ast.args.content, context);
},
'Q': htmlRenderSimpleElem('blockquote'),
'sub': htmlRenderSimpleElem('sub'),
'sup': htmlRenderSimpleElem('sup'),
[Macro.TABLE_MACRO_NAME]: function(ast, context) {
let attrs = htmlRenderAttrsId(ast, context);
let content = renderArg(ast.args.content, context);
let ret = ``;
let { description, force_separator, multiline_caption } = getDescription(ast.args.description, context)
ret += `<div class="table${multiline_caption}"${attrs}>`;
// TODO not using caption because I don't know how to allow the caption to be wider than the table.
// I don't want the caption to wrap to a small table size.
//
// If we ever solve that, re-add the following style:
//
// caption {
// color: black;
// text-align: left;
// }
//
//Caption on top as per: https://tex.stackexchange.com/questions/3243/why-should-a-table-caption-be-placed-above-the-table */
let href = htmlAttr('href', '#' + htmlEscapeAttr(ast.id));
if (ast.index_id || ast.validation_output.description.given) {
const { full: title, inner } = xTextBase(ast, context, {
href_prefix: href,
force_separator,
})
const title_and_description = getTitleAndDescription({ title, description, inner })
ret += `<div class="caption">${title_and_description}</div>`;
}
ret += `<table>${content}</table>`;
ret += `</div>`;
return ret;
},
[Macro.TD_MACRO_NAME]: htmlRenderSimpleElem('td'),
[Macro.TOPLEVEL_MACRO_NAME]: function(ast, context) {
let title = ast.args[Macro.TITLE_ARGUMENT_NAME];
if (title === undefined) {
let text_title;
if (Macro.TITLE_ARGUMENT_NAME in context.options) {
text_title = context.options[Macro.TITLE_ARGUMENT_NAME];
} else if (context.header_tree.children.length > 0) {
text_title = renderArg(
context.header_tree.children[0].ast.args[Macro.TITLE_ARGUMENT_NAME],
cloneAndSet(context, 'id_conversion', true)
);
} else {
text_title = 'dummy title because title is mandatory in HTML';
}
title = new AstArgument([
new PlaintextAstNode(text_title, ast.source_location)],
ast.source_location,
text_title
);
}
let body = renderArg(ast.args.content, context);
// Footer metadata.
if (context.options.render_metadata) {
body += renderToc(context)
}
body += context.renderBeforeNextHeader.map(s => htmlToplevelChildModifierById(s)).join('')
if (
context.toplevel_ast !== undefined &&
context.options.render_metadata
) {
{
const target_ids = context.db_provider.get_refs_to_as_ids(
REFS_TABLE_X_CHILD, context.toplevel_ast.id, true);
body += createLinkList(context, ast, TAGGED_ID_UNRESERVED, `${TAGS_MARKER} Tagged`, target_ids)
}
// Ancestors
{
const ancestors = context.toplevel_ast.ancestors(context)
if (ancestors.length !== 0) {
// TODO factor this out more with real headers.
body += htmlToplevelChildModifierById(`<h2 id="${ANCESTORS_ID}"><a href="#${ANCESTORS_ID}">${HTML_PARENT_MARKER} Ancestors</a></h2>`, ANCESTORS_ID)
const ancestor_id_asts = [];
for (const ancestor of ancestors) {
//let counts_str;
//if (ancestor.header_tree_node !== undefined) {
// counts_str = getDescendantCountHtml(ancestor.header_tree_node, false);
//} else {
// counts_str = '';
//}
ancestor_id_asts.push(new AstNode(
AstType.MACRO,
Macro.LIST_ITEM_MACRO_NAME,
{
'content': new AstArgument(
[
new AstNode(
AstType.MACRO,
Macro.X_MACRO_NAME,
{
'href': new AstArgument(
[
new PlaintextAstNode(ancestor.id),
],
),
'c': new AstArgument(),
},
),
//new AstNode(
// AstType.MACRO,
// 'passthrough',
// {
// 'content': new AstArgument(
// [
// new PlaintextAstNode(counts_str),
// ],
// ),
// },
// undefined,
// {
// xss_safe: true,
// }
//),
],
),
},
));
}
const ulArgs = {
'content': new AstArgument(ancestor_id_asts)
}
if (context.options.add_test_instrumentation) {
ulArgs[Macro.TEST_DATA_ARGUMENT_NAME] = [new PlaintextAstNode(ANCESTORS_ID_UNRESERVED)]
}
const incoming_ul_ast = new AstNode(
AstType.MACRO,
'Ol',
ulArgs,
);
const new_context = cloneAndSet(context, 'validateAst', true);
new_context.source_location = ast.source_location;
body += htmlToplevelChildModifierById(incoming_ul_ast.render(new_context))
}
}
{
const target_ids = context.db_provider.get_refs_to_as_ids(REFS_TABLE_X, context.toplevel_ast.id);
body += createLinkList(context, ast, INCOMING_LINKS_ID_UNRESERVED, `${INCOMING_LINKS_MARKER} Incoming links`, target_ids)
}
{
const target_ids = context.db_provider.get_refs_to_as_ids(REFS_TABLE_SYNONYM, context.toplevel_ast.id);
body += createLinkList(context, ast, SYNONYM_LINKS_ID_UNRESERVED, `${SYNONYM_LINKS_MARKER} Synonyms`, target_ids)
}
}
let ret
if (context.options.body_only) {
ret = body;
} else {
let template;
if (context.options.template !== undefined) {
template = context.options.template;
} else {
template = `<!doctype html>
<html lang=en>
<head>
<meta charset=utf-8>
<title>{{ title }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>{{ style }}</style>
{{ head }}</head>
<body class="ourbigbook">
{{ body }}
{{ post_body }}</body>
</html>
`;
}
let root_page;
if (context.options.htmlXExtension) {
context.options.template_vars.html_ext = '.html';
context.options.template_vars.html_index = '/index.html';
root_page = context.options.template_vars.root_relpath + INDEX_BASENAME_NOEXT + '.' + HTML_EXT;
} else {
context.options.template_vars.html_ext = '';
context.options.template_vars.html_index = '';
if (context.options.template_vars.root_relpath === '') {
root_page = '.'
} else {
root_page = context.options.template_vars.root_relpath;
}
}
if (root_page === context.toplevel_output_path) {
root_page = '';
}
const render_env = {
body,
root_page,
title: renderArg(title, context),
};
if (context.options.auto_generated_source) {
render_env.input_path = ''
} else {
render_env.input_path = context.options.input_path
}
const github_prefix = githubProviderPrefix(context)
if (github_prefix) {
render_env.github_prefix = github_prefix
}
Object.assign(render_env, context.options.template_vars);
// Resolve relative styles and scripts.
let relative_scripts = [];
for (const script of context.options.template_scripts_relative) {
relative_scripts.push(`<script src="${context.options.template_vars.root_relpath}${script}"></script>`);
}
const toplevel_scope = context.toplevel_ast ? context.toplevel_ast.calculate_scope() : undefined
const ourbigbook_redirect_prefix_raw = toplevel_scope ? `${toplevel_scope}${URL_SEP}` : ''
const ourbigbook_redirect_prefix = JSON.stringify(ourbigbook_redirect_prefix_raw).replace(/</g, '\\u003c')
const data_script = `<script>
window.ourbigbook_split_headers = ${context.options.split_headers};
window.ourbigbook_html_x_extension = ${context.options.htmlXExtension};
window.ourbigbook_redirect_prefix = ${ourbigbook_redirect_prefix};
</script>
`
render_env.post_body = data_script + relative_scripts.join('') + render_env.post_body + "<script>ourbigbook_runtime.ourbigbook_runtime()</script>";
let relative_styles = [];
for (const style of context.options.template_styles_relative) {
relative_styles.push(`@import "${context.options.template_vars.root_relpath}${style}";\n`);
}
render_env.style = relative_styles.join('') + render_env.style
render_env.is_index_article = !!(context.options.isindex && context.toplevel_ast.is_first_header_in_input_file && !toplevel_scope)
const { Liquid } = require('liquidjs');
ret = (new Liquid()).parseAndRenderSync(
template,
render_env,
{
strictFilters: true,
strictVariables: true,
}
);
}
return ret;
},
[Macro.TH_MACRO_NAME]: htmlRenderSimpleElem('th'),
[Macro.TR_MACRO_NAME]: function(ast, context) {
let content_ast = ast.args.content;
let content = renderArg(content_ast, context);
let res = '';
if (ast.args.content.get(0).macro_name === Macro.TH_MACRO_NAME) {
if (
ast.parent_argument_index === 0 ||
ast.parent_argument.get(ast.parent_argument_index - 1).args.content.get(0).macro_name !== Macro.TH_MACRO_NAME
) {
res += `<thead>`;
}
}
if (ast.args.content.get(0).macro_name === Macro.TD_MACRO_NAME) {
if (
ast.parent_argument_index === 0 ||
ast.parent_argument.get(ast.parent_argument_index - 1).args.content.get(0).macro_name !== Macro.TD_MACRO_NAME
) {
res += `<tbody>`;
}
}
res += `<tr${htmlRenderAttrsId(ast, context)}>${content}</tr>`;
if (ast.args.content.get(0).macro_name === Macro.TH_MACRO_NAME) {
if (
ast.parent_argument_index === ast.parent_argument.length() - 1 ||
ast.parent_argument.get(ast.parent_argument_index + 1).args.content.get(0).macro_name !== Macro.TH_MACRO_NAME
) {
res += `</thead>`;
}
}
if (ast.args.content.get(0).macro_name === Macro.TD_MACRO_NAME) {
if (
ast.parent_argument_index === ast.parent_argument.length() - 1 ||
ast.parent_argument.get(ast.parent_argument_index + 1).args.content.get(0).macro_name !== Macro.TD_MACRO_NAME
) {
res += `</tbody>`;
}
}
return res;
},
'Ul': htmlRenderSimpleElem('ul', UL_OL_OPTS),
[Macro.X_MACRO_NAME]: function(ast, context) {
let [href, content, target_ast] = xGetHrefContent(ast, context);
let incompatible_pair
if (ast.validation_output.full.given) {
if (ast.validation_output.ref.given) {
incompatible_pair = ['full', 'ref']
}
if (ast.validation_output.content.given) {
incompatible_pair = ['full', 'content']
}
if (ast.validation_output.c.given) {
incompatible_pair = ['full', 'c']
}
if (ast.validation_output.p.given) {
incompatible_pair = ['full', 'p']
}
} else if (ast.validation_output.content.given) {
if (ast.validation_output.ref.given) {
incompatible_pair = ['content', 'ref']
}
if (ast.validation_output.c.given) {
incompatible_pair = ['content', 'c']
}
if (ast.validation_output.p.given) {
incompatible_pair = ['content', 'p']
}
} else if (ast.validation_output.ref.given) {
if (ast.validation_output.c.given) {
incompatible_pair = ['ref', 'c']
}
if (ast.validation_output.p.given) {
incompatible_pair = ['ref', 'p']
}
}
if (incompatible_pair) {
const message = `"${incompatible_pair[0]}" and "${incompatible_pair[1]}" are incompatible`;
renderError(context, message, ast.source_location);
content = errorMessageInOutput(message, context);
} else if (ast.validation_output.ref.boolean) {
content = HTML_REF_MARKER;
}
if (context.x_parents.size === 0) {
// Counts.
let counts_str;
if (
// Happens on error case of linking to non existent ID.
target_ast === undefined ||
// Happens for cross links. TODO make those work too...
target_ast.parent_ast === undefined
) {
counts_str = '';
} else {
const counts = getDescendantCount(target_ast.header_tree_node);
for (let i = 0; i < counts.length; i++) {
counts[i] = formatNumberApprox(counts[i]);
}
counts_str = `\nword count: ${counts[0]}\ndescendant word count: ${counts[2]}\ndescendant count: ${counts[1]}`;
}
const attrs = htmlRenderAttrsId(ast, context);
// It would be cleaner to pass this up from xHrefParts. But lazy.
let target = ''
if (context.options.ourbigbook_json.openLinksOnNewTabs && href) {
const splitFragment = href.split('#')
let href_path, fragment
if (splitFragment.length > 1) {
;[href_path, fragment] = splitFragment
} else {
href_path = href
}
if (href_path !== '') {
target = ' target="_blank"'
}
}
return `<a${href}${attrs}${context.options.internalLinkMetadata ? htmlAttr('title', 'internal link' + counts_str) : ''}${target}>${content}</a>`;
} else {
return content;
}
},
'Video': macroImageVideoBlockConvertFunction,
},
}
),
new OutputFormat(
OUTPUT_FORMAT_ID,
{
ext: 'id',
convert_funcs: {
[Macro.LINK_MACRO_NAME]: function(ast, context) {
const [href, content] = linkGetHrefContent(ast, context);
return content;
},
'b': idConvertSimpleElem(),
'br': function(ast, context) { return '\n'; },
[Macro.CODE_MACRO_NAME.toUpperCase()]: idConvertSimpleElem(),
[Macro.CODE_MACRO_NAME]: idConvertSimpleElem(),
[Macro.OURBIGBOOK_EXAMPLE_MACRO_NAME]: unconvertible,
'Comment': function(ast, context) { return ''; },
'comment': function(ast, context) { return ''; },
[Macro.HEADER_MACRO_NAME]: idConvertSimpleElem(),
[Macro.INCLUDE_MACRO_NAME]: unconvertible,
[Macro.LIST_ITEM_MACRO_NAME]: idConvertSimpleElem(),
[Macro.MATH_MACRO_NAME.toUpperCase()]: idConvertSimpleElem(),
[Macro.MATH_MACRO_NAME]: idConvertSimpleElem(),
'i': idConvertSimpleElem(),
'Image': function(ast, context) { return ''; },
'image': function(ast, context) { return ''; },
'JsCanvasDemo': idConvertSimpleElem(),
'Ol': idConvertSimpleElem(),
[Macro.PARAGRAPH_MACRO_NAME]: idConvertSimpleElem(),
[Macro.PLAINTEXT_MACRO_NAME]: function(ast, context) { return ast.text },
'passthrough': idConvertSimpleElem(),
'Q': idConvertSimpleElem(),
'sub': idConvertSimpleElem(),
'sup': idConvertSimpleElem(),
[Macro.TABLE_MACRO_NAME]: idConvertSimpleElem(),
[Macro.TD_MACRO_NAME]: idConvertSimpleElem(),
[Macro.TOPLEVEL_MACRO_NAME]: idConvertSimpleElem(),
[Macro.TH_MACRO_NAME]: idConvertSimpleElem(),
[Macro.TR_MACRO_NAME]: idConvertSimpleElem(),
'Ul': idConvertSimpleElem(),
[Macro.X_MACRO_NAME]: function(ast, context) {
if (ast.args.content) {
return idConvertSimpleElem('content')(ast, context)
} else {
return idConvertSimpleElem('href')(ast, context)
}
},
'Video': function(ast, context) { return ''; },
}
}
),
]
function ourbigbookCodeMathInline(c) {
return function(ast, context) {
const content = renderArg(ast.args.content, cloneAndSet(context, 'in_literal', true))
if (content.indexOf(c) === -1) {
return `${c}${content}${c}`
} else {
return ourbigbookConvertSimpleElem(ast, context)
}
}
}
function ourbigbookCodeMathBlock(c) {
return function(ast, context) {
context = cloneAndSet(context, 'in_literal', true)
const content = renderArg(ast.args.content, context)
let delim = c + c
while (content.indexOf(delim) !== -1) {
delim += c
}
const newline = '\n'.repeat(ourbigbookAddNewlinesAfterBlock(ast, context))
const attrs = ourbigbookConvertArgs(ast, context, { skip: new Set(['content']) }).join('')
return `${delim}
${content}
${delim}${attrs === '' ? '' : '\n'}${attrs}${newline}`
}
}
/** Get the preferred x href for the ourbigbook output format of an \x. */
function ourbigbookGetXHref({
ast,
context,
target_ast,
target_id,
href,
c,
p,
magic,
scope,
for_header_parent,
}) {
href = href.replaceAll(ID_SEPARATOR, ' ')
if (p) {
const href_plural = pluralizeWrap(href, 2)
let target_ast_plural = context.db_provider.get(magicTitleToId(href_plural), context)
if (
// When we have \x without magic to a destination that exists
// in both plural and singular, we can't use the magic plural,
// or it will resolve to plural rather than the correct singular.
!magic &&
target_ast &&
target_ast_plural
) {
return { override_href: `<${href}>${ourbigbookConvertArgs(ast, context, { skip: new Set(['c', 'href', 'magic']) }).join('')}` }
}
href = href_plural
if (target_ast_plural) {
target_ast = target_ast_plural
}
}
if (!target_ast) {
return { override_href: `${ourbigbookConvertSimpleElem(ast, context)} ${renderErrorXUndefined(ast, context, target_id)}` }
}
let was_magic_plural, was_magic_uppercase
if (magic && !for_header_parent) {
const href_singular = pluralizeWrap(href, 1)
if (!target_ast.args[Macro.DISAMBIGUATE_ARGUMENT_NAME] && href !== href_singular) {
was_magic_plural = true
}
const components = href.split(Macro.HEADER_SCOPE_SEPARATOR)
const c = components[components.length - 1][0]
was_magic_uppercase = c === c.toUpperCase()
}
const href_from_id = href
if (!(
(
target_ast.macro_name === Macro.HEADER_MACRO_NAME &&
target_ast.validation_output.file.given
)
)) {
const macro = context.macros[target_ast.macro_name];
const title_arg = macro.options.get_title_arg(target_ast, context);
href = renderArg(title_arg, cloneAndSet(context, 'id_conversion', true));
href = href.replaceAll(Macro.HEADER_SCOPE_SEPARATOR, ' ')
let was_pluralized
let disambiguate
const disambiguate_arg = target_ast.args[Macro.DISAMBIGUATE_ARGUMENT_NAME];
if (disambiguate_arg) {
disambiguate = renderArg(disambiguate_arg, cloneAndSet(context, 'id_conversion', true))
} else {
disambiguate = ''
}
const explicit_id = target_ast.first_toplevel_child || (
target_ast.validation_output[Macro.ID_ARGUMENT_NAME] &&
target_ast.validation_output[Macro.ID_ARGUMENT_NAME].given
)
if (macro.options.id_prefix) {
href = `${macro.options.id_prefix} ${href}`
} else {
const first_ast = title_arg.get(0);
if (!(
(
target_ast.macro_name === Macro.HEADER_MACRO_NAME &&
target_ast.validation_output.c.boolean
) ||
(
first_ast &&
first_ast.node_type !== AstType.PLAINTEXT
) ||
for_header_parent
)) {
if (c || was_magic_uppercase) {
href = href[0].toUpperCase() + href.substring(1)
} else {
href = href[0].toLowerCase() + href.substring(1)
}
}
if (
was_magic_plural ||
p
) {
const href_plural = pluralizeWrap(href, 2)
let disambiguate_sep
if (disambiguate) {
disambiguate_sep = ID_SEPARATOR + disambiguate
} else {
disambiguate_sep = ''
}
let target_scope = target_ast.scope
if (target_scope) {
target_scope = `${target_scope}${Macro.HEADER_SCOPE_SEPARATOR}`
} else {
target_scope = ''
}
const plural_id = `${target_scope}${magicTitleToId(href_plural, context)}${disambiguate_sep}`
const plural_target = context.db_provider.get(plural_id, context)
if (!plural_target || plural_target.id !== target_ast.id) {
const singular_id = `${target_scope}${magicTitleToId(pluralizeWrap(href, 1), context)}${disambiguate_sep}`
const singular_target = context.db_provider.get(singular_id, context)
if ((!singular_target || singular_target.id !== target_ast.id) && !explicit_id) {
// This can happen due to pluralize bugs:
// https://github.com/plurals/pluralize/issues/172
// Just bail out in those cases.
return { override_href: ourbigbookConvertSimpleElem(ast, context) }
}
}
href = href_plural
was_pluralized = true
}
}
if (explicit_id && magicTitleToId(href, context) !== target_ast.id) {
href = href_from_id
} else {
if (disambiguate_arg) {
if (was_pluralized) {
// TODO https://github.com/ourbigbook/ourbigbook/issues/244
return { override_href: ourbigbookConvertSimpleElem(ast, context) }
}
href = `${href} (${disambiguate})`;
}
let target_scope = target_ast.scope
if (target_scope) {
if (ast.scope) {
const target_scope_split = target_scope.split(Macro.HEADER_SCOPE_SEPARATOR)
const scope_split = ast.scope.split(Macro.HEADER_SCOPE_SEPARATOR)
let last_common = 0
while (
last_common < target_scope_split.length &&
last_common < scope_split.length
) {
if (target_scope_split[last_common] !== scope_split[last_common]) {
break
}
last_common++
}
target_scope = target_scope_split.slice(last_common).join(Macro.HEADER_SCOPE_SEPARATOR)
}
if (target_scope) {
target_scope = `${target_scope}${Macro.HEADER_SCOPE_SEPARATOR}`
}
target_scope = target_scope.replaceAll(ID_SEPARATOR, ' ')
href = `${target_scope}${href}`
}
}
if (isAbsoluteXref(target_id, context)) {
href = target_id[0] + href
}
}
return { href, override_href: undefined }
}
function ourbigbookLi(marker) {
return function(ast, context) {
if (!ast.args.content || Object.keys(ast.args).length !== 1) {
return ourbigbookConvertSimpleElem(ast, context)
} else {
let newline_before
if (
ast.parent_argument_index === 0 &&
!(
INSANE_STARTS_MACRO_NAMES.has(ast.parent_ast.parent_ast.macro_name) &&
ast.parent_ast.parent_argument_index === 0
) &&
// This feels hacky, but I can't find a better way.
context.last_render &&
context.last_render[context.last_render.length - 1] !== '\n'
) {
newline_before = '\n'
} else {
newline_before = ''
}
const content = renderArg(ast.args.content, context)
const content_indent = content.replace(/\n(.)/g, '\n $1')
const newline = ast.is_last_in_argument() ? '' : '\n'
let marker_eff
if (!content_indent) {
marker_eff = marker.substring(0, marker.length - 1)
} else {
marker_eff = marker
}
return `${newline_before}${marker_eff}${content_indent}${newline}`
}
}
}
function ourbigbookAddNewlinesAfterBlock(ast, context, options={}) {
const { auto_parent } = options
if (
!context.macros[ast.macro_name].options.phrasing &&
!ast.is_last_in_argument() &&
(
// It is a bit sad that we have to use this "am I on toplevel" checks processing here.
// It would be saner if indented blocks were exactly the same as toplevel.
// But it just intuitively feels that the "no loose on toplevel, but loose in subelements"
// sound good, so going for it like that now... I think this is implemented by always adding
// a PARAGRAPH Token on toplevel in case we ever want to dump that.
ast.parent_ast.macro_name === Macro.TOPLEVEL_MACRO_NAME ||
(
!(
ast.parent_ast.macro_name === Macro.PARAGRAPH_MACRO_NAME &&
ast.parent_ast.parent_ast.macro_name === Macro.TOPLEVEL_MACRO_NAME
) &&
ast.parent_ast.macro_name !== Macro.TOPLEVEL_MACRO_NAME &&
(
( ast.parent_argument.has_paragraph ) ||
( !ast.parent_argument.has_paragraph && !ast.parent_argument.not_all_block )
)
)
)
) {
let n = 2
if (auto_parent) {
if (context.last_render) {
if (context.last_render[context.last_render.length - 1] === '\n') {
n--
if (context.last_render[context.last_render.length - 2] === '\n') {
n--
}
}
}
}
return n
} else {
return 0
}
}
function ourbigbookUl(ast, context) {
if (!ast.args.content || Object.keys(ast.args).length !== 1) {
return ourbigbookConvertSimpleElem(ast, context)
} else {
const argstr = renderArg(ast.args.content, context)
const newline = '\n'.repeat(ourbigbookAddNewlinesAfterBlock(ast, context, { auto_parent: true }))
return `${argstr}${newline}`
}
}
function ourbigbookPreferLiteral(ast, context, ast_arg, arg, open, close) {
let rendered_arg
let delim_repeat
let has_newline
const argname = arg.name
if (
ast_arg.asts.length === 1 &&
ast_arg.asts[0].node_type === AstType.PLAINTEXT
) {
const rendered_arg_non_literal = renderArg(ast_arg, context)
if (
// Prefer literals if any escapes would be needed.
arg.ourbigbook_output_prefer_literal ||
rendered_arg_non_literal !== ast_arg.asts[0].text
) {
rendered_arg = ast_arg.asts[0].text
delim_repeat = 2
while (
rendered_arg.indexOf(open.repeat(delim_repeat)) !== -1 ||
rendered_arg.indexOf(close.repeat(delim_repeat)) !== -1
) {
delim_repeat++
}
has_newline = rendered_arg.indexOf('\n') !== -1
if (
rendered_arg[0] === open &&
!has_newline
) {
rendered_arg = ESCAPE_CHAR + rendered_arg
}
if (
rendered_arg[rendered_arg.length - 1] === close &&
!has_newline
) {
rendered_arg = rendered_arg.substring(0, rendered_arg.length - 1) + ESCAPE_CHAR + close
}
}
}
if (!rendered_arg) {
// Not a literal.
delim_repeat = 1
rendered_arg = renderArg(ast_arg, context)
has_newline = rendered_arg.indexOf('\n') !== -1
}
return { delim_repeat, has_newline, rendered_arg }
}
function ourbigbookConvertArgs(ast, context, options={}) {
const ret = options.ret || []
const skip = options.skip || new Set()
const modify_callbacks = options.modify_callbacks || {}
const macro = context.macros[ast.macro_name]
const named_args = Macro.COMMON_ARGNAMES.concat(macro.options.named_args.map(arg => arg.name)).filter(
(argname) => !skip.has(argname) && ast.validation_output[argname].given
)
const ret_args = []
for (const arg of macro.positional_args) {
const ret_arg = []
const argname = arg.name
if (!skip.has(argname) && ast.validation_output[argname].given) {
let { delim_repeat, has_newline, rendered_arg } = ourbigbookPreferLiteral(
ast, context, ast.args[argname], arg, START_POSITIONAL_ARGUMENT_CHAR, END_POSITIONAL_ARGUMENT_CHAR)
if (argname in modify_callbacks) {
rendered_arg = modify_callbacks[argname](ast, context, rendered_arg)
}
ret_arg.push(START_POSITIONAL_ARGUMENT_CHAR.repeat(delim_repeat))
if (arg.remove_whitespace_children) {
ret_arg.push('\n')
} else {
if (has_newline) {
if (rendered_arg[0] !== '\n') {
ret_arg.push('\n')
}
}
}
ret_arg.push(rendered_arg)
if (
has_newline &&
(
rendered_arg.length === 0 ||
rendered_arg[rendered_arg.length - 1] !== '\n'
)
) {
ret_arg.push('\n')
}
ret_arg.push(END_POSITIONAL_ARGUMENT_CHAR.repeat(delim_repeat))
}
if (ret_arg.length) {
ret_args.push(ret_arg)
}
}
for (const argname of named_args) {
const arg = macro.named_args[argname]
const validation_output = ast.validation_output[argname]
let ast_args
if (arg.multiple) {
ast_args = ast.args[argname].asts.map(ast => ast.args.content)
} else {
ast_args = [ast.args[argname]]
}
for (const ast_arg of ast_args) {
const ret_arg = []
const macro_arg = macro.name_to_arg[argname]
let { delim_repeat, has_newline, rendered_arg } = ourbigbookPreferLiteral(
ast, context, ast_arg, arg, START_NAMED_ARGUMENT_CHAR, END_NAMED_ARGUMENT_CHAR)
if (argname in modify_callbacks) {
rendered_arg = modify_callbacks[argname](ast, context, ast_arg, rendered_arg)
}
let skip_val = false
if (macro_arg.boolean) {
const argstr_default = macro_arg.default === undefined ? '0' : '1'
const argstr_eff = validation_output.boolean ? '1' : '0'
if (argstr_default === argstr_eff) {
continue
}
skip_val = validation_output.boolean
} else if(rendered_arg === '') {
skip_val = true
}
ret_arg.push(
START_NAMED_ARGUMENT_CHAR.repeat(delim_repeat) +
argname
)
if (!skip_val) {
ret_arg.push(NAMED_ARGUMENT_EQUAL_CHAR)
if (has_newline && rendered_arg[0] !== '\n') {
ret_arg.push('\n')
}
ret_arg.push(rendered_arg)
if (
has_newline &&
(
rendered_arg.length === 0 ||
rendered_arg[rendered_arg.length - 1] !== '\n'
)
) {
ret_arg.push('\n')
}
}
ret_arg.push(END_NAMED_ARGUMENT_CHAR.repeat(delim_repeat))
if (ret_arg.length) {
ret_args.push(ret_arg)
}
}
}
let i = 0
for (const ret_arg of ret_args) {
ret.push(...ret_arg)
if (!macro.options.phrasing && i !== ret_args.length - 1) {
ret.push('\n')
}
i++
}
return ret
}
function ourbigbookConvertSimpleElem(ast, context) {
const ret = []
ret.push(ESCAPE_CHAR + ast.macro_name)
const macro = context.macros[ast.macro_name]
ourbigbookConvertArgs(ast, context, { ret })
ret.push('\n'.repeat(ourbigbookAddNewlinesAfterBlock(ast, context)))
return ret.join('')
}
OUTPUT_FORMATS_LIST.push(
new OutputFormat(
OUTPUT_FORMAT_OURBIGBOOK,
{
ext: OURBIGBOOK_EXT,
convert_funcs: {
[Macro.LINK_MACRO_NAME]: function(ast, context) {
const href = renderArg(ast.args.href, context)
if (protocolIsKnown(href)) {
return `${href}${ourbigbookConvertArgs(ast, context, { skip: new Set(['href']) }).join('')}`
} else {
return ourbigbookConvertSimpleElem(ast, context)
}
},
'b': ourbigbookConvertSimpleElem,
'br': ourbigbookConvertSimpleElem,
[Macro.CODE_MACRO_NAME.toUpperCase()]: ourbigbookCodeMathBlock(INSANE_CODE_CHAR),
[Macro.CODE_MACRO_NAME]: ourbigbookCodeMathInline(INSANE_CODE_CHAR),
[Macro.OURBIGBOOK_EXAMPLE_MACRO_NAME]: ourbigbookConvertSimpleElem,
'Comment': ourbigbookConvertSimpleElem,
'comment': ourbigbookConvertSimpleElem,
[Macro.HEADER_MACRO_NAME]: function(ast, context) {
const newline = ast.is_last_in_argument() ? '' : '\n\n'
function modifyCallback(ast, context, arg, rendered_arg) {
const { target_ast, target_id } = xGetTargetAstBase({
context,
do_magic_title_to_id: true,
do_singularize: false,
scope: ast.scope,
target_id: rendered_arg,
})
if (!target_ast) {
return `${rendered_arg} ${renderErrorXUndefined(ast, context, rendered_arg, { source_location: arg.source_location })}`
}
const href = ourbigbookGetXHref({
ast,
context,
href: rendered_arg,
target_id,
target_ast,
c: false,
p: false,
magic: true,
scope: ast.scope,
for_header_parent: true,
}).href
// Return only some whitelisted characters to prevent creating new elements
// or breaking out of parent=} argument. Maybe one day we can force parent=
// to be always literal. But this would require changing the tokenizer somehow,
// not sure it would be super easy.
return href.replace(MUST_ESCAPE_CHARS_REGEX_CHAR_CLASS_REGEX, ' ').replace(MUST_ESCAPE_CHARS_AT_START_REGEX_CHAR_CLASS_REGEX, '$1').replace(/ +/g, ' ').replace(/^ | $/g, '')
}
let level = ast.header_tree_node.get_level() - context.header_tree_top_level + 1;
let output_level
if (
ast.validation_output.parent.given ||
ast.validation_output.synonym.boolean
) {
output_level = 1
} else {
output_level = level
}
const skip = new Set(['level', 'title'])
if (level === 1) {
// Happens on split headers.
skip.add('parent')
}
const args_string = ourbigbookConvertArgs(
ast,
context,
{
skip,
modify_callbacks: {
'child': modifyCallback,
'parent': modifyCallback,
'tag': modifyCallback,
}
}
).join('')
//ast.validation_output.level.positive_nonzero_integer
return `${INSANE_HEADER_CHAR.repeat(output_level)} ${renderArg(ast.args.title, context)}${args_string ? '\n' : '' }${args_string}${newline}`
},
[Macro.INCLUDE_MACRO_NAME]: function(ast, context) {
let newline
if (
context.last_render.length &&
context.last_render[context.last_render.length - 1] !== '\n') {
newline = '\n'
} else {
newline = ''
}
return newline + ourbigbookConvertSimpleElem(ast, context)
},
[Macro.LIST_ITEM_MACRO_NAME]: ourbigbookLi(INSANE_LIST_START),
[Macro.MATH_MACRO_NAME.toUpperCase()]: ourbigbookCodeMathBlock(INSANE_MATH_CHAR),
[Macro.MATH_MACRO_NAME]: ourbigbookCodeMathInline(INSANE_MATH_CHAR),
'i': ourbigbookConvertSimpleElem,
'Image': ourbigbookConvertSimpleElem,
'image': ourbigbookConvertSimpleElem,
'JsCanvasDemo': ourbigbookConvertSimpleElem,
'Ol': ourbigbookConvertSimpleElem,
[Macro.PARAGRAPH_MACRO_NAME]: function(ast, context) {
if (!ast.args.content || Object.keys(ast.args).length !== 1) {
return ourbigbookConvertSimpleElem(ast, context)
} else {
const rendered_arg = renderArg(ast.args.content, context)
const newline = ast.is_last_in_argument() ? '' : '\n\n'
return `${rendered_arg}${newline}`
}
},
[Macro.PLAINTEXT_MACRO_NAME]: function(ast, context) {
const text = ast.text
if (context.in_literal) {
return text
} else {
return escapeNotStart(text).replace(MUST_ESCAPE_CHARS_AT_START_REGEX_CHAR_CLASS_REGEX, '$1\\$2')
}
},
'passthrough': ourbigbookConvertSimpleElem,
'Q': ourbigbookConvertSimpleElem,
'sub': ourbigbookConvertSimpleElem,
'sup': ourbigbookConvertSimpleElem,
[Macro.TABLE_MACRO_NAME]: ourbigbookUl,
[Macro.TD_MACRO_NAME]: ourbigbookLi(INSANE_TD_START),
[Macro.TOPLEVEL_MACRO_NAME]: idConvertSimpleElem(),
[Macro.TH_MACRO_NAME]: ourbigbookLi(INSANE_TH_START),
[Macro.TR_MACRO_NAME]: ourbigbookUl,
'Ul': ourbigbookUl,
[Macro.X_MACRO_NAME]: function(ast, context) {
let href = renderArg(ast.args.href, context)
if (ast.validation_output.topic.boolean) {
for (const c of href) {
if (INSANE_LINK_END_CHARS.has(c)) {
return `${INSANE_X_START}${INSANE_TOPIC_CHAR}${href}${INSANE_X_END}`
}
}
return `${INSANE_TOPIC_CHAR}${href}`
}
//if (AstType.PLAINTEXT === ast.args.href[0] === INSANE_TOPIC_CHAR) {
// return `<${href}>`
//}
// Remove any > from the ref. There's currently no way to escape them, would cut argument short.
let { target_id, target_ast } = xGetTargetAst(ast, context)
const magic = ast.validation_output.magic.boolean
if (!magic && href !== magicTitleToId(href, context)) {
// Explicit IDs with weird characters weird cannot be converted to insane, e.g.
//
// = Dollar
// {{id=$}}
//
// \x[[$]]
return ourbigbookConvertSimpleElem(ast, context)
}
let override_href
;({ href, override_href } = ourbigbookGetXHref({
ast,
context,
target_ast,
target_id,
href,
c: ast.validation_output.c.boolean,
p: ast.validation_output.p.boolean,
magic,
scope: ast.scope,
}))
if (override_href) {
return override_href
}
href = href.replace(/[ >]+/g, ' ')
return `<${href}>${ourbigbookConvertArgs(ast, context, { skip: new Set(['c', 'href', 'magic', 'p']) }).join('')}`
},
'Video': ourbigbookConvertSimpleElem,
}
}
)
)
const OUTPUT_FORMATS = {}
exports.OUTPUT_FORMATS = OUTPUT_FORMATS
for (const output_format of OUTPUT_FORMATS_LIST) {
OUTPUT_FORMATS[output_format.id] = output_format
}