UNPKG

78.7 kBJavaScriptView Raw
1"use strict";
2Object.defineProperty(exports, "__esModule", { value: true });
3exports.maybeAddClauseToEffectWorklist = void 0;
4const path = require("path");
5const fs = require("fs");
6const crypto = require("crypto");
7const yaml = require("js-yaml");
8const utils = require("./utils");
9const hljs = require("highlight.js");
10// Builders
11const Import_1 = require("./Import");
12const Clause_1 = require("./Clause");
13const clauseNums_1 = require("./clauseNums");
14const Algorithm_1 = require("./Algorithm");
15const Dfn_1 = require("./Dfn");
16const Example_1 = require("./Example");
17const Figure_1 = require("./Figure");
18const Note_1 = require("./Note");
19const Toc_1 = require("./Toc");
20const Menu_1 = require("./Menu");
21const Production_1 = require("./Production");
22const NonTerminal_1 = require("./NonTerminal");
23const ProdRef_1 = require("./ProdRef");
24const Grammar_1 = require("./Grammar");
25const Xref_1 = require("./Xref");
26const Eqn_1 = require("./Eqn");
27const Biblio_1 = require("./Biblio");
28const Meta_1 = require("./Meta");
29const H1_1 = require("./H1");
30const autolinker_1 = require("./autolinker");
31const lint_1 = require("./lint/lint");
32const prex_1 = require("prex");
33const utils_1 = require("./lint/utils");
34const utils_2 = require("./utils");
35const expr_parser_1 = require("./expr-parser");
36const DRAFT_DATE_FORMAT = {
37 year: 'numeric',
38 month: 'long',
39 day: 'numeric',
40 timeZone: 'UTC',
41};
42const STANDARD_DATE_FORMAT = {
43 year: 'numeric',
44 month: 'long',
45 timeZone: 'UTC',
46};
47const NO_EMD = new Set(['PRE', 'CODE', 'EMU-PRODUCTION', 'EMU-ALG', 'EMU-GRAMMAR', 'EMU-EQN']);
48const YES_EMD = new Set(['EMU-GMOD']); // these are processed even if they are nested in NO_EMD contexts
49const builders = [
50 Clause_1.default,
51 Algorithm_1.default,
52 Xref_1.default,
53 Dfn_1.default,
54 Eqn_1.default,
55 Grammar_1.default,
56 Production_1.default,
57 Example_1.default,
58 Figure_1.default,
59 NonTerminal_1.default,
60 ProdRef_1.default,
61 Note_1.default,
62 Meta_1.default,
63 H1_1.default,
64];
65const visitorMap = builders.reduce((map, T) => {
66 T.elements.forEach(e => (map[e] = T));
67 return map;
68}, {});
69function wrapWarn(source, spec, warn) {
70 return (e) => {
71 const { message, ruleId } = e;
72 let line;
73 let column;
74 let nodeType;
75 let file = undefined;
76 switch (e.type) {
77 case 'global':
78 line = undefined;
79 column = undefined;
80 nodeType = 'html';
81 break;
82 case 'raw':
83 ({ line, column } = e);
84 nodeType = 'html';
85 if (e.file != null) {
86 file = e.file;
87 source = e.source;
88 }
89 break;
90 case 'node':
91 if (e.node.nodeType === 3 /* Node.TEXT_NODE */) {
92 const loc = spec.locate(e.node);
93 if (loc) {
94 file = loc.file;
95 source = loc.source;
96 ({ startLine: line, startCol: column } = loc);
97 }
98 nodeType = 'text';
99 }
100 else {
101 const loc = spec.locate(e.node);
102 if (loc) {
103 file = loc.file;
104 source = loc.source;
105 ({ startLine: line, startCol: column } = loc.startTag);
106 }
107 nodeType = e.node.tagName.toLowerCase();
108 }
109 break;
110 case 'attr': {
111 const loc = spec.locate(e.node);
112 if (loc) {
113 file = loc.file;
114 source = loc.source;
115 ({ line, column } = utils.attrLocation(source, loc, e.attr));
116 }
117 nodeType = e.node.tagName.toLowerCase();
118 break;
119 }
120 case 'attr-value': {
121 const loc = spec.locate(e.node);
122 if (loc) {
123 file = loc.file;
124 source = loc.source;
125 ({ line, column } = utils.attrValueLocation(source, loc, e.attr));
126 }
127 nodeType = e.node.tagName.toLowerCase();
128 break;
129 }
130 case 'contents': {
131 const { nodeRelativeLine, nodeRelativeColumn } = e;
132 if (e.node.nodeType === 3 /* Node.TEXT_NODE */) {
133 // i.e. a text node, which does not have a tag
134 const loc = spec.locate(e.node);
135 if (loc) {
136 file = loc.file;
137 source = loc.source;
138 line = loc.startLine + nodeRelativeLine - 1;
139 column =
140 nodeRelativeLine === 1 ? loc.startCol + nodeRelativeColumn - 1 : nodeRelativeColumn;
141 }
142 nodeType = 'text';
143 }
144 else {
145 const loc = spec.locate(e.node);
146 if (loc) {
147 file = loc.file;
148 source = loc.source;
149 line = loc.startTag.endLine + nodeRelativeLine - 1;
150 if (nodeRelativeLine === 1) {
151 column = loc.startTag.endCol + nodeRelativeColumn - 1;
152 }
153 else {
154 column = nodeRelativeColumn;
155 }
156 }
157 nodeType = e.node.tagName.toLowerCase();
158 }
159 break;
160 }
161 }
162 warn({
163 message,
164 ruleId,
165 // we omit source for global errors so that we don't get a codeframe
166 source: e.type === 'global' ? undefined : source,
167 file,
168 nodeType,
169 line,
170 column,
171 });
172 };
173}
174function isEmuImportElement(node) {
175 return node.nodeType === 1 && node.nodeName === 'EMU-IMPORT';
176}
177function maybeAddClauseToEffectWorklist(effectName, clause, worklist) {
178 if (!worklist.some(i => i.aoid === clause.aoid) &&
179 clause.canHaveEffect(effectName) &&
180 !clause.effects.includes(effectName)) {
181 clause.effects.push(effectName);
182 worklist.push(clause);
183 }
184}
185exports.maybeAddClauseToEffectWorklist = maybeAddClauseToEffectWorklist;
186/*@internal*/
187class Spec {
188 constructor(rootPath, fetch, dom, opts, sourceText, token = prex_1.CancellationToken.none) {
189 var _a;
190 opts = opts || {};
191 this.spec = this;
192 this.opts = {};
193 this.rootPath = rootPath;
194 this.rootDir = path.dirname(this.rootPath);
195 this.sourceText = sourceText;
196 this.doc = dom.window.document;
197 this.dom = dom;
198 this._fetch = fetch;
199 this.subclauses = [];
200 this.imports = [];
201 this.node = this.doc.body;
202 this.nodeIds = new Set();
203 this.replacementAlgorithmToContainedLabeledStepEntries = new Map();
204 this.labeledStepsToBeRectified = new Set();
205 this.replacementAlgorithms = [];
206 this.cancellationToken = token;
207 this.generatedFiles = new Map();
208 this.log = (_a = opts.log) !== null && _a !== void 0 ? _a : (() => { });
209 this.warn = opts.warn ? wrapWarn(sourceText, this, opts.warn) : () => { };
210 this._figureCounts = {
211 table: 0,
212 figure: 0,
213 };
214 this._xrefs = [];
215 this._ntRefs = [];
216 this._ntStringRefs = [];
217 this._prodRefs = [];
218 this._textNodes = {};
219 this._effectWorklist = new Map();
220 this._effectfulAOs = new Map();
221 this._emuMetasToRender = new Set();
222 this._emuMetasToRemove = new Set();
223 this.refsByClause = Object.create(null);
224 this.processMetadata();
225 Object.assign(this.opts, opts);
226 if (this.opts.multipage) {
227 if (this.opts.jsOut || this.opts.cssOut) {
228 throw new Error('Cannot use --multipage with --js-out or --css-out');
229 }
230 if (this.opts.outfile == null) {
231 this.opts.outfile = '';
232 }
233 if (this.opts.assets !== 'none') {
234 this.opts.jsOut = path.join(this.opts.outfile, 'ecmarkup.js');
235 this.opts.cssOut = path.join(this.opts.outfile, 'ecmarkup.css');
236 }
237 }
238 if (typeof this.opts.status === 'undefined') {
239 this.opts.status = 'proposal';
240 }
241 if (typeof this.opts.toc === 'undefined') {
242 this.opts.toc = true;
243 }
244 if (typeof this.opts.copyright === 'undefined') {
245 this.opts.copyright = true;
246 }
247 if (!this.opts.date) {
248 this.opts.date = new Date();
249 }
250 if (this.opts.stage != undefined) {
251 this.opts.stage = String(this.opts.stage);
252 }
253 if (!this.opts.location) {
254 this.opts.location = '<no location>';
255 }
256 this.namespace = this.opts.location;
257 this.biblio = new Biblio_1.default(this.opts.location);
258 }
259 fetch(file) {
260 return this._fetch(file, this.cancellationToken);
261 }
262 async build() {
263 /*
264 The Ecmarkup build process proceeds as follows:
265
266 1. Load biblios, making xrefs and auto-linking from external specs work
267 2. Load imports by recursively inlining the import files' content into the emu-import element
268 3. Generate boilerplate text
269 4. Do a walk of the DOM visting elements and text nodes. Text nodes are replaced by text and HTML nodes depending
270 on content. Elements are built by delegating to builders. Builders work by modifying the DOM ahead of them so
271 the new elements they make are visited during the walk. Elements added behind the current iteration must be
272 handled specially (eg. see static exit method of clause). Xref, nt, and prodref's are collected for linking
273 in the next step.
274 5. Linking. After the DOM walk we have a complete picture of all the symbols in the document so we proceed to link
275 the various xrefs to the proper place.
276 6. Adding charset, highlighting code, etc.
277 7. Add CSS & JS dependencies.
278 */
279 var _a;
280 this.log('Loading biblios...');
281 await this.loadBiblios();
282 this.log('Loading imports...');
283 await this.loadImports();
284 this.log('Building boilerplate...');
285 this.buildBoilerplate();
286 const context = {
287 spec: this,
288 node: this.doc.body,
289 importStack: [],
290 clauseStack: [],
291 tagStack: [],
292 clauseNumberer: (0, clauseNums_1.default)(this),
293 inNoAutolink: false,
294 inAlg: false,
295 inNoEmd: false,
296 followingEmd: null,
297 currentId: null,
298 };
299 const document = this.doc;
300 if (this.opts.lintSpec) {
301 this.log('Linting...');
302 const source = this.sourceText;
303 if (source === undefined) {
304 throw new Error('Cannot lint when source text is not available');
305 }
306 await (0, lint_1.lint)(this.warn, source, this, document);
307 }
308 this.log('Walking document, building various elements...');
309 const walker = document.createTreeWalker(document.body, 1 | 4 /* elements and text nodes */);
310 await walk(walker, context);
311 const sdoJs = this.generateSDOMap();
312 this.setReplacementAlgorithmOffsets();
313 this.autolink();
314 if (this.opts.lintSpec) {
315 this.log('Checking types...');
316 this.typecheck();
317 }
318 this.log('Propagating effect annotations...');
319 this.propagateEffects();
320 this.log('Linking xrefs...');
321 this._xrefs.forEach(xref => xref.build());
322 this.log('Linking non-terminal references...');
323 this._ntRefs.forEach(nt => nt.build());
324 this._emuMetasToRender.forEach(node => {
325 Meta_1.default.render(this, node);
326 });
327 this._emuMetasToRemove.forEach(node => {
328 node.replaceWith(...node.childNodes);
329 });
330 // TODO: make these look good
331 // this.log('Adding clause labels...');
332 // this.labelClauses();
333 if (this.opts.lintSpec) {
334 this._ntStringRefs.forEach(({ name, loc, node, namespace }) => {
335 if (this.biblio.byProductionName(name, namespace) == null) {
336 this.warn({
337 type: 'contents',
338 ruleId: 'undefined-nonterminal',
339 message: `could not find a definition for nonterminal ${name}`,
340 node,
341 nodeRelativeLine: loc.line,
342 nodeRelativeColumn: loc.column,
343 });
344 }
345 });
346 }
347 this.log('Linking production references...');
348 this._prodRefs.forEach(prod => prod.build());
349 this.log('Building reference graph...');
350 this.buildReferenceGraph();
351 this.highlightCode();
352 this.setCharset();
353 const wrapper = this.buildSpecWrapper();
354 let commonEles = [];
355 let tocJs = '';
356 if (this.opts.toc) {
357 this.log('Building table of contents...');
358 if (this.opts.oldToc) {
359 new Toc_1.default(this).build();
360 }
361 else {
362 ({ js: tocJs, eles: commonEles } = (0, Menu_1.default)(this));
363 }
364 }
365 this.log('Building shortcuts help dialog...');
366 commonEles.push(this.buildShortcutsHelp());
367 for (const ele of commonEles) {
368 this.doc.body.insertBefore(ele, this.doc.body.firstChild);
369 }
370 const jsContents = (await concatJs(sdoJs, tocJs)) + `\n;let usesMultipage = ${!!this.opts.multipage}`;
371 const jsSha = sha(jsContents);
372 if (this.opts.multipage) {
373 await this.buildMultipage(wrapper, commonEles, jsSha);
374 }
375 await this.buildAssets(jsContents, jsSha);
376 const file = this.opts.multipage
377 ? path.join(this.opts.outfile, 'index.html')
378 : (_a = this.opts.outfile) !== null && _a !== void 0 ? _a : null;
379 this.generatedFiles.set(file, this.toHTML());
380 return this;
381 }
382 labelClauses() {
383 const label = (clause) => {
384 var _a, _b;
385 if (clause.header != null) {
386 if (((_b = (_a = clause.signature) === null || _a === void 0 ? void 0 : _a.return) === null || _b === void 0 ? void 0 : _b.kind) === 'completion' &&
387 clause.signature.return.completionType !== 'normal') {
388 // 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;"
389 // TODO: make this a different color
390 clause.header.innerHTML += `<span class="clause-tag abrupt-tag" title="this can return an abrupt completion">abrupt</span>`;
391 }
392 // TODO: make this look like the [UC] annotation
393 // TODO: hide this if [UC] is not enabled (maybe)
394 // the querySelector is gross; you are welcome to replace it with a better analysis which actually keeps track of stuff
395 if (clause.node.querySelector('.e-user-code')) {
396 clause.header.innerHTML += `<span class="clause-tag user-code-tag" title="this can invoke user code">user code</span>`;
397 }
398 }
399 for (const sub of clause.subclauses) {
400 label(sub);
401 }
402 };
403 for (const sub of this.subclauses) {
404 label(sub);
405 }
406 }
407 // checks that AOs which do/don't return completion records are invoked appropriately
408 // also checks that the appropriate number of arguments are passed
409 typecheck() {
410 const isUnused = (t) => {
411 var _a;
412 return t.kind === 'unused' ||
413 (t.kind === 'completion' &&
414 (t.completionType === 'abrupt' || ((_a = t.typeOfValueIfNormal) === null || _a === void 0 ? void 0 : _a.kind) === 'unused'));
415 };
416 const AOs = this.biblio
417 .localEntries()
418 .filter(e => { var _a; return e.type === 'op' && ((_a = e.signature) === null || _a === void 0 ? void 0 : _a.return) != null; });
419 const onlyPerformed = new Map(AOs.filter(e => !isUnused(e.signature.return)).map(a => [a.aoid, null]));
420 const alwaysAssertedToBeNormal = new Map(AOs.filter(e => e.signature.return.kind === 'completion').map(a => [a.aoid, null]));
421 // TODO strictly speaking this needs to be done in the namespace of the current algorithm
422 const opNames = this.biblio.getOpNames(this.namespace);
423 // TODO move declarations out of loop
424 for (const node of this.doc.querySelectorAll('emu-alg')) {
425 if (node.hasAttribute('example') || !('ecmarkdownTree' in node)) {
426 continue;
427 }
428 // @ts-ignore
429 const tree = node.ecmarkdownTree;
430 if (tree == null) {
431 continue;
432 }
433 // @ts-ignore
434 const originalHtml = node.originalHtml;
435 const expressionVisitor = (expr, path) => {
436 if (expr.type !== 'call' && expr.type !== 'sdo-call') {
437 return;
438 }
439 const { callee, arguments: args } = expr;
440 if (!(callee.parts.length === 1 && callee.parts[0].name === 'text')) {
441 return;
442 }
443 const calleeName = callee.parts[0].contents;
444 const warn = (message) => {
445 const { line, column } = (0, utils_2.offsetToLineAndColumn)(originalHtml, callee.parts[0].location.start.offset);
446 this.warn({
447 type: 'contents',
448 ruleId: 'typecheck',
449 message,
450 node,
451 nodeRelativeLine: line,
452 nodeRelativeColumn: column,
453 });
454 };
455 const biblioEntry = this.biblio.byAoid(calleeName);
456 if (biblioEntry == null) {
457 if (![
458 'thisTimeValue',
459 'thisStringValue',
460 'thisBigIntValue',
461 'thisNumberValue',
462 'thisSymbolValue',
463 'thisBooleanValue',
464 'toUppercase',
465 'toLowercase',
466 ].includes(calleeName)) {
467 // TODO make the spec not do this
468 warn(`could not find definition for ${calleeName}`);
469 }
470 return;
471 }
472 if (biblioEntry.kind === 'syntax-directed operation' && expr.type === 'call') {
473 warn(`${calleeName} is a syntax-directed operation and should not be invoked like a regular call`);
474 }
475 else if (biblioEntry.kind != null &&
476 biblioEntry.kind !== 'syntax-directed operation' &&
477 expr.type === 'sdo-call') {
478 warn(`${calleeName} is not a syntax-directed operation but here is being invoked as one`);
479 }
480 if (biblioEntry.signature == null) {
481 return;
482 }
483 const min = biblioEntry.signature.parameters.length;
484 const max = min + biblioEntry.signature.optionalParameters.length;
485 if (args.length < min || args.length > max) {
486 const count = `${min}${min === max ? '' : `-${max}`}`;
487 // prettier-ignore
488 const message = `${calleeName} takes ${count} argument${count === '1' ? '' : 's'}, but this invocation passes ${args.length}`;
489 warn(message);
490 }
491 const { return: returnType } = biblioEntry.signature;
492 if (returnType == null) {
493 return;
494 }
495 const consumedAsCompletion = isConsumedAsCompletion(expr, path);
496 // checks elsewhere ensure that well-formed documents never have a union of completion and non-completion, so checking the first child suffices
497 // 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?
498 const isCompletion = returnType.kind === 'completion' ||
499 (returnType.kind === 'union' && returnType.types[0].kind === 'completion');
500 if (['Completion', 'ThrowCompletion', 'NormalCompletion'].includes(calleeName)) {
501 if (consumedAsCompletion) {
502 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`);
503 }
504 }
505 else if (isCompletion && !consumedAsCompletion) {
506 warn(`${calleeName} returns a Completion Record, but is not consumed as if it does`);
507 }
508 else if (!isCompletion && consumedAsCompletion) {
509 warn(`${calleeName} does not return a Completion Record, but is consumed as if it does`);
510 }
511 if (returnType.kind === 'unused' && !isCalledAsPerform(expr, path, false)) {
512 warn(`${calleeName} does not return a meaningful value and should only be invoked as \`Perform ${calleeName}(...).\``);
513 }
514 if (onlyPerformed.has(calleeName) && onlyPerformed.get(calleeName) !== 'top') {
515 const old = onlyPerformed.get(calleeName);
516 const performed = isCalledAsPerform(expr, path, true);
517 if (!performed) {
518 onlyPerformed.set(calleeName, 'top');
519 }
520 else if (old === null) {
521 onlyPerformed.set(calleeName, 'only performed');
522 }
523 }
524 if (alwaysAssertedToBeNormal.has(calleeName) &&
525 alwaysAssertedToBeNormal.get(calleeName) !== 'top') {
526 const old = alwaysAssertedToBeNormal.get(calleeName);
527 const asserted = isAssertedToBeNormal(expr, path);
528 if (!asserted) {
529 alwaysAssertedToBeNormal.set(calleeName, 'top');
530 }
531 else if (old === null) {
532 alwaysAssertedToBeNormal.set(calleeName, 'always asserted normal');
533 }
534 }
535 };
536 const walkLines = (list) => {
537 var _a;
538 for (const line of list.contents) {
539 const item = (0, expr_parser_1.parse)(line.contents, opNames);
540 if (item.type === 'failure') {
541 const { line, column } = (0, utils_2.offsetToLineAndColumn)(originalHtml, item.offset);
542 this.warn({
543 type: 'contents',
544 ruleId: 'expression-parsing',
545 message: item.message,
546 node,
547 nodeRelativeLine: line,
548 nodeRelativeColumn: column,
549 });
550 }
551 else {
552 (0, expr_parser_1.walk)(expressionVisitor, item);
553 }
554 if (((_a = line.sublist) === null || _a === void 0 ? void 0 : _a.name) === 'ol') {
555 walkLines(line.sublist);
556 }
557 }
558 };
559 walkLines(tree.contents);
560 }
561 for (const [aoid, state] of onlyPerformed) {
562 if (state !== 'only performed') {
563 continue;
564 }
565 const message = `${aoid} is only ever invoked with Perform, so it should return ~unused~ or a Completion Record which, if normal, contains ~unused~`;
566 const ruleId = 'perform-not-unused';
567 const biblioEntry = this.biblio.byAoid(aoid);
568 if (biblioEntry._node) {
569 this.spec.warn({
570 type: 'node',
571 ruleId,
572 message,
573 node: biblioEntry._node,
574 });
575 }
576 else {
577 this.spec.warn({
578 type: 'global',
579 ruleId,
580 message,
581 });
582 }
583 }
584 for (const [aoid, state] of alwaysAssertedToBeNormal) {
585 if (state !== 'always asserted normal') {
586 continue;
587 }
588 if (aoid === 'AsyncGeneratorAwaitReturn') {
589 // TODO remove this when https://github.com/tc39/ecma262/issues/2412 is fixed
590 continue;
591 }
592 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`;
593 const ruleId = 'always-asserted-normal';
594 const biblioEntry = this.biblio.byAoid(aoid);
595 if (biblioEntry._node) {
596 this.spec.warn({
597 type: 'node',
598 ruleId,
599 message,
600 node: biblioEntry._node,
601 });
602 }
603 else {
604 this.spec.warn({
605 type: 'global',
606 ruleId,
607 message,
608 });
609 }
610 }
611 }
612 toHTML() {
613 const htmlEle = this.doc.documentElement;
614 return '<!doctype html>\n' + (htmlEle.hasAttributes() ? htmlEle.outerHTML : htmlEle.innerHTML);
615 }
616 locate(node) {
617 let pointer = node;
618 while (pointer != null) {
619 if (isEmuImportElement(pointer)) {
620 break;
621 }
622 pointer = pointer.parentElement;
623 }
624 const dom = pointer == null ? this.dom : pointer.dom;
625 if (!dom) {
626 return;
627 }
628 // the jsdom types are wrong
629 const loc = dom.nodeLocation(node);
630 if (loc) {
631 // we can't just spread `loc` because not all properties are own/enumerable
632 const out = {
633 source: this.sourceText,
634 startTag: loc.startTag,
635 endTag: loc.endTag,
636 startOffset: loc.startOffset,
637 endOffset: loc.endOffset,
638 attrs: loc.attrs,
639 startLine: loc.startLine,
640 startCol: loc.startCol,
641 endLine: loc.endLine,
642 endCol: loc.endCol,
643 };
644 if (pointer != null) {
645 out.file = pointer.importPath;
646 out.source = pointer.source;
647 }
648 return out;
649 }
650 }
651 buildReferenceGraph() {
652 const refToClause = this.refsByClause;
653 const setParent = (node) => {
654 let pointer = node;
655 while (pointer && !['EMU-CLAUSE', 'EMU-INTRO', 'EMU-ANNEX'].includes(pointer.nodeName)) {
656 pointer = pointer.parentNode;
657 }
658 // @ts-ignore
659 if (pointer == null || pointer.id == null) {
660 // @ts-ignore
661 pointer = { id: 'sec-intro' };
662 }
663 // @ts-ignore
664 if (refToClause[pointer.id] == null) {
665 // @ts-ignore
666 refToClause[pointer.id] = [];
667 }
668 // @ts-ignore
669 refToClause[pointer.id].push(node.id);
670 };
671 let counter = 0;
672 this._xrefs.forEach(xref => {
673 let entry = xref.entry;
674 if (!entry || entry.namespace === 'external')
675 return;
676 if (!entry.id && entry.refId) {
677 entry = this.spec.biblio.byId(entry.refId);
678 }
679 if (!xref.id) {
680 const id = `_ref_${counter++}`;
681 xref.node.setAttribute('id', id);
682 xref.id = id;
683 }
684 setParent(xref.node);
685 entry.referencingIds.push(xref.id);
686 });
687 this._ntRefs.forEach(prod => {
688 const entry = prod.entry;
689 if (!entry || entry.namespace === 'external')
690 return;
691 // if this is the defining nt of an emu-production, don't create a ref
692 if (prod.node.parentNode.nodeName === 'EMU-PRODUCTION')
693 return;
694 const id = `_ref_${counter++}`;
695 prod.node.setAttribute('id', id);
696 setParent(prod.node);
697 entry.referencingIds.push(id);
698 });
699 }
700 checkValidSectionId(ele) {
701 if (!ele.id.startsWith('sec-')) {
702 this.warn({
703 type: 'node',
704 ruleId: 'top-level-section-id',
705 message: 'When using --multipage, top-level sections must have ids beginning with `sec-`',
706 node: ele,
707 });
708 return false;
709 }
710 if (!/^[A-Za-z0-9-_]+$/.test(ele.id)) {
711 this.warn({
712 type: 'node',
713 ruleId: 'top-level-section-id',
714 message: 'When using --multipage, top-level sections must have ids matching /^[A-Za-z0-9-_]+$/',
715 node: ele,
716 });
717 return false;
718 }
719 if (ele.id.toLowerCase() === 'sec-index') {
720 this.warn({
721 type: 'node',
722 ruleId: 'top-level-section-id',
723 message: 'When using --multipage, top-level sections must not be named "index"',
724 node: ele,
725 });
726 return false;
727 }
728 return true;
729 }
730 propagateEffects() {
731 for (const [effectName, worklist] of this._effectWorklist) {
732 this.propagateEffect(effectName, worklist);
733 }
734 }
735 propagateEffect(effectName, worklist) {
736 const usersOfAoid = new Map();
737 for (const xref of this._xrefs) {
738 if (xref.clause == null || xref.aoid == null)
739 continue;
740 if (!xref.shouldPropagateEffect(effectName))
741 continue;
742 if (xref.hasAddedEffect(effectName)) {
743 maybeAddClauseToEffectWorklist(effectName, xref.clause, worklist);
744 }
745 const usedAoid = xref.aoid;
746 if (!usersOfAoid.has(usedAoid)) {
747 usersOfAoid.set(usedAoid, new Set());
748 }
749 usersOfAoid.get(usedAoid).add(xref.clause);
750 }
751 while (worklist.length !== 0) {
752 const clause = worklist.shift();
753 const aoid = clause.aoid;
754 if (aoid == null || !usersOfAoid.has(aoid)) {
755 continue;
756 }
757 this._effectfulAOs.set(aoid, clause.effects);
758 for (const userClause of usersOfAoid.get(aoid)) {
759 maybeAddClauseToEffectWorklist(effectName, userClause, worklist);
760 }
761 }
762 }
763 getEffectsByAoid(aoid) {
764 if (this._effectfulAOs.has(aoid)) {
765 return this._effectfulAOs.get(aoid);
766 }
767 return null;
768 }
769 async buildMultipage(wrapper, commonEles, jsSha) {
770 let stillIntro = true;
771 const introEles = [];
772 const sections = [];
773 const containedIdToSection = new Map();
774 const sectionToContainedIds = new Map();
775 const clauseTypes = ['EMU-ANNEX', 'EMU-CLAUSE'];
776 // @ts-ignore
777 for (const child of wrapper.children) {
778 if (stillIntro) {
779 if (clauseTypes.includes(child.nodeName)) {
780 throw new Error('cannot make multipage build without intro');
781 }
782 else if (child.nodeName === 'EMU-INTRO') {
783 stillIntro = false;
784 if (child.id == null) {
785 this.warn({
786 type: 'node',
787 ruleId: 'top-level-section-id',
788 message: 'When using --multipage, top-level sections must have ids',
789 node: child,
790 });
791 continue;
792 }
793 if (child.id !== 'sec-intro') {
794 this.warn({
795 type: 'node',
796 ruleId: 'top-level-section-id',
797 message: 'When using --multipage, the introduction must have id "sec-intro"',
798 node: child,
799 });
800 continue;
801 }
802 const name = 'index';
803 introEles.push(child);
804 sections.push({ name, eles: introEles });
805 const contained = [];
806 sectionToContainedIds.set(name, contained);
807 for (const item of introEles) {
808 if (item.id) {
809 contained.push(item.id);
810 containedIdToSection.set(item.id, name);
811 }
812 }
813 // @ts-ignore
814 for (const item of [...introEles].flatMap(e => [...e.querySelectorAll('[id]')])) {
815 contained.push(item.id);
816 containedIdToSection.set(item.id, name);
817 }
818 }
819 else {
820 introEles.push(child);
821 }
822 }
823 else {
824 if (!clauseTypes.includes(child.nodeName)) {
825 throw new Error('non-clause children are not yet implemented: ' + child.nodeName);
826 }
827 if (child.id == null) {
828 this.warn({
829 type: 'node',
830 ruleId: 'top-level-section-id',
831 message: 'When using --multipage, top-level sections must have ids',
832 node: child,
833 });
834 continue;
835 }
836 if (!this.checkValidSectionId(child)) {
837 continue;
838 }
839 const name = child.id.substring(4);
840 const contained = [];
841 sectionToContainedIds.set(name, contained);
842 contained.push(child.id);
843 containedIdToSection.set(child.id, name);
844 for (const item of child.querySelectorAll('[id]')) {
845 contained.push(item.id);
846 containedIdToSection.set(item.id, name);
847 }
848 sections.push({ name, eles: [child] });
849 }
850 }
851 let htmlEle = '';
852 if (this.doc.documentElement.hasAttributes()) {
853 const clonedHtmlEle = this.doc.documentElement.cloneNode(false);
854 clonedHtmlEle.innerHTML = '';
855 const src = clonedHtmlEle.outerHTML;
856 htmlEle = src.substring(0, src.length - '<head></head><body></body></html>'.length);
857 }
858 const head = this.doc.head.cloneNode(true);
859 this.addStyle(head, 'ecmarkup.css'); // omit `../` because we rewrite `<link>` elements below
860 this.addStyle(head, `https://cdnjs.cloudflare.com/ajax/libs/highlight.js/${hljs.versionString}/styles/base16/solarized-light.min.css`);
861 const script = this.doc.createElement('script');
862 script.src = '../ecmarkup.js?cache=' + jsSha;
863 script.setAttribute('defer', '');
864 head.appendChild(script);
865 const containedMap = JSON.stringify(Object.fromEntries(sectionToContainedIds)).replace(/[\\`$]/g, '\\$&');
866 const multipageJsContents = `'use strict';
867let multipageMap = JSON.parse(\`${containedMap}\`);
868${await utils.readFile(path.join(__dirname, '../js/multipage.js'))}
869`;
870 if (this.opts.assets !== 'none') {
871 this.generatedFiles.set(path.join(this.opts.outfile, 'multipage/multipage.js'), multipageJsContents);
872 }
873 const multipageScript = this.doc.createElement('script');
874 multipageScript.src = 'multipage.js?cache=' + sha(multipageJsContents);
875 multipageScript.setAttribute('defer', '');
876 head.insertBefore(multipageScript, head.querySelector('script'));
877 for (const { name, eles } of sections) {
878 this.log(`Generating section ${name}...`);
879 const headClone = head.cloneNode(true);
880 const commonClone = commonEles.map(e => e.cloneNode(true));
881 const clones = eles.map(e => e.cloneNode(true));
882 const allClones = [headClone, ...commonClone, ...clones];
883 // @ts-ignore
884 const links = allClones.flatMap(e => [...e.querySelectorAll('a,link')]);
885 for (const link of links) {
886 if (linkIsAbsolute(link)) {
887 continue;
888 }
889 if (linkIsInternal(link)) {
890 let p = link.hash.substring(1);
891 if (!containedIdToSection.has(p)) {
892 try {
893 p = decodeURIComponent(p);
894 }
895 catch {
896 // pass
897 }
898 if (!containedIdToSection.has(p)) {
899 this.warn({
900 type: 'node',
901 ruleId: 'multipage-link-target',
902 message: 'could not find appropriate section for ' + link.hash,
903 node: link,
904 });
905 continue;
906 }
907 }
908 const targetSec = containedIdToSection.get(p);
909 link.href = (targetSec === 'index' ? './' : targetSec + '.html') + link.hash;
910 }
911 else if (linkIsPathRelative(link)) {
912 link.href = '../' + pathFromRelativeLink(link);
913 }
914 }
915 // @ts-ignore
916 for (const img of allClones.flatMap(e => [...e.querySelectorAll('img')])) {
917 if (!/^(http:|https:|:|\/)/.test(img.src)) {
918 img.src = '../' + img.src;
919 }
920 }
921 // prettier-ignore
922 // @ts-ignore
923 for (const object of allClones.flatMap(e => [...e.querySelectorAll('object[data]')])) {
924 if (!/^(http:|https:|:|\/)/.test(object.data)) {
925 object.data = '../' + object.data;
926 }
927 }
928 if (eles[0].hasAttribute('id')) {
929 const canonical = this.doc.createElement('link');
930 canonical.setAttribute('rel', 'canonical');
931 canonical.setAttribute('href', `../#${eles[0].id}`);
932 headClone.appendChild(canonical);
933 }
934 // @ts-ignore
935 const commonHTML = commonClone.map(e => e.outerHTML).join('\n');
936 // @ts-ignore
937 const clonesHTML = clones.map(e => e.outerHTML).join('\n');
938 const content = `<!doctype html>${htmlEle}\n${headClone.outerHTML}\n<body>${commonHTML}<div id='spec-container'>${clonesHTML}</div></body>`;
939 this.generatedFiles.set(path.join(this.opts.outfile, `multipage/${name}.html`), content);
940 }
941 }
942 async buildAssets(jsContents, jsSha) {
943 const cssContents = await utils.readFile(path.join(__dirname, '../css/elements.css'));
944 if (this.opts.jsOut) {
945 this.generatedFiles.set(this.opts.jsOut, jsContents);
946 }
947 if (this.opts.cssOut) {
948 this.generatedFiles.set(this.opts.cssOut, cssContents);
949 }
950 if (this.opts.assets === 'none')
951 return;
952 const outDir = this.opts.outfile
953 ? this.opts.multipage
954 ? this.opts.outfile
955 : path.dirname(this.opts.outfile)
956 : process.cwd();
957 if (this.opts.jsOut) {
958 let skipJs = false;
959 const scripts = this.doc.querySelectorAll('script');
960 for (let i = 0; i < scripts.length; i++) {
961 const script = scripts[i];
962 const src = script.getAttribute('src');
963 if (src && path.normalize(path.join(outDir, src)) === path.normalize(this.opts.jsOut)) {
964 this.log(`Found existing js link to ${src}, skipping inlining...`);
965 skipJs = true;
966 }
967 }
968 if (!skipJs) {
969 const script = this.doc.createElement('script');
970 script.src = path.relative(outDir, this.opts.jsOut) + '?cache=' + jsSha;
971 script.setAttribute('defer', '');
972 this.doc.head.appendChild(script);
973 }
974 }
975 else {
976 this.log('Inlining JavaScript assets...');
977 const script = this.doc.createElement('script');
978 script.textContent = jsContents;
979 this.doc.head.appendChild(script);
980 }
981 if (this.opts.cssOut) {
982 let skipCss = false;
983 const links = this.doc.querySelectorAll('link[rel=stylesheet]');
984 for (let i = 0; i < links.length; i++) {
985 const link = links[i];
986 const href = link.getAttribute('href');
987 if (href && path.normalize(path.join(outDir, href)) === path.normalize(this.opts.cssOut)) {
988 this.log(`Found existing css link to ${href}, skipping inlining...`);
989 skipCss = true;
990 }
991 }
992 if (!skipCss) {
993 this.addStyle(this.doc.head, path.relative(outDir, this.opts.cssOut));
994 }
995 }
996 else {
997 this.log('Inlining CSS assets...');
998 const style = this.doc.createElement('style');
999 style.textContent = cssContents;
1000 this.doc.head.appendChild(style);
1001 }
1002 this.addStyle(this.doc.head, `https://cdnjs.cloudflare.com/ajax/libs/highlight.js/${hljs.versionString}/styles/base16/solarized-light.min.css`);
1003 }
1004 addStyle(head, href) {
1005 const style = this.doc.createElement('link');
1006 style.setAttribute('rel', 'stylesheet');
1007 style.setAttribute('href', href);
1008 // insert early so that the document's own stylesheets can override
1009 const firstLink = head.querySelector('link[rel=stylesheet], style');
1010 if (firstLink != null) {
1011 head.insertBefore(style, firstLink);
1012 }
1013 else {
1014 head.appendChild(style);
1015 }
1016 }
1017 buildSpecWrapper() {
1018 const elements = this.doc.body.childNodes;
1019 const wrapper = this.doc.createElement('div');
1020 wrapper.id = 'spec-container';
1021 while (elements.length > 0) {
1022 wrapper.appendChild(elements[0]);
1023 }
1024 this.doc.body.appendChild(wrapper);
1025 return wrapper;
1026 }
1027 buildShortcutsHelp() {
1028 const shortcutsHelp = this.doc.createElement('div');
1029 shortcutsHelp.setAttribute('id', 'shortcuts-help');
1030 shortcutsHelp.innerHTML = `
1031<ul>
1032 <li><span>Toggle shortcuts help</span><code>?</code></li>
1033 <li><span>Toggle "can call user code" annotations</span><code>u</code></li>
1034${this.opts.multipage ? `<li><span>Navigate to/from multipage</span><code>m</code></li>` : ''}
1035 <li><span>Jump to search box</span><code>/</code></li>
1036</ul>`;
1037 return shortcutsHelp;
1038 }
1039 processMetadata() {
1040 const block = this.doc.querySelector('pre.metadata');
1041 if (!block || !block.parentNode) {
1042 return;
1043 }
1044 let data;
1045 try {
1046 data = yaml.safeLoad(block.textContent);
1047 }
1048 catch (e) {
1049 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') {
1050 this.warn({
1051 type: 'contents',
1052 ruleId: 'invalid-metadata',
1053 message: `metadata block failed to parse: ${e.reason}`,
1054 node: block,
1055 nodeRelativeLine: e.mark.line + 1,
1056 nodeRelativeColumn: e.mark.column + 1,
1057 });
1058 }
1059 else {
1060 this.warn({
1061 type: 'node',
1062 ruleId: 'invalid-metadata',
1063 message: 'metadata block failed to parse',
1064 node: block,
1065 });
1066 }
1067 return;
1068 }
1069 finally {
1070 block.parentNode.removeChild(block);
1071 }
1072 Object.assign(this.opts, data);
1073 }
1074 async loadBiblios() {
1075 var _a, _b;
1076 this.cancellationToken.throwIfCancellationRequested();
1077 const biblioPaths = [];
1078 for (const biblioEle of this.doc.querySelectorAll('emu-biblio')) {
1079 const href = biblioEle.getAttribute('href');
1080 if (href == null) {
1081 this.spec.warn({
1082 type: 'node',
1083 node: biblioEle,
1084 ruleId: 'biblio-href',
1085 message: 'emu-biblio elements must have an href attribute',
1086 });
1087 }
1088 else {
1089 biblioPaths.push(href);
1090 }
1091 }
1092 const biblioContents = await Promise.all(biblioPaths.map(p => this.fetch(path.join(this.rootDir, p))));
1093 const biblios = biblioContents.flatMap(c => JSON.parse(c));
1094 for (const biblio of biblios.concat((_a = this.opts.extraBiblios) !== null && _a !== void 0 ? _a : [])) {
1095 if ((biblio === null || biblio === void 0 ? void 0 : biblio.entries) == null) {
1096 let message = Object.keys(biblio !== null && biblio !== void 0 ? biblio : {}).some(k => k.startsWith('http'))
1097 ? 'This is an old-style biblio.'
1098 : 'Biblio does not appear to be in the correct format, are you using an old-style biblio?';
1099 message += ' You will need to update it to work with versions of ecmarkup >= 12.0.0.';
1100 throw new Error(message);
1101 }
1102 this.biblio.addExternalBiblio(biblio);
1103 for (const entry of biblio.entries) {
1104 if (entry.type === 'op' && ((_b = entry.effects) === null || _b === void 0 ? void 0 : _b.length) > 0) {
1105 this._effectfulAOs.set(entry.aoid, entry.effects);
1106 for (const effect of entry.effects) {
1107 if (!this._effectWorklist.has(effect)) {
1108 this._effectWorklist.set(effect, []);
1109 }
1110 this._effectWorklist.get(effect).push(entry);
1111 }
1112 }
1113 }
1114 }
1115 }
1116 async loadImports() {
1117 await loadImports(this, this.spec.doc.body, this.rootDir);
1118 }
1119 exportBiblio() {
1120 if (!this.opts.location) {
1121 this.warn({
1122 type: 'global',
1123 ruleId: 'no-location',
1124 message: "no spec location specified; biblio not generated. try setting the location in the document's metadata block",
1125 });
1126 return null;
1127 }
1128 return this.biblio.export();
1129 }
1130 highlightCode() {
1131 this.log('Highlighting syntax...');
1132 const codes = this.doc.querySelectorAll('pre code');
1133 for (let i = 0; i < codes.length; i++) {
1134 const classAttr = codes[i].getAttribute('class');
1135 if (!classAttr)
1136 continue;
1137 const language = classAttr.replace(/lang(uage)?-/, '');
1138 let input = codes[i].textContent;
1139 // remove leading and trailing blank lines
1140 input = input.replace(/^(\s*[\r\n])+|([\r\n]\s*)+$/g, '');
1141 // remove base inden based on indent of first non-blank line
1142 const baseIndent = input.match(/^\s*/) || '';
1143 const baseIndentRe = new RegExp('^' + baseIndent, 'gm');
1144 input = input.replace(baseIndentRe, '');
1145 // @ts-expect-error the type definitions for highlight.js are broken
1146 const result = hljs.highlight(input, { language });
1147 codes[i].innerHTML = result.value;
1148 codes[i].setAttribute('class', classAttr + ' hljs');
1149 }
1150 }
1151 buildBoilerplate() {
1152 this.cancellationToken.throwIfCancellationRequested();
1153 const status = this.opts.status;
1154 const version = this.opts.version;
1155 const title = this.opts.title;
1156 const shortname = this.opts.shortname;
1157 const location = this.opts.location;
1158 const stage = this.opts.stage;
1159 if (this.opts.copyright) {
1160 if (status !== 'draft' && status !== 'standard' && !this.opts.contributors) {
1161 this.warn({
1162 type: 'global',
1163 ruleId: 'no-contributors',
1164 message: 'contributors not specified, skipping copyright boilerplate. specify contributors in your frontmatter metadata',
1165 });
1166 }
1167 else {
1168 this.buildCopyrightBoilerplate();
1169 }
1170 }
1171 // no title boilerplate generated if title not specified
1172 if (!title)
1173 return;
1174 // title
1175 if (title && !this._updateBySelector('title', title)) {
1176 const titleElem = this.doc.createElement('title');
1177 titleElem.innerHTML = title;
1178 this.doc.head.appendChild(titleElem);
1179 const h1 = this.doc.createElement('h1');
1180 h1.setAttribute('class', 'title');
1181 h1.innerHTML = title;
1182 this.doc.body.insertBefore(h1, this.doc.body.firstChild);
1183 }
1184 // version string, ala 6th Edition July 2016 or Draft 10 / September 26, 2015
1185 let versionText = '';
1186 let omitShortname = false;
1187 if (version) {
1188 versionText += version + ' / ';
1189 }
1190 else if (status === 'proposal' && stage) {
1191 versionText += 'Stage ' + stage + ' Draft / ';
1192 }
1193 else if (shortname && status === 'draft') {
1194 versionText += 'Draft ' + shortname + ' / ';
1195 omitShortname = true;
1196 }
1197 else {
1198 return;
1199 }
1200 const defaultDateFormat = status === 'standard' ? STANDARD_DATE_FORMAT : DRAFT_DATE_FORMAT;
1201 const date = new Intl.DateTimeFormat('en-US', defaultDateFormat).format(this.opts.date);
1202 versionText += date;
1203 if (!this._updateBySelector('h1.version', versionText)) {
1204 const h1 = this.doc.createElement('h1');
1205 h1.setAttribute('class', 'version');
1206 h1.innerHTML = versionText;
1207 this.doc.body.insertBefore(h1, this.doc.body.firstChild);
1208 }
1209 // shortname and status, ala 'Draft ECMA-262
1210 if (shortname && !omitShortname) {
1211 // for proposals, link shortname to location
1212 const shortnameLinkHtml = status === 'proposal' && location ? `<a href="${location}">${shortname}</a>` : shortname;
1213 const shortnameHtml = status.charAt(0).toUpperCase() + status.slice(1) + ' ' + shortnameLinkHtml;
1214 if (!this._updateBySelector('h1.shortname', shortnameHtml)) {
1215 const h1 = this.doc.createElement('h1');
1216 h1.setAttribute('class', 'shortname');
1217 h1.innerHTML = shortnameHtml;
1218 this.doc.body.insertBefore(h1, this.doc.body.firstChild);
1219 }
1220 }
1221 }
1222 buildCopyrightBoilerplate() {
1223 let addressFile;
1224 let copyrightFile;
1225 let licenseFile;
1226 if (this.opts.boilerplate) {
1227 if (this.opts.boilerplate.address) {
1228 addressFile = path.join(process.cwd(), this.opts.boilerplate.address);
1229 }
1230 if (this.opts.boilerplate.copyright) {
1231 copyrightFile = path.join(process.cwd(), this.opts.boilerplate.copyright);
1232 }
1233 if (this.opts.boilerplate.license) {
1234 licenseFile = path.join(process.cwd(), this.opts.boilerplate.license);
1235 }
1236 }
1237 // Get content from files
1238 let address = getBoilerplate(addressFile || 'address');
1239 let copyright = getBoilerplate(copyrightFile || `${this.opts.status}-copyright`);
1240 const license = getBoilerplate(licenseFile || 'software-license');
1241 if (this.opts.status === 'proposal') {
1242 address = '';
1243 }
1244 // Operate on content
1245 copyright = copyright.replace(/!YEAR!/g, '' + this.opts.date.getFullYear());
1246 if (this.opts.contributors) {
1247 copyright = copyright.replace(/!CONTRIBUTORS!/g, this.opts.contributors);
1248 }
1249 let copyrightClause = this.doc.querySelector('.copyright-and-software-license');
1250 if (!copyrightClause) {
1251 let last;
1252 utils.domWalkBackward(this.doc.body, node => {
1253 if (last)
1254 return false;
1255 if (node.nodeName === 'EMU-CLAUSE' || node.nodeName === 'EMU-ANNEX') {
1256 last = node;
1257 return false;
1258 }
1259 });
1260 copyrightClause = this.doc.createElement('emu-annex');
1261 copyrightClause.setAttribute('id', 'sec-copyright-and-software-license');
1262 if (last && last.parentNode) {
1263 last.parentNode.insertBefore(copyrightClause, last.nextSibling);
1264 }
1265 else {
1266 this.doc.body.appendChild(copyrightClause);
1267 }
1268 }
1269 copyrightClause.innerHTML = `
1270 <h1>Copyright &amp; Software License</h1>
1271 ${address}
1272 <h2>Copyright Notice</h2>
1273 ${copyright.replace('!YEAR!', '' + this.opts.date.getFullYear())}
1274 <h2>Software License</h2>
1275 ${license}
1276 `;
1277 }
1278 generateSDOMap() {
1279 var _a;
1280 const sdoMap = Object.create(null);
1281 this.log('Building SDO map...');
1282 const mainGrammar = new Set(this.doc.querySelectorAll('emu-grammar[type=definition]:not([example])'));
1283 // we can't just do `:not(emu-annex emu-grammar)` because that selector is too complicated for this version of jsdom
1284 for (const annexEle of this.doc.querySelectorAll('emu-annex emu-grammar[type=definition]')) {
1285 mainGrammar.delete(annexEle);
1286 }
1287 const mainProductions = new Map();
1288 for (const grammarEle of mainGrammar) {
1289 if (!('grammarSource' in grammarEle)) {
1290 // this should only happen if it failed to parse/emit, which we'll have already warned for
1291 continue;
1292 }
1293 for (const [name, { rhses }] of this.getProductions(grammarEle)) {
1294 if (mainProductions.has(name)) {
1295 mainProductions.set(name, mainProductions.get(name).concat(rhses));
1296 }
1297 else {
1298 mainProductions.set(name, rhses);
1299 }
1300 }
1301 }
1302 const sdos = this.doc.querySelectorAll('emu-clause[type=sdo],emu-clause[type="syntax-directed operation"]');
1303 outer: for (const sdo of sdos) {
1304 let header;
1305 for (const child of sdo.children) {
1306 if (child.tagName === 'SPAN' && child.childNodes.length === 0) {
1307 // an `oldid` marker, presumably
1308 continue;
1309 }
1310 if (child.tagName === 'H1') {
1311 header = child;
1312 break;
1313 }
1314 this.warn({
1315 type: 'node',
1316 node: child,
1317 ruleId: 'sdo-name',
1318 message: 'expected H1 as first child of syntax-directed operation',
1319 });
1320 continue outer;
1321 }
1322 if (!header) {
1323 continue;
1324 }
1325 const clause = header.firstElementChild.textContent;
1326 const nameMatch = (_a = header.textContent) === null || _a === void 0 ? void 0 : _a.slice(clause.length + 1).match(/^(?:(?:Static|Runtime) Semantics: )?\s*(\w+)\b/);
1327 if (nameMatch == null) {
1328 this.warn({
1329 type: 'contents',
1330 node: header,
1331 ruleId: 'sdo-name',
1332 message: 'could not parse name of syntax-directed operation',
1333 nodeRelativeLine: 1,
1334 nodeRelativeColumn: 1,
1335 });
1336 continue;
1337 }
1338 const sdoName = nameMatch[1];
1339 for (const grammarEle of sdo.children) {
1340 if (grammarEle.tagName !== 'EMU-GRAMMAR') {
1341 continue;
1342 }
1343 if (!('grammarSource' in grammarEle)) {
1344 // this should only happen if it failed to parse/emit, which we'll have already warned for
1345 continue;
1346 }
1347 // prettier-ignore
1348 for (const [name, { production, rhses }] of this.getProductions(grammarEle)) {
1349 if (!mainProductions.has(name)) {
1350 if (this.biblio.byProductionName(name) != null) {
1351 // in an ideal world we'd keep the full grammar in the biblio so we could check for a matching RHS, not just a matching LHS
1352 // but, we're not in that world
1353 // https://github.com/tc39/ecmarkup/issues/431
1354 continue;
1355 }
1356 const { line, column } = (0, utils_1.getLocationInGrammarFile)(grammarEle.grammarSource, production.pos);
1357 this.warn({
1358 type: 'contents',
1359 node: grammarEle,
1360 nodeRelativeLine: line,
1361 nodeRelativeColumn: column,
1362 ruleId: 'grammar-shape',
1363 message: `could not find definition corresponding to production ${name}`,
1364 });
1365 continue;
1366 }
1367 const mainRhses = mainProductions.get(name);
1368 for (const { rhs, rhsEle } of rhses) {
1369 const matches = mainRhses.filter(p => (0, utils_1.rhsMatches)(rhs, p.rhs));
1370 if (matches.length === 0) {
1371 const { line, column } = (0, utils_1.getLocationInGrammarFile)(grammarEle.grammarSource, rhs.pos);
1372 this.warn({
1373 type: 'contents',
1374 node: grammarEle,
1375 nodeRelativeLine: line,
1376 nodeRelativeColumn: column,
1377 ruleId: 'grammar-shape',
1378 message: `could not find definition for rhs ${JSON.stringify(rhsEle.textContent)}`,
1379 });
1380 continue;
1381 }
1382 if (matches.length > 1) {
1383 const { line, column } = (0, utils_1.getLocationInGrammarFile)(grammarEle.grammarSource, rhs.pos);
1384 this.warn({
1385 type: 'contents',
1386 node: grammarEle,
1387 nodeRelativeLine: line,
1388 nodeRelativeColumn: column,
1389 ruleId: 'grammar-shape',
1390 message: `found multiple definitions for rhs ${JSON.stringify(rhsEle.textContent)}`,
1391 });
1392 continue;
1393 }
1394 const match = matches[0].rhsEle;
1395 if (match.id === '') {
1396 match.id = 'prod-' + sha(`${name} : ${match.textContent}`);
1397 }
1398 const mainId = match.id;
1399 if (rhsEle.id === '') {
1400 rhsEle.id = 'prod-' + sha(`[${sdoName}] ${name} ${rhsEle.textContent}`);
1401 }
1402 if (!{}.hasOwnProperty.call(sdoMap, mainId)) {
1403 sdoMap[mainId] = Object.create(null);
1404 }
1405 const sdosForThisId = sdoMap[mainId];
1406 if (!{}.hasOwnProperty.call(sdosForThisId, sdoName)) {
1407 sdosForThisId[sdoName] = { clause, ids: [] };
1408 }
1409 else if (sdosForThisId[sdoName].clause !== clause) {
1410 this.warn({
1411 type: 'node',
1412 node: grammarEle,
1413 ruleId: 'grammar-shape',
1414 message: `SDO ${sdoName} found in multiple clauses`,
1415 });
1416 }
1417 sdosForThisId[sdoName].ids.push(rhsEle.id);
1418 }
1419 }
1420 }
1421 }
1422 const json = JSON.stringify(sdoMap);
1423 return `let sdoMap = JSON.parse(\`${json.replace(/[\\`$]/g, '\\$&')}\`);`;
1424 }
1425 getProductions(grammarEle) {
1426 // unfortunately we need both the element and the grammarkdown node
1427 // there is no immediate association between them
1428 // so we are going to reconstruct the association by hand
1429 const productions = new Map();
1430 const productionEles = new Map();
1431 for (const productionEle of grammarEle.querySelectorAll('emu-production')) {
1432 if (!productionEle.hasAttribute('name')) {
1433 // I don't think this is possible, but we can at least be graceful about it
1434 this.warn({
1435 type: 'node',
1436 // All of these elements are synthetic, and hence lack locations, so we point error messages to the containing emu-grammar
1437 node: grammarEle,
1438 ruleId: 'grammar-shape',
1439 message: 'expected emu-production node to have name',
1440 });
1441 continue;
1442 }
1443 const name = productionEle.getAttribute('name');
1444 const rhses = [...productionEle.querySelectorAll('emu-rhs')];
1445 if (productionEles.has(name)) {
1446 productionEles.set(name, productionEles.get(name).concat(rhses));
1447 }
1448 else {
1449 productionEles.set(name, rhses);
1450 }
1451 }
1452 const sourceFile = grammarEle.grammarSource;
1453 for (const [name, { production, rhses }] of (0, utils_1.getProductions)([sourceFile])) {
1454 if (!productionEles.has(name)) {
1455 this.warn({
1456 type: 'node',
1457 node: grammarEle,
1458 ruleId: 'rhs-consistency',
1459 message: `failed to locate element for production ${name}. This is is a bug in ecmarkup; please report it.`,
1460 });
1461 continue;
1462 }
1463 const rhsEles = productionEles.get(name);
1464 if (rhsEles.length !== rhses.length) {
1465 this.warn({
1466 type: 'node',
1467 node: grammarEle,
1468 ruleId: 'rhs-consistency',
1469 message: `inconsistent RHS lengths for production ${name}. This is is a bug in ecmarkup; please report it.`,
1470 });
1471 continue;
1472 }
1473 productions.set(name, {
1474 production,
1475 rhses: rhses.map((rhs, i) => ({ rhs, rhsEle: rhsEles[i] })),
1476 });
1477 }
1478 return productions;
1479 }
1480 setReplacementAlgorithmOffsets() {
1481 this.log('Finding offsets for replacement algorithm steps...');
1482 const pending = new Map();
1483 const setReplacementAlgorithmStart = (element, stepNumbers) => {
1484 const rootList = element.firstElementChild;
1485 rootList.start = stepNumbers[stepNumbers.length - 1];
1486 if (stepNumbers.length > 1) {
1487 // Note the off-by-one here: a length of 1 indicates we are replacing a top-level step, which means we do not need to adjust the styling.
1488 if (stepNumbers.length === 2) {
1489 rootList.classList.add('nested-once');
1490 }
1491 else if (stepNumbers.length === 3) {
1492 rootList.classList.add('nested-twice');
1493 }
1494 else if (stepNumbers.length === 4) {
1495 rootList.classList.add('nested-thrice');
1496 }
1497 else if (stepNumbers.length === 5) {
1498 rootList.classList.add('nested-four-times');
1499 }
1500 else {
1501 // At the sixth level and deeper (so once we have nested five times) we use a consistent line numbering style, so no further cases are necessary
1502 rootList.classList.add('nested-lots');
1503 }
1504 }
1505 // Fix up the biblio entries for any labeled steps in the algorithm
1506 for (const entry of this.replacementAlgorithmToContainedLabeledStepEntries.get(element)) {
1507 entry.stepNumbers = [...stepNumbers, ...entry.stepNumbers.slice(1)];
1508 this.labeledStepsToBeRectified.delete(entry.id);
1509 // Now that we've figured out where the step is, we can deal with algorithms replacing it
1510 if (pending.has(entry.id)) {
1511 const todo = pending.get(entry.id);
1512 pending.delete(entry.id);
1513 for (const replacementAlgorithm of todo) {
1514 setReplacementAlgorithmStart(replacementAlgorithm, entry.stepNumbers);
1515 }
1516 }
1517 }
1518 };
1519 for (const { element, target } of this.replacementAlgorithms) {
1520 if (this.labeledStepsToBeRectified.has(target)) {
1521 if (!pending.has(target)) {
1522 pending.set(target, []);
1523 }
1524 pending.get(target).push(element);
1525 }
1526 else {
1527 // When the target is not itself within a replacement, or is within a replacement which we have already rectified, we can just use its step number directly
1528 const targetEntry = this.biblio.byId(target);
1529 if (targetEntry == null) {
1530 this.warn({
1531 type: 'attr-value',
1532 attr: 'replaces-step',
1533 ruleId: 'invalid-replacement',
1534 message: `could not find step ${JSON.stringify(target)}`,
1535 node: element,
1536 });
1537 }
1538 else if (targetEntry.type !== 'step') {
1539 this.warn({
1540 type: 'attr-value',
1541 attr: 'replaces-step',
1542 ruleId: 'invalid-replacement',
1543 message: `expected algorithm to replace a step, not a ${targetEntry.type}`,
1544 node: element,
1545 });
1546 }
1547 else {
1548 setReplacementAlgorithmStart(element, targetEntry.stepNumbers);
1549 }
1550 }
1551 }
1552 if (pending.size > 0) {
1553 // todo consider line/column missing cases
1554 this.warn({
1555 type: 'global',
1556 ruleId: 'invalid-replacement',
1557 message: 'could not unambiguously determine replacement algorithm offsets - do you have a cycle in your replacement algorithms?',
1558 });
1559 }
1560 }
1561 autolink() {
1562 this.log('Autolinking terms and abstract ops...');
1563 const namespaces = Object.keys(this._textNodes);
1564 for (let i = 0; i < namespaces.length; i++) {
1565 const namespace = namespaces[i];
1566 const { replacer, autolinkmap } = (0, autolinker_1.replacerForNamespace)(namespace, this.biblio);
1567 const nodes = this._textNodes[namespace];
1568 for (let j = 0; j < nodes.length; j++) {
1569 const { node, clause, inAlg, currentId } = nodes[j];
1570 (0, autolinker_1.autolink)(node, replacer, autolinkmap, clause, currentId, inAlg);
1571 }
1572 }
1573 }
1574 setCharset() {
1575 let current = this.spec.doc.querySelector('meta[charset]');
1576 if (!current) {
1577 current = this.spec.doc.createElement('meta');
1578 this.spec.doc.head.insertBefore(current, this.spec.doc.head.firstChild);
1579 }
1580 current.setAttribute('charset', 'utf-8');
1581 }
1582 _updateBySelector(selector, contents) {
1583 const elem = this.doc.querySelector(selector);
1584 if (elem && elem.textContent.trim().length > 0) {
1585 return true;
1586 }
1587 if (elem) {
1588 elem.innerHTML = contents;
1589 return true;
1590 }
1591 return false;
1592 }
1593}
1594exports.default = Spec;
1595function getBoilerplate(file) {
1596 let boilerplateFile = file;
1597 try {
1598 if (fs.lstatSync(file).isFile()) {
1599 boilerplateFile = file;
1600 }
1601 }
1602 catch (error) {
1603 boilerplateFile = path.join(__dirname, '../boilerplate', `${file}.html`);
1604 }
1605 return fs.readFileSync(boilerplateFile, 'utf8');
1606}
1607async function loadImports(spec, rootElement, rootPath) {
1608 const imports = rootElement.querySelectorAll('EMU-IMPORT');
1609 for (let i = 0; i < imports.length; i++) {
1610 const node = imports[i];
1611 const imp = await Import_1.default.build(spec, node, rootPath);
1612 await loadImports(spec, node, imp.relativeRoot);
1613 }
1614}
1615async function walk(walker, context) {
1616 const previousInNoAutolink = context.inNoAutolink;
1617 let previousInNoEmd = context.inNoEmd;
1618 const { spec } = context;
1619 context.node = walker.currentNode;
1620 context.tagStack.push(context.node);
1621 if (context.node === context.followingEmd) {
1622 context.followingEmd = null;
1623 context.inNoEmd = false;
1624 // inNoEmd is true because we're walking past the output of emd, rather than by being within a NO_EMD context
1625 // so reset previousInNoEmd so we exit it properly
1626 previousInNoEmd = false;
1627 }
1628 if (context.node.nodeType === 3 /* Node.TEXT_NODE */) {
1629 if (context.node.textContent.trim().length === 0)
1630 return; // skip empty nodes; nothing to do!
1631 const clause = context.clauseStack[context.clauseStack.length - 1] || context.spec;
1632 const namespace = clause ? clause.namespace : context.spec.namespace;
1633 if (!context.inNoEmd) {
1634 // new nodes as a result of emd processing should be skipped
1635 context.inNoEmd = true;
1636 let node = context.node;
1637 function nextRealSibling(node) {
1638 var _a;
1639 while (((_a = node === null || node === void 0 ? void 0 : node.nextSibling) === null || _a === void 0 ? void 0 : _a.nodeType) === 8 /* Node.COMMENT_NODE */) {
1640 node = node.nextSibling;
1641 }
1642 return node === null || node === void 0 ? void 0 : node.nextSibling;
1643 }
1644 while (node && !nextRealSibling(node)) {
1645 node = node.parentNode;
1646 }
1647 if (node) {
1648 // inNoEmd will be set to false when we walk to this node
1649 context.followingEmd = nextRealSibling(node);
1650 }
1651 // else, there's no more nodes to process and inNoEmd does not need to be tracked
1652 utils.emdTextNode(context.spec, context.node, namespace);
1653 }
1654 if (!context.inNoAutolink) {
1655 // stuff the text nodes into an array for auto-linking with later
1656 // (since we can't autolink at this point without knowing the biblio).
1657 context.spec._textNodes[namespace] = context.spec._textNodes[namespace] || [];
1658 context.spec._textNodes[namespace].push({
1659 node: context.node,
1660 clause,
1661 inAlg: context.inAlg,
1662 currentId: context.currentId,
1663 });
1664 }
1665 return;
1666 }
1667 // context.node is an HTMLElement (node type 1)
1668 // handle oldids
1669 const oldids = context.node.getAttribute('oldids');
1670 if (oldids) {
1671 if (!context.node.childNodes) {
1672 throw new Error('oldids found on unsupported element: ' + context.node.nodeName);
1673 }
1674 oldids
1675 .split(/,/g)
1676 .map(s => s.trim())
1677 .forEach(oid => {
1678 const s = spec.doc.createElement('span');
1679 s.setAttribute('id', oid);
1680 context.node.insertBefore(s, context.node.childNodes[0]);
1681 });
1682 }
1683 const parentId = context.currentId;
1684 if (context.node.hasAttribute('id')) {
1685 context.currentId = context.node.getAttribute('id');
1686 }
1687 if (autolinker_1.NO_CLAUSE_AUTOLINK.has(context.node.nodeName)) {
1688 context.inNoAutolink = true;
1689 }
1690 else if (autolinker_1.YES_CLAUSE_AUTOLINK.has(context.node.nodeName)) {
1691 context.inNoAutolink = false;
1692 }
1693 if (NO_EMD.has(context.node.nodeName)) {
1694 context.inNoEmd = true;
1695 }
1696 else if (YES_EMD.has(context.node.nodeName)) {
1697 context.inNoEmd = false;
1698 }
1699 const visitor = visitorMap[context.node.nodeName];
1700 if (visitor) {
1701 await visitor.enter(context);
1702 }
1703 const firstChild = walker.firstChild();
1704 if (firstChild) {
1705 while (true) {
1706 await walk(walker, context);
1707 const next = walker.nextSibling();
1708 if (!next)
1709 break;
1710 }
1711 walker.parentNode();
1712 context.node = walker.currentNode;
1713 }
1714 if (visitor)
1715 visitor.exit(context);
1716 context.inNoAutolink = previousInNoAutolink;
1717 context.inNoEmd = previousInNoEmd;
1718 context.currentId = parentId;
1719 context.tagStack.pop();
1720}
1721const jsDependencies = ['sdoMap.js', 'menu.js', 'listNumbers.js'];
1722async function concatJs(...extras) {
1723 let dependencies = await Promise.all(jsDependencies.map(dependency => utils.readFile(path.join(__dirname, '../js/' + dependency))));
1724 dependencies = dependencies.concat(extras);
1725 return dependencies.join('\n');
1726}
1727function sha(str) {
1728 return crypto
1729 .createHash('sha256')
1730 .update(str)
1731 .digest('base64')
1732 .slice(0, 8)
1733 .replace(/\+/g, '-')
1734 .replace(/\//g, '_');
1735}
1736// jsdom does not handle the `.hostname` (etc) parts correctly, so we have to look at the href directly
1737// it also (some?) relative links as links to about:blank, for the purposes of testing the href
1738function linkIsAbsolute(link) {
1739 return !link.href.startsWith('about:blank') && /^[a-z]+:/.test(link.href);
1740}
1741function linkIsInternal(link) {
1742 return link.href.startsWith('#') || link.href.startsWith('about:blank#');
1743}
1744function linkIsPathRelative(link) {
1745 return !link.href.startsWith('/') && !link.href.startsWith('about:blank/');
1746}
1747function pathFromRelativeLink(link) {
1748 return link.href.startsWith('about:blank') ? link.href.substring(11) : link.href;
1749}
1750function parentSkippingBlankSpace(expr, path) {
1751 for (let pointer = expr, i = path.length - 1; i >= 0; pointer = path[i].parent, --i) {
1752 const { parent } = path[i];
1753 if (parent.type === 'seq' &&
1754 parent.items.every(i => (i.type === 'prose' &&
1755 i.parts.every(p => p.name === 'tag' || (p.name === 'text' && /^\s*$/.test(p.contents)))) ||
1756 i === pointer)) {
1757 // if parent is just whitespace/tags around the call, walk up the tree further
1758 continue;
1759 }
1760 return path[i];
1761 }
1762 return null;
1763}
1764function previousText(expr, path) {
1765 const part = parentSkippingBlankSpace(expr, path);
1766 if (part == null) {
1767 return null;
1768 }
1769 const { parent, index } = part;
1770 if (parent.type === 'seq') {
1771 return textFromPreviousPart(parent, index);
1772 }
1773 return null;
1774}
1775function textFromPreviousPart(seq, index) {
1776 const prev = seq.items[index - 1];
1777 if ((prev === null || prev === void 0 ? void 0 : prev.type) === 'prose' && prev.parts.length > 0) {
1778 let prevIndex = prev.parts.length - 1;
1779 while (prevIndex > 0 && prev.parts[prevIndex].name === 'tag') {
1780 --prevIndex;
1781 }
1782 const prevProse = prev.parts[prevIndex];
1783 if (prevProse.name === 'text') {
1784 return prevProse.contents;
1785 }
1786 }
1787 return null;
1788}
1789function isCalledAsPerform(expr, path, allowQuestion) {
1790 const prev = previousText(expr, path);
1791 return prev != null && (allowQuestion ? /\bperform ([?!]\s)?$/i : /\bperform $/i).test(prev);
1792}
1793function isAssertedToBeNormal(expr, path) {
1794 const prev = previousText(expr, path);
1795 return prev != null && /\s!\s$/.test(prev);
1796}
1797function isConsumedAsCompletion(expr, path) {
1798 const part = parentSkippingBlankSpace(expr, path);
1799 if (part == null) {
1800 return false;
1801 }
1802 const { parent, index } = part;
1803 if (parent.type === 'seq') {
1804 // if the previous text ends in `! ` or `? `, this is a completion
1805 const text = textFromPreviousPart(parent, index);
1806 if (text != null && /[!?]\s$/.test(text)) {
1807 return true;
1808 }
1809 }
1810 else if (parent.type === 'call' && index === 0 && parent.arguments.length === 1) {
1811 // if this is `Completion(Expr())`, this is a completion
1812 const { parts } = parent.callee;
1813 if (parts.length === 1 && parts[0].name === 'text' && parts[0].contents === 'Completion') {
1814 return true;
1815 }
1816 }
1817 return false;
1818}