@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.
@@ -1,10 +1,10 @@
1
- import { A as isTextAssetMetadata, C as isBinaryAssetMetadata, D as isScriptMetadata, E as isPackageMetadata, F as readJsonFile, H as splitHtmlLink, L as safeReadFileSync, M as isScriptType, N as METADATA_TYPES, O as isStyleMetadata, P as Resolver, S as filterStyleMetadata, T as isMarkdownMetadata, U as DependencyTracker, V as isValidRelativePath, _ 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, s as cloneObject, w as isHtmlMetadata, x as filterScriptMetadata, y as PrintFormattedError, z as valueOrError } from "../utilities-D0KXIZ-B.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-DUFKS3lW.mjs";
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-D-FtbUws.mjs";
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 both the unused and the size report.
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 missingFiles = usedFiles.difference(availableFiles);
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.size > 0) this.log.error(missingFilesLog);
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
- const relativePath = relative(this.outdir, unused);
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), relativePath)));
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-env-only-plugin.ts
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 devOnlyAttribute = options.devOnlyAttribute ?? "dev-only";
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 devOnlyElements = metadata.ast.querySelectorAll(`[${devOnlyAttribute}]`);
4497
- for (const node of devOnlyElements) if (this.production) node.remove();
4498
- else node.removeAttribute(devOnlyAttribute);
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: devOnlyAttribute,
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
- scriptMetadataClone.module = scriptTag.getAttribute("type") === "module";
4727
- const metadataID = hashContent(await this.stringify(scriptMetadataClone));
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
- /** (any){{any}} */
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 layoutTags = options.tags ?? ["layout", "part"];
5264
- const sourceAttribute = options.layoutSourceAttribute ?? "src";
5265
- const dataSourceAttribute = options.dataSourceAttribute ?? "dataSource";
5266
- const dataJsonAttribute = options.dataJsonAttribute ?? "dataJson";
5267
- const alwaysIncludeAttribute = options.alwaysIncludeAttribute ?? "always-include";
5268
- const addAttributeAttribute = options.addAttributeAttribute ?? "add-attr";
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
- const fileName = basename(path);
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 = calculateHash(node, sourceAttribute);
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
- const addAttributePrefix = `${addAttributeAttribute}-`;
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
- const scriptsAndStyles = layoutHtmlMetadata.ast.querySelectorAll("script,style");
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
- const filledSlots = /* @__PURE__ */ new Set();
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
- const attributes = [
5574
- {
5575
- name: sourceAttribute,
5576
- description: "The path to the layout to use."
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
- scriptMetadata.module = script.getAttribute("type") === "module";
6748
- const astID = hashContent(scriptCode);
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: true
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 address = await fastify.listen({
7794
- port,
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(chalk.bold("Server listening on"), chalk.green(address + entry));
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
- }).on("change", () => this.#queueRestart()).on("add", () => this.#queueRestart());
10245
- this.#restartQueue = this.#app.run();
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 = new App(nextConfig, this.#configPath);
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);