ecmarkup
Version:
Custom element definitions and core utilities for markup that specifies ECMAScript and related technologies.
1,150 lines (1,149 loc) • 82.9 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.maybeAddClauseToEffectWorklist = void 0;
const path = require("path");
const fs = require("fs");
const crypto = require("crypto");
const yaml = require("js-yaml");
const utils = require("./utils");
const hljs = require("highlight.js");
const Import_1 = require("./Import");
const Clause_1 = require("./Clause");
const clauseNums_1 = require("./clauseNums");
const Algorithm_1 = require("./Algorithm");
const Dfn_1 = require("./Dfn");
const Example_1 = require("./Example");
const Figure_1 = require("./Figure");
const Note_1 = require("./Note");
const Toc_1 = require("./Toc");
const Menu_1 = require("./Menu");
const Production_1 = require("./Production");
const NonTerminal_1 = require("./NonTerminal");
const ProdRef_1 = require("./ProdRef");
const Grammar_1 = require("./Grammar");
const Xref_1 = require("./Xref");
const Eqn_1 = require("./Eqn");
const Biblio_1 = require("./Biblio");
const Meta_1 = require("./Meta");
const H1_1 = require("./H1");
const autolinker_1 = require("./autolinker");
const lint_1 = require("./lint/lint");
const prex_1 = require("prex");
const utils_1 = require("./lint/utils");
const utils_2 = require("./utils");
const expr_parser_1 = require("./expr-parser");
const DRAFT_DATE_FORMAT = {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
};
const STANDARD_DATE_FORMAT = {
year: 'numeric',
month: 'long',
timeZone: 'UTC',
};
const NO_EMD = new Set([
'PRE',
'CODE',
'SCRIPT',
'STYLE',
'EMU-PRODUCTION',
'EMU-ALG',
'EMU-GRAMMAR',
'EMU-EQN',
]);
const YES_EMD = new Set(['EMU-GMOD']); // these are processed even if they are nested in NO_EMD contexts
// maps PostScript font names to files in fonts/
const FONT_FILES = new Map([
['IBMPlexSerif-Regular', 'IBMPlexSerif-Regular-SlashedZero.woff2'],
['IBMPlexSerif-Bold', 'IBMPlexSerif-Bold-SlashedZero.woff2'],
['IBMPlexSerif-Italic', 'IBMPlexSerif-Italic-SlashedZero.woff2'],
['IBMPlexSerif-BoldItalic', 'IBMPlexSerif-BoldItalic-SlashedZero.woff2'],
['IBMPlexSans-Regular', 'IBMPlexSans-Regular-SlashedZero.woff2'],
['IBMPlexSans-Bold', 'IBMPlexSans-Bold-SlashedZero.woff2'],
['IBMPlexSans-Italic', 'IBMPlexSans-Italic-SlashedZero.woff2'],
['IBMPlexSans-BoldItalic', 'IBMPlexSans-BoldItalic-SlashedZero.woff2'],
['IBMPlexMono-Regular', 'IBMPlexMono-Regular-SlashedZero.woff2'],
['IBMPlexMono-Bold', 'IBMPlexMono-Bold-SlashedZero.woff2'],
['IBMPlexMono-Italic', 'IBMPlexMono-Italic-SlashedZero.woff2'],
['IBMPlexMono-BoldItalic', 'IBMPlexMono-BoldItalic-SlashedZero.woff2'],
]);
const builders = [
Clause_1.default,
Algorithm_1.default,
Xref_1.default,
Dfn_1.default,
Eqn_1.default,
Grammar_1.default,
Production_1.default,
Example_1.default,
Figure_1.default,
NonTerminal_1.default,
ProdRef_1.default,
Note_1.default,
Meta_1.default,
H1_1.default,
];
const visitorMap = builders.reduce((map, T) => {
T.elements.forEach(e => (map[e] = T));
return map;
}, {});
function wrapWarn(source, spec, warn) {
return (e) => {
const { message, ruleId } = e;
let line;
let column;
let nodeType;
let file = undefined;
switch (e.type) {
case 'global':
line = undefined;
column = undefined;
nodeType = 'html';
break;
case 'raw':
({ line, column } = e);
nodeType = 'html';
if (e.file != null) {
file = e.file;
source = e.source;
}
break;
case 'node':
if (e.node.nodeType === 3 /* Node.TEXT_NODE */) {
const loc = spec.locate(e.node);
if (loc) {
file = loc.file;
source = loc.source;
({ startLine: line, startCol: column } = loc);
}
nodeType = 'text';
}
else {
const loc = spec.locate(e.node);
if (loc) {
file = loc.file;
source = loc.source;
({ startLine: line, startCol: column } = loc.startTag);
}
nodeType = e.node.tagName.toLowerCase();
}
break;
case 'attr': {
const loc = spec.locate(e.node);
if (loc) {
file = loc.file;
source = loc.source;
({ line, column } = utils.attrLocation(source, loc, e.attr));
}
nodeType = e.node.tagName.toLowerCase();
break;
}
case 'attr-value': {
const loc = spec.locate(e.node);
if (loc) {
file = loc.file;
source = loc.source;
({ line, column } = utils.attrValueLocation(source, loc, e.attr));
}
nodeType = e.node.tagName.toLowerCase();
break;
}
case 'contents': {
const { nodeRelativeLine, nodeRelativeColumn } = e;
if (e.node.nodeType === 3 /* Node.TEXT_NODE */) {
// i.e. a text node, which does not have a tag
const loc = spec.locate(e.node);
if (loc) {
file = loc.file;
source = loc.source;
line = loc.startLine + nodeRelativeLine - 1;
column =
nodeRelativeLine === 1 ? loc.startCol + nodeRelativeColumn - 1 : nodeRelativeColumn;
}
nodeType = 'text';
}
else {
const loc = spec.locate(e.node);
if (loc) {
file = loc.file;
source = loc.source;
line = loc.startTag.endLine + nodeRelativeLine - 1;
if (nodeRelativeLine === 1) {
column = loc.startTag.endCol + nodeRelativeColumn - 1;
}
else {
column = nodeRelativeColumn;
}
}
nodeType = e.node.tagName.toLowerCase();
}
break;
}
}
warn({
message,
ruleId,
// we omit source for global errors so that we don't get a codeframe
source: e.type === 'global' ? undefined : source,
file,
nodeType,
line,
column,
});
};
}
function isEmuImportElement(node) {
return node.nodeType === 1 && node.nodeName === 'EMU-IMPORT';
}
function maybeAddClauseToEffectWorklist(effectName, clause, worklist) {
if (!worklist.some(i => i.aoid === clause.aoid) &&
clause.canHaveEffect(effectName) &&
!clause.effects.includes(effectName)) {
clause.effects.push(effectName);
worklist.push(clause);
}
}
exports.maybeAddClauseToEffectWorklist = maybeAddClauseToEffectWorklist;
/*@internal*/
class Spec {
constructor(rootPath, fetch, dom, opts = {}, sourceText, token = prex_1.CancellationToken.none) {
var _a, _b, _c, _d, _e, _f;
var _g;
this.spec = this;
this.opts = {};
this.rootPath = rootPath;
this.rootDir = path.dirname(this.rootPath);
this.sourceText = sourceText;
this.doc = dom.window.document;
this.dom = dom;
this._fetch = fetch;
this.subclauses = [];
this.imports = [];
this.node = this.doc.body;
this.nodeIds = new Set();
this.replacementAlgorithmToContainedLabeledStepEntries = new Map();
this.labeledStepsToBeRectified = new Set();
this.replacementAlgorithms = [];
this.cancellationToken = token;
this.generatedFiles = new Map();
this.log = (_a = opts.log) !== null && _a !== void 0 ? _a : (() => { });
// TODO warnings should probably default to console.error, with a reasonable formatting
this.warn = opts.warn ? wrapWarn(sourceText, this, opts.warn) : () => { };
this._figureCounts = {
table: 0,
figure: 0,
};
this._xrefs = [];
this._ntRefs = [];
this._ntStringRefs = [];
this._prodRefs = [];
this._textNodes = {};
this._effectWorklist = new Map();
this._effectfulAOs = new Map();
this._emuMetasToRender = new Set();
this._emuMetasToRemove = new Set();
this.refsByClause = Object.create(null);
this.processMetadata();
Object.assign(this.opts, opts);
if (this.opts.jsOut || this.opts.cssOut) {
throw new Error('--js-out and --css-out have been removed; use --assets-dir instead');
}
if (this.opts.assets != null &&
this.opts.assets !== 'external' &&
this.opts.assetsDir != null) {
throw new Error(`--assets=${this.opts.assets} cannot be used --assets-dir"`);
}
if (this.opts.multipage) {
(_b = (_g = this.opts).outfile) !== null && _b !== void 0 ? _b : (_g.outfile = '');
const type = (_c = this.opts.assets) !== null && _c !== void 0 ? _c : 'external';
if (type === 'inline') {
throw new Error('assets cannot be inline for multipage builds');
}
else if (type === 'none') {
this.assets = { type: 'none' };
}
else {
this.assets = {
type: 'external',
directory: (_d = this.opts.assetsDir) !== null && _d !== void 0 ? _d : path.join(this.opts.outfile, 'assets'),
};
}
}
else {
const type = (_e = this.opts.assets) !== null && _e !== void 0 ? _e : (this.opts.assetsDir == null ? 'inline' : 'external');
if (type === 'inline') {
console.error('Warning: inlining assets; this should not be done for production builds.');
this.assets = { type: 'inline' };
}
else if (type === 'none') {
this.assets = { type: 'none' };
}
else {
this.assets = {
type: 'external',
directory: (_f = this.opts.assetsDir) !== null && _f !== void 0 ? _f : (this.opts.outfile ? path.join(path.dirname(this.opts.outfile), 'assets') : 'assets'),
};
}
}
if (typeof this.opts.status === 'undefined') {
this.opts.status = 'proposal';
}
if (typeof this.opts.toc === 'undefined') {
this.opts.toc = true;
}
if (typeof this.opts.copyright === 'undefined') {
this.opts.copyright = true;
}
if (!this.opts.date) {
this.opts.date = new Date();
}
if (this.opts.stage != undefined) {
this.opts.stage = String(this.opts.stage);
}
if (!this.opts.location) {
this.opts.location = '<no location>';
}
this.namespace = this.opts.location;
this.biblio = new Biblio_1.default(this.opts.location);
}
fetch(file) {
return this._fetch(file, this.cancellationToken);
}
async build() {
/*
The Ecmarkup build process proceeds as follows:
1. Load biblios, making xrefs and auto-linking from external specs work
2. Load imports by recursively inlining the import files' content into the emu-import element
3. Generate boilerplate text
4. Do a walk of the DOM visting elements and text nodes. Text nodes are replaced by text and HTML nodes depending
on content. Elements are built by delegating to builders. Builders work by modifying the DOM ahead of them so
the new elements they make are visited during the walk. Elements added behind the current iteration must be
handled specially (eg. see static exit method of clause). Xref, nt, and prodref's are collected for linking
in the next step.
5. Linking. After the DOM walk we have a complete picture of all the symbols in the document so we proceed to link
the various xrefs to the proper place.
6. Adding charset, highlighting code, etc.
7. Add CSS & JS dependencies.
*/
var _a;
this.log('Loading biblios...');
await this.loadBiblios();
this.log('Loading imports...');
await this.loadImports();
this.log('Building boilerplate...');
this.buildBoilerplate();
const context = {
spec: this,
node: this.doc.body,
importStack: [],
clauseStack: [],
tagStack: [],
clauseNumberer: (0, clauseNums_1.default)(this),
inNoAutolink: false,
inAlg: false,
inNoEmd: false,
followingEmd: null,
currentId: null,
};
const document = this.doc;
if (this.opts.lintSpec) {
this.log('Linting...');
const source = this.sourceText;
if (source === undefined) {
throw new Error('Cannot lint when source text is not available');
}
await (0, lint_1.lint)(this.warn, source, this, document);
}
this.log('Walking document, building various elements...');
const walker = document.createTreeWalker(document.body, 1 | 4 /* elements and text nodes */);
await walk(walker, context);
const sdoJs = this.generateSDOMap();
this.setReplacementAlgorithmOffsets();
this.autolink();
if (this.opts.lintSpec) {
this.log('Checking types...');
this.typecheck();
}
this.log('Propagating effect annotations...');
this.propagateEffects();
this.log('Linking xrefs...');
this._xrefs.forEach(xref => xref.build());
this.log('Linking non-terminal references...');
this._ntRefs.forEach(nt => nt.build());
this._emuMetasToRender.forEach(node => {
Meta_1.default.render(this, node);
});
this._emuMetasToRemove.forEach(node => {
node.replaceWith(...node.childNodes);
});
// TODO: make these look good
// this.log('Adding clause labels...');
// this.labelClauses();
if (this.opts.lintSpec) {
this._ntStringRefs.forEach(({ name, loc, node, namespace }) => {
if (this.biblio.byProductionName(name, namespace) == null) {
this.warn({
type: 'contents',
ruleId: 'undefined-nonterminal',
message: `could not find a definition for nonterminal ${name}`,
node,
nodeRelativeLine: loc.line,
nodeRelativeColumn: loc.column,
});
}
});
}
this.log('Linking production references...');
this._prodRefs.forEach(prod => prod.build());
this.log('Building reference graph...');
this.buildReferenceGraph();
this.highlightCode();
this.setCharset();
const wrapper = this.buildSpecWrapper();
let commonEles = [];
let tocJs = '';
if (this.opts.toc) {
this.log('Building table of contents...');
if (this.opts.oldToc) {
new Toc_1.default(this).build();
}
else {
({ js: tocJs, eles: commonEles } = (0, Menu_1.default)(this));
}
}
this.log('Building shortcuts help dialog...');
commonEles.push(this.buildShortcutsHelp());
for (const ele of commonEles) {
this.doc.body.insertBefore(ele, this.doc.body.firstChild);
}
const jsContents = (await concatJs(sdoJs, tocJs)) + `\n;let usesMultipage = ${!!this.opts.multipage}`;
const jsSha = sha(jsContents);
await this.buildAssets(jsContents, jsSha);
if (this.opts.multipage) {
await this.buildMultipage(wrapper, commonEles);
}
const file = this.opts.multipage
? path.join(this.opts.outfile, 'index.html')
: (_a = this.opts.outfile) !== null && _a !== void 0 ? _a : null;
this.generatedFiles.set(file, this.toHTML());
return this;
}
labelClauses() {
const label = (clause) => {
var _a, _b;
if (clause.header != null) {
if (((_b = (_a = clause.signature) === null || _a === void 0 ? void 0 : _a.return) === null || _b === void 0 ? void 0 : _b.kind) === 'completion' &&
clause.signature.return.completionType !== 'normal') {
// style="border: 1px #B50000; background-color: #FFE6E6; padding: .2rem;border-radius: 5px;/*! color: white; */font-size: small;vertical-align: middle;/*! line-height: 1em; */border-style: dotted;color: #B50000;cursor: default;user-select: none;"
// TODO: make this a different color
clause.header.innerHTML += `<span class="clause-tag abrupt-tag" title="this can return an abrupt completion">abrupt</span>`;
}
// TODO: make this look like the [UC] annotation
// TODO: hide this if [UC] is not enabled (maybe)
// the querySelector is gross; you are welcome to replace it with a better analysis which actually keeps track of stuff
if (clause.node.querySelector('.e-user-code')) {
clause.header.innerHTML += `<span class="clause-tag user-code-tag" title="this can invoke user code">user code</span>`;
}
}
for (const sub of clause.subclauses) {
label(sub);
}
};
for (const sub of this.subclauses) {
label(sub);
}
}
// checks that AOs which do/don't return completion records are invoked appropriately
// also checks that the appropriate number of arguments are passed
typecheck() {
const isUnused = (t) => {
var _a;
return t.kind === 'unused' ||
(t.kind === 'completion' &&
(t.completionType === 'abrupt' || ((_a = t.typeOfValueIfNormal) === null || _a === void 0 ? void 0 : _a.kind) === 'unused'));
};
const AOs = this.biblio
.localEntries()
.filter(e => { var _a; return e.type === 'op' && ((_a = e.signature) === null || _a === void 0 ? void 0 : _a.return) != null; });
const onlyPerformed = new Map(AOs.filter(e => !isUnused(e.signature.return)).map(a => [a.aoid, null]));
const alwaysAssertedToBeNormal = new Map(AOs.filter(e => e.signature.return.kind === 'completion').map(a => [a.aoid, null]));
// TODO strictly speaking this needs to be done in the namespace of the current algorithm
const opNames = this.biblio.getOpNames(this.namespace);
// TODO move declarations out of loop
for (const node of this.doc.querySelectorAll('emu-alg')) {
if (node.hasAttribute('example') || !('ecmarkdownTree' in node)) {
continue;
}
const tree = node.ecmarkdownTree;
if (tree == null) {
continue;
}
const originalHtml = node.originalHtml;
const expressionVisitor = (expr, path) => {
if (expr.name !== 'call' && expr.name !== 'sdo-call') {
return;
}
const { callee, arguments: args } = expr;
if (!(callee.length === 1 && callee[0].name === 'text')) {
return;
}
const calleeName = callee[0].contents;
const warn = (message) => {
const { line, column } = (0, utils_2.offsetToLineAndColumn)(originalHtml, callee[0].location.start.offset);
this.warn({
type: 'contents',
ruleId: 'typecheck',
message,
node,
nodeRelativeLine: line,
nodeRelativeColumn: column,
});
};
const biblioEntry = this.biblio.byAoid(calleeName);
if (biblioEntry == null) {
if (![
'thisTimeValue',
'thisStringValue',
'thisBigIntValue',
'thisNumberValue',
'thisSymbolValue',
'thisBooleanValue',
'toUppercase',
'toLowercase',
].includes(calleeName)) {
// TODO make the spec not do this
warn(`could not find definition for ${calleeName}`);
}
return;
}
if (biblioEntry.kind === 'syntax-directed operation' && expr.name === 'call') {
warn(`${calleeName} is a syntax-directed operation and should not be invoked like a regular call`);
}
else if (biblioEntry.kind != null &&
biblioEntry.kind !== 'syntax-directed operation' &&
expr.name === 'sdo-call') {
warn(`${calleeName} is not a syntax-directed operation but here is being invoked as one`);
}
if (biblioEntry.signature == null) {
return;
}
const min = biblioEntry.signature.parameters.length;
const max = min + biblioEntry.signature.optionalParameters.length;
if (args.length < min || args.length > max) {
const count = `${min}${min === max ? '' : `-${max}`}`;
// prettier-ignore
const message = `${calleeName} takes ${count} argument${count === '1' ? '' : 's'}, but this invocation passes ${args.length}`;
warn(message);
}
const { return: returnType } = biblioEntry.signature;
if (returnType == null) {
return;
}
const consumedAsCompletion = isConsumedAsCompletion(expr, path);
// checks elsewhere ensure that well-formed documents never have a union of completion and non-completion, so checking the first child suffices
// TODO: this is for 'a break completion or a throw completion', which is kind of a silly union; maybe address that in some other way?
const isCompletion = returnType.kind === 'completion' ||
(returnType.kind === 'union' && returnType.types[0].kind === 'completion');
if (['Completion', 'ThrowCompletion', 'NormalCompletion'].includes(calleeName)) {
if (consumedAsCompletion) {
warn(`${calleeName} clearly creates a Completion Record; it does not need to be marked as such, and it would not be useful to immediately unwrap its result`);
}
}
else if (isCompletion && !consumedAsCompletion) {
warn(`${calleeName} returns a Completion Record, but is not consumed as if it does`);
}
else if (!isCompletion && consumedAsCompletion) {
warn(`${calleeName} does not return a Completion Record, but is consumed as if it does`);
}
if (returnType.kind === 'unused' && !isCalledAsPerform(expr, path, false)) {
warn(`${calleeName} does not return a meaningful value and should only be invoked as \`Perform ${calleeName}(...).\``);
}
if (onlyPerformed.has(calleeName) && onlyPerformed.get(calleeName) !== 'top') {
const old = onlyPerformed.get(calleeName);
const performed = isCalledAsPerform(expr, path, true);
if (!performed) {
onlyPerformed.set(calleeName, 'top');
}
else if (old === null) {
onlyPerformed.set(calleeName, 'only performed');
}
}
if (alwaysAssertedToBeNormal.has(calleeName) &&
alwaysAssertedToBeNormal.get(calleeName) !== 'top') {
const old = alwaysAssertedToBeNormal.get(calleeName);
const asserted = isAssertedToBeNormal(expr, path);
if (!asserted) {
alwaysAssertedToBeNormal.set(calleeName, 'top');
}
else if (old === null) {
alwaysAssertedToBeNormal.set(calleeName, 'always asserted normal');
}
}
};
const walkLines = (list) => {
var _a;
for (const line of list.contents) {
const item = (0, expr_parser_1.parse)(line.contents, opNames);
if (item.name === 'failure') {
const { line, column } = (0, utils_2.offsetToLineAndColumn)(originalHtml, item.offset);
this.warn({
type: 'contents',
ruleId: 'expression-parsing',
message: item.message,
node,
nodeRelativeLine: line,
nodeRelativeColumn: column,
});
}
else {
(0, expr_parser_1.walk)(expressionVisitor, item);
}
if (((_a = line.sublist) === null || _a === void 0 ? void 0 : _a.name) === 'ol') {
walkLines(line.sublist);
}
}
};
walkLines(tree.contents);
}
for (const [aoid, state] of onlyPerformed) {
if (state !== 'only performed') {
continue;
}
const message = `${aoid} is only ever invoked with Perform, so it should return ~unused~ or a Completion Record which, if normal, contains ~unused~`;
const ruleId = 'perform-not-unused';
const biblioEntry = this.biblio.byAoid(aoid);
if (biblioEntry._node) {
this.spec.warn({
type: 'node',
ruleId,
message,
node: biblioEntry._node,
});
}
else {
this.spec.warn({
type: 'global',
ruleId,
message,
});
}
}
for (const [aoid, state] of alwaysAssertedToBeNormal) {
if (state !== 'always asserted normal') {
continue;
}
if (aoid === 'AsyncGeneratorAwaitReturn') {
// TODO remove this when https://github.com/tc39/ecma262/issues/2412 is fixed
continue;
}
const message = `every call site of ${aoid} asserts the return value is a normal completion; it should be refactored to not return a completion record at all`;
const ruleId = 'always-asserted-normal';
const biblioEntry = this.biblio.byAoid(aoid);
if (biblioEntry._node) {
this.spec.warn({
type: 'node',
ruleId,
message,
node: biblioEntry._node,
});
}
else {
this.spec.warn({
type: 'global',
ruleId,
message,
});
}
}
}
toHTML() {
const htmlEle = this.doc.documentElement;
return '<!doctype html>\n' + (htmlEle.hasAttributes() ? htmlEle.outerHTML : htmlEle.innerHTML);
}
locate(node) {
let pointer = node;
while (pointer != null) {
if (isEmuImportElement(pointer)) {
break;
}
pointer = pointer.parentElement;
}
const dom = pointer == null ? this.dom : pointer.dom;
if (!dom) {
return;
}
// the jsdom types are wrong
const loc = dom.nodeLocation(node);
if (loc) {
// we can't just spread `loc` because not all properties are own/enumerable
const out = {
source: this.sourceText,
startTag: loc.startTag,
endTag: loc.endTag,
startOffset: loc.startOffset,
endOffset: loc.endOffset,
attrs: loc.attrs,
startLine: loc.startLine,
startCol: loc.startCol,
endLine: loc.endLine,
endCol: loc.endCol,
};
if (pointer != null) {
out.file = pointer.importPath;
out.source = pointer.source;
}
return out;
}
}
buildReferenceGraph() {
const refToClause = this.refsByClause;
const setParent = (node) => {
let pointer = node;
while (pointer && !['EMU-CLAUSE', 'EMU-INTRO', 'EMU-ANNEX'].includes(pointer.nodeName)) {
pointer = pointer.parentNode;
}
// @ts-ignore
if (pointer == null || pointer.id == null) {
// @ts-ignore
pointer = { id: 'sec-intro' };
}
// @ts-ignore
if (refToClause[pointer.id] == null) {
// @ts-ignore
refToClause[pointer.id] = [];
}
// @ts-ignore
refToClause[pointer.id].push(node.id);
};
let counter = 0;
this._xrefs.forEach(xref => {
let entry = xref.entry;
if (!entry || entry.namespace === 'external')
return;
if (!entry.id && entry.refId) {
entry = this.spec.biblio.byId(entry.refId);
}
if (!xref.id) {
const id = `_ref_${counter++}`;
xref.node.setAttribute('id', id);
xref.id = id;
}
setParent(xref.node);
entry.referencingIds.push(xref.id);
});
this._ntRefs.forEach(prod => {
const entry = prod.entry;
if (!entry || entry.namespace === 'external')
return;
// if this is the defining nt of an emu-production, don't create a ref
if (prod.node.parentNode.nodeName === 'EMU-PRODUCTION')
return;
const id = `_ref_${counter++}`;
prod.node.setAttribute('id', id);
setParent(prod.node);
entry.referencingIds.push(id);
});
}
checkValidSectionId(ele) {
if (!ele.id.startsWith('sec-')) {
this.warn({
type: 'node',
ruleId: 'top-level-section-id',
message: 'When using --multipage, top-level sections must have ids beginning with `sec-`',
node: ele,
});
return false;
}
if (!/^[A-Za-z0-9-_]+$/.test(ele.id)) {
this.warn({
type: 'node',
ruleId: 'top-level-section-id',
message: 'When using --multipage, top-level sections must have ids matching /^[A-Za-z0-9-_]+$/',
node: ele,
});
return false;
}
if (ele.id.toLowerCase() === 'sec-index') {
this.warn({
type: 'node',
ruleId: 'top-level-section-id',
message: 'When using --multipage, top-level sections must not be named "index"',
node: ele,
});
return false;
}
return true;
}
propagateEffects() {
for (const [effectName, worklist] of this._effectWorklist) {
this.propagateEffect(effectName, worklist);
}
}
propagateEffect(effectName, worklist) {
const usersOfAoid = new Map();
for (const xref of this._xrefs) {
if (xref.clause == null || xref.aoid == null)
continue;
if (!xref.shouldPropagateEffect(effectName))
continue;
if (xref.hasAddedEffect(effectName)) {
maybeAddClauseToEffectWorklist(effectName, xref.clause, worklist);
}
const usedAoid = xref.aoid;
if (!usersOfAoid.has(usedAoid)) {
usersOfAoid.set(usedAoid, new Set());
}
usersOfAoid.get(usedAoid).add(xref.clause);
}
while (worklist.length !== 0) {
const clause = worklist.shift();
const aoid = clause.aoid;
if (aoid == null || !usersOfAoid.has(aoid)) {
continue;
}
this._effectfulAOs.set(aoid, clause.effects);
for (const userClause of usersOfAoid.get(aoid)) {
maybeAddClauseToEffectWorklist(effectName, userClause, worklist);
}
}
}
getEffectsByAoid(aoid) {
if (this._effectfulAOs.has(aoid)) {
return this._effectfulAOs.get(aoid);
}
return null;
}
async buildMultipage(wrapper, commonEles) {
let stillIntro = true;
const introEles = [];
const sections = [];
const containedIdToSection = new Map();
const sectionToContainedIds = new Map();
const clauseTypes = ['EMU-ANNEX', 'EMU-CLAUSE'];
// @ts-ignore
for (const child of wrapper.children) {
if (stillIntro) {
if (clauseTypes.includes(child.nodeName)) {
throw new Error('cannot make multipage build without intro');
}
else if (child.nodeName === 'EMU-INTRO') {
stillIntro = false;
if (child.id == null) {
this.warn({
type: 'node',
ruleId: 'top-level-section-id',
message: 'When using --multipage, top-level sections must have ids',
node: child,
});
continue;
}
if (child.id !== 'sec-intro') {
this.warn({
type: 'node',
ruleId: 'top-level-section-id',
message: 'When using --multipage, the introduction must have id "sec-intro"',
node: child,
});
continue;
}
const name = 'index';
introEles.push(child);
sections.push({ name, eles: introEles });
const contained = [];
sectionToContainedIds.set(name, contained);
for (const item of introEles) {
if (item.id) {
contained.push(item.id);
containedIdToSection.set(item.id, name);
}
}
// @ts-ignore
for (const item of [...introEles].flatMap(e => [...e.querySelectorAll('[id]')])) {
contained.push(item.id);
containedIdToSection.set(item.id, name);
}
}
else {
introEles.push(child);
}
}
else {
if (!clauseTypes.includes(child.nodeName)) {
throw new Error('non-clause children are not yet implemented: ' + child.nodeName);
}
if (child.id == null) {
this.warn({
type: 'node',
ruleId: 'top-level-section-id',
message: 'When using --multipage, top-level sections must have ids',
node: child,
});
continue;
}
if (!this.checkValidSectionId(child)) {
continue;
}
const name = child.id.substring(4);
const contained = [];
sectionToContainedIds.set(name, contained);
contained.push(child.id);
containedIdToSection.set(child.id, name);
for (const item of child.querySelectorAll('[id]')) {
contained.push(item.id);
containedIdToSection.set(item.id, name);
}
sections.push({ name, eles: [child] });
}
}
let htmlEle = '';
if (this.doc.documentElement.hasAttributes()) {
const clonedHtmlEle = this.doc.documentElement.cloneNode(false);
clonedHtmlEle.innerHTML = '';
const src = clonedHtmlEle.outerHTML;
htmlEle = src.substring(0, src.length - '<head></head><body></body></html>'.length);
}
const head = this.doc.head.cloneNode(true);
const containedMap = JSON.stringify(Object.fromEntries(sectionToContainedIds)).replace(/[\\`$]/g, '\\$&');
if (this.assets.type !== 'none') {
const multipageJsContents = `'use strict';
let multipageMap = JSON.parse(\`${containedMap}\`);
${await utils.readFile(path.join(__dirname, '../js/multipage.js'))}
`;
// assets are never internal for multipage builds
// @ts-expect-error
const multipageLocationOnDisk = path.join(this.assets.directory, 'multipage.js');
this.generatedFiles.set(multipageLocationOnDisk, multipageJsContents);
// the path will be rewritten below
// so it should initially be relative to outfile, not outfile/multipage
const multipageScript = this.doc.createElement('script');
multipageScript.src =
path.relative(this.opts.outfile, multipageLocationOnDisk) +
'?cache=' +
sha(multipageJsContents);
multipageScript.setAttribute('defer', '');
head.insertBefore(multipageScript, head.querySelector('script'));
}
for (const { name, eles } of sections) {
this.log(`Generating section ${name}...`);
const headClone = head.cloneNode(true);
const commonClone = commonEles.map(e => e.cloneNode(true));
const clones = eles.map(e => e.cloneNode(true));
const allClones = [headClone, ...commonClone, ...clones];
for (const anchor of allClones.flatMap(e => [...e.querySelectorAll('a')])) {
if (linkIsAbsolute(anchor)) {
continue;
}
if (linkIsInternal(anchor)) {
let p = anchor.hash.substring(1);
if (!containedIdToSection.has(p)) {
try {
p = decodeURIComponent(p);
}
catch {
// pass
}
if (!containedIdToSection.has(p)) {
this.warn({
type: 'node',
ruleId: 'multipage-link-target',
message: 'could not find appropriate section for ' + anchor.hash,
node: anchor,
});
continue;
}
}
const targetSec = containedIdToSection.get(p);
anchor.href = (targetSec === 'index' ? './' : targetSec + '.html') + anchor.hash;
}
else if (linkIsPathRelative(anchor)) {
anchor.href = path.relative('multipage', pathFromRelativeLink(anchor));
}
}
for (const link of allClones.flatMap(e => [...e.querySelectorAll('link')])) {
if (!linkIsAbsolute(link) && linkIsPathRelative(link)) {
link.href = path.relative('multipage', pathFromRelativeLink(link));
}
}
for (const img of allClones.flatMap(e => [...e.querySelectorAll('img')])) {
if (!/^(http:|https:|:|\/)/.test(img.src)) {
img.src = path.relative('multipage', img.src);
}
}
for (const script of allClones.flatMap(e => [...e.querySelectorAll('script')])) {
if (script.src != null && !/^(http:|https:|:|\/)/.test(script.src)) {
script.src = path.relative('multipage', script.src);
}
}
// prettier-ignore
for (const object of allClones.flatMap(e => [...e.querySelectorAll('object[data]')])) {
if (!/^(http:|https:|:|\/)/.test(object.data)) {
object.data = path.relative('multipage', object.data);
}
}
if (eles[0].hasAttribute('id')) {
const canonical = this.doc.createElement('link');
canonical.setAttribute('rel', 'canonical');
canonical.setAttribute('href', `../#${eles[0].id}`);
headClone.appendChild(canonical);
}
// @ts-expect-error
const commonHTML = commonClone.map(e => e.outerHTML).join('\n');
// @ts-expect-error
const clonesHTML = clones.map(e => e.outerHTML).join('\n');
const content = `<!doctype html>${htmlEle}\n${headClone.outerHTML}\n<body>${commonHTML}<div id='spec-container'>${clonesHTML}</div></body>`;
this.generatedFiles.set(path.join(this.opts.outfile, `multipage/${name}.html`), content);
}
}
async buildAssets(jsContents, jsSha) {
if (this.assets.type === 'none')
return;
// check for very old manual 'ecmarkup.js'/'ecmarkup.css'
const oldEles = this.doc.querySelectorAll("script[src='ecmarkup.js'],link[href='ecmarkup.css']");
for (const item of oldEles) {
this.warn({
type: 'attr',
ruleId: 'old-script-or-style',
node: item,
attr: item.tagName === 'SCRIPT' ? 'src' : 'href',
message: 'ecmarkup will insert its own js/css; the input document should not include tags for them',
});
}
let cssContents = await utils.readFile(path.join(__dirname, '../css/elements.css'));
const FONT_FILE_CONTENTS = new Map(zip(FONT_FILES.values(), await Promise.all(Array.from(FONT_FILES.values()).map(fontFile => utils.readBinaryFile(path.join(__dirname, '..', 'fonts', fontFile))))));
cssContents = cssContents.replace(/^([ \t]*)src: +local\(([^)]+)\), +local\(([^)]+)\);$/gm, (match, indent, displayName, postScriptName) => {
var _a;
const fontFile = (_a = FONT_FILES.get(postScriptName)) !== null && _a !== void 0 ? _a : FONT_FILES.get(displayName);
if (fontFile == null) {
throw new Error(`Unrecognised font: ${JSON.stringify(postScriptName)}`);
}
const fontType = path.extname(fontFile).slice(1);
const urlRef = this.assets.type === 'inline'
? // prettier-ignore
`data:font/${fontType};base64,${FONT_FILE_CONTENTS.get(fontFile).toString('base64')}`
: `./${fontFile}`;
return `${indent}src: local(${displayName}), local(${postScriptName}), url(${urlRef}) format('${fontType}');`;
});
if (this.assets.type === 'external') {
const outDir = this.opts.outfile
? this.opts.multipage
? this.opts.outfile
: path.dirname(this.opts.outfile)
: process.cwd();
const scriptLocationOnDisk = path.join(this.assets.directory, 'ecmarkup.js');
const styleLocationOnDisk = path.join(this.assets.directory, 'ecmarkup.css');
this.generatedFiles.set(scriptLocationOnDisk, jsContents);
this.generatedFiles.set(styleLocationOnDisk, cssContents);
for (const [, fontFile] of FONT_FILES) {
this.generatedFiles.set(path.join(this.assets.directory, fontFile), FONT_FILE_CONTENTS.get(fontFile));
}
const script = this.doc.createElement('script');
script.src = path.relative(outDir, scriptLocationOnDisk) + '?cache=' + jsSha;
script.setAttribute('defer', '');
this.doc.head.appendChild(script);
this.addStyle(this.doc.head, path.relative(outDir, styleLocationOnDisk));
}
else {
// i.e. assets.type === 'inline'
this.log('Inlining JavaScript assets...');
const script = this.doc.createElement('script');
script.textContent = jsContents;
this.doc.head.appendChild(script);
this.log('Inlining CSS assets...');
const style = this.doc.createElement('style');
style.textContent = cssContents;
this.doc.head.appendChild(style);
}
this.addStyle(this.doc.head, `https://cdnjs.cloudflare.com/ajax/libs/highlight.js/${hljs.versionString}/styles/base16/solarized-light.min.css`);
}
addStyle(head, href) {
const style = this.doc.createElement('link');
style.setAttribute('rel', 'stylesheet');
style.setAttribute('href', href);
// insert early so that the document's own stylesheets can override
const firstLink = head.querySelector('link[rel=stylesheet], style');
if (firstLink != null) {
head.insertBefore(style, firstLink);
}
else {
head.appendChild(style);
}
}
buildSpecWrapper() {
const elements = this.doc.body.childNodes;
const wrapper = this.doc.createElement('div');
wrapper.id = 'spec-container';
while (elements.length > 0) {
wrapper.appendChild(elements[0]);
}
this.doc.body.appendChild(wrapper);
return wrapper;
}
buildShortcutsHelp() {
const shortcutsHelp = this.doc.createElement('div');
shortcutsHelp.setAttribute('id', 'shortcuts-help');
shortcutsHelp.innerHTML = `
<ul>
<li><span>Toggle shortcuts help</span><code>?</code></li>
<li><span>Toggle "can call user code" annotations</span><code>u</code></li>
${this.opts.multipage ? `<li><span>Navigate to/from multipage</span><code>m</code></li>` : ''}
<li><span>Jump to search box</span><code>/</code></li>
</ul>`;
return shortcutsHelp;
}
processMetadata() {
const block = this.doc.querySelector('pre.metadata');
if (!block || !block.parentNode) {
return;
}
let data;
try {
data = yaml.safeLoad(block.textContent);
}
catch (e) {
if (typeof (e === null || e === void 0 ? void 0 : e.mark.line) === 'number' && typeof (e === null || e === void 0 ? void 0 : e.mark.column) === 'number') {
this.warn({
type: 'contents',
ruleId: 'invalid-metadata',
message: `metadata block failed to parse: ${e.reason}`,
node: block,
nodeRelativeLine: e.mark.line + 1,
nodeRelativeColumn: e.mark.column + 1,
});
}
else {
this.warn({
type: 'node',
ruleId: 'invalid-metadata',
message: 'metadata block failed to parse',
node: block,
});
}
return;
}
finally {
block.parentNode.removeChild(block);
}
Object.assign(this.opts, data);
}
async loadBiblios() {
var _a, _b;
this.cancellationToken.throwIfCancellationRequested();
const biblioPaths = [];
for (const biblioEle of this.doc.querySelectorAll('emu-biblio')) {
const href = biblioEle.getAttribute('href');
if (href == null) {
this.spec.warn({
type: 'node',
node: biblioEle,
ruleId: 'biblio-href',
message: 'emu-biblio elements must have an href attribute',
});
}
else {
biblioPaths.push(href);
}
}
const biblioContents = await Promise.all(biblioPaths.map(p => this.fetch(path.join(this.rootDir, p))));
const biblios = biblioContents.flatMap(c