@jsenv/core 38.4.17 → 38.4.19

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,134 +6,134 @@ import {
6
6
  getDOMNodesUsingUrl,
7
7
  } from "./reload.js";
8
8
 
9
- let debug = false;
10
- const reloader = {
11
- urlHotMetas,
12
- status: {
13
- value: "idle",
14
- onchange: () => {},
15
- goTo: (value) => {
16
- reloader.status.value = value;
17
- reloader.status.onchange();
9
+ export const initAutoreload = ({ mainFilePath }) => {
10
+ let debug = false;
11
+ const reloader = {
12
+ urlHotMetas,
13
+ status: {
14
+ value: "idle",
15
+ onchange: () => {},
16
+ goTo: (value) => {
17
+ reloader.status.value = value;
18
+ reloader.status.onchange();
19
+ },
18
20
  },
19
- },
20
- autoreload: {
21
- enabled: ["1", null].includes(window.localStorage.getItem("autoreload")),
22
- onchange: () => {},
23
- enable: () => {
24
- reloader.autoreload.enabled = true;
25
- window.localStorage.setItem("autoreload", "1");
26
- reloader.autoreload.onchange();
21
+ autoreload: {
22
+ enabled: ["1", null].includes(window.localStorage.getItem("autoreload")),
23
+ onchange: () => {},
24
+ enable: () => {
25
+ reloader.autoreload.enabled = true;
26
+ window.localStorage.setItem("autoreload", "1");
27
+ reloader.autoreload.onchange();
28
+ },
29
+ disable: () => {
30
+ reloader.autoreload.enabled = false;
31
+ window.localStorage.setItem("autoreload", "0");
32
+ reloader.autoreload.onchange();
33
+ },
27
34
  },
28
- disable: () => {
29
- reloader.autoreload.enabled = false;
30
- window.localStorage.setItem("autoreload", "0");
31
- reloader.autoreload.onchange();
32
- },
33
- },
34
- changes: {
35
- value: [],
36
- onchange: () => {},
37
- add: (reloadMessage) => {
38
- if (debug) {
39
- console.debug("received reload message", reloadMessage);
40
- }
41
- reloader.changes.value.push(reloadMessage);
42
- reloader.changes.onchange();
43
- if (reloader.autoreload.enabled) {
44
- reloader.reload();
45
- } else {
46
- reloader.status.goTo("can_reload");
47
- }
48
- },
49
- remove: (reloadMessage) => {
50
- const index = reloader.changes.value.indexOf(reloadMessage);
51
- if (index > -1) {
52
- reloader.changes.value.splice(index, 1);
53
- if (reloader.changes.value.length === 0) {
54
- reloader.status.goTo("idle");
35
+ changes: {
36
+ value: [],
37
+ onchange: () => {},
38
+ add: (reloadMessage) => {
39
+ if (debug) {
40
+ console.debug("received reload message", reloadMessage);
55
41
  }
42
+ reloader.changes.value.push(reloadMessage);
56
43
  reloader.changes.onchange();
44
+ if (reloader.autoreload.enabled) {
45
+ reloader.reload();
46
+ } else {
47
+ reloader.status.goTo("can_reload");
48
+ }
49
+ },
50
+ remove: (reloadMessage) => {
51
+ const index = reloader.changes.value.indexOf(reloadMessage);
52
+ if (index > -1) {
53
+ reloader.changes.value.splice(index, 1);
54
+ if (reloader.changes.value.length === 0) {
55
+ reloader.status.goTo("idle");
56
+ }
57
+ reloader.changes.onchange();
58
+ }
59
+ },
60
+ },
61
+ currentExecution: null,
62
+ reload: () => {
63
+ const someEffectIsFullReload = reloader.changes.value.some(
64
+ (reloadMessage) => reloadMessage.type === "full",
65
+ );
66
+ if (someEffectIsFullReload) {
67
+ reloadHtmlPage();
68
+ return;
57
69
  }
70
+ reloader.status.goTo("reloading");
71
+ const onApplied = (reloadMessage) => {
72
+ reloader.changes.remove(reloadMessage);
73
+ };
74
+ const setReloadMessagePromise = (reloadMessage, promise) => {
75
+ promise.then(
76
+ () => {
77
+ onApplied(reloadMessage);
78
+ reloader.currentExecution = null;
79
+ },
80
+ (e) => {
81
+ reloader.status.goTo("failed");
82
+ if (typeof window.reportError === "function") {
83
+ window.reportError(e);
84
+ } else {
85
+ console.error(e);
86
+ }
87
+ console.error(
88
+ `[jsenv] Hot reload failed after ${reloadMessage.reason}.
89
+ This could be due to syntax errors or importing non-existent modules (see errors in console)`,
90
+ );
91
+ reloader.currentExecution = null;
92
+ },
93
+ );
94
+ };
95
+ reloader.changes.value.forEach((reloadMessage) => {
96
+ if (reloadMessage.type === "hot") {
97
+ const promise = addToHotQueue(() => {
98
+ return applyHotReload(reloadMessage);
99
+ });
100
+ setReloadMessagePromise(reloadMessage, promise);
101
+ } else {
102
+ setReloadMessagePromise(reloadMessage, Promise.resolve());
103
+ }
104
+ });
58
105
  },
59
- },
60
- currentExecution: null,
61
- reload: () => {
62
- const someEffectIsFullReload = reloader.changes.value.some(
63
- (reloadMessage) => reloadMessage.type === "full",
64
- );
65
- if (someEffectIsFullReload) {
66
- reloadHtmlPage();
106
+ };
107
+
108
+ let pendingCallbacks = [];
109
+ let running = false;
110
+ const addToHotQueue = async (callback) => {
111
+ pendingCallbacks.push(callback);
112
+ dequeue();
113
+ };
114
+ const dequeue = async () => {
115
+ if (running) {
67
116
  return;
68
117
  }
69
- reloader.status.goTo("reloading");
70
- const onApplied = (reloadMessage) => {
71
- reloader.changes.remove(reloadMessage);
72
- };
73
- const setReloadMessagePromise = (reloadMessage, promise) => {
74
- promise.then(
75
- () => {
76
- onApplied(reloadMessage);
77
- reloader.currentExecution = null;
78
- },
79
- (e) => {
80
- reloader.status.goTo("failed");
81
- if (typeof window.reportError === "function") {
82
- window.reportError(e);
83
- } else {
84
- console.error(e);
85
- }
86
- console.error(
87
- `[jsenv] Hot reload failed after ${reloadMessage.reason}.
88
- This could be due to syntax errors or importing non-existent modules (see errors in console)`,
89
- );
90
- reloader.currentExecution = null;
91
- },
92
- );
93
- };
94
- reloader.changes.value.forEach((reloadMessage) => {
95
- if (reloadMessage.type === "hot") {
96
- const promise = addToHotQueue(() => {
97
- return applyHotReload(reloadMessage);
98
- });
99
- setReloadMessagePromise(reloadMessage, promise);
100
- } else {
101
- setReloadMessagePromise(reloadMessage, Promise.resolve());
118
+ const callbacks = pendingCallbacks.slice();
119
+ pendingCallbacks = [];
120
+ running = true;
121
+ try {
122
+ await callbacks.reduce(async (previous, callback) => {
123
+ await previous;
124
+ await callback();
125
+ }, Promise.resolve());
126
+ } finally {
127
+ running = false;
128
+ if (pendingCallbacks.length) {
129
+ dequeue();
102
130
  }
103
- });
104
- },
105
- };
106
-
107
- let pendingCallbacks = [];
108
- let running = false;
109
- const addToHotQueue = async (callback) => {
110
- pendingCallbacks.push(callback);
111
- dequeue();
112
- };
113
- const dequeue = async () => {
114
- if (running) {
115
- return;
116
- }
117
- const callbacks = pendingCallbacks.slice();
118
- pendingCallbacks = [];
119
- running = true;
120
- try {
121
- await callbacks.reduce(async (previous, callback) => {
122
- await previous;
123
- await callback();
124
- }, Promise.resolve());
125
- } finally {
126
- running = false;
127
- if (pendingCallbacks.length) {
128
- dequeue();
129
131
  }
130
- }
131
- };
132
+ };
132
133
 
133
- const applyHotReload = async ({ cause, hotInstructions }) => {
134
- await hotInstructions.reduce(
135
- async (previous, { type, boundary, acceptedBy }) => {
136
- await previous;
134
+ const applyHotReload = async ({ cause, hotInstructions }) => {
135
+ for (const instruction of hotInstructions) {
136
+ const { type, boundary, acceptedBy } = instruction;
137
137
 
138
138
  const hot = Date.now();
139
139
  const urlToFetch = new URL(boundary, `${window.location.origin}/`).href;
@@ -141,7 +141,6 @@ const applyHotReload = async ({ cause, hotInstructions }) => {
141
141
  // there is no url hot meta when:
142
142
  // - code was not executed (code splitting with dynamic import)
143
143
  // - import.meta.hot.accept() is not called (happens for HTML and CSS)
144
-
145
144
  if (type === "prune") {
146
145
  if (urlHotMeta) {
147
146
  delete urlHotMetas[urlToFetch];
@@ -154,9 +153,8 @@ const applyHotReload = async ({ cause, hotInstructions }) => {
154
153
  console.groupEnd();
155
154
  }
156
155
  }
157
- return null;
156
+ continue;
158
157
  }
159
-
160
158
  if (acceptedBy === boundary) {
161
159
  console.groupCollapsed(`[jsenv] hot reloading ${boundary} (${cause})`);
162
160
  } else {
@@ -167,7 +165,7 @@ const applyHotReload = async ({ cause, hotInstructions }) => {
167
165
  if (type === "js_module") {
168
166
  if (!urlHotMeta) {
169
167
  // code was not executed, no need to re-execute it
170
- return null;
168
+ continue;
171
169
  }
172
170
  if (urlHotMeta.disposeCallback) {
173
171
  console.log(`call dispose callback`);
@@ -184,18 +182,23 @@ const applyHotReload = async ({ cause, hotInstructions }) => {
184
182
  }
185
183
  console.log(`js module import done`);
186
184
  console.groupEnd();
187
- return namespace;
185
+ continue;
188
186
  }
189
187
  if (type === "html") {
190
- const isRootHtmlFile =
191
- window.location.pathname === "/" &&
192
- new URL(urlToFetch).pathname.slice(1).indexOf("/") === -1;
188
+ let isRootHtmlFile;
189
+ if (window.location.pathname === "/") {
190
+ if (new URL(urlToFetch).pathname.slice(1).indexOf("/") === -1) {
191
+ isRootHtmlFile = true;
192
+ } else if (new URL(urlToFetch).pathname === mainFilePath) {
193
+ isRootHtmlFile = true;
194
+ }
195
+ }
193
196
  if (
194
197
  !isRootHtmlFile &&
195
198
  !compareTwoUrlPaths(urlToFetch, window.location.href)
196
199
  ) {
197
200
  // we are not in that HTML page
198
- return null;
201
+ continue;
199
202
  }
200
203
  const urlToReload = new URL(acceptedBy, `${window.location.origin}/`)
201
204
  .href;
@@ -213,18 +216,16 @@ const applyHotReload = async ({ cause, hotInstructions }) => {
213
216
  });
214
217
  }
215
218
  console.groupEnd();
216
- return null;
219
+ continue;
217
220
  }
218
221
  console.warn(`unknown update type: "${type}"`);
219
- return null;
222
+ }
223
+ };
224
+
225
+ window.__reloader__ = reloader;
226
+ window.__server_events__.listenEvents({
227
+ reload: (reloadServerEvent) => {
228
+ reloader.changes.add(reloadServerEvent.data);
220
229
  },
221
- Promise.resolve(),
222
- );
230
+ });
223
231
  };
224
-
225
- window.__reloader__ = reloader;
226
- window.__server_events__.listenEvents({
227
- reload: (reloadServerEvent) => {
228
- reloader.changes.add(reloadServerEvent.data);
229
- },
230
- });
@@ -26,12 +26,21 @@ export const jsenvPluginAutoreloadClient = () => {
26
26
  expectedType: "js_module",
27
27
  specifier: autoreloadClientFileUrl,
28
28
  });
29
+ const paramsJson = JSON.stringify(
30
+ {
31
+ mainFilePath: `/${htmlUrlInfo.kitchen.context.mainFilePath}`,
32
+ },
33
+ null,
34
+ " ",
35
+ );
29
36
  injectHtmlNodeAsEarlyAsPossible(
30
37
  htmlAst,
31
38
  createHtmlNode({
32
39
  tagName: "script",
33
40
  type: "module",
34
- src: autoreloadClientReference.generatedSpecifier,
41
+ textContent: `import { initAutoreload } from "${autoreloadClientReference.generatedSpecifier}";
42
+
43
+ initAutoreload(${paramsJson});`,
35
44
  }),
36
45
  "jsenv:autoreload_client",
37
46
  );
@@ -38,7 +38,10 @@ export const jsenvPluginAutoreloadServer = ({
38
38
  : `a dependent file accepts hot reload`,
39
39
  };
40
40
  }
41
- if (urlInfo.data.hotDecline) {
41
+ if (
42
+ urlInfo.data.hotDecline ||
43
+ urlInfo.firstReference?.type === "http_request"
44
+ ) {
42
45
  return {
43
46
  declined: true,
44
47
  reason: `file declines hot reload`,
@@ -0,0 +1,20 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <title>Directory explorer</title>
5
+ <meta charset="utf-8" />
6
+ <link rel="icon" href="data:," />
7
+ </head>
8
+
9
+ <body>
10
+ <h1>
11
+ <a jsenv-ignore href="/${directoryRelativeUrl}"
12
+ >/${directoryRelativeUrl}</a
13
+ >
14
+ directory content:
15
+ </h1>
16
+ <ul>
17
+ ${directoryContent}
18
+ </ul>
19
+ </body>
20
+ </html>
@@ -0,0 +1,21 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <title>404 ENOENT</title>
5
+ <meta charset="utf-8" />
6
+ <link rel="icon" href="data:," />
7
+ </head>
8
+
9
+ <body>
10
+ <p>No entry on the filesystem for <strong>/${fileRelativeUrl}</strong></p>
11
+ <p>
12
+ <a jsenv-ignore href="/${parentDirectoryRelativeUrl}"
13
+ >/${parentDirectoryRelativeUrl}</a
14
+ >
15
+ directory content:
16
+ </p>
17
+ <ul>
18
+ ${parentDirectoryContent}
19
+ </ul>
20
+ </body>
21
+ </html>
@@ -0,0 +1,18 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <title>404 ENOENT</title>
5
+ <meta charset="utf-8" />
6
+ <link rel="icon" href="data:," />
7
+ </head>
8
+
9
+ <body>
10
+ <p>No entry on the filesystem for <strong>/${fileRelativeUrl}</strong></p>
11
+ <p>
12
+ <a jsenv-ignore href="/${parentDirectoryRelativeUrl}"
13
+ >/${parentDirectoryRelativeUrl}</a
14
+ >
15
+ directory is empty.
16
+ </p>
17
+ </body>
18
+ </html>
@@ -1,5 +1,11 @@
1
- import { readFileSync, realpathSync, statSync } from "node:fs";
2
- import { serveDirectory } from "@jsenv/server";
1
+ import {
2
+ existsSync,
3
+ readFileSync,
4
+ realpathSync,
5
+ statSync,
6
+ lstatSync,
7
+ readdirSync,
8
+ } from "node:fs";
3
9
  import { pathToFileURL } from "node:url";
4
10
  import {
5
11
  urlIsInsideOf,
@@ -11,7 +17,19 @@ import {
11
17
  applyFileSystemMagicResolution,
12
18
  getExtensionsToTry,
13
19
  } from "@jsenv/node-esm-resolution";
20
+ import { pickContentType } from "@jsenv/server";
14
21
  import { CONTENT_TYPE } from "@jsenv/utils/src/content_type/content_type.js";
22
+ import { assertAndNormalizeDirectoryUrl } from "@jsenv/filesystem";
23
+
24
+ const html404AndParentDirIsEmptyFileUrl = new URL(
25
+ "./html_404_and_parent_dir_is_empty.html",
26
+ import.meta.url,
27
+ );
28
+ const html404AndParentDirFileUrl = new URL(
29
+ "./html_404_and_parent_dir.html",
30
+ import.meta.url,
31
+ );
32
+ const htmlFileUrlForDirectory = new URL("./directory.html", import.meta.url);
15
33
 
16
34
  export const jsenvPluginProtocolFile = ({
17
35
  magicExtensions = ["inherit", ".js"],
@@ -99,7 +117,9 @@ export const jsenvPluginProtocolFile = ({
99
117
  reference.leadsToADirectory = stat && stat.isDirectory();
100
118
  if (reference.leadsToADirectory) {
101
119
  let actionForDirectory;
102
- if (
120
+ if (reference.type === "a_href") {
121
+ actionForDirectory = "ignore";
122
+ } else if (
103
123
  reference.type === "http_request" ||
104
124
  reference.type === "filesystem"
105
125
  ) {
@@ -184,17 +204,33 @@ export const jsenvPluginProtocolFile = ({
184
204
  urlInfo.filenameHint = `${urlToFilename(urlInfo.url)}/`;
185
205
  }
186
206
  }
187
- const { headers, body } = serveDirectory(urlObject.href, {
188
- headers: urlInfo.context.request
189
- ? urlInfo.context.request.headers
190
- : {},
191
- rootDirectoryUrl: urlInfo.context.rootDirectoryUrl,
192
- });
207
+ const directoryContentArray = readdirSync(urlObject);
208
+ if (urlInfo.firstReference.type === "filesystem") {
209
+ const content = JSON.stringify(directoryContentArray, null, " ");
210
+ return {
211
+ type: "directory",
212
+ contentType: "application/json",
213
+ content,
214
+ };
215
+ }
216
+ const acceptsHtml = urlInfo.context.request
217
+ ? pickContentType(urlInfo.context.request, ["text/html"])
218
+ : false;
219
+ if (acceptsHtml) {
220
+ const html = generateHtmlForDirectory(
221
+ urlObject.href,
222
+ directoryContentArray,
223
+ urlInfo.context.rootDirectoryUrl,
224
+ );
225
+ return {
226
+ contentType: "text/html",
227
+ content: html,
228
+ };
229
+ }
193
230
  return {
194
231
  type: "directory",
195
- contentType: headers["content-type"],
196
- contentLength: headers["content-length"],
197
- content: body,
232
+ contentType: "application/json",
233
+ content: JSON.stringify(directoryContentArray, null, " "),
198
234
  };
199
235
  }
200
236
  if (
@@ -204,20 +240,144 @@ export const jsenvPluginProtocolFile = ({
204
240
  urlInfo.dirnameHint =
205
241
  urlInfo.firstReference.ownerUrlInfo.filenameHint;
206
242
  }
207
- const fileBuffer = readFileSync(urlObject);
208
243
  const contentType = CONTENT_TYPE.fromUrlExtension(urlInfo.url);
244
+ if (contentType === "text/html") {
245
+ try {
246
+ const fileBuffer = readFileSync(urlObject);
247
+ const content = String(fileBuffer);
248
+ return {
249
+ content,
250
+ contentType,
251
+ contentLength: fileBuffer.length,
252
+ };
253
+ } catch (e) {
254
+ if (e.code !== "ENOENT") {
255
+ throw e;
256
+ }
257
+ const parentDirectoryUrl = new URL("./", urlInfo.url);
258
+ if (!existsSync(parentDirectoryUrl)) {
259
+ throw e;
260
+ }
261
+ const parentDirectoryContentArray = readdirSync(
262
+ new URL(parentDirectoryUrl),
263
+ );
264
+ const html = generateHtmlForENOENTOnHtmlFile(
265
+ urlInfo.url,
266
+ parentDirectoryContentArray,
267
+ parentDirectoryUrl,
268
+ urlInfo.context.rootDirectoryUrl,
269
+ );
270
+ return {
271
+ contentType: "text/html",
272
+ content: html,
273
+ };
274
+ }
275
+ }
276
+ const fileBuffer = readFileSync(urlObject);
209
277
  const content = CONTENT_TYPE.isTextual(contentType)
210
278
  ? String(fileBuffer)
211
279
  : fileBuffer;
212
280
  return {
213
281
  content,
214
282
  contentType,
283
+ contentLength: fileBuffer.length,
215
284
  };
216
285
  },
217
286
  },
218
287
  ];
219
288
  };
220
289
 
290
+ const generateHtmlForDirectory = (
291
+ directoryUrl,
292
+ directoryContentArray,
293
+ rootDirectoryUrl,
294
+ ) => {
295
+ directoryUrl = assertAndNormalizeDirectoryUrl(directoryUrl);
296
+ const htmlForDirectory = String(readFileSync(htmlFileUrlForDirectory));
297
+ const replacers = {
298
+ directoryRelativeUrl: urlToRelativeUrl(directoryUrl, rootDirectoryUrl),
299
+ directoryUrl,
300
+ directoryContent: () =>
301
+ generateDirectoryContent(
302
+ directoryContentArray,
303
+ directoryUrl,
304
+ rootDirectoryUrl,
305
+ ),
306
+ };
307
+ const html = replacePlaceholders(htmlForDirectory, replacers);
308
+ return html;
309
+ };
310
+ const generateHtmlForENOENTOnHtmlFile = (
311
+ url,
312
+ parentDirectoryContentArray,
313
+ parentDirectoryUrl,
314
+ rootDirectoryUrl,
315
+ ) => {
316
+ if (parentDirectoryContentArray.length === 0) {
317
+ const htmlFor404AndParentDirIsEmpty = String(
318
+ readFileSync(html404AndParentDirIsEmptyFileUrl),
319
+ );
320
+ return replacePlaceholders(htmlFor404AndParentDirIsEmpty, {
321
+ fileRelativeUrl: urlToRelativeUrl(url, rootDirectoryUrl),
322
+ parentDirectoryRelativeUrl: urlToRelativeUrl(
323
+ parentDirectoryUrl,
324
+ rootDirectoryUrl,
325
+ ),
326
+ });
327
+ }
328
+ const htmlFor404AndParentDir = String(
329
+ readFileSync(html404AndParentDirFileUrl),
330
+ );
331
+
332
+ const replacers = {
333
+ fileUrl: url,
334
+ fileRelativeUrl: urlToRelativeUrl(url, rootDirectoryUrl),
335
+ parentDirectoryUrl,
336
+ parentDirectoryRelativeUrl: urlToRelativeUrl(
337
+ parentDirectoryUrl,
338
+ rootDirectoryUrl,
339
+ ),
340
+ parentDirectoryContent: () =>
341
+ generateDirectoryContent(
342
+ parentDirectoryContentArray,
343
+ parentDirectoryUrl,
344
+ rootDirectoryUrl,
345
+ ),
346
+ };
347
+ const html = replacePlaceholders(htmlFor404AndParentDir, replacers);
348
+ return html;
349
+ };
350
+ const generateDirectoryContent = (
351
+ directoryContentArray,
352
+ directoryUrl,
353
+ rootDirectoryUrl,
354
+ ) => {
355
+ return directoryContentArray.map((filename) => {
356
+ const fileUrlObject = new URL(filename, directoryUrl);
357
+ const fileUrl = String(fileUrlObject);
358
+ let fileUrlRelative = urlToRelativeUrl(fileUrl, rootDirectoryUrl);
359
+ if (lstatSync(fileUrlObject).isDirectory()) {
360
+ fileUrlRelative += "/";
361
+ }
362
+ return `<li>
363
+ <a href="/${fileUrlRelative}">/${fileUrlRelative}</a>
364
+ </li>`;
365
+ }).join(`
366
+ `);
367
+ };
368
+ const replacePlaceholders = (html, replacers) => {
369
+ return html.replace(/\${([\w]+)}/g, (match, name) => {
370
+ const replacer = replacers[name];
371
+ if (replacer === undefined) {
372
+ return match;
373
+ }
374
+ if (typeof replacer === "function") {
375
+ return replacer();
376
+ }
377
+ return replacer;
378
+ });
379
+ };
380
+
221
381
  const resolveSymlink = (fileUrl) => {
222
382
  const urlObject = new URL(fileUrl);
223
383
  const realpath = realpathSync(urlObject);
@@ -0,0 +1,14 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <title>Syntax error in HTML</title>
5
+ <meta charset="utf-8" />
6
+ <link rel="icon" href="data:," />
7
+ </head>
8
+
9
+ <body>
10
+ <p>Syntax error: <strong>${reasonCode}</strong></p>
11
+ <a jsenv-ignore href="${errorLinkHref}">${errorLinkText}</a>
12
+ <pre>${syntaxError}</pre>
13
+ </body>
14
+ </html>