@staticbolt/core 1.0.0-beta.30 → 1.0.0-beta.32
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.
- package/README.md +2 -1
- package/lib/cli/index.mjs +31 -8
- package/lib/cli/index.mjs.map +1 -1
- package/lib/{common-DUFKS3lW.mjs → common-D1QTZ8ra.mjs} +2 -2
- package/lib/{common-DUFKS3lW.mjs.map → common-D1QTZ8ra.mjs.map} +1 -1
- package/lib/index.d.mts +151 -3
- package/lib/index.d.mts.map +1 -1
- package/lib/index.mjs +3 -3
- package/lib/index.mjs.map +1 -1
- package/lib/{load-config-D-FtbUws.mjs → load-config-CsbiJ01A.mjs} +2 -2
- package/lib/{load-config-D-FtbUws.mjs.map → load-config-CsbiJ01A.mjs.map} +1 -1
- package/lib/plugins/index.d.mts +16 -7
- package/lib/plugins/index.d.mts.map +1 -1
- package/lib/plugins/index.mjs +747 -211
- package/lib/plugins/index.mjs.map +1 -1
- package/lib/{utilities-D0KXIZ-B.mjs → utilities-jK4uUZBV.mjs} +85 -26
- package/lib/utilities-jK4uUZBV.mjs.map +1 -0
- package/package.json +8 -4
- package/lib/utilities-D0KXIZ-B.mjs.map +0 -1
package/lib/plugins/index.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { A as isTextAssetMetadata, C as isBinaryAssetMetadata, D as isScriptMetadata, E as isPackageMetadata,
|
|
2
|
-
import { _ as replaceExtension, a as basename, c as isAbsolute, d as join, f as normalize, g as relative, i as createLog, l as isPathMatch, m as parsePatterns, n as CUSTOM_ATTRIBUTES, o as dirname, p as parse$2, r as Log, s as extname, u as isSubpath, v as resolve } from "../common-
|
|
1
|
+
import { A as isTextAssetMetadata, B as checkJsonObject, C as isBinaryAssetMetadata, D as isScriptMetadata, E as isPackageMetadata, G as isTypeScriptScript, H as isEmptyElement, I as safeReadFileSync, K as TYPESCRIPT_TYPE, M as METADATA_TYPES, N as Resolver, O as isStyleMetadata, P as readJsonFile, R as valueOrError, S as filterStyleMetadata, T as isMarkdownMetadata, U as isJavaScript, V as isDynamic, X as splitHtmlLink, Y as isValidRelativePath, Z as DependencyTracker, _ as mergeMaps, b as printFmtError, c as downloadContent, d as hashContent, f as humanReadableBytes, h as isURL, j as isWebManifestMetadata, k as isSvgMetadata, l as escapeHtml, n as bytesToKB, q as isScriptType, s as cloneObject, w as isHtmlMetadata, x as filterScriptMetadata, y as PrintFormattedError, z as checkFileExists } from "../utilities-jK4uUZBV.mjs";
|
|
2
|
+
import { _ as replaceExtension, a as basename, c as isAbsolute, d as join, f as normalize, g as relative, i as createLog, l as isPathMatch, m as parsePatterns, n as CUSTOM_ATTRIBUTES, o as dirname, p as parse$2, r as Log, s as extname, u as isSubpath, v as resolve } from "../common-D1QTZ8ra.mjs";
|
|
3
3
|
import { _ as minifyHtml, a as formatStyle, c as minifyStylePass, d as minifyScript, f as minifyScriptPass, g as formatHtmlPass, h as formatHtml, i as minifySvgPass, l as formatScript, m as formatMarkdownPass, n as formatSvgPass, o as formatStylePass, p as formatMarkdown, r as minifySvg, s as minifyStyle, t as formatSvg, u as formatScriptPass, v as minifyHtmlPass, y as formatCode } from "../deferred-DTj91vEg.mjs";
|
|
4
|
-
import { t as loadConfigFile } from "../load-config-
|
|
4
|
+
import { t as loadConfigFile } from "../load-config-CsbiJ01A.mjs";
|
|
5
5
|
import { createRequire } from "node:module";
|
|
6
|
-
import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
7
6
|
import nodePath from "node:path";
|
|
7
|
+
import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
8
8
|
import chalk from "chalk";
|
|
9
9
|
import json5 from "json5";
|
|
10
10
|
import _generator from "@babel/generator";
|
|
@@ -20,7 +20,7 @@ import * as babel from "@babel/core";
|
|
|
20
20
|
import babelPresetEnv from "@babel/preset-env";
|
|
21
21
|
import babelTypeScriptPreset from "@babel/preset-typescript";
|
|
22
22
|
import transformDefine from "babel-plugin-transform-define";
|
|
23
|
-
import { availableParallelism } from "node:os";
|
|
23
|
+
import { availableParallelism, networkInterfaces } from "node:os";
|
|
24
24
|
import { Worker } from "node:worker_threads";
|
|
25
25
|
import vm from "node:vm";
|
|
26
26
|
import transformModulesCommonjs from "@babel/plugin-transform-modules-commonjs";
|
|
@@ -41,6 +41,9 @@ import fastifyStatic from "@fastify/static";
|
|
|
41
41
|
import fastifyUrlData from "@fastify/url-data";
|
|
42
42
|
import Fastify from "fastify";
|
|
43
43
|
import { WebSocketServer } from "ws";
|
|
44
|
+
import { createServer } from "node:net";
|
|
45
|
+
import { createInterface } from "node:readline/promises";
|
|
46
|
+
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
44
47
|
import * as z from "zod";
|
|
45
48
|
import browserslistFn from "browserslist";
|
|
46
49
|
import chokidar from "chokidar";
|
|
@@ -122,7 +125,8 @@ var DependenciesFinder = class {
|
|
|
122
125
|
* - Reachability is walked from HTML pages only, so anything reachable through another entry point counts as unused.
|
|
123
126
|
* - HTML files are never listed as unused or deleted.
|
|
124
127
|
* - Empty directories under the output are removed on every run, not just when deleting unused files.
|
|
125
|
-
* - "exclude" is matched against paths relative to the output directory and applies to
|
|
128
|
+
* - "exclude" is matched against paths relative to the output directory and applies to every report, so a file a later plugin
|
|
129
|
+
* writes (a service worker, say) can be referenced without being reported missing.
|
|
126
130
|
*/
|
|
127
131
|
function analyzeOutputPlugin(options = {}) {
|
|
128
132
|
const isRemoveUnused = options.deleteUnused ?? false;
|
|
@@ -149,21 +153,21 @@ function analyzeOutputPlugin(options = {}) {
|
|
|
149
153
|
const dependencies = await dependenciesFinder.findDeps(metadataItem);
|
|
150
154
|
for (const dependency of dependencies) usedFiles.add(dependency);
|
|
151
155
|
}
|
|
152
|
-
const
|
|
156
|
+
const isExcluded = (file) => isPathMatch(relative(this.outdir, file), {
|
|
157
|
+
include: exclude,
|
|
158
|
+
root: this.outdir
|
|
159
|
+
});
|
|
160
|
+
const missingFiles = [...usedFiles.difference(availableFiles)].filter((file) => !isExcluded(file));
|
|
153
161
|
let missingFilesLog = chalk.bold("The following files are missing:");
|
|
154
162
|
for (const missing of missingFiles) missingFilesLog += "\n" + chalk.red("File not found: ") + chalk.yellow(relative(this.root, missing));
|
|
155
|
-
if (missingFiles.
|
|
163
|
+
if (missingFiles.length > 0) this.log.error(missingFilesLog);
|
|
156
164
|
const unusedFiles = availableFiles.difference(usedFiles);
|
|
157
165
|
const unusedFilesLog = [];
|
|
158
166
|
for (const unused of unusedFiles) {
|
|
159
167
|
if (extname(unused) === ".html") continue;
|
|
160
|
-
|
|
161
|
-
if (isPathMatch(relativePath, {
|
|
162
|
-
include: exclude,
|
|
163
|
-
root: this.outdir
|
|
164
|
-
})) continue;
|
|
168
|
+
if (isExcluded(unused)) continue;
|
|
165
169
|
if (isRemoveUnused) unlinkSync(unused);
|
|
166
|
-
unusedFilesLog.push(chalk.yellow(join(basename(this.outdir),
|
|
170
|
+
unusedFilesLog.push(chalk.yellow(join(basename(this.outdir), relative(this.outdir, unused))));
|
|
167
171
|
}
|
|
168
172
|
const logTitle = isRemoveUnused ? chalk.bold(chalk.red("Removed"), "(" + chalk.yellow(unusedFilesLog.length) + ")", "unused files from the output directory") : chalk.bold("Found", "(" + chalk.yellow(unusedFilesLog.length) + ")", "unused files");
|
|
169
173
|
if (unusedFilesLog.length > 0) this.log.warn(logTitle + (isLogUnusedFiles ? "\n" + unusedFilesLog.join("\n") : ""));
|
|
@@ -2967,6 +2971,7 @@ function transformJsPlugin(options = {}) {
|
|
|
2967
2971
|
},
|
|
2968
2972
|
async transform(inputMetadata) {
|
|
2969
2973
|
for (const { metadata } of filterScriptMetadata(inputMetadata)) {
|
|
2974
|
+
metadata.ast.program.sourceType = metadata.module ? "module" : "script";
|
|
2970
2975
|
const [, transformError] = useBabelTransform({
|
|
2971
2976
|
ast: metadata.ast,
|
|
2972
2977
|
root: this.root,
|
|
@@ -3460,6 +3465,54 @@ function collectImportsAndRewriteMeta(imports) {
|
|
|
3460
3465
|
}
|
|
3461
3466
|
const executeJsInVM = valueOrError(executeJsInVmUnsafe);
|
|
3462
3467
|
|
|
3468
|
+
//#endregion
|
|
3469
|
+
//#region src/plugins/html-build-time-script/lsp.ts
|
|
3470
|
+
/** What the sandbox gives every build-time script; these shadow the DOM globals of the same name. */
|
|
3471
|
+
const GLOBALS = `declare const StaticBolt: import("@staticbolt/core").App;
|
|
3472
|
+
declare const window: typeof globalThis;
|
|
3473
|
+
declare const __filepath: string;
|
|
3474
|
+
declare const __id: string;`;
|
|
3475
|
+
/** The page's tree is the parser's, not the DOM's: its node types take the DOM names, so the code reads as it runs. */
|
|
3476
|
+
const PARSER_TYPES = [
|
|
3477
|
+
"Document",
|
|
3478
|
+
"HTMLElement",
|
|
3479
|
+
"Node",
|
|
3480
|
+
"TextNode",
|
|
3481
|
+
"CommentNode",
|
|
3482
|
+
"NodeType"
|
|
3483
|
+
].map((name) => `type ${name} = import("@staticbolt/node-html-parser").${name};`).join("\n");
|
|
3484
|
+
/**
|
|
3485
|
+
* The bodies of the build-time scripts marked `type="application/x-typescript"`, served as TypeScript that sees the sandbox
|
|
3486
|
+
* globals: completion, hover and type errors. Claimed before the core plugin, later in the config, sees the browser in them.
|
|
3487
|
+
*/
|
|
3488
|
+
function embeddedLanguages$2(names) {
|
|
3489
|
+
return [{
|
|
3490
|
+
name: "build-time",
|
|
3491
|
+
filter: () => true,
|
|
3492
|
+
findRegions: (document) => buildTimeScripts(document, names).flatMap((script) => script.contentRange ? {
|
|
3493
|
+
...script.contentRange,
|
|
3494
|
+
isModule: true
|
|
3495
|
+
} : []),
|
|
3496
|
+
isColouredByEditor: true,
|
|
3497
|
+
prelude: (document) => prelude(buildTimeScripts(document, names), names)
|
|
3498
|
+
}];
|
|
3499
|
+
}
|
|
3500
|
+
/** The build-time scripts of a document that are marked as TypeScript. */
|
|
3501
|
+
function buildTimeScripts(document, names) {
|
|
3502
|
+
return document.select("script").filter((script) => script.has(names.build) && isTypeScriptScript(script));
|
|
3503
|
+
}
|
|
3504
|
+
/**
|
|
3505
|
+
* The declarations for a document's build-time scripts: the sandbox globals, and `document` as the parser's tree or, with a full
|
|
3506
|
+
* DOM, the DOM's.
|
|
3507
|
+
*/
|
|
3508
|
+
function prelude(scripts, names) {
|
|
3509
|
+
const hasFullDom = scripts.some((script) => script.has(names.fullDom));
|
|
3510
|
+
const lines = [GLOBALS];
|
|
3511
|
+
if (!hasFullDom) lines.push(PARSER_TYPES);
|
|
3512
|
+
lines.push("declare const document: Document;");
|
|
3513
|
+
return lines.join("\n");
|
|
3514
|
+
}
|
|
3515
|
+
|
|
3463
3516
|
//#endregion
|
|
3464
3517
|
//#region src/plugins/html-build-time-script/html-build-time-script.ts
|
|
3465
3518
|
/**
|
|
@@ -3580,6 +3633,13 @@ function htmlBuildTimeScript(options = {}) {
|
|
|
3580
3633
|
if (event !== "change") return;
|
|
3581
3634
|
for (const id of Array.from(compileSet)) for (const importer of dependencies.getImporters(id)) compileSet.add(importer);
|
|
3582
3635
|
},
|
|
3636
|
+
lspEmbeddedLanguages() {
|
|
3637
|
+
return embeddedLanguages$2({
|
|
3638
|
+
build: buildAttribute,
|
|
3639
|
+
fullDom: fullDomAttribute,
|
|
3640
|
+
context: contextAttribute
|
|
3641
|
+
});
|
|
3642
|
+
},
|
|
3583
3643
|
lspHtmlData() {
|
|
3584
3644
|
return {
|
|
3585
3645
|
version: 1.1,
|
|
@@ -3589,7 +3649,7 @@ function htmlBuildTimeScript(options = {}) {
|
|
|
3589
3649
|
attributes: [
|
|
3590
3650
|
{
|
|
3591
3651
|
name: buildAttribute,
|
|
3592
|
-
description: "This attribute is used to mark the script as build-time script.",
|
|
3652
|
+
description: "This attribute is used to mark the script as build-time script.\n\nWith `type=\"application/x-typescript\"` the staticbolt language server serves the script with the sandbox globals (`StaticBolt`, `document`, `window`, `__filepath`, `__id`) in place of the browser's.",
|
|
3593
3653
|
valueSet: "v"
|
|
3594
3654
|
},
|
|
3595
3655
|
{
|
|
@@ -3604,6 +3664,25 @@ function htmlBuildTimeScript(options = {}) {
|
|
|
3604
3664
|
]
|
|
3605
3665
|
}]
|
|
3606
3666
|
};
|
|
3667
|
+
},
|
|
3668
|
+
lspValidate(document, report) {
|
|
3669
|
+
const scripts = document.select("script");
|
|
3670
|
+
for (const element of scripts) {
|
|
3671
|
+
const build = element.attribute(buildAttribute);
|
|
3672
|
+
if (!build) continue;
|
|
3673
|
+
if (!isJavaScript(element)) {
|
|
3674
|
+
report.warn(build.nameRange, `"${buildAttribute}" does nothing on a script that is not JavaScript`);
|
|
3675
|
+
continue;
|
|
3676
|
+
}
|
|
3677
|
+
const context = element.attribute(contextAttribute);
|
|
3678
|
+
if (context) checkJsonObject(context, report);
|
|
3679
|
+
const source = element.attribute("src");
|
|
3680
|
+
if (!source?.value && isEmptyElement(element, document)) {
|
|
3681
|
+
report.error(build.nameRange, `"${buildAttribute}" needs a "src" or some code in the script to run`);
|
|
3682
|
+
continue;
|
|
3683
|
+
}
|
|
3684
|
+
if (source) checkFileExists(source, document, report);
|
|
3685
|
+
}
|
|
3607
3686
|
}
|
|
3608
3687
|
};
|
|
3609
3688
|
}
|
|
@@ -3884,6 +3963,23 @@ function htmlBundleScriptPlugin(options = {}) {
|
|
|
3884
3963
|
]
|
|
3885
3964
|
}]
|
|
3886
3965
|
};
|
|
3966
|
+
},
|
|
3967
|
+
lspValidate(document, report) {
|
|
3968
|
+
const scripts = document.select("script");
|
|
3969
|
+
for (const element of scripts) {
|
|
3970
|
+
const bundle = element.attribute(bundleAttribute);
|
|
3971
|
+
if (!bundle) continue;
|
|
3972
|
+
if (!isJavaScript(element)) {
|
|
3973
|
+
report.warn(bundle.nameRange, `"${bundleAttribute}" does nothing on a script that is not JavaScript`);
|
|
3974
|
+
continue;
|
|
3975
|
+
}
|
|
3976
|
+
const source = element.attribute("src");
|
|
3977
|
+
if (!source?.value && isEmptyElement(element, document)) {
|
|
3978
|
+
report.error(bundle.nameRange, `"${bundleAttribute}" needs a "src" or some code in the script to bundle`);
|
|
3979
|
+
continue;
|
|
3980
|
+
}
|
|
3981
|
+
if (source) checkFileExists(source, document, report);
|
|
3982
|
+
}
|
|
3887
3983
|
}
|
|
3888
3984
|
};
|
|
3889
3985
|
}
|
|
@@ -4472,7 +4568,7 @@ function htmlBundleStylePlugin(options = {}) {
|
|
|
4472
4568
|
}
|
|
4473
4569
|
|
|
4474
4570
|
//#endregion
|
|
4475
|
-
//#region src/plugins/html-env-only/html-
|
|
4571
|
+
//#region src/plugins/html-env-only/html-environment-only-plugin.ts
|
|
4476
4572
|
/**
|
|
4477
4573
|
* Removes the HTML tags that do not belong in the current environment.
|
|
4478
4574
|
*
|
|
@@ -4487,15 +4583,15 @@ function htmlBundleStylePlugin(options = {}) {
|
|
|
4487
4583
|
* - Where the tag survives, the marker attribute is stripped from the output.
|
|
4488
4584
|
*/
|
|
4489
4585
|
function htmlEnvOnlyPlugin(options = {}) {
|
|
4490
|
-
const
|
|
4586
|
+
const developmentOnlyAttribute = options.devOnlyAttribute ?? "dev-only";
|
|
4491
4587
|
const productionOnlyAttribute = options.productionOnlyAttribute ?? "production-only";
|
|
4492
4588
|
return {
|
|
4493
4589
|
name: "html-env-only",
|
|
4494
4590
|
transform(metadata) {
|
|
4495
4591
|
if (!isHtmlMetadata(metadata)) return;
|
|
4496
|
-
const
|
|
4497
|
-
for (const node of
|
|
4498
|
-
else node.removeAttribute(
|
|
4592
|
+
const developmentOnlyElements = metadata.ast.querySelectorAll(`[${developmentOnlyAttribute}]`);
|
|
4593
|
+
for (const node of developmentOnlyElements) if (this.production) node.remove();
|
|
4594
|
+
else node.removeAttribute(developmentOnlyAttribute);
|
|
4499
4595
|
const productionOnlyElements = metadata.ast.querySelectorAll(`[${productionOnlyAttribute}]`);
|
|
4500
4596
|
for (const node of productionOnlyElements) if (this.production) node.removeAttribute(productionOnlyAttribute);
|
|
4501
4597
|
else node.remove();
|
|
@@ -4504,7 +4600,7 @@ function htmlEnvOnlyPlugin(options = {}) {
|
|
|
4504
4600
|
return {
|
|
4505
4601
|
version: 1.1,
|
|
4506
4602
|
globalAttributes: [{
|
|
4507
|
-
name:
|
|
4603
|
+
name: developmentOnlyAttribute,
|
|
4508
4604
|
description: "Keep this tag during dev serve only; it is removed at build time.",
|
|
4509
4605
|
valueSet: "v"
|
|
4510
4606
|
}, {
|
|
@@ -4666,6 +4762,18 @@ function htmlIifeScriptPlugin(options = {}) {
|
|
|
4666
4762
|
}]
|
|
4667
4763
|
}]
|
|
4668
4764
|
};
|
|
4765
|
+
},
|
|
4766
|
+
lspValidate(document, report) {
|
|
4767
|
+
const scripts = document.select("script");
|
|
4768
|
+
for (const element of scripts) {
|
|
4769
|
+
const iife = element.attribute(iifeAttribute);
|
|
4770
|
+
if (!iife) continue;
|
|
4771
|
+
if (!isJavaScript(element)) {
|
|
4772
|
+
report.warn(iife.nameRange, `"${iifeAttribute}" does nothing on a script that is not JavaScript`);
|
|
4773
|
+
continue;
|
|
4774
|
+
}
|
|
4775
|
+
if (element.attribute("type")?.value === "module") report.warn(iife.nameRange, `"${iifeAttribute}" is for global scripts, a module already has its own scope`);
|
|
4776
|
+
}
|
|
4669
4777
|
}
|
|
4670
4778
|
};
|
|
4671
4779
|
}
|
|
@@ -4723,8 +4831,9 @@ function htmlInlineScriptPlugin(options = {}) {
|
|
|
4723
4831
|
}
|
|
4724
4832
|
const scriptMetadataClone = this.clone(scriptMetadata);
|
|
4725
4833
|
await this.rebase(scriptMetadataClone, join(this.root, metadata.filePath));
|
|
4726
|
-
|
|
4727
|
-
|
|
4834
|
+
const isModule = scriptTag.getAttribute("type") === "module";
|
|
4835
|
+
scriptMetadataClone.module = isModule;
|
|
4836
|
+
const metadataID = hashContent((isModule ? "module:" : "script:") + await this.stringify(scriptMetadataClone));
|
|
4728
4837
|
scriptTag.setAttribute(CUSTOM_ATTRIBUTES.MetadataID, metadataID);
|
|
4729
4838
|
metadata.scriptsMetadataList.set(metadataID, scriptMetadataClone);
|
|
4730
4839
|
scriptTag.removeAttribute("src");
|
|
@@ -4743,6 +4852,24 @@ function htmlInlineScriptPlugin(options = {}) {
|
|
|
4743
4852
|
}]
|
|
4744
4853
|
}]
|
|
4745
4854
|
};
|
|
4855
|
+
},
|
|
4856
|
+
lspValidate(document, report) {
|
|
4857
|
+
const scripts = document.select("script");
|
|
4858
|
+
for (const element of scripts) {
|
|
4859
|
+
const inline = element.attribute(inlineAttribute);
|
|
4860
|
+
if (!inline) continue;
|
|
4861
|
+
if (!isJavaScript(element)) {
|
|
4862
|
+
report.warn(inline.nameRange, `"${inlineAttribute}" does nothing on a script that is not JavaScript`);
|
|
4863
|
+
continue;
|
|
4864
|
+
}
|
|
4865
|
+
const source = element.attribute("src");
|
|
4866
|
+
if (!source?.value) {
|
|
4867
|
+
report.error(inline.nameRange, `"${inlineAttribute}" needs a "src" pointing at the script to inline`);
|
|
4868
|
+
continue;
|
|
4869
|
+
}
|
|
4870
|
+
if (!isEmptyElement(element, document)) report.error(inline.nameRange, `"${inlineAttribute}" needs an empty script tag, the content comes from "src"`);
|
|
4871
|
+
checkFileExists(source, document, report);
|
|
4872
|
+
}
|
|
4746
4873
|
}
|
|
4747
4874
|
};
|
|
4748
4875
|
}
|
|
@@ -4837,6 +4964,23 @@ function htmlInlineStylePlugin(options = {}) {
|
|
|
4837
4964
|
}]
|
|
4838
4965
|
}]
|
|
4839
4966
|
};
|
|
4967
|
+
},
|
|
4968
|
+
lspValidate(document, report) {
|
|
4969
|
+
const links = document.select("link");
|
|
4970
|
+
for (const element of links) {
|
|
4971
|
+
const inline = element.attribute(inlineAttribute);
|
|
4972
|
+
if (!inline) continue;
|
|
4973
|
+
if (element.attribute("rel")?.value !== "stylesheet") {
|
|
4974
|
+
report.warn(inline.nameRange, `"${inlineAttribute}" only works on a link with rel="stylesheet"`);
|
|
4975
|
+
continue;
|
|
4976
|
+
}
|
|
4977
|
+
const source = element.attribute("href");
|
|
4978
|
+
if (!source?.value) {
|
|
4979
|
+
report.error(inline.nameRange, `"${inlineAttribute}" needs an "href" pointing at the stylesheet to inline`);
|
|
4980
|
+
continue;
|
|
4981
|
+
}
|
|
4982
|
+
checkFileExists(source, document, report);
|
|
4983
|
+
}
|
|
4840
4984
|
}
|
|
4841
4985
|
};
|
|
4842
4986
|
}
|
|
@@ -4940,6 +5084,19 @@ function HtmlInlineSvgPlugin(options = {}) {
|
|
|
4940
5084
|
}]
|
|
4941
5085
|
}]
|
|
4942
5086
|
};
|
|
5087
|
+
},
|
|
5088
|
+
lspValidate(document, report) {
|
|
5089
|
+
const svgs = document.select("svg");
|
|
5090
|
+
for (const element of svgs) {
|
|
5091
|
+
const inline = element.attribute(inlineAttribute);
|
|
5092
|
+
if (!inline) continue;
|
|
5093
|
+
const source = element.attribute(sourceAttribute);
|
|
5094
|
+
if (!source?.value) {
|
|
5095
|
+
report.error(inline.nameRange, `"${inlineAttribute}" needs a "${sourceAttribute}" pointing at the SVG to inline`);
|
|
5096
|
+
continue;
|
|
5097
|
+
}
|
|
5098
|
+
checkFileExists(source, document, report);
|
|
5099
|
+
}
|
|
4943
5100
|
}
|
|
4944
5101
|
};
|
|
4945
5102
|
}
|
|
@@ -5083,12 +5240,30 @@ function htmlInlineTextPlugin(options = {}) {
|
|
|
5083
5240
|
}]
|
|
5084
5241
|
}]
|
|
5085
5242
|
};
|
|
5243
|
+
},
|
|
5244
|
+
lspValidate(document, report) {
|
|
5245
|
+
const inlineTags = document.select(tag);
|
|
5246
|
+
for (const element of inlineTags) {
|
|
5247
|
+
const source = element.attribute(sourceAttribute);
|
|
5248
|
+
if (!source?.value) {
|
|
5249
|
+
report.error(element, `<${tag}> needs a "${sourceAttribute}" pointing at a text file`);
|
|
5250
|
+
continue;
|
|
5251
|
+
}
|
|
5252
|
+
checkFileExists(source, document, report);
|
|
5253
|
+
}
|
|
5086
5254
|
}
|
|
5087
5255
|
};
|
|
5088
5256
|
}
|
|
5089
5257
|
|
|
5090
5258
|
//#endregion
|
|
5091
5259
|
//#region src/plugins/html-insert/html-insert-plugin.ts
|
|
5260
|
+
/** Where `insertAdjacentHTML` can put content. */
|
|
5261
|
+
const POSITIONS = /* @__PURE__ */ new Set([
|
|
5262
|
+
"beforebegin",
|
|
5263
|
+
"afterbegin",
|
|
5264
|
+
"beforeend",
|
|
5265
|
+
"afterend"
|
|
5266
|
+
]);
|
|
5092
5267
|
/**
|
|
5093
5268
|
* Moves the content of an "<insert>" tag into any element on the same page, addressed by a CSS selector, then removes the tag
|
|
5094
5269
|
* itself (names configurable via HtmlInsertOptions).
|
|
@@ -5178,28 +5353,53 @@ function htmlInsertPlugin(options = {}) {
|
|
|
5178
5353
|
{
|
|
5179
5354
|
name: whereAttribute,
|
|
5180
5355
|
description: "The position of the inserted element.",
|
|
5181
|
-
values: [
|
|
5182
|
-
{ name: "beforebegin" },
|
|
5183
|
-
{ name: "afterbegin" },
|
|
5184
|
-
{ name: "beforeend" },
|
|
5185
|
-
{ name: "afterend" }
|
|
5186
|
-
]
|
|
5356
|
+
values: [...POSITIONS].map((position) => ({ name: position }))
|
|
5187
5357
|
}
|
|
5188
5358
|
]
|
|
5189
5359
|
}]
|
|
5190
5360
|
};
|
|
5361
|
+
},
|
|
5362
|
+
lspValidate(document, report) {
|
|
5363
|
+
const inserts = document.select(tag);
|
|
5364
|
+
for (const element of inserts) {
|
|
5365
|
+
const selector = element.attribute(selectorAttribute);
|
|
5366
|
+
const where = element.attribute(whereAttribute);
|
|
5367
|
+
if (isEmptyElement(element, document)) report.error(element, `<${tag}> has nothing to insert`);
|
|
5368
|
+
if (!selector?.value) report.error(element, `<${tag}> needs a "${selectorAttribute}" with the CSS selector of the target element`);
|
|
5369
|
+
if (where?.value && !isDynamic(where.value) && !POSITIONS.has(where.value)) report.error(where, `"${whereAttribute}" must be one of ${[...POSITIONS].join(", ")}`);
|
|
5370
|
+
if (where && element.has(replaceAttribute)) report.warn(where.nameRange, `"${whereAttribute}" does nothing with "${replaceAttribute}"`);
|
|
5371
|
+
}
|
|
5191
5372
|
}
|
|
5192
5373
|
};
|
|
5193
5374
|
}
|
|
5194
5375
|
|
|
5195
5376
|
//#endregion
|
|
5196
5377
|
//#region src/plugins/html-layout/fill-placeholders.ts
|
|
5197
|
-
/**
|
|
5198
|
-
const placeholderRe$2 = /(\\)?{{\s*([\s\S]*?)\s*}}/g;
|
|
5378
|
+
/** `{{ expression }}`, or `\{{ … }}` to have it left alone. The expression is captured without its surrounding whitespace. */
|
|
5379
|
+
const placeholderRe$2 = /(\\)?{{(\s*)([\s\S]*?)\s*}}/g;
|
|
5380
|
+
/** Where the expressions of the unescaped placeholders sit in `html`. */
|
|
5381
|
+
function findPlaceholders(html) {
|
|
5382
|
+
const regions = [];
|
|
5383
|
+
for (const match of html.matchAll(placeholderRe$2)) {
|
|
5384
|
+
const [, escape, leadingSpace, expression] = match;
|
|
5385
|
+
if (escape) continue;
|
|
5386
|
+
const start = match.index + 2 + leadingSpace.length;
|
|
5387
|
+
const extent = {
|
|
5388
|
+
start: match.index,
|
|
5389
|
+
end: match.index + match[0].length
|
|
5390
|
+
};
|
|
5391
|
+
regions.push({
|
|
5392
|
+
start,
|
|
5393
|
+
end: start + expression.length,
|
|
5394
|
+
extent
|
|
5395
|
+
});
|
|
5396
|
+
}
|
|
5397
|
+
return regions;
|
|
5398
|
+
}
|
|
5199
5399
|
/** `{{ JavaScript expression }}` */
|
|
5200
5400
|
function fillLayoutPlaceholders(html, placeholders) {
|
|
5201
5401
|
const errors = [];
|
|
5202
|
-
const result = html.replace(placeholderRe$2, (match, escape, content) => {
|
|
5402
|
+
const result = html.replace(placeholderRe$2, (match, escape, _leadingSpace, content) => {
|
|
5203
5403
|
if (escape === "\\") return match.slice(1);
|
|
5204
5404
|
const [expression, executeError] = executeExpression$1(content, placeholders);
|
|
5205
5405
|
if (executeError) {
|
|
@@ -5232,6 +5432,255 @@ function executeExpression$1(expressionString, data) {
|
|
|
5232
5432
|
}
|
|
5233
5433
|
}
|
|
5234
5434
|
|
|
5435
|
+
//#endregion
|
|
5436
|
+
//#region src/plugins/html-layout/inline-layout.ts
|
|
5437
|
+
/** Turns every `add-attr-x` and `remove-attr-x` of a layout into adding or removing `x`, by the value each holds. */
|
|
5438
|
+
function applyConditionalAttributes(layout, names) {
|
|
5439
|
+
const addPrefix = `${names.addAttribute}-`;
|
|
5440
|
+
const removePrefix = `${names.removeAttribute}-`;
|
|
5441
|
+
const elements = layout.ast.querySelectorAll("*");
|
|
5442
|
+
for (const element of elements) {
|
|
5443
|
+
if (!element.attributes) continue;
|
|
5444
|
+
for (const [name, value] of Object.entries(element.attributes)) {
|
|
5445
|
+
if (name.startsWith(addPrefix)) {
|
|
5446
|
+
element.removeAttribute(name);
|
|
5447
|
+
if (isSwitchedOn(value)) element.setAttribute(name.slice(addPrefix.length), "");
|
|
5448
|
+
continue;
|
|
5449
|
+
}
|
|
5450
|
+
if (!name.startsWith(removePrefix)) continue;
|
|
5451
|
+
element.removeAttribute(name);
|
|
5452
|
+
if (isSwitchedOn(value)) element.removeAttribute(name.slice(removePrefix.length));
|
|
5453
|
+
}
|
|
5454
|
+
}
|
|
5455
|
+
}
|
|
5456
|
+
/**
|
|
5457
|
+
* Drops from a layout instance the scripts, styles and stylesheet links the page already has from an earlier instance, so a
|
|
5458
|
+
* layout used twice emits each once. A tag marked always-include is kept, and the mark removed.
|
|
5459
|
+
*/
|
|
5460
|
+
function dropRepeatedAssets(page, layout, alwaysInclude) {
|
|
5461
|
+
const scriptsAndStyles = layout.ast.querySelectorAll("script,style");
|
|
5462
|
+
for (const scriptOrStyle of scriptsAndStyles) {
|
|
5463
|
+
const id = scriptOrStyle.getAttribute(CUSTOM_ATTRIBUTES.MetadataID);
|
|
5464
|
+
if (!id) continue;
|
|
5465
|
+
if (scriptOrStyle.hasAttribute(alwaysInclude)) {
|
|
5466
|
+
scriptOrStyle.removeAttribute(alwaysInclude);
|
|
5467
|
+
continue;
|
|
5468
|
+
}
|
|
5469
|
+
const list = scriptOrStyle.tagName === "SCRIPT" ? "scriptsMetadataList" : "stylesMetadataList";
|
|
5470
|
+
if (!page[list].has(id)) continue;
|
|
5471
|
+
layout[list].delete(id);
|
|
5472
|
+
scriptOrStyle.remove();
|
|
5473
|
+
}
|
|
5474
|
+
const scriptsAndLinks = layout.ast.querySelectorAll("script[src], link[rel=\"stylesheet\"]");
|
|
5475
|
+
for (const scriptOrLink of scriptsAndLinks) {
|
|
5476
|
+
if (scriptOrLink.hasAttribute(alwaysInclude)) {
|
|
5477
|
+
scriptOrLink.removeAttribute(alwaysInclude);
|
|
5478
|
+
continue;
|
|
5479
|
+
}
|
|
5480
|
+
const isScript = scriptOrLink.tagName === "SCRIPT";
|
|
5481
|
+
const attribute = isScript ? "src" : "href";
|
|
5482
|
+
const selector = isScript ? "script[src]" : "link[rel=\"stylesheet\"]";
|
|
5483
|
+
const layoutSource = join(dirname(layout.filePath), scriptOrLink.attributes[attribute]);
|
|
5484
|
+
if (page.ast.querySelectorAll(selector).some((element) => {
|
|
5485
|
+
return join(dirname(page.filePath), element.attributes[attribute]) === layoutSource;
|
|
5486
|
+
})) scriptOrLink.remove();
|
|
5487
|
+
}
|
|
5488
|
+
}
|
|
5489
|
+
/**
|
|
5490
|
+
* Puts the tag's children into the layout's slots, by the `slot` attribute they name or into the unnamed slots, then unwraps
|
|
5491
|
+
* every slot. A slot keeps its own content as the fallback: the first fill replaces it, later ones append.
|
|
5492
|
+
*/
|
|
5493
|
+
function fillSlots(tag, layout, layoutPath, printError) {
|
|
5494
|
+
const filledSlots = /* @__PURE__ */ new Set();
|
|
5495
|
+
const fillSlot = (slot, html) => {
|
|
5496
|
+
if (!filledSlots.has(slot)) {
|
|
5497
|
+
filledSlots.add(slot);
|
|
5498
|
+
slot.innerHTML = "";
|
|
5499
|
+
}
|
|
5500
|
+
slot.insertAdjacentHTML("beforeend", html);
|
|
5501
|
+
};
|
|
5502
|
+
const children = tag.querySelectorAll(":scope > *");
|
|
5503
|
+
for (const element of children) {
|
|
5504
|
+
if (element.nodeType !== NodeType.ELEMENT_NODE) continue;
|
|
5505
|
+
if (!element.hasAttribute("slot")) {
|
|
5506
|
+
const defaultSlots = layout.ast.querySelectorAll("slot:not([name])");
|
|
5507
|
+
if (defaultSlots.length === 0) {
|
|
5508
|
+
printError("No default slot in layout file:", layoutPath, { node: element });
|
|
5509
|
+
continue;
|
|
5510
|
+
}
|
|
5511
|
+
for (const defaultSlot of defaultSlots) fillSlot(defaultSlot, element.outerHTML);
|
|
5512
|
+
continue;
|
|
5513
|
+
}
|
|
5514
|
+
const slotName = element.getAttribute("slot");
|
|
5515
|
+
if (!slotName) continue;
|
|
5516
|
+
const namedSlots = layout.ast.querySelectorAll(`slot[name="${slotName}"]`);
|
|
5517
|
+
if (namedSlots.length === 0) {
|
|
5518
|
+
printError(`No slots with name "${slotName}" in layout file:`, layoutPath, { node: element });
|
|
5519
|
+
continue;
|
|
5520
|
+
}
|
|
5521
|
+
element.removeAttribute("slot");
|
|
5522
|
+
for (const namedSlot of namedSlots) fillSlot(namedSlot, element.outerHTML);
|
|
5523
|
+
}
|
|
5524
|
+
for (const slot of layout.ast.querySelectorAll("slot")) slot.replaceWith(slot.innerHTML.trim());
|
|
5525
|
+
}
|
|
5526
|
+
/**
|
|
5527
|
+
* Has a markdown page render inside the layout tag its front matter names: the naming key becomes the source attribute and the
|
|
5528
|
+
* other keys the tag's data.
|
|
5529
|
+
*/
|
|
5530
|
+
function wrapMarkdownInLayout(metadata, names) {
|
|
5531
|
+
const tag = Object.keys(metadata.ast.frontmatter).find((key) => names.tags.includes(key));
|
|
5532
|
+
if (!tag) return;
|
|
5533
|
+
let attributes = "";
|
|
5534
|
+
for (const [key, value] of Object.entries(metadata.ast.frontmatter)) attributes += `${key === tag ? names.source : key}="${value}" `;
|
|
5535
|
+
const openTag = `<${tag} ${attributes}>`;
|
|
5536
|
+
const closeTag = `</${tag}>`;
|
|
5537
|
+
const render = metadata.ast.render;
|
|
5538
|
+
metadata.ast.render = async () => {
|
|
5539
|
+
const html = await render();
|
|
5540
|
+
return openTag + html + closeTag;
|
|
5541
|
+
};
|
|
5542
|
+
}
|
|
5543
|
+
/** Whether a conditional attribute's value, after the placeholders are filled, switches the attribute on. */
|
|
5544
|
+
function isSwitchedOn(value) {
|
|
5545
|
+
const trimmed = value.trim();
|
|
5546
|
+
return trimmed !== "" && trimmed !== "false";
|
|
5547
|
+
}
|
|
5548
|
+
/** The tag's attributes other than the source, as the key a rendered instance is cached under. */
|
|
5549
|
+
function instanceKey(tag, names) {
|
|
5550
|
+
let key = "";
|
|
5551
|
+
for (const [attribute, value] of Object.entries(tag.attributes)) {
|
|
5552
|
+
if (attribute === names.source) continue;
|
|
5553
|
+
key += `${attribute}=${value}`;
|
|
5554
|
+
}
|
|
5555
|
+
return key;
|
|
5556
|
+
}
|
|
5557
|
+
|
|
5558
|
+
//#endregion
|
|
5559
|
+
//#region src/plugins/html-layout/options.ts
|
|
5560
|
+
/** The options with their defaults filled in. */
|
|
5561
|
+
function resolveLayoutNames(options) {
|
|
5562
|
+
return {
|
|
5563
|
+
tags: options.tags ?? ["layout", "part"],
|
|
5564
|
+
source: options.layoutSourceAttribute ?? "src",
|
|
5565
|
+
dataSource: options.dataSourceAttribute ?? "dataSource",
|
|
5566
|
+
dataJson: options.dataJsonAttribute ?? "dataJson",
|
|
5567
|
+
alwaysInclude: options.alwaysIncludeAttribute ?? "always-include",
|
|
5568
|
+
addAttribute: options.addAttributeAttribute ?? "add-attr",
|
|
5569
|
+
removeAttribute: options.removeAttributeAttribute ?? "remove-attr"
|
|
5570
|
+
};
|
|
5571
|
+
}
|
|
5572
|
+
/** Whether a path is a file the tags inline: one named after a tag, `main.layout.html` say. */
|
|
5573
|
+
function isLayoutFile(file, names) {
|
|
5574
|
+
const fileName = basename(file);
|
|
5575
|
+
return names.tags.some((tag) => fileName.endsWith(`.${tag}.html`));
|
|
5576
|
+
}
|
|
5577
|
+
/** Whether an attribute adds or removes a boolean attribute. */
|
|
5578
|
+
function isConditionalAttribute(name, names) {
|
|
5579
|
+
return name.startsWith(`${names.addAttribute}-`) || name.startsWith(`${names.removeAttribute}-`);
|
|
5580
|
+
}
|
|
5581
|
+
|
|
5582
|
+
//#endregion
|
|
5583
|
+
//#region src/plugins/html-layout/lsp.ts
|
|
5584
|
+
/** The tags and attributes the plugin adds to HTML, for completion and hover. */
|
|
5585
|
+
function htmlData$1(names) {
|
|
5586
|
+
const layoutAttributes = [
|
|
5587
|
+
{
|
|
5588
|
+
name: names.source,
|
|
5589
|
+
description: "The path to the layout to use."
|
|
5590
|
+
},
|
|
5591
|
+
{
|
|
5592
|
+
name: names.dataSource,
|
|
5593
|
+
description: "The path for a JSON file containing data for the layout."
|
|
5594
|
+
},
|
|
5595
|
+
{
|
|
5596
|
+
name: names.dataJson,
|
|
5597
|
+
description: "The layout data in JSON format."
|
|
5598
|
+
}
|
|
5599
|
+
];
|
|
5600
|
+
const alwaysInclude = {
|
|
5601
|
+
name: names.alwaysInclude,
|
|
5602
|
+
description: "When using a layout more than once on the same page. Always include this script/style/link[rel='stylesheet'] even if it is already present.",
|
|
5603
|
+
valueSet: "v"
|
|
5604
|
+
};
|
|
5605
|
+
const alwaysIncludeDescription = `Adds the \`${names.alwaysInclude}\` attribute, which gives this tag one copy per layout instance instead of the single copy a layout used more than once on the same page emits.`;
|
|
5606
|
+
const tags = [
|
|
5607
|
+
{
|
|
5608
|
+
name: "script",
|
|
5609
|
+
description: alwaysIncludeDescription,
|
|
5610
|
+
attributes: [alwaysInclude]
|
|
5611
|
+
},
|
|
5612
|
+
{
|
|
5613
|
+
name: "link",
|
|
5614
|
+
description: alwaysIncludeDescription,
|
|
5615
|
+
attributes: [alwaysInclude]
|
|
5616
|
+
},
|
|
5617
|
+
{
|
|
5618
|
+
name: "style",
|
|
5619
|
+
description: alwaysIncludeDescription,
|
|
5620
|
+
attributes: [alwaysInclude]
|
|
5621
|
+
}
|
|
5622
|
+
];
|
|
5623
|
+
for (const tag of names.tags) tags.push({
|
|
5624
|
+
name: tag,
|
|
5625
|
+
description: `This tag allows inlining a \`${tag}\` file.`,
|
|
5626
|
+
attributes: layoutAttributes
|
|
5627
|
+
});
|
|
5628
|
+
return {
|
|
5629
|
+
version: 1.1,
|
|
5630
|
+
tags,
|
|
5631
|
+
globalAttributes: [{
|
|
5632
|
+
name: names.addAttribute,
|
|
5633
|
+
description: "Inside a layout file only. Prefix for conditionally adding boolean attributes. Append the target attribute name as a suffix — `add-attr-open`, `add-attr-disabled`, etc. The value is a JS expression evaluated after placeholder resolution; if it is non-empty and not `\"false\"`, the named attribute is added to the element. Multiple `add-attr-*` attributes can appear on the same element."
|
|
5634
|
+
}, {
|
|
5635
|
+
name: names.removeAttribute,
|
|
5636
|
+
description: "Inside a layout file only. Prefix for conditionally removing boolean attributes. Append the target attribute name as a suffix — `remove-attr-open`, `remove-attr-disabled`, etc. The value is a JS expression evaluated after placeholder resolution; if it is non-empty and not `\"false\"`, the named attribute is removed from the element. Multiple `remove-attr-*` attributes can appear on the same element."
|
|
5637
|
+
}]
|
|
5638
|
+
};
|
|
5639
|
+
}
|
|
5640
|
+
/** The placeholders of layout files, as JavaScript that sees `$data`. */
|
|
5641
|
+
function embeddedLanguages$1(names) {
|
|
5642
|
+
return [{
|
|
5643
|
+
name: "placeholders",
|
|
5644
|
+
filter: (file) => isLayoutFile(file, names),
|
|
5645
|
+
findRegions: (document) => findPlaceholders(document.text),
|
|
5646
|
+
prelude: "declare const $data: Record<string, any>;"
|
|
5647
|
+
}];
|
|
5648
|
+
}
|
|
5649
|
+
/** Checks the layout tags of a document, and that the conditional attributes sit in a layout file. */
|
|
5650
|
+
function validate(names) {
|
|
5651
|
+
return (document, report) => {
|
|
5652
|
+
const layoutTags = document.select(...names.tags);
|
|
5653
|
+
for (const element of layoutTags) checkLayoutTag(element, document, report, names);
|
|
5654
|
+
if (isLayoutFile(document.file, names)) return;
|
|
5655
|
+
if (document.file.endsWith(".md")) return;
|
|
5656
|
+
for (const element of document.elements) for (const attribute of element.attributes) {
|
|
5657
|
+
if (!isConditionalAttribute(attribute.name, names)) continue;
|
|
5658
|
+
report.warn(attribute.nameRange, `"${attribute.name}" only works inside a layout file`);
|
|
5659
|
+
}
|
|
5660
|
+
};
|
|
5661
|
+
}
|
|
5662
|
+
/** A layout tag points at a file named after it, and its data attributes point at a JSON file and hold a JSON object. */
|
|
5663
|
+
function checkLayoutTag(element, document, report, names) {
|
|
5664
|
+
const source = element.attribute(names.source);
|
|
5665
|
+
const dataSource = element.attribute(names.dataSource);
|
|
5666
|
+
const dataJson = element.attribute(names.dataJson);
|
|
5667
|
+
if (!source?.value) report.error(element, `<${element.name}> needs a "${names.source}" pointing at a .${element.name}.html file`);
|
|
5668
|
+
if (source) {
|
|
5669
|
+
checkFileExists(source, document, report);
|
|
5670
|
+
checkFileSuffix(source, report);
|
|
5671
|
+
}
|
|
5672
|
+
if (dataSource) checkFileExists(dataSource, document, report);
|
|
5673
|
+
if (dataJson) checkJsonObject(dataJson, report);
|
|
5674
|
+
}
|
|
5675
|
+
/** A tag inlines files named after it, so `<part>` takes a `.part.html` file. */
|
|
5676
|
+
function checkFileSuffix(source, report) {
|
|
5677
|
+
if (!source.value) return;
|
|
5678
|
+
if (isDynamic(source.value)) return;
|
|
5679
|
+
const suffix = `.${source.element.name}.html`;
|
|
5680
|
+
if (source.value.endsWith(suffix)) return;
|
|
5681
|
+
report.warn(source, `<${source.element.name}> takes a file ending in "${suffix}"`);
|
|
5682
|
+
}
|
|
5683
|
+
|
|
5235
5684
|
//#endregion
|
|
5236
5685
|
//#region src/plugins/html-layout/html-layout-plugin.ts
|
|
5237
5686
|
/**
|
|
@@ -5260,13 +5709,12 @@ function executeExpression$1(expressionString, data) {
|
|
|
5260
5709
|
* - In production an instance is rendered once per file and attribute set, then cloned for the identical ones.
|
|
5261
5710
|
*/
|
|
5262
5711
|
function htmlLayoutPlugin(options = {}) {
|
|
5263
|
-
const
|
|
5264
|
-
const
|
|
5265
|
-
const
|
|
5266
|
-
const
|
|
5267
|
-
const
|
|
5268
|
-
const
|
|
5269
|
-
const removeAttributeAttribute = options.removeAttributeAttribute ?? "remove-attr";
|
|
5712
|
+
const names = resolveLayoutNames(options);
|
|
5713
|
+
const layoutTags = names.tags;
|
|
5714
|
+
const sourceAttribute = names.source;
|
|
5715
|
+
const dataSourceAttribute = names.dataSource;
|
|
5716
|
+
const dataJsonAttribute = names.dataJson;
|
|
5717
|
+
const alwaysIncludeAttribute = names.alwaysInclude;
|
|
5270
5718
|
const query = layoutTags.join(",");
|
|
5271
5719
|
const printFmtError = PrintFormattedError.create({ function: htmlLayoutPlugin });
|
|
5272
5720
|
const htmlLayoutMetadataCache = [];
|
|
@@ -5276,8 +5724,7 @@ function htmlLayoutPlugin(options = {}) {
|
|
|
5276
5724
|
*/
|
|
5277
5725
|
const dataSourceCache = /* @__PURE__ */ new Map();
|
|
5278
5726
|
function isLayoutPath(path) {
|
|
5279
|
-
|
|
5280
|
-
return layoutTags.some((tag) => fileName.endsWith(`.${tag}.html`));
|
|
5727
|
+
return isLayoutFile(path, names);
|
|
5281
5728
|
}
|
|
5282
5729
|
return {
|
|
5283
5730
|
name: "html-layout",
|
|
@@ -5432,7 +5879,7 @@ function htmlLayoutPlugin(options = {}) {
|
|
|
5432
5879
|
continue;
|
|
5433
5880
|
}
|
|
5434
5881
|
let layoutHtmlMetadata;
|
|
5435
|
-
const hash =
|
|
5882
|
+
const hash = instanceKey(node, names);
|
|
5436
5883
|
if (this.production) {
|
|
5437
5884
|
const cachedMetadata = htmlLayoutMetadataCache.find((metadata) => metadata.id === layoutRelativePath && metadata.hash === hash);
|
|
5438
5885
|
if (cachedMetadata) layoutHtmlMetadata = this.clone(cachedMetadata);
|
|
@@ -5453,25 +5900,7 @@ function htmlLayoutPlugin(options = {}) {
|
|
|
5453
5900
|
const metadataList = await this.resolveAndLoad(layoutMetadata);
|
|
5454
5901
|
this.addMetadata(metadataList.slice(1));
|
|
5455
5902
|
await this.transformAndSync(metadataList);
|
|
5456
|
-
|
|
5457
|
-
const removeAttributePrefix = `${removeAttributeAttribute}-`;
|
|
5458
|
-
const elements = layoutMetadata.ast.querySelectorAll("*");
|
|
5459
|
-
for (const element of elements) {
|
|
5460
|
-
if (!element.attributes) continue;
|
|
5461
|
-
for (const [name, value] of Object.entries(element.attributes)) {
|
|
5462
|
-
if (name.startsWith(addAttributePrefix)) {
|
|
5463
|
-
const targetAttribute = name.slice(addAttributePrefix.length);
|
|
5464
|
-
element.removeAttribute(name);
|
|
5465
|
-
if (value.trim() && value.trim() !== "false") element.setAttribute(targetAttribute, "");
|
|
5466
|
-
continue;
|
|
5467
|
-
}
|
|
5468
|
-
if (name.startsWith(removeAttributePrefix)) {
|
|
5469
|
-
const targetAttribute = name.slice(removeAttributePrefix.length);
|
|
5470
|
-
element.removeAttribute(name);
|
|
5471
|
-
if (value.trim() && value.trim() !== "false") element.removeAttribute(targetAttribute);
|
|
5472
|
-
}
|
|
5473
|
-
}
|
|
5474
|
-
}
|
|
5903
|
+
applyConditionalAttributes(layoutMetadata, names);
|
|
5475
5904
|
if (this.production) {
|
|
5476
5905
|
layoutMetadata.hash = hash;
|
|
5477
5906
|
htmlLayoutMetadataCache.push(layoutMetadata);
|
|
@@ -5486,67 +5915,10 @@ function htmlLayoutPlugin(options = {}) {
|
|
|
5486
5915
|
skip();
|
|
5487
5916
|
continue;
|
|
5488
5917
|
}
|
|
5489
|
-
|
|
5490
|
-
for (const scriptOrStyle of scriptsAndStyles) {
|
|
5491
|
-
const id = scriptOrStyle.getAttribute(CUSTOM_ATTRIBUTES.MetadataID);
|
|
5492
|
-
if (!id) continue;
|
|
5493
|
-
if (scriptOrStyle.hasAttribute(alwaysIncludeAttribute)) {
|
|
5494
|
-
scriptOrStyle.removeAttribute(alwaysIncludeAttribute);
|
|
5495
|
-
continue;
|
|
5496
|
-
}
|
|
5497
|
-
const isScript = scriptOrStyle.tagName === "SCRIPT";
|
|
5498
|
-
if (metadata[isScript ? "scriptsMetadataList" : "stylesMetadataList"].get(id)) {
|
|
5499
|
-
layoutHtmlMetadata[isScript ? "scriptsMetadataList" : "stylesMetadataList"].delete(id);
|
|
5500
|
-
scriptOrStyle.remove();
|
|
5501
|
-
}
|
|
5502
|
-
}
|
|
5503
|
-
const scriptsAndLinks = layoutHtmlMetadata.ast.querySelectorAll("script[src], link[rel=\"stylesheet\"]");
|
|
5504
|
-
for (const scriptOrLink of scriptsAndLinks) {
|
|
5505
|
-
if (scriptOrLink.hasAttribute(alwaysIncludeAttribute)) {
|
|
5506
|
-
scriptOrLink.removeAttribute(alwaysIncludeAttribute);
|
|
5507
|
-
continue;
|
|
5508
|
-
}
|
|
5509
|
-
const isScript = scriptOrLink.tagName === "SCRIPT";
|
|
5510
|
-
const attribute = isScript ? "src" : "href";
|
|
5511
|
-
const layoutSourceRelative = join(dirname(layoutHtmlMetadata.filePath), scriptOrLink.attributes[attribute]);
|
|
5512
|
-
const selector = isScript ? "script[src]" : "link[rel=\"stylesheet\"]";
|
|
5513
|
-
if (metadata.ast.querySelectorAll(selector).some((element) => {
|
|
5514
|
-
return join(dirname(metadata.filePath), element.attributes[attribute]) === layoutSourceRelative;
|
|
5515
|
-
})) scriptOrLink.remove();
|
|
5516
|
-
}
|
|
5918
|
+
dropRepeatedAssets(metadata, layoutHtmlMetadata, alwaysIncludeAttribute);
|
|
5517
5919
|
for (const dependency of layoutHtmlMetadata.directDependencies) metadata.directDependencies.add(dependency);
|
|
5518
5920
|
await this.rebase(layoutHtmlMetadata, join(this.root, metadata.filePath));
|
|
5519
|
-
|
|
5520
|
-
const fillSlot = (slot, html) => {
|
|
5521
|
-
if (!filledSlots.has(slot)) {
|
|
5522
|
-
filledSlots.add(slot);
|
|
5523
|
-
slot.innerHTML = "";
|
|
5524
|
-
}
|
|
5525
|
-
slot.insertAdjacentHTML("beforeend", html);
|
|
5526
|
-
};
|
|
5527
|
-
const children = node.querySelectorAll(":scope > *");
|
|
5528
|
-
for (const element of children) {
|
|
5529
|
-
if (element.nodeType !== NodeType.ELEMENT_NODE) continue;
|
|
5530
|
-
if (!element.hasAttribute("slot")) {
|
|
5531
|
-
const defaultSlots = layoutHtmlMetadata.ast.querySelectorAll(`slot:not([name])`);
|
|
5532
|
-
if (defaultSlots.length === 0) {
|
|
5533
|
-
printFmtError(`No default slot in layout file:`, layoutRelativePath, { node: element });
|
|
5534
|
-
continue;
|
|
5535
|
-
}
|
|
5536
|
-
for (const defaultSlot of defaultSlots) fillSlot(defaultSlot, element.outerHTML);
|
|
5537
|
-
continue;
|
|
5538
|
-
}
|
|
5539
|
-
const slotName = element.getAttribute("slot");
|
|
5540
|
-
if (!slotName) continue;
|
|
5541
|
-
const namedSlots = layoutHtmlMetadata.ast.querySelectorAll(`slot[name="${slotName}"]`);
|
|
5542
|
-
if (namedSlots.length === 0) {
|
|
5543
|
-
printFmtError(`No slots with name "${slotName}" in layout file:`, layoutRelativePath, { node: element });
|
|
5544
|
-
continue;
|
|
5545
|
-
}
|
|
5546
|
-
element.removeAttribute("slot");
|
|
5547
|
-
for (const namedSlot of namedSlots) fillSlot(namedSlot, element.outerHTML);
|
|
5548
|
-
}
|
|
5549
|
-
for (const slot of layoutHtmlMetadata.ast.querySelectorAll("slot")) slot.replaceWith(slot.innerHTML.trim());
|
|
5921
|
+
fillSlots(node, layoutHtmlMetadata, layoutRelativePath, printFmtError);
|
|
5550
5922
|
mergeMaps(metadata.scriptsMetadataList, layoutHtmlMetadata.scriptsMetadataList);
|
|
5551
5923
|
mergeMaps(metadata.stylesMetadataList, layoutHtmlMetadata.stylesMetadataList);
|
|
5552
5924
|
node.replaceWith(layoutHtmlMetadata.ast.root);
|
|
@@ -5554,86 +5926,17 @@ function htmlLayoutPlugin(options = {}) {
|
|
|
5554
5926
|
}
|
|
5555
5927
|
return;
|
|
5556
5928
|
}
|
|
5557
|
-
if (isMarkdownMetadata(metadata))
|
|
5558
|
-
const tag = Object.keys(metadata.ast.frontmatter).find((key) => layoutTags.includes(key));
|
|
5559
|
-
if (!tag) return;
|
|
5560
|
-
let attributesString = "";
|
|
5561
|
-
for (const [key, value] of Object.entries(metadata.ast.frontmatter)) attributesString += `${key === tag ? sourceAttribute : key}="${value}" `;
|
|
5562
|
-
const openTag = `<${tag} ${attributesString}>`;
|
|
5563
|
-
const closeTag = `</${tag}>`;
|
|
5564
|
-
const oldRender = metadata.ast.render;
|
|
5565
|
-
metadata.ast.render = async () => {
|
|
5566
|
-
const html = await oldRender();
|
|
5567
|
-
return openTag + html + closeTag;
|
|
5568
|
-
};
|
|
5569
|
-
return;
|
|
5570
|
-
}
|
|
5929
|
+
if (isMarkdownMetadata(metadata)) wrapMarkdownInLayout(metadata, names);
|
|
5571
5930
|
},
|
|
5572
5931
|
lspHtmlData() {
|
|
5573
|
-
|
|
5574
|
-
|
|
5575
|
-
|
|
5576
|
-
|
|
5577
|
-
|
|
5578
|
-
|
|
5579
|
-
name: dataSourceAttribute,
|
|
5580
|
-
description: "The path for a JSON file containing data for the layout."
|
|
5581
|
-
},
|
|
5582
|
-
{
|
|
5583
|
-
name: dataJsonAttribute,
|
|
5584
|
-
description: "The layout data in JSON format."
|
|
5585
|
-
}
|
|
5586
|
-
];
|
|
5587
|
-
const alwaysInclude = {
|
|
5588
|
-
name: alwaysIncludeAttribute,
|
|
5589
|
-
description: "When using a layout more than once on the same page. Always include this script/style/link[rel='stylesheet'] even if it is already present.",
|
|
5590
|
-
valueSet: "v"
|
|
5591
|
-
};
|
|
5592
|
-
const alwaysIncludeDescription = `Adds the \`${alwaysIncludeAttribute}\` attribute, which gives this tag one copy per layout instance instead of the single copy a layout used more than once on the same page emits.`;
|
|
5593
|
-
const tags = [
|
|
5594
|
-
{
|
|
5595
|
-
name: "script",
|
|
5596
|
-
description: alwaysIncludeDescription,
|
|
5597
|
-
attributes: [alwaysInclude]
|
|
5598
|
-
},
|
|
5599
|
-
{
|
|
5600
|
-
name: "link",
|
|
5601
|
-
description: alwaysIncludeDescription,
|
|
5602
|
-
attributes: [alwaysInclude]
|
|
5603
|
-
},
|
|
5604
|
-
{
|
|
5605
|
-
name: "style",
|
|
5606
|
-
description: alwaysIncludeDescription,
|
|
5607
|
-
attributes: [alwaysInclude]
|
|
5608
|
-
}
|
|
5609
|
-
];
|
|
5610
|
-
for (const tag of layoutTags) tags.push({
|
|
5611
|
-
name: tag,
|
|
5612
|
-
description: `This tag allows inlining a \`${tag}\` file.`,
|
|
5613
|
-
attributes
|
|
5614
|
-
});
|
|
5615
|
-
return {
|
|
5616
|
-
version: 1.1,
|
|
5617
|
-
tags,
|
|
5618
|
-
globalAttributes: [{
|
|
5619
|
-
name: addAttributeAttribute,
|
|
5620
|
-
description: "Inside a layout file only. Prefix for conditionally adding boolean attributes. Append the target attribute name as a suffix — `add-attr-open`, `add-attr-disabled`, etc. The value is a JS expression evaluated after placeholder resolution; if it is non-empty and not `\"false\"`, the named attribute is added to the element. Multiple `add-attr-*` attributes can appear on the same element."
|
|
5621
|
-
}, {
|
|
5622
|
-
name: removeAttributeAttribute,
|
|
5623
|
-
description: "Inside a layout file only. Prefix for conditionally removing boolean attributes. Append the target attribute name as a suffix — `remove-attr-open`, `remove-attr-disabled`, etc. The value is a JS expression evaluated after placeholder resolution; if it is non-empty and not `\"false\"`, the named attribute is removed from the element. Multiple `remove-attr-*` attributes can appear on the same element."
|
|
5624
|
-
}]
|
|
5625
|
-
};
|
|
5626
|
-
}
|
|
5932
|
+
return htmlData$1(names);
|
|
5933
|
+
},
|
|
5934
|
+
lspEmbeddedLanguages() {
|
|
5935
|
+
return embeddedLanguages$1(names);
|
|
5936
|
+
},
|
|
5937
|
+
lspValidate: validate(names)
|
|
5627
5938
|
};
|
|
5628
5939
|
}
|
|
5629
|
-
function calculateHash(node, sourceAttribute) {
|
|
5630
|
-
let hash = "";
|
|
5631
|
-
for (const [attribute, value] of Object.entries(node.attributes)) {
|
|
5632
|
-
if (attribute === sourceAttribute) continue;
|
|
5633
|
-
hash += `${attribute}=${value}`;
|
|
5634
|
-
}
|
|
5635
|
-
return hash;
|
|
5636
|
-
}
|
|
5637
5940
|
function isHtmlLayoutMetadata(metadata) {
|
|
5638
5941
|
return isHtmlMetadata(metadata);
|
|
5639
5942
|
}
|
|
@@ -5789,6 +6092,23 @@ function htmlMarkdownPlugin(options = {}) {
|
|
|
5789
6092
|
}]
|
|
5790
6093
|
}]
|
|
5791
6094
|
};
|
|
6095
|
+
},
|
|
6096
|
+
lspValidate(document, report) {
|
|
6097
|
+
const markdowns = document.select(markdownTag);
|
|
6098
|
+
for (const element of markdowns) {
|
|
6099
|
+
const source = element.attribute(sourceAttribute);
|
|
6100
|
+
if (!source?.value) {
|
|
6101
|
+
report.error(element, `<${markdownTag}> needs a "${sourceAttribute}" pointing at a markdown file`);
|
|
6102
|
+
continue;
|
|
6103
|
+
}
|
|
6104
|
+
checkFileExists(source, document, report);
|
|
6105
|
+
}
|
|
6106
|
+
for (const element of document.elements) {
|
|
6107
|
+
const markdown = element.attribute(mdAttribute);
|
|
6108
|
+
if (!markdown) continue;
|
|
6109
|
+
if (!isEmptyElement(element, document)) continue;
|
|
6110
|
+
report.warn(markdown.nameRange, `There is no markdown in this <${element.name}> to render`);
|
|
6111
|
+
}
|
|
5792
6112
|
}
|
|
5793
6113
|
};
|
|
5794
6114
|
}
|
|
@@ -6569,9 +6889,30 @@ You can narrow down the files to preload by specifying a space-separated list of
|
|
|
6569
6889
|
attributes
|
|
6570
6890
|
}]
|
|
6571
6891
|
};
|
|
6892
|
+
},
|
|
6893
|
+
lspValidate(document, report) {
|
|
6894
|
+
for (const element of document.elements) {
|
|
6895
|
+
const preload = element.attribute(preloadAttribute);
|
|
6896
|
+
if (!preload) continue;
|
|
6897
|
+
if (!isPreloadable(element)) {
|
|
6898
|
+
report.warn(preload.nameRange, `"${preloadAttribute}" only works on a script, a style or a stylesheet link`);
|
|
6899
|
+
continue;
|
|
6900
|
+
}
|
|
6901
|
+
if (!preload.value) continue;
|
|
6902
|
+
if (isDynamic(preload.value)) continue;
|
|
6903
|
+
const unknownTypes = preload.value.split(" ").filter((type) => type && !asTypes.includes(type));
|
|
6904
|
+
if (unknownTypes.length === 0) continue;
|
|
6905
|
+
report.error(preload, `Unknown preload types ${unknownTypes.join(", ")}, use ${asTypes.join(", ")}`);
|
|
6906
|
+
}
|
|
6572
6907
|
}
|
|
6573
6908
|
};
|
|
6574
6909
|
}
|
|
6910
|
+
/** Whether the preload attribute means something on an element: a JavaScript script, a style or a stylesheet link. */
|
|
6911
|
+
function isPreloadable(element) {
|
|
6912
|
+
if (element.name === "script") return isJavaScript(element);
|
|
6913
|
+
if (element.name === "style") return true;
|
|
6914
|
+
return element.name === "link" && element.attribute("rel")?.value === "stylesheet";
|
|
6915
|
+
}
|
|
6575
6916
|
function getMetadata(metadataList, htmlMetadata, node) {
|
|
6576
6917
|
const isScriptTag = node.tagName === "SCRIPT";
|
|
6577
6918
|
const isStyleTag = node.tagName === "STYLE";
|
|
@@ -6734,6 +7075,13 @@ async function htmlLoader(relativePath, code) {
|
|
|
6734
7075
|
const scriptsMetadataList = /* @__PURE__ */ new Map();
|
|
6735
7076
|
const scripts = rootElement.querySelectorAll("script");
|
|
6736
7077
|
for (const script of scripts) {
|
|
7078
|
+
if (script.getAttribute("type")?.toLowerCase() === "application/x-typescript") {
|
|
7079
|
+
script.removeAttribute("type");
|
|
7080
|
+
if (script.hasAttribute("module")) {
|
|
7081
|
+
script.removeAttribute("module");
|
|
7082
|
+
script.setAttribute("type", "module");
|
|
7083
|
+
}
|
|
7084
|
+
}
|
|
6737
7085
|
if (script.hasAttribute("src")) continue;
|
|
6738
7086
|
const scriptType = script.getAttribute("type");
|
|
6739
7087
|
if (!isScriptType(scriptType)) continue;
|
|
@@ -6744,8 +7092,9 @@ async function htmlLoader(relativePath, code) {
|
|
|
6744
7092
|
type: "js"
|
|
6745
7093
|
});
|
|
6746
7094
|
if (!isScriptMetadata(scriptMetadata)) return [null, /* @__PURE__ */ new Error("Script loader returned non-script metadata")];
|
|
6747
|
-
|
|
6748
|
-
|
|
7095
|
+
const isTypeModule = script.getAttribute("type") === "module";
|
|
7096
|
+
scriptMetadata.module = isTypeModule;
|
|
7097
|
+
const astID = hashContent((isTypeModule ? "module:" : "script:") + scriptCode);
|
|
6749
7098
|
script.setAttribute(CUSTOM_ATTRIBUTES.MetadataID, astID);
|
|
6750
7099
|
scriptsMetadataList.set(astID, scriptMetadata);
|
|
6751
7100
|
}
|
|
@@ -6774,6 +7123,46 @@ async function htmlLoader(relativePath, code) {
|
|
|
6774
7123
|
}, null];
|
|
6775
7124
|
}
|
|
6776
7125
|
|
|
7126
|
+
//#endregion
|
|
7127
|
+
//#region src/plugins/core-plugins/html-metadata/lsp.ts
|
|
7128
|
+
/**
|
|
7129
|
+
* The bodies of the inline scripts marked `type="${TYPESCRIPT_TYPE}"`, served as TypeScript through the project's own TypeScript,
|
|
7130
|
+
* so they see the browser the way the project's tsconfig describes it. A plugin that runs a script elsewhere claims it over
|
|
7131
|
+
* this.
|
|
7132
|
+
*/
|
|
7133
|
+
function embeddedLanguages() {
|
|
7134
|
+
return [{
|
|
7135
|
+
name: "scripts",
|
|
7136
|
+
filter: () => true,
|
|
7137
|
+
findRegions: (document) => document.select("script").flatMap((script) => {
|
|
7138
|
+
if (!isTypeScriptScript(script) || script.has("src") || !script.contentRange) return [];
|
|
7139
|
+
return {
|
|
7140
|
+
...script.contentRange,
|
|
7141
|
+
isModule: script.has("module")
|
|
7142
|
+
};
|
|
7143
|
+
}),
|
|
7144
|
+
isColouredByEditor: true
|
|
7145
|
+
}];
|
|
7146
|
+
}
|
|
7147
|
+
/** The attributes the plugin adds to `<script>`, for completion and hover. */
|
|
7148
|
+
function htmlData() {
|
|
7149
|
+
return {
|
|
7150
|
+
version: 1.1,
|
|
7151
|
+
tags: [{
|
|
7152
|
+
name: "script",
|
|
7153
|
+
attributes: [{
|
|
7154
|
+
name: "type",
|
|
7155
|
+
description: `\`${TYPESCRIPT_TYPE}\` marks the script as TypeScript for the editor: Prettier formats it as such, the staticbolt language server serves it through the project's TypeScript, and the build drops the type.`,
|
|
7156
|
+
values: [{ name: TYPESCRIPT_TYPE }]
|
|
7157
|
+
}, {
|
|
7158
|
+
name: "module",
|
|
7159
|
+
description: `With \`type="${TYPESCRIPT_TYPE}"\`, whose type slot is taken: the build prints the script as \`type="module"\`.`,
|
|
7160
|
+
valueSet: "v"
|
|
7161
|
+
}]
|
|
7162
|
+
}]
|
|
7163
|
+
};
|
|
7164
|
+
}
|
|
7165
|
+
|
|
6777
7166
|
//#endregion
|
|
6778
7167
|
//#region src/plugins/core-plugins/html-metadata/html-metadata-plugin.ts
|
|
6779
7168
|
/**
|
|
@@ -6898,6 +7287,12 @@ function coreHtmlPlugin(options = {}) {
|
|
|
6898
7287
|
metadata.filePath = relative(this.root, newAbsolutePath);
|
|
6899
7288
|
for (const [, scriptMetadata] of metadata.scriptsMetadataList) scriptMetadata.filePath = metadata.filePath;
|
|
6900
7289
|
for (const [, cssMetadata] of metadata.stylesMetadataList) cssMetadata.filePath = metadata.filePath;
|
|
7290
|
+
},
|
|
7291
|
+
lspEmbeddedLanguages() {
|
|
7292
|
+
return embeddedLanguages();
|
|
7293
|
+
},
|
|
7294
|
+
lspHtmlData() {
|
|
7295
|
+
return htmlData();
|
|
6901
7296
|
}
|
|
6902
7297
|
};
|
|
6903
7298
|
}
|
|
@@ -7161,7 +7556,7 @@ function scriptLoader(relativePath, code) {
|
|
|
7161
7556
|
filePath: relativePath,
|
|
7162
7557
|
id: relativePath,
|
|
7163
7558
|
directDependencies: /* @__PURE__ */ new Set(),
|
|
7164
|
-
module:
|
|
7559
|
+
module: ast.program.sourceType === "module"
|
|
7165
7560
|
}, null];
|
|
7166
7561
|
}
|
|
7167
7562
|
|
|
@@ -7568,6 +7963,104 @@ function coreWebManifestPlugin() {
|
|
|
7568
7963
|
};
|
|
7569
7964
|
}
|
|
7570
7965
|
|
|
7966
|
+
//#endregion
|
|
7967
|
+
//#region src/plugins/core-plugins/development-server/claim-port.ts
|
|
7968
|
+
const PROJECT_HEADER = "x-staticbolt-project";
|
|
7969
|
+
const PID_HEADER = "x-staticbolt-pid";
|
|
7970
|
+
const FREE_PORT_SEARCH = 20;
|
|
7971
|
+
const KILL_TIMEOUT_MS = 3e3;
|
|
7972
|
+
/**
|
|
7973
|
+
* The port to listen on: `port` when it is free, otherwise what the user picks. Another dev server of ours can be killed to take
|
|
7974
|
+
* its port over, any holder can be sidestepped with the next free port, and declining, or having no terminal to ask on, fails
|
|
7975
|
+
* with the wanted port's error.
|
|
7976
|
+
*/
|
|
7977
|
+
async function claimPort(port, host, project) {
|
|
7978
|
+
if (await isPortFree(port, host)) return port;
|
|
7979
|
+
const owner = await identifyOwner(port);
|
|
7980
|
+
const problem = describeOwner(owner, port, project);
|
|
7981
|
+
const choice = process.stdin.isTTY ? await ask(problem, await listChoices(owner, port, host)) : void 0;
|
|
7982
|
+
if (choice?.port === void 0) throw new Error(`${problem} Stop what is using it, or pick another with developmentServerPlugin({ port })`, { cause: owner });
|
|
7983
|
+
if (choice.killPid !== void 0) await kill(choice.killPid, port, host);
|
|
7984
|
+
return choice.port;
|
|
7985
|
+
}
|
|
7986
|
+
/** A `HEAD /` answered with the project header means a dev server of ours. Anything else, or no answer, is another application. */
|
|
7987
|
+
async function identifyOwner(port) {
|
|
7988
|
+
try {
|
|
7989
|
+
const response = await fetch(`http://127.0.0.1:${port}/`, {
|
|
7990
|
+
method: "HEAD",
|
|
7991
|
+
signal: AbortSignal.timeout(1e3)
|
|
7992
|
+
});
|
|
7993
|
+
const project = response.headers.get(PROJECT_HEADER);
|
|
7994
|
+
const pid = Number(response.headers.get(PID_HEADER));
|
|
7995
|
+
return project && pid ? {
|
|
7996
|
+
kind: "staticbolt",
|
|
7997
|
+
project,
|
|
7998
|
+
pid
|
|
7999
|
+
} : { kind: "other" };
|
|
8000
|
+
} catch {
|
|
8001
|
+
return { kind: "other" };
|
|
8002
|
+
}
|
|
8003
|
+
}
|
|
8004
|
+
function describeOwner(owner, port, project) {
|
|
8005
|
+
if (owner.kind === "other") return `Port ${port} is used by another application.`;
|
|
8006
|
+
if (owner.project === project) return `A dev server for this project is already running on port ${port}.`;
|
|
8007
|
+
return `Port ${port} is used by the dev server of "${owner.project}".`;
|
|
8008
|
+
}
|
|
8009
|
+
async function listChoices(owner, port, host) {
|
|
8010
|
+
const choices = [];
|
|
8011
|
+
if (owner.kind === "staticbolt") choices.push({
|
|
8012
|
+
label: `Kill it and use port ${port}`,
|
|
8013
|
+
port,
|
|
8014
|
+
killPid: owner.pid
|
|
8015
|
+
});
|
|
8016
|
+
const freePort = await findFreePort(port + 1, host);
|
|
8017
|
+
if (freePort !== void 0) choices.push({
|
|
8018
|
+
label: `Use port ${freePort}`,
|
|
8019
|
+
port: freePort
|
|
8020
|
+
});
|
|
8021
|
+
choices.push({ label: "Do nothing" });
|
|
8022
|
+
return choices;
|
|
8023
|
+
}
|
|
8024
|
+
async function kill(pid, port, host) {
|
|
8025
|
+
process.kill(pid);
|
|
8026
|
+
const deadline = Date.now() + KILL_TIMEOUT_MS;
|
|
8027
|
+
while (Date.now() < deadline) {
|
|
8028
|
+
if (await isPortFree(port, host)) return;
|
|
8029
|
+
await setTimeout$1(100);
|
|
8030
|
+
}
|
|
8031
|
+
throw new Error(`Port ${port} is still busy after stopping process ${pid}`);
|
|
8032
|
+
}
|
|
8033
|
+
async function findFreePort(from, host) {
|
|
8034
|
+
for (let port = from; port < from + FREE_PORT_SEARCH; port++) if (await isPortFree(port, host)) return port;
|
|
8035
|
+
}
|
|
8036
|
+
function isPortFree(port, host) {
|
|
8037
|
+
return new Promise((resolve) => {
|
|
8038
|
+
const server = createServer();
|
|
8039
|
+
server.once("error", () => resolve(false));
|
|
8040
|
+
server.listen(port, host, () => server.close(() => resolve(true)));
|
|
8041
|
+
});
|
|
8042
|
+
}
|
|
8043
|
+
/** Numbered choices in the terminal. An empty answer takes the first, a closed input the last. */
|
|
8044
|
+
async function ask(question, choices) {
|
|
8045
|
+
const readline = createInterface({
|
|
8046
|
+
input: process.stdin,
|
|
8047
|
+
output: process.stdout
|
|
8048
|
+
});
|
|
8049
|
+
try {
|
|
8050
|
+
Log.warn(question);
|
|
8051
|
+
for (const [index, choice] of choices.entries()) console.log(` ${chalk.bold(index + 1)}) ${choice.label}`);
|
|
8052
|
+
while (true) {
|
|
8053
|
+
const answer = await readline.question(chalk.bold(" Choice (1): "));
|
|
8054
|
+
const picked = choices[answer.trim() === "" ? 0 : Number(answer) - 1];
|
|
8055
|
+
if (picked) return picked;
|
|
8056
|
+
}
|
|
8057
|
+
} catch {
|
|
8058
|
+
return choices.at(-1);
|
|
8059
|
+
} finally {
|
|
8060
|
+
readline.close();
|
|
8061
|
+
}
|
|
8062
|
+
}
|
|
8063
|
+
|
|
7571
8064
|
//#endregion
|
|
7572
8065
|
//#region src/plugins/core-plugins/development-server/inject-reload-script.ts
|
|
7573
8066
|
const id = "inject-reload-script";
|
|
@@ -7725,6 +8218,7 @@ function orderByDependencyDepth(allMetadata, entryPoints, filePaths) {
|
|
|
7725
8218
|
|
|
7726
8219
|
//#endregion
|
|
7727
8220
|
//#region src/plugins/core-plugins/development-server/development-server-plugin.ts
|
|
8221
|
+
const DEVELOPMENT_SERVER_OVERRIDES = "development-server";
|
|
7728
8222
|
/**
|
|
7729
8223
|
* The dev server: serves the build from memory over Fastify, compiles pages on demand, and pushes updates to the browser over a
|
|
7730
8224
|
* web socket.
|
|
@@ -7743,7 +8237,6 @@ function orderByDependencyDepth(allMetadata, entryPoints, filePaths) {
|
|
|
7743
8237
|
* itself as soon as it reconnects.
|
|
7744
8238
|
*/
|
|
7745
8239
|
function developmentServerPlugin(options = {}) {
|
|
7746
|
-
const host = options.host ?? "0.0.0.0";
|
|
7747
8240
|
const port = options.port ?? 3e3;
|
|
7748
8241
|
const entry = options.entry ?? "/";
|
|
7749
8242
|
const publicDirectory = options.publicDirectory ?? "public";
|
|
@@ -7773,7 +8266,9 @@ function developmentServerPlugin(options = {}) {
|
|
|
7773
8266
|
if (this.production) return;
|
|
7774
8267
|
const websocketServer = new WebSocketServer({ noServer: true });
|
|
7775
8268
|
websocket = websocketServer;
|
|
8269
|
+
const projectName = basename(this.root);
|
|
7776
8270
|
const fastifyServe = async (request, reply) => {
|
|
8271
|
+
reply.header(PROJECT_HEADER, projectName).header(PID_HEADER, process.pid);
|
|
7777
8272
|
const requestPath = normalize("." + decodeURIComponent(request.urlData().path || "./"));
|
|
7778
8273
|
await this.handleRequest(requestPath, reply, request);
|
|
7779
8274
|
};
|
|
@@ -7790,11 +8285,14 @@ function developmentServerPlugin(options = {}) {
|
|
|
7790
8285
|
removeAndPruneOrphans(this.metadataList, id);
|
|
7791
8286
|
}
|
|
7792
8287
|
});
|
|
7793
|
-
const
|
|
7794
|
-
|
|
8288
|
+
const overrides = this.pluginData[DEVELOPMENT_SERVER_OVERRIDES];
|
|
8289
|
+
const host = resolveHost(overrides?.host ?? options.host);
|
|
8290
|
+
const listeningPort = await claimPort(port, host, projectName);
|
|
8291
|
+
await fastify.listen({
|
|
8292
|
+
port: listeningPort,
|
|
7795
8293
|
host
|
|
7796
8294
|
});
|
|
7797
|
-
this.log.info(
|
|
8295
|
+
this.log.info(describeAddresses(host, listeningPort, entry));
|
|
7798
8296
|
},
|
|
7799
8297
|
async teardown() {
|
|
7800
8298
|
for (const client of wsClients) client.terminate();
|
|
@@ -7867,6 +8365,19 @@ function developmentServerPlugin(options = {}) {
|
|
|
7867
8365
|
}
|
|
7868
8366
|
};
|
|
7869
8367
|
}
|
|
8368
|
+
/** The URLs the server answers on: localhost, plus every LAN address when it is bound to all interfaces. */
|
|
8369
|
+
function describeAddresses(host, port, entry) {
|
|
8370
|
+
const isEveryInterface = host === "0.0.0.0" || host === "::";
|
|
8371
|
+
const isLoopback = host === "127.0.0.1" || host === "::1";
|
|
8372
|
+
const url = (hostname) => chalk.green(`http://${hostname}:${port}${entry}`);
|
|
8373
|
+
const lines = [`${chalk.bold("Local:")} ${url(isEveryInterface || isLoopback ? "localhost" : host)}`];
|
|
8374
|
+
if (isEveryInterface) for (const address of lanAddresses()) lines.push(`${chalk.bold("Network:")} ${url(address)}`);
|
|
8375
|
+
else if (isLoopback) lines.push(`${chalk.bold("Network:")} ${chalk.dim("use --host to expose")}`);
|
|
8376
|
+
return "\n" + lines.join("\n");
|
|
8377
|
+
}
|
|
8378
|
+
function lanAddresses() {
|
|
8379
|
+
return Object.values(networkInterfaces()).flat().filter((address) => address?.family === "IPv4" && !address.internal).map((address) => address.address);
|
|
8380
|
+
}
|
|
7870
8381
|
function isDependency(allMetadata, metadata, filePath) {
|
|
7871
8382
|
if (metadata.directDependencies.has(filePath)) return true;
|
|
7872
8383
|
const metadataByPath = new Map(allMetadata.map((m) => [m.id, m]));
|
|
@@ -7909,6 +8420,12 @@ function removeAndPruneOrphans(metadataList, targetId) {
|
|
|
7909
8420
|
for (const dependencyId of dependencies) if (!metadataList.some((m) => m.directDependencies.has(dependencyId))) queue.push(dependencyId);
|
|
7910
8421
|
}
|
|
7911
8422
|
}
|
|
8423
|
+
/** Every address, a given one, or the IPv4 loopback: "localhost" itself can resolve to IPv6 only, Windows being the usual case. */
|
|
8424
|
+
function resolveHost(host) {
|
|
8425
|
+
if (host === true) return "0.0.0.0";
|
|
8426
|
+
if (typeof host === "string" && host !== "") return host;
|
|
8427
|
+
return "127.0.0.1";
|
|
8428
|
+
}
|
|
7912
8429
|
|
|
7913
8430
|
//#endregion
|
|
7914
8431
|
//#region src/helpers/pirnt-debug.ts
|
|
@@ -10205,9 +10722,14 @@ function serveCliPlugin(options = {}) {
|
|
|
10205
10722
|
const serveCommand = this.defineSubcommand({
|
|
10206
10723
|
name,
|
|
10207
10724
|
aliases,
|
|
10208
|
-
meta: { description: "Dev server for staticbolt." }
|
|
10725
|
+
meta: { description: "Dev server for staticbolt." },
|
|
10726
|
+
options: { host: {
|
|
10727
|
+
schema: z.boolean().optional(),
|
|
10728
|
+
coerce: this.coerce.boolean,
|
|
10729
|
+
meta: { description: "Listen on every address, so the site is reachable from other devices on the network." }
|
|
10730
|
+
} }
|
|
10209
10731
|
});
|
|
10210
|
-
serveCommand.onExecute(() => new DevelopmentRun(config, configPath, projectDirectory).start());
|
|
10732
|
+
serveCommand.onExecute(({ options }) => new DevelopmentRun(config, configPath, projectDirectory, { host: options.host }).start());
|
|
10211
10733
|
this.addCommand(serveCommand);
|
|
10212
10734
|
}
|
|
10213
10735
|
};
|
|
@@ -10219,30 +10741,44 @@ var DevelopmentRun = class {
|
|
|
10219
10741
|
#configPath;
|
|
10220
10742
|
#configName;
|
|
10221
10743
|
#projectDirectory;
|
|
10744
|
+
#overrides;
|
|
10222
10745
|
/** Restarts run one at a time; a change that lands during one waits its turn. */
|
|
10223
10746
|
#restartQueue = Promise.resolve();
|
|
10224
10747
|
/** The most recent change, so a queued restart can be skipped once a newer change is waiting behind it. */
|
|
10225
10748
|
#latestChange = 0;
|
|
10226
10749
|
/** When the config file was last written, as of the last time it was loaded. */
|
|
10227
10750
|
#configModified;
|
|
10228
|
-
constructor(config, configPath, projectDirectory) {
|
|
10751
|
+
constructor(config, configPath, projectDirectory, overrides) {
|
|
10229
10752
|
config.production = false;
|
|
10230
10753
|
this.#config = config;
|
|
10231
|
-
this.#app = new App(config, configPath);
|
|
10232
10754
|
this.#configPath = configPath;
|
|
10233
10755
|
this.#configName = basename(configPath);
|
|
10234
10756
|
this.#projectDirectory = projectDirectory;
|
|
10757
|
+
this.#overrides = overrides;
|
|
10758
|
+
this.#app = this.#createApp(config);
|
|
10235
10759
|
this.#configModified = this.#modifiedTime();
|
|
10236
10760
|
}
|
|
10761
|
+
#createApp(config) {
|
|
10762
|
+
const app = new App(config, this.#configPath);
|
|
10763
|
+
app.pluginData[DEVELOPMENT_SERVER_OVERRIDES] = this.#overrides;
|
|
10764
|
+
return app;
|
|
10765
|
+
}
|
|
10237
10766
|
async start() {
|
|
10238
|
-
chokidar.watch(this.#configPath, {
|
|
10767
|
+
const watcher = chokidar.watch(this.#configPath, {
|
|
10239
10768
|
ignoreInitial: true,
|
|
10240
10769
|
awaitWriteFinish: {
|
|
10241
10770
|
stabilityThreshold: 100,
|
|
10242
10771
|
pollInterval: 20
|
|
10243
10772
|
}
|
|
10244
|
-
})
|
|
10245
|
-
this.#
|
|
10773
|
+
});
|
|
10774
|
+
watcher.on("change", () => this.#queueRestart()).on("add", () => this.#queueRestart());
|
|
10775
|
+
this.#restartQueue = this.#app.run().catch(async (error) => {
|
|
10776
|
+
Log.error("Failed to start the dev server:");
|
|
10777
|
+
console.error(error instanceof Error && error.cause ? error.message : error);
|
|
10778
|
+
await watcher.close();
|
|
10779
|
+
await this.#app.close();
|
|
10780
|
+
process.exitCode = 1;
|
|
10781
|
+
});
|
|
10246
10782
|
await this.#restartQueue;
|
|
10247
10783
|
this.#restartIfModified();
|
|
10248
10784
|
}
|
|
@@ -10269,7 +10805,7 @@ var DevelopmentRun = class {
|
|
|
10269
10805
|
if (!nextConfig) return;
|
|
10270
10806
|
let nextApp;
|
|
10271
10807
|
try {
|
|
10272
|
-
nextApp =
|
|
10808
|
+
nextApp = this.#createApp(nextConfig);
|
|
10273
10809
|
} catch (error) {
|
|
10274
10810
|
Log.error("Failed to create the app, keeping the current server:");
|
|
10275
10811
|
console.error(error);
|