UNPKG

ecmarkup

Version:

Custom element definitions and core utilities for markup that specifies ECMAScript and related technologies.

1,168 lines (1,167 loc) 78.3 kB
"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"); // Builders 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', '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 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; opts = opts || {}; 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 : (() => { }); 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.multipage) { if (this.opts.jsOut || this.opts.cssOut) { throw new Error('Cannot use --multipage with --js-out or --css-out'); } if (this.opts.outfile == null) { this.opts.outfile = ''; } if (this.opts.assets !== 'none') { this.opts.jsOut = path.join(this.opts.outfile, 'ecmarkup.js'); this.opts.cssOut = path.join(this.opts.outfile, 'ecmarkup.css'); } } 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); if (this.opts.multipage) { await this.buildMultipage(wrapper, commonEles, jsSha); } await this.buildAssets(jsContents, jsSha); 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; } // @ts-ignore const tree = node.ecmarkdownTree; if (tree == null) { continue; } // @ts-ignore const originalHtml = node.originalHtml; const expressionVisitor = (expr, path) => { if (expr.type !== 'call' && expr.type !== 'sdo-call') { return; } const { callee, arguments: args } = expr; if (!(callee.parts.length === 1 && callee.parts[0].name === 'text')) { return; } const calleeName = callee.parts[0].contents; const warn = (message) => { const { line, column } = (0, utils_2.offsetToLineAndColumn)(originalHtml, callee.parts[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.type === '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.type === '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.type === '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, jsSha) { 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); this.addStyle(head, 'ecmarkup.css'); // omit `../` because we rewrite `<link>` elements below this.addStyle(head, `https://cdnjs.cloudflare.com/ajax/libs/highlight.js/${hljs.versionString}/styles/base16/solarized-light.min.css`); const script = this.doc.createElement('script'); script.src = '../ecmarkup.js?cache=' + jsSha; script.setAttribute('defer', ''); head.appendChild(script); const containedMap = JSON.stringify(Object.fromEntries(sectionToContainedIds)).replace(/[\\`$]/g, '\\$&'); const multipageJsContents = `'use strict'; let multipageMap = JSON.parse(\`${containedMap}\`); ${await utils.readFile(path.join(__dirname, '../js/multipage.js'))} `; if (this.opts.assets !== 'none') { this.generatedFiles.set(path.join(this.opts.outfile, 'multipage/multipage.js'), multipageJsContents); } const multipageScript = this.doc.createElement('script'); multipageScript.src = 'multipage.js?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]; // @ts-ignore const links = allClones.flatMap(e => [...e.querySelectorAll('a,link')]); for (const link of links) { if (linkIsAbsolute(link)) { continue; } if (linkIsInternal(link)) { let p = link.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 ' + link.hash, node: link, }); continue; } } const targetSec = containedIdToSection.get(p); link.href = (targetSec === 'index' ? './' : targetSec + '.html') + link.hash; } else if (linkIsPathRelative(link)) { link.href = '../' + pathFromRelativeLink(link); } } // @ts-ignore for (const img of allClones.flatMap(e => [...e.querySelectorAll('img')])) { if (!/^(http:|https:|:|\/)/.test(img.src)) { img.src = '../' + img.src; } } // prettier-ignore // @ts-ignore for (const object of allClones.flatMap(e => [...e.querySelectorAll('object[data]')])) { if (!/^(http:|https:|:|\/)/.test(object.data)) { object.data = '../' + 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-ignore const commonHTML = commonClone.map(e => e.outerHTML).join('\n'); // @ts-ignore 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) { const cssContents = await utils.readFile(path.join(__dirname, '../css/elements.css')); if (this.opts.jsOut) { this.generatedFiles.set(this.opts.jsOut, jsContents); } if (this.opts.cssOut) { this.generatedFiles.set(this.opts.cssOut, cssContents); } if (this.opts.assets === 'none') return; const outDir = this.opts.outfile ? this.opts.multipage ? this.opts.outfile : path.dirname(this.opts.outfile) : process.cwd(); if (this.opts.jsOut) { let skipJs = false; const scripts = this.doc.querySelectorAll('script'); for (let i = 0; i < scripts.length; i++) { const script = scripts[i]; const src = script.getAttribute('src'); if (src && path.normalize(path.join(outDir, src)) === path.normalize(this.opts.jsOut)) { this.log(`Found existing js link to ${src}, skipping inlining...`); skipJs = true; } } if (!skipJs) { const script = this.doc.createElement('script'); script.src = path.relative(outDir, this.opts.jsOut) + '?cache=' + jsSha; script.setAttribute('defer', ''); this.doc.head.appendChild(script); } } else { this.log('Inlining JavaScript assets...'); const script = this.doc.createElement('script'); script.textContent = jsContents; this.doc.head.appendChild(script); } if (this.opts.cssOut) { let skipCss = false; const links = this.doc.querySelectorAll('link[rel=stylesheet]'); for (let i = 0; i < links.length; i++) { const link = links[i]; const href = link.getAttribute('href'); if (href && path.normalize(path.join(outDir, href)) === path.normalize(this.opts.cssOut)) { this.log(`Found existing css link to ${href}, skipping inlining...`); skipCss = true; } } if (!skipCss) { this.addStyle(this.doc.head, path.relative(outDir, this.opts.cssOut)); } } else { 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 => JSON.parse(c)); for (const biblio of biblios.concat((_a = this.opts.extraBiblios) !== null && _a !== void 0 ? _a : [])) { if ((biblio === null || biblio === void 0 ? void 0 : biblio.entries) == null) { let message = Object.keys(biblio !== null && biblio !== void 0 ? biblio : {}).some(k => k.startsWith('http')) ? 'This is an old-style biblio.' : 'Biblio does not appear to be in the correct format, are you using an old-style biblio?'; message += ' You will need to update it to work with versions of ecmarkup >= 12.0.0.'; throw new Error(message); } this.biblio.addExternalBiblio(biblio); for (const entry of biblio.entries) { if (entry.type === 'op' && ((_b = entry.effects) === null || _b === void 0 ? void 0 : _b.length) > 0) { this._effectfulAOs.set(entry.aoid, entry.effects); for (const effect of entry.effects) { if (!this._effectWorklist.has(effect)) { this._effectWorklist.set(effect, []); } this._effectWorklist.get(effect).push(entry); } } } } } async loadImports() { await loadImports(this, this.spec.doc.body, this.rootDir); } exportBiblio() { if (!this.opts.location) { this.warn({ type: 'global', ruleId: 'no-location', message: "no spec location specified; biblio not generated. try setting the location in the document's metadata block", }); return null; } return this.biblio.export(); } highlightCode() { this.log('Highlighting syntax...'); const codes = this.doc.querySelectorAll('pre code'); for (let i = 0; i < codes.length; i++) { const classAttr = codes[i].getAttribute('class'); if (!classAttr) continue; const language = classAttr.replace(/lang(uage)?-/, ''); let input = codes[i].textContent; // remove leading and trailing blank lines input = input.replace(/^(\s*[\r\n])+|([\r\n]\s*)+$/g, ''); // remove base inden based on indent of first non-blank line const baseIndent = input.match(/^\s*/) || ''; const baseIndentRe = new RegExp('^' + baseIndent, 'gm'); input = input.replace(baseIndentRe, ''); // @ts-expect-error the type definitions for highlight.js are broken const result = hljs.highlight(input, { language }); codes[i].innerHTML = result.value; codes[i].setAttribute('class', classAttr + ' hljs'); } } buildBoilerplate() { this.cancellationToken.throwIfCancellationRequested(); const status = this.opts.status; const version = this.opts.version; const title = this.opts.title; const shortname = this.opts.shortname; const location = this.opts.location; const stage = this.opts.stage; if (this.opts.copyright) { if (status !== 'draft' && status !== 'standard' && !this.opts.contributors) { this.warn({ type: 'global', ruleId: 'no-contributors', message: 'contributors not specified, skipping copyright boilerplate. specify contributors in your frontmatter metadata', }); } else { this.buildCopyrightBoiler