pour-engine 1.38.0 → 1.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,6 @@
6
6
  export default {
7
7
  engine: {
8
8
  name: 'pour engine',
9
- version: '1.38.0',
9
+ version: '1.40.0',
10
10
  },
11
11
  };
@@ -115,10 +115,21 @@ function visibleContentText(element, includeHidden) {
115
115
 
116
116
  /** CSS generated content contributes to name-from-contents per the accname
117
117
  * spec — icon-font prefixes and content:"…" labels are real names. Only
118
- * plain strings count: counters, url(), attr() are not resolvable here. */
118
+ * plain strings count: counters, url(), attr() are not resolvable here.
119
+ *
120
+ * The exception is the CSS alternative text syntax, content: <image or
121
+ * string> / "alt", which exists precisely to give assistive technology the
122
+ * text for generated content and which browsers expose: an icon-only button
123
+ * drawn with content: url(menu.svg) / "Menu" IS named "Menu" (Chromium's
124
+ * accessibility tree agrees), and calling it nameless asserted a critical
125
+ * 4.1.2 failure against the recommended pattern. An EMPTY alternative marks
126
+ * the generated content decorative and contributes nothing, which is how
127
+ * the same syntax silences icon glyphs. */
119
128
  function generatedContent(element, pseudo) {
120
129
  const content = getComputedStyle(element, pseudo).content;
121
130
  if (!content || content === 'none' || content === 'normal') return '';
131
+ const alt = content.match(/\/\s*"((?:[^"\\]|\\.)*)"\s*$/);
132
+ if (alt) return alt[1].replace(/\\(.)/g, '$1');
122
133
  const match = content.match(/^"((?:[^"\\]|\\.)*)"$/);
123
134
  return match ? match[1].replace(/\\(.)/g, '$1') : '';
124
135
  }
@@ -208,11 +208,23 @@ export function implicitRole(element) {
208
208
  * but adding tabindex or aria-describedby exposes `heading`. Note a non-global
209
209
  * property applicable to the implicit role (aria-level on a heading) does NOT
210
210
  * trigger it, even though a strict reading of the spec suggests it should.
211
+ *
212
+ * "Focusable" means focusable in fact, not focusable by tag. A disabled
213
+ * control and anything inside an inert subtree are not focusable areas, so
214
+ * their presentational role STANDS and the browser keeps them out of the
215
+ * accessibility tree: Chromium exposes `<button role="none" disabled>` as
216
+ * role none, with no name to owe. Reading focusability from the tag alone
217
+ * (or from tabIndex, which Chromium still reports as 0 on a disabled
218
+ * button) resurrected those elements and asserted a missing name on a
219
+ * control the user is never offered.
211
220
  */
212
221
  function presentationDiscarded(element) {
222
+ // A global property is exposed whatever the element's state, so it
223
+ // discards the role on its own, disabled or not.
224
+ if ([...GLOBAL_ARIA].some((name) => element.hasAttribute(`aria-${name}`))) return true;
225
+ if (element.matches(':disabled') || element.closest('[inert]')) return false;
213
226
  if (element.tabIndex >= 0) return true;
214
- if (element.matches('a[href], button, input, select, textarea, summary, [contenteditable="true"]')) return true;
215
- return [...GLOBAL_ARIA].some((name) => element.hasAttribute(`aria-${name}`));
227
+ return element.matches('a[href], button, input, select, textarea, summary, [contenteditable="true"]');
216
228
  }
217
229
 
218
230
  export function effectiveRole(element) {
@@ -34,10 +34,18 @@ export default {
34
34
  helpUrl: 'https://www.w3.org/WAI/ARIA/apg/practices/landmark-regions/',
35
35
  // Implicit landmarks (header, footer, labelled section/form) join the
36
36
  // grouping — a page's <header> and a div[role="banner"] ARE duplicates.
37
+ // region and form need a name to BE landmarks (ARIA 1.2 gives both "Name
38
+ // From: author", and Chromium exposes an unnamed one as generic), which is
39
+ // why the native <section> and <form> above are already spelled with the
40
+ // name requirement. Without it, two unnamed div[role="region"] were
41
+ // reported as indistinguishable landmarks when neither is a landmark at
42
+ // all, sending the author to label something no screen reader announces.
37
43
  selector:
38
44
  'nav, search, aside, header, footer, section[aria-label], section[aria-labelledby], ' +
39
45
  'form[aria-label], form[aria-labelledby], [role="navigation"], [role="complementary"], ' +
40
- '[role="banner"], [role="contentinfo"], [role="region"], [role="search"], [role="form"], [role="main"], main',
46
+ '[role="banner"], [role="contentinfo"], [role="region"][aria-label], ' +
47
+ '[role="region"][aria-labelledby], [role="form"][aria-label], [role="form"][aria-labelledby], ' +
48
+ '[role="search"], [role="main"], main',
41
49
  // Judged as a set: two unlabelled navs are the problem, not either alone.
42
50
  evaluateAll(elements) {
43
51
  const roles = elements.map(landmarkRole);
@@ -14,8 +14,16 @@ const LANDMARK =
14
14
  'header:not(:is(article, aside, main, nav, section) header), ' +
15
15
  'footer:not(:is(article, aside, main, nav, section) footer), ' +
16
16
  'section[aria-label], section[aria-labelledby], [role="main"], [role="navigation"], ' +
17
- '[role="banner"], [role="contentinfo"], [role="complementary"], [role="region"], ' +
18
- '[role="search"], [role="form"], [role="dialog"], [role="alertdialog"], dialog[open]';
17
+ '[role="banner"], [role="contentinfo"], [role="complementary"], ' +
18
+ // region and form are exposed as landmarks only once they are named (ARIA
19
+ // 1.2 gives both "Name From: author"), which is why the native <section>
20
+ // and <form> above already carry that requirement. Accepting the ARIA
21
+ // spelling bare let a nameless div[role="region"] stand in for a landmark
22
+ // and hid every stray element inside it; Chromium exposes that div as
23
+ // generic, so screen-reader users reach none of it by region navigation.
24
+ '[role="region"][aria-label], [role="region"][aria-labelledby], ' +
25
+ '[role="form"][aria-label], [role="form"][aria-labelledby], ' +
26
+ '[role="search"], [role="dialog"], [role="alertdialog"], dialog[open]';
19
27
 
20
28
  export default {
21
29
  id: 'region',
@@ -1,5 +1,5 @@
1
1
  // WCAG SC 1.3.1 Info and Relationships (Level A)
2
- import { NEVER_RENDERED } from '../../lib/dom.js';
2
+ import { NEVER_RENDERED, isInert } from '../../lib/dom.js';
3
3
 
4
4
  const ALLOWED = new Set(['DT', 'DD', 'DIV']);
5
5
  // NEVER_RENDERED cannot corrupt the term/description pairing: HTML's content
@@ -17,11 +17,14 @@ export default {
17
17
  helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html',
18
18
  selector: 'dl:not([role])', // a role attribute replaces the dl semantics
19
19
  evaluate(element, { isRendered }) {
20
+ const exposed = (child) => !NEVER_RENDERED.has(child.tagName)
21
+ && !child.closest('[aria-hidden="true"]') && !isInert(child)
22
+ && (!isRendered || isRendered(child) || getComputedStyle(child).display === 'contents');
20
23
  // A child the browser never renders (display:none, hidden) is not in
21
24
  // the accessibility tree either, so it cannot break the pairing a
22
25
  // reader hears; list-structure applies the same gate.
23
26
  const invalid = [...element.children].filter((child) =>
24
- !ALLOWED.has(child.tagName) && !NEVER_RENDERED.has(child.tagName) && (!isRendered || isRendered(child)));
27
+ !ALLOWED.has(child.tagName) && exposed(child));
25
28
  if (invalid.length) {
26
29
  const tags = [...new Set(invalid.map((child) => `<${child.tagName.toLowerCase()}>`))].join(', ');
27
30
  return {
@@ -36,7 +39,7 @@ export default {
36
39
  // out of the dl's content model, so the term/description association is
37
40
  // no longer guaranteed to assistive technology.
38
41
  const emptyWrappers = [...element.children].filter((child) =>
39
- child.tagName === 'DIV' && ![...child.children].some((inner) => inner.tagName === 'DT' || inner.tagName === 'DD'));
42
+ child.tagName === 'DIV' && exposed(child) && ![...child.children].some((inner) => inner.tagName === 'DT' || inner.tagName === 'DD'));
40
43
  if (emptyWrappers.length) {
41
44
  return {
42
45
  status: 'fail',
@@ -44,6 +47,42 @@ export default {
44
47
  fix: 'Make each <div> child of the <dl> contain its <dt>/<dd> pair directly, or flatten the pairs into the <dl> itself.',
45
48
  };
46
49
  }
50
+ // HTML defines a group as one or more terms followed by one or more
51
+ // descriptions. Check each wrapper independently, so a term in one
52
+ // group cannot supply the missing term of another.
53
+ // https://html.spec.whatwg.org/multipage/grouping-content.html#the-dl-element
54
+ const children = [...element.children].filter(exposed);
55
+ const groups = [[...element.children].filter((child) => child.tagName !== 'DIV'),
56
+ ...children.filter((child) => child.tagName === 'DIV').map((child) => [...child.children])];
57
+ for (const members of groups) {
58
+ const group = members.filter(exposed);
59
+ const concealedPairMember = members.some((child) => (child.tagName === 'DT' || child.tagName === 'DD') && !exposed(child));
60
+ const concealedResult = {
61
+ status: 'incomplete',
62
+ message: 'A term or description in this group is hidden from assistive technology. Check that any disclosure control exposes the associated content when opened, and that the term and description relationship remains available.',
63
+ };
64
+ let last = null;
65
+ for (const child of group) {
66
+ if (child.tagName !== 'DT' && child.tagName !== 'DD') continue;
67
+ if (child.tagName === 'DD' && last === null) {
68
+ if (concealedPairMember) return concealedResult;
69
+ return {
70
+ status: 'fail',
71
+ message: 'This description list has a description without a preceding term in its group.',
72
+ fix: 'Add a <dt> before the <dd>, or use a paragraph when the content is not a term and description.',
73
+ };
74
+ }
75
+ last = child.tagName;
76
+ }
77
+ if (last === 'DT') {
78
+ if (concealedPairMember) return concealedResult;
79
+ return {
80
+ status: 'fail',
81
+ message: 'This description list ends a group with a term that has no description.',
82
+ fix: 'Follow the <dt> element or elements with at least one <dd> in the same group.',
83
+ };
84
+ }
85
+ }
47
86
  return { status: 'pass' };
48
87
  },
49
88
  };
@@ -3,6 +3,7 @@
3
3
  // 3.3.2 mapping: a field with no label at all, or only a vanishing
4
4
  // placeholder, is failure F82 — the classic 3.3.2 pattern.
5
5
  import { labelledByName } from '../../lib/accessible-name.js';
6
+ import { effectiveRole } from '../../lib/roles.js';
6
7
 
7
8
  export default {
8
9
  id: 'form-label',
@@ -14,6 +15,12 @@ export default {
14
15
  selector:
15
16
  'input:not([type="hidden"]):not([type="button"]):not([type="submit"]):not([type="reset"]):not([type="image"]), select, textarea',
16
17
  evaluate(element) {
18
+ // A field whose presentational role survives the conflict resolution is
19
+ // not in the accessibility tree at all: <select role="none" disabled> is
20
+ // exposed as role none, so there is no field to name under 4.1.2 and
21
+ // nothing to fill in under 3.3.2. The role library owns that judgment.
22
+ const role = effectiveRole(element);
23
+ if (role === 'presentation' || role === 'none') return { status: 'pass' };
17
24
  // Only perceivable labels count: a display:none / aria-hidden label
18
25
  // technically feeds the accessible name, but nobody can see it and
19
26
  // that defeats the point of a label.
@@ -121,16 +121,23 @@ export default {
121
121
  // embedded in prose exactly as 1.4.1 means it. Pure link lists (navs,
122
122
  // blogrolls) are still excluded, but by the ownText guard below rather than
123
123
  // by tag: in <li><a>Home</a></li> the item has no text of its own.
124
- // <div> was measured too and added nothing on any test site, so it stays
125
- // out rather than widening the blast radius for no gain.
126
- selector: 'p a[href], dd a[href], blockquote a[href], td a[href], li a[href]',
124
+ // Prose in a generic container has the same requirement. F73 does not
125
+ // limit colour-only links to paragraph markup. The text and shared-line
126
+ // checks below keep navigation and separate blocks out of this lane.
127
+ // https://www.w3.org/WAI/WCAG22/Techniques/failures/F73
128
+ selector: 'p a[href], dd a[href], blockquote a[href], td a[href], li a[href], div a[href]',
127
129
  visibility: 'visual', // colour distinction is a purely visual concern
128
130
  evaluate(element, { ownText }) {
129
- const parent = element.closest('p, dd, blockquote, td, li');
131
+ const parent = element.closest('p, dd, blockquote, td, li, div');
130
132
  if (!element.textContent.trim() || !parent) return { status: 'pass' };
131
133
  // Needs real surrounding text to blend into (1.4.1 is about links
132
134
  // embedded in prose, not a link that IS the content).
133
- if (ownText(parent).replace(/\s+/g, '').length < 10) return { status: 'pass' };
135
+ const surroundingText = ownText(parent).trim();
136
+ if (!/[\p{L}\p{N}]/u.test(surroundingText)) return { status: 'pass' };
137
+ // A short fragment may be prose ("See ..."), an equation or a label.
138
+ // F73 has no minimum character count. Keep evaluating the visual cues
139
+ // and ask about ambiguous context instead of silently passing it.
140
+ const shortContext = surroundingText.replace(/\s+/g, '').length < 10;
134
141
 
135
142
  const style = getComputedStyle(element);
136
143
  const parentStyle = getComputedStyle(parent);
@@ -330,6 +337,13 @@ export default {
330
337
  fix: reviewFix,
331
338
  };
332
339
  }
340
+ if (shortContext) {
341
+ return {
342
+ status: 'incomplete',
343
+ message: `This link has no detected non-colour cue and only ${shown}:1 colour difference from nearby text. The surrounding text is a short fragment. Check whether it forms a sentence or other prose with the link; if it does, colour alone does not distinguish the link sufficiently.`,
344
+ fix: reviewFix,
345
+ };
346
+ }
333
347
  return {
334
348
  status: 'fail',
335
349
  // The measured fact is the ratio against G183's threshold, and that is
@@ -23,6 +23,33 @@ function refreshDestination(content) {
23
23
  return rest.trim();
24
24
  }
25
25
 
26
+ /**
27
+ * The delay the browser would actually use, and null when the HTML "shared
28
+ * declarative refresh steps" bail out and schedule nothing at all. Those
29
+ * steps collect ASCII digits and stop: a leading "+" ends the parse before a
30
+ * single digit is read (content="+5; url=…" refreshes nothing), and anything
31
+ * other than ";", "," or whitespace directly after the digits does the same
32
+ * (content="1x; url=…"). parseInt accepts both and returned 5 and 1, so the
33
+ * rule asserted a time limit on pages the browser never moves. Verified in
34
+ * Chromium: both of those pages stay put while content="1; url=…" navigates.
35
+ */
36
+ function refreshDelay(content) {
37
+ const isAsciiSpace = (c) => c === ' ' || c === '\t' || c === '\n' || c === '\f' || c === '\r';
38
+ const isDigit = (c) => c >= '0' && c <= '9';
39
+ let i = 0;
40
+ while (i < content.length && isAsciiSpace(content[i])) i++;
41
+ let digits = '';
42
+ while (i < content.length && isDigit(content[i])) digits += content[i++];
43
+ // No digits is only allowed when a decimal point follows, which the steps
44
+ // read as a fractional delay of zero seconds.
45
+ if (!digits && content[i] !== '.') return null;
46
+ const delay = digits ? Number(digits) : 0;
47
+ // Trailing digits and full stops are collected and thrown away.
48
+ while (i < content.length && (isDigit(content[i]) || content[i] === '.')) i++;
49
+ if (i < content.length && content[i] !== ';' && content[i] !== ',' && !isAsciiSpace(content[i])) return null;
50
+ return delay;
51
+ }
52
+
26
53
  export default {
27
54
  id: 'meta-refresh',
28
55
  name: 'Timed page refresh',
@@ -34,7 +61,10 @@ export default {
34
61
  visibleOnly: false,
35
62
  evaluate(element) {
36
63
  const content = element.getAttribute('content') ?? '';
37
- const delay = parseInt(content, 10);
64
+ const delay = refreshDelay(content);
65
+ // The browser discards this directive, so no time limit is set by the
66
+ // content and 2.2.1 has nothing to judge.
67
+ if (delay === null) return { status: 'pass' };
38
68
  // A refresh with no destination reloads THIS page. At zero seconds that
39
69
  // is not the allowed instant redirect — it is a loop that throws the
40
70
  // user back to the top of the page over and over (failure F41).
@@ -31,6 +31,31 @@ const TARGETS = 'a[href], button, input, select, [role="button"], [role="link"]'
31
31
  */
32
32
  import { isInert } from '../../lib/dom.js';
33
33
 
34
+ // A web component's internal link and its enclosing light-DOM link are
35
+ // not independent pointer targets. DOM contains() stops at shadow roots.
36
+ function containsComposed(ancestor, element) {
37
+ if (ancestor.contains(element)) return true;
38
+ if (ancestor.getRootNode() === element.getRootNode() && !element.assignedSlot) return false;
39
+ for (let node = element; node; node = node.assignedSlot ?? node.parentElement ?? node.getRootNode()?.host) {
40
+ if (node === ancestor) return true;
41
+ }
42
+ return false;
43
+ }
44
+
45
+ function outsideHiddenOverflow(element, rect) {
46
+ for (let parent = element.parentElement; parent; parent = parent.parentElement) {
47
+ const style = getComputedStyle(parent);
48
+ if (style.display === 'contents') continue; // no principal box to clip its children
49
+ const x = /^(hidden|clip)$/.test(style.overflowX);
50
+ const y = /^(hidden|clip)$/.test(style.overflowY);
51
+ if (!x && !y) continue;
52
+ const clip = parent.getBoundingClientRect();
53
+ if ((x && (clip.width === 0 || rect.right <= clip.left || rect.left >= clip.right))
54
+ || (y && (clip.height === 0 || rect.bottom <= clip.top || rect.top >= clip.bottom))) return true;
55
+ }
56
+ return false;
57
+ }
58
+
34
59
  function isHiddenFromPointer(element, rect) {
35
60
  if (rect.width <= 1 || rect.height <= 1) return true;
36
61
  if (rect.right <= 0 || rect.bottom <= 0) return true; // parked above/left of the canvas
@@ -567,7 +592,7 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
567
592
  // target's circle.
568
593
  const crowds = (other, j) => {
569
594
  if (j === i || !laidOut[j]) return false;
570
- if (other.contains(element) || element.contains(other)) return false; // same control, nested markup
595
+ if (containsComposed(other, element) || containsComposed(element, other)) return false; // same control, including shadow content
571
596
  // "One control drawn twice" (stretched-link overlays) is a claim
572
597
  // about PAINT, so it must be proved on a painted fragment, not on
573
598
  // the bounding box: a two-line wrapped link's box swallows a small
@@ -576,7 +601,20 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
576
601
  // the honest fragment test below. A single-fragment element's one
577
602
  // fragment IS its box, so genuine overlays are dismissed exactly
578
603
  // as before.
579
- if (paintedEncloses(other, rects[j], rects[i]) || paintedEncloses(element, rects[i], rects[j])) return false;
604
+ // Containment of one painted box inside another does not prove
605
+ // that they perform the same action. A small preview button over
606
+ // an image link is explicitly a spacing failure in SC 2.5.8.
607
+ // Only two links with the same real destination can use the
608
+ // equivalent-control exemption here.
609
+ // https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html
610
+ if (paintedEncloses(other, rects[j], rects[i]) || paintedEncloses(element, rects[i], rects[j])) {
611
+ const destination = destinationOf(element);
612
+ if (destination && destination === destinationOf(other)) return false;
613
+ // Collapsed disclosures can leave their descendants' boxes
614
+ // overlapping nearby controls even though the overflow clips
615
+ // them completely. Such boxes accept no pointer input.
616
+ if (outsideHiddenOverflow(element, rects[i]) || outsideHiddenOverflow(other, rects[j])) return false;
617
+ }
580
618
  // Crowding across fixed/sticky contexts is scroll state, not
581
619
  // layout, exactly as the obscured-target test reads overlap: a
582
620
  // consent banner pinned to the bottom of the viewport lays its
@@ -1,4 +1,5 @@
1
1
  // WCAG SC 4.1.2 Name, Role, Value (Level A)
2
+ import { isInert } from '../../lib/dom.js';
2
3
  // Deliberately excludes bare [tabindex]: a focusable container (e.g. a
3
4
  // scrollable region with tabindex="0") holding links is correct markup.
4
5
  const INTERACTIVE =
@@ -16,8 +17,7 @@ export default {
16
17
  selector: INTERACTIVE,
17
18
  evaluate(element, { isRendered }) {
18
19
  // Only descendants a user can actually reach count: hidden inputs,
19
- // disabled controls, display:none templates, and tabindex="-1" nodes
20
- // inside a control are inert markup, not competing tab stops.
20
+ // disabled controls and display:none templates are excluded.
21
21
  // Two more shapes are unreachable by the same principle: aria-hidden
22
22
  // content (AT ignores it wholesale — a decorative svg role="button"
23
23
  // aria-hidden="true" inside a play button competes with nothing; if it
@@ -26,16 +26,28 @@ export default {
26
26
  // role="button" with no tabindex is not in the tab order — keyboard
27
27
  // users cannot reach it, so it cannot be a competing stop).
28
28
  const NATIVE = 'a[href], button, input, select, textarea, summary, audio[controls], video[controls]';
29
- // Any negative tabindex is unreachable by Tab (HTML: "a negative
30
- // integer"), not only the literal "-1"; hasAttribute guards the default
31
- // -1 Chromium reports for attribute-less contenteditable elements.
32
- // 2026-08-25 overnight audit.
33
- const nested = [...element.querySelectorAll(INTERACTIVE)].find((el) =>
34
- !el.matches(':disabled') && !(el.hasAttribute('tabindex') && el.tabIndex < 0)
29
+ // Negative tabindex removes sequential focus navigation, not focusability.
30
+ // https://html.spec.whatwg.org/multipage/interaction.html#attr-tabindex
31
+ const candidates = [...element.querySelectorAll(INTERACTIVE)].filter((el) =>
32
+ !el.matches(':disabled') && !isInert(el)
35
33
  && !(el.tagName === 'INPUT' && el.type === 'hidden') && isRendered(el)
36
34
  && !el.closest('[aria-hidden="true"]')
37
35
  && (el.matches(NATIVE) || el.hasAttribute('tabindex')));
36
+ // A child requiring review must not conceal a later definite finding.
37
+ const nested = candidates.find((el) => !(el.hasAttribute('tabindex') && el.tabIndex < 0)) ?? candidates[0];
38
38
  if (!nested) return { status: 'pass' };
39
+ // A programmatically focusable child must not silently pass. In an
40
+ // ARIA widget its name and role can survive presentational-descendant
41
+ // processing, so nesting alone does not prove a 4.1.2 failure.
42
+ // Native links/buttons separately forbid interactive descendants.
43
+ // https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion
44
+ if (nested.hasAttribute('tabindex') && nested.tabIndex < 0
45
+ && !element.matches('a[href], button')) {
46
+ return {
47
+ status: 'incomplete',
48
+ message: `This control contains an element (<${nested.tagName.toLowerCase()}>) with a negative tabindex. It can still receive focus. Check that both controls expose the intended name and role, and that focusing and activating the child works correctly.`,
49
+ };
50
+ }
39
51
  // <summary> is the one outer control HTML does not forbid this on: its
40
52
  // content model is phrasing content with no "no interactive content
41
53
  // descendant" clause (a and button both carry one), it maps to no ARIA
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.38.0",
3
+ "version": "1.40.0",
4
4
  "description": "Accessibility (WCAG 2.2) audit engine written from the W3C specifications — runs entirely in the browser, no dependencies.",
5
5
  "type": "module",
6
6
  "main": "engine/index.js",