blogger-hbs 0.0.4 → 0.0.6

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.
Files changed (74) hide show
  1. package/README.md +20 -126
  2. package/dist/cli/index.js +261 -36
  3. package/dist/cli/index.js.map +1 -1
  4. package/dist/cli/stats.d.ts +58 -0
  5. package/dist/cli/stats.js +156 -0
  6. package/dist/cli/stats.js.map +1 -0
  7. package/dist/compiler/chunks.d.ts +3 -1
  8. package/dist/compiler/compile.d.ts +1 -1
  9. package/dist/compiler/compile.js +3 -3
  10. package/dist/compiler/compile.js.map +1 -1
  11. package/dist/compiler/dependencies.js +2 -1
  12. package/dist/compiler/dependencies.js.map +1 -1
  13. package/dist/compiler/errors.d.ts +8 -1
  14. package/dist/compiler/errors.js +4 -0
  15. package/dist/compiler/errors.js.map +1 -1
  16. package/dist/compiler/plugins.js +44 -24
  17. package/dist/compiler/plugins.js.map +1 -1
  18. package/dist/compiler/rsbuild.d.ts +2 -2
  19. package/dist/compiler/rsbuild.js +8 -2
  20. package/dist/compiler/rsbuild.js.map +1 -1
  21. package/dist/compiler/scan.d.ts +5 -1
  22. package/dist/compiler/scan.js +28 -11
  23. package/dist/compiler/scan.js.map +1 -1
  24. package/dist/compiler/validate.d.ts +3 -2
  25. package/dist/compiler/validate.js +16 -7
  26. package/dist/compiler/validate.js.map +1 -1
  27. package/dist/compiler/watch.d.ts +6 -3
  28. package/dist/compiler/watch.js +8 -2
  29. package/dist/compiler/watch.js.map +1 -1
  30. package/dist/index.d.ts +4 -3
  31. package/dist/index.js +2 -2
  32. package/dist/plugins/index.d.ts +19 -4
  33. package/dist/plugins/index.js +25 -4
  34. package/dist/plugins/index.js.map +1 -1
  35. package/dist/runtime/helpers/args.d.ts +26 -0
  36. package/dist/runtime/helpers/args.js +73 -0
  37. package/dist/runtime/helpers/args.js.map +1 -0
  38. package/dist/runtime/helpers/error.d.ts +10 -4
  39. package/dist/runtime/helpers/error.js +21 -11
  40. package/dist/runtime/helpers/error.js.map +1 -1
  41. package/dist/runtime/helpers/functions.d.ts +99 -9
  42. package/dist/runtime/helpers/functions.js +446 -64
  43. package/dist/runtime/helpers/functions.js.map +1 -1
  44. package/dist/runtime/helpers/index.d.ts +2 -2
  45. package/dist/runtime/helpers/index.js +2 -2
  46. package/dist/runtime/import.js +11 -9
  47. package/dist/runtime/import.js.map +1 -1
  48. package/dist/runtime/index.d.ts +3 -2
  49. package/dist/runtime/index.js +2 -2
  50. package/dist/runtime/render.d.ts +1 -1
  51. package/dist/runtime/render.js +43 -9
  52. package/dist/runtime/render.js.map +1 -1
  53. package/dist/runtime/tags.js +7 -6
  54. package/dist/runtime/tags.js.map +1 -1
  55. package/dist/xml/expressions.js +48 -17
  56. package/dist/xml/expressions.js.map +1 -1
  57. package/dist/xml/format.d.ts +23 -16
  58. package/dist/xml/format.js +128 -144
  59. package/dist/xml/format.js.map +1 -1
  60. package/dist/xml/index.d.ts +3 -2
  61. package/dist/xml/index.js +2 -2
  62. package/dist/xml/info.d.ts +30 -0
  63. package/dist/xml/info.js +46 -0
  64. package/dist/xml/info.js.map +1 -0
  65. package/dist/xml/layout.d.ts +39 -0
  66. package/dist/xml/layout.js +245 -0
  67. package/dist/xml/layout.js.map +1 -0
  68. package/dist/xml/process.d.ts +40 -0
  69. package/dist/xml/process.js +176 -0
  70. package/dist/xml/process.js.map +1 -0
  71. package/dist/xml/scanner.d.ts +37 -1
  72. package/dist/xml/scanner.js +255 -97
  73. package/dist/xml/scanner.js.map +1 -1
  74. package/package.json +2 -2
package/README.md CHANGED
@@ -8,7 +8,8 @@ Build Blogger themes from Handlebars templates, with bundled scripts and styles.
8
8
  - Split a theme into templates with `{{import "./header.hbs"}}`, data with `{{import "./data.json"}}`.
9
9
  - Import scripts and styles, bundled with [Rsbuild](https://rsbuild.rs) and inlined: `{{import "./main.ts"}}`.
10
10
  - Every template is validated as XML, and the rendered theme is validated and formatted as Blogger saves it.
11
- - Compile plugins provide partials and data, runtime plugins provide helpers.
11
+ - Plugins provide partials, data and helpers, bundled into the theme.
12
+ - The compiled theme renders in any JavaScript runtime, a Cloudflare Worker too, with `blogger-hbs/runtime`.
12
13
 
13
14
  ## Usage
14
15
 
@@ -18,139 +19,32 @@ pnpm add -D blogger-hbs
18
19
 
19
20
  ```ts
20
21
  // blogger-hbs.config.ts
21
- import { defineConfig } from 'blogger-hbs';
22
+ import { defineConfig } from "blogger-hbs";
22
23
 
23
24
  export default defineConfig({
24
- entry: 'src/index.hbs',
25
- alias: { '~': 'src' },
26
- context: {},
25
+ entry: "src/index.hbs",
26
+ alias: { "~": "src" },
27
+ context: {},
27
28
  });
28
29
  ```
29
30
 
30
31
  ```sh
31
- blogger-hbs # build to dist/theme.xml
32
- blogger-hbs --watch # build again on changes
33
- blogger-hbs --help # all the options
32
+ blogger-hbs # build to dist/theme.xml
33
+ blogger-hbs --watch # build again on changes
34
+ blogger-hbs validate theme.xml # validate a theme
35
+ blogger-hbs format theme.xml -o out.xml # format it
36
+ blogger-hbs --help # all the options
34
37
  ```
35
38
 
36
39
  The same is available from JavaScript with `compileTemplate`, `watchTemplate` and `renderTemplate`.
37
40
 
38
- ## Rendering anywhere
41
+ ## Documentation
39
42
 
40
- The build writes `dist/compiled.js`, an ES module that imports nothing: the templates, scripts, styles and the runtimes of the plugins are all in it. `blogger-hbs/runtime` renders it in any JavaScript runtime (Node.js, Deno, Bun, Cloudflare Workers, a browser) without the compiler or Node.js.
41
-
42
- ```ts
43
- // a Cloudflare Worker
44
- import { createRenderer } from 'blogger-hbs/runtime';
45
- import * as theme from './dist/compiled.js';
46
-
47
- // set up once, render many times, each render on its own
48
- const renderer = createRenderer(theme);
49
-
50
- export default {
51
- async fetch() {
52
- const html = await renderer.render({ title: 'Blog' });
53
- return new Response(html, { headers: { 'content-type': 'text/html' } });
54
- },
55
- };
56
- ```
57
-
58
- `createRenderer(theme, { plugins })` takes runtime plugins of the host rendering the theme, for example with data of the request in `prepare`. The helpers of the theme itself are in `compiled.js`, see [Helpers of the theme](#helpers-of-the-theme). `formatXml` and `validateXml` are in `blogger-hbs/runtime` as well. A compiled module is rendered by the version of `blogger-hbs` it is compiled with, `createRenderer` checks it.
59
-
60
- ## Helpers of the theme
61
-
62
- Helpers are given by the runtime of a plugin, which is bundled into `compiled.js`: the theme renders with them wherever it is rendered. A plugin of the theme itself only needs a name and its runtime.
63
-
64
- ```ts
65
- // blogger-hbs.config.ts
66
- export default defineConfig({
67
- plugins: [{ name: 'theme', runtime: { entry: './src/runtime.ts' } }],
68
- });
69
- ```
70
-
71
- ```ts
72
- // src/runtime.ts
73
- import { defineRuntimePlugin } from 'blogger-hbs/runtime';
74
-
75
- export default defineRuntimePlugin((api) => ({
76
- helpers: {
77
- upper: (text: string) => text.toUpperCase(),
78
- },
79
- }));
80
- ```
81
-
82
- ## Plugins
83
-
84
- A compile plugin provides partials (`{{> name.partial}}`) and data (`{{@name.value}}`) when compiling, and can come with a runtime half for rendering.
85
-
86
- ```ts
87
- import { fileURLToPath } from 'node:url';
88
- import type { CompilePlugin } from 'blogger-hbs';
89
-
90
- export function myPlugin(options: { prefix: string }): CompilePlugin {
91
- return {
92
- name: 'my',
93
- provide({ requests }, plugin) {
94
- // every partial used, with where and how: `{{> my.badge size=16}}`
95
- for (const { name, usages } of requests) {
96
- for (const usage of usages) {
97
- if ('color' in usage.hash) {
98
- plugin.error(`'color' is not a prop of ${name}`, usage);
99
- }
100
- }
101
- }
102
- return Object.fromEntries(
103
- requests.map(({ name }) => [name, `<span class='${name}'/>`]),
104
- );
105
- },
106
- // bundled into compiled.js, it can not use Node.js modules
107
- runtime: {
108
- entry: fileURLToPath(new URL('./runtime.js', import.meta.url)),
109
- options: { prefix: options.prefix },
110
- },
111
- };
112
- }
113
- ```
114
-
115
- ```ts
116
- // runtime.ts, published as runtime.js
117
- import { defineRuntimePlugin } from 'blogger-hbs/runtime';
118
-
119
- export default defineRuntimePlugin((api, options: { prefix: string }) => ({
120
- helpers: {
121
- greet: (name: string) =>
122
- new api.SafeString(`${options.prefix} ${api.escapeExpression(name)}`),
123
- },
124
- // before every render, can be async, read as `{{@my.posts}}`
125
- async prepare(context) {
126
- return { posts: [] };
127
- },
128
- // after every render
129
- transform: (html) => html,
130
- }));
131
- ```
132
-
133
- | Hook | When |
134
- | --- | --- |
135
- | `buildStart(plugin)` | first, in every compilation, also each time when watching |
136
- | `preprocess(templates, plugin)` | with every template, their ASTs can be changed |
137
- | `provide({ partials, requests, templates }, plugin)` | with the partials used, again for those the provided ones use |
138
- | `finalize({ provided, missing, requests, available }, plugin)` | once, for partials that need all the others |
139
- | `data({ provided, available, templates }, plugin)` | once, the values are JSON |
140
- | `buildEnd(plugin, error?)` | last, when the templates are compiled |
141
-
142
- ### Partials of a plugin
143
-
144
- A plugin `my` provides its main partial and any names separated by single dots:
145
-
146
- | In a template | Key provided |
147
- | --- | --- |
148
- | `{{> my}}`, `{{#> my}}...{{/my}}` | `.` |
149
- | `{{> my.badge}}` | `badge` |
150
- | `{{> my.badge.small}}` | `badge.small` |
151
-
152
- A name is checked as it is written, `{{> my.}}`, `{{> my..badge}}`, `{{> my.badge.}}` and `{{> my/badge}}` are errors where they are, and a theme can not define an inline partial with a name of a plugin, `{{#*inline "my.badge"}}`. Partials of no plugin are left to Handlebars.
153
-
154
- Every hook gets the plugin context: `root`, `entry`, `mode`, `watch`, `resolve(specifier, importer?)` with the aliases, `addWatchFile(file)`, `warn(message, loc?)` and `error(message, loc?)`. `enforce: 'pre'` or `'post'` runs a plugin before or after the others.
155
-
156
- The runtime halves of all the plugins are bundled together, a module they share (a dependency of two plugins) is in `compiled.js` once and is the same instance for both. The runtime half is set up with `api` (`SafeString`, `escapeExpression`, `createFrame`, `HelperError`) and the options, which are JSON. Its helpers also work with `renderTemplate`, `prepare` and `transform` need `createRenderer`.
43
+ - [Configuration](./docs/configuration.md): the options of the config and of the CLI.
44
+ - [Templates](./docs/templates.md): importing templates, data, scripts and styles.
45
+ - [Helpers](./docs/helpers.md): the built-in helpers and those of a theme.
46
+ - [Plugins](./docs/plugins.md): partials, data, hooks and runtimes of plugins.
47
+ - [Validation and formatting](./docs/validation.md): what is checked, and how the theme is written.
48
+ - [Rendering anywhere](./docs/rendering.md): `blogger-hbs/runtime` and `createRenderer`.
49
+ - [JavaScript API](./docs/api.md): `compileTemplate`, `watchTemplate` and the others.
50
+ - [Errors](./docs/errors.md): what errors say and how to throw them.
package/dist/cli/index.js CHANGED
@@ -1,27 +1,34 @@
1
1
  import { CompileError } from "../compiler/errors.js";
2
+ import { withLocation } from "../plugins/index.js";
2
3
  import { didYouMean } from "../utils/suggest.js";
3
4
  import { COMPILE_OPTION_KEYS, assertCompileOptions, assertDirectories, assertEntry, resolvePaths } from "../compiler/options.js";
4
- import { formatXml, validateXml } from "../xml/format.js";
5
+ import { XmlError, processXml } from "../xml/process.js";
5
6
  import { compileTemplate } from "../compiler/compile.js";
6
7
  import { watchTemplate } from "../compiler/watch.js";
7
8
  import { loadConfig } from "../config.js";
8
9
  import { createRenderer } from "../runtime/render.js";
10
+ import { collectStats, formatStats } from "./stats.js";
9
11
  import * as path from "node:path";
10
12
  import * as fs from "node:fs/promises";
11
- import { createRequire } from "node:module";
12
13
  import { watch } from "node:fs";
13
14
  import { styleText } from "node:util";
14
15
  import { cac } from "cac";
15
16
  //#region src/cli/index.ts
16
- const { version } = createRequire(import.meta.url)("../../package.json");
17
- const log = {
18
- success: (message) => console.log(`${styleText("green", "✓")} ${message}`),
19
- info: (message) => console.log(`${styleText("cyan", "●")} ${message}`),
20
- warn: (message) => console.warn(`${styleText("yellow", "⚠")} ${message}`),
21
- error: (error) => console.error(`${styleText("red", "✗")} ${formatError(error)}`)
22
- };
17
+ const { version, license, repository } = JSON.parse(await fs.readFile(new URL("../../package.json", import.meta.url), "utf-8"));
18
+ /** What the CLI prints, with `write`, warnings and errors on stderr. */
19
+ function logTo(write) {
20
+ return {
21
+ write,
22
+ success: (message) => write(`${styleText("green", "✓")} ${message}`),
23
+ info: (message) => write(`${styleText("cyan", "●")} ${message}`),
24
+ warn: (message) => console.warn(`${styleText("yellow", "⚠")} ${message}`),
25
+ error: (error) => console.error(`${styleText("red", "✗")} ${formatError(error)}`)
26
+ };
27
+ }
28
+ const log = logTo(console.log);
23
29
  const cli = cac("blogger-hbs");
24
30
  cli.command("[root]", "Compile the template and render it").alias("build").option("-c, --config <file>", "Path of the config file").option("--entry <file>", "Entry template, relative to the root").option("-o, --out-dir <dir>", "Output directory, relative to the root").option("--context <file>", "JSON file with the context to render with").option("-w, --watch", "Build again when a file changes").option("--no-clean", "Keep the files in the output directory").option("--no-validate", "Do not validate the rendered template as XML, nor format it (templates are always validated when compiling)").option("--no-format", "Do not format the rendered template").example("blogger-hbs").example("blogger-hbs --watch").example("blogger-hbs ./theme --entry src/index.hbs --context context.json").action(async (rootArg, rawFlags) => {
31
+ printHeader();
25
32
  const text = (value) => value === void 0 ? void 0 : String(value);
26
33
  const root = text(rootArg);
27
34
  const flags = {
@@ -34,22 +41,45 @@ cli.command("[root]", "Compile the template and render it").alias("build").optio
34
41
  if (flags.watch) await watchBuild(root, flags);
35
42
  else await build(root, flags);
36
43
  });
44
+ cli.command("validate <file>", "Validate a theme as Blogger reads it").option("--json", "Print the result as JSON").example("blogger-hbs validate dist/theme.xml").example("blogger-hbs validate dist/theme.xml --json").action(async (file, flags) => {
45
+ await processFile(String(file), {
46
+ format: false,
47
+ json: flags.json
48
+ });
49
+ });
50
+ cli.command("format <file>", "Format a theme as Blogger saves it, written to stdout, or to --out").option("-o, --out <file>", "Write it to this file instead of stdout").option("--json", "Print the result as JSON, the theme in it without --out").example("blogger-hbs format theme.xml").example("blogger-hbs format theme.xml -o theme.formatted.xml").action(async (file, flags) => {
51
+ const out = flags.out === void 0 ? void 0 : String(flags.out);
52
+ await processFile(String(file), {
53
+ format: true,
54
+ out,
55
+ json: flags.json
56
+ });
57
+ });
37
58
  cli.help();
38
- cli.version(version);
59
+ cli.globalCommand.versionNumber = version;
60
+ cli.option("-v, --version", "Display the version, the copyright and the license");
61
+ /** Whether the header is printed, so that an error before a command runs prints it too. */
62
+ let headerPrinted = false;
39
63
  try {
40
64
  cli.parse(process.argv, { run: false });
41
- await cli.runMatchedCommand();
65
+ if (cli.options.version) await printVersion();
66
+ else await cli.runMatchedCommand();
42
67
  } catch (error) {
68
+ if (!headerPrinted && !cli.options.json) printHeader(formatsToStdout() ? console.error : console.log);
43
69
  log.error(describeCliError(error));
44
70
  process.exitCode = 1;
45
71
  }
72
+ /** Whether the command formats a theme to stdout, which has nothing else on it. */
73
+ function formatsToStdout() {
74
+ return cli.matchedCommandName === "format" && cli.options.out === void 0;
75
+ }
46
76
  /** Adds a suggestion and a pointer to `--help` to the errors of the arguments. */
47
77
  function describeCliError(error) {
48
78
  if (!(error instanceof Error) || error.name !== "CACError") return error;
49
79
  const flags = [...cli.commands, cli.globalCommand].flatMap((command) => command.options.flatMap((option) => option.rawName.match(/--[\w-]+/g) ?? []));
50
80
  const unknown = /^Unknown option `(.+)`$/.exec(error.message)?.[1];
51
81
  const hint = unknown ? didYouMean(unknown, flags, "`") : "";
52
- return /* @__PURE__ */ new Error(`${error.message}${hint}\n Run 'blogger-hbs --help' to see the available options`);
82
+ return /* @__PURE__ */ new Error(`${error.message}${hint}\nRun 'blogger-hbs ${cli.matchedCommandName ? `${cli.matchedCommandName} ` : ""}--help' to see the available options`);
53
83
  }
54
84
  async function setup(root, flags) {
55
85
  const cwd = process.cwd();
@@ -83,7 +113,8 @@ async function setup(root, flags) {
83
113
  format: flags.format === false ? false : output.format ?? true,
84
114
  clean,
85
115
  context: flags.context ? await readContext(path.resolve(cwd, flags.context), flags.context) : config.context ?? {},
86
- publicDir: await resolvePublicDir(resolvedRoot, config.publicDir, outDir)
116
+ publicDir: await resolvePublicDir(resolvedRoot, config.publicDir, outDir),
117
+ sizes: /* @__PURE__ */ new Map()
87
118
  };
88
119
  }
89
120
  /** The public directory, `public` when it exists unless set otherwise. */
@@ -155,7 +186,7 @@ async function build(root, flags) {
155
186
  const start = performance.now();
156
187
  const compiled = await compileTemplate(current.options);
157
188
  log.success(`Compiled in ${elapsed(start)}`);
158
- await write(current, compiled);
189
+ await write(current, compiled, start);
159
190
  }
160
191
  /** Watches the template and restarts when the config file changes. */
161
192
  async function watchBuild(root, flags) {
@@ -167,6 +198,15 @@ async function watchBuild(root, flags) {
167
198
  const start = async () => {
168
199
  let begin = performance.now();
169
200
  let first = true;
201
+ /** Shown before what a rebuild prints, after the first build. */
202
+ let divider = "";
203
+ /** Once, before the first thing a rebuild prints, its output can be the same. */
204
+ const printDivider = () => {
205
+ if (divider) {
206
+ console.log(divider);
207
+ divider = "";
208
+ }
209
+ };
170
210
  try {
171
211
  await copyPublic(current);
172
212
  } catch (error) {
@@ -174,15 +214,20 @@ async function watchBuild(root, flags) {
174
214
  }
175
215
  watcher = await watchTemplate({
176
216
  ...current.options,
177
- onStart() {
217
+ onStart({ files }) {
178
218
  begin = performance.now();
219
+ divider = first ? "" : formatDivider(files);
179
220
  },
180
221
  async onCompile(compiled) {
222
+ printDivider();
181
223
  log.success(`${first ? "Compiled" : "Rebuilt"} in ${elapsed(begin)}`);
224
+ await write(current, compiled, begin);
182
225
  first = false;
183
- await write(current, compiled);
184
226
  },
185
- onError: log.error
227
+ onError(error) {
228
+ printDivider();
229
+ log.error(error);
230
+ }
186
231
  });
187
232
  for (const file of current.loaded.dependencies) try {
188
233
  const configWatcher = watch(file, () => {
@@ -262,45 +307,225 @@ async function prepareOutDir({ outDir, clean }) {
262
307
  * Writes the compiled module and the rendered template, validated and
263
308
  * formatted after rendering when enabled.
264
309
  */
265
- async function write(current, compiled) {
266
- const { outDir, moduleFile, templateFile, context } = current;
267
- if (moduleFile) await fs.writeFile(path.join(outDir, moduleFile), compiled.source);
268
- if (!templateFile) return;
310
+ async function write(current, compiled, begin) {
311
+ const { outDir, moduleFile, templateFile } = current;
312
+ const written = [];
313
+ let theme;
314
+ if (moduleFile) {
315
+ const file = path.join(outDir, moduleFile);
316
+ await fs.writeFile(file, compiled.source);
317
+ written.push({
318
+ path: path.relative(process.cwd(), file),
319
+ content: compiled.source
320
+ });
321
+ }
322
+ if (templateFile) {
323
+ theme = await writeTheme(current, compiled, templateFile);
324
+ written.unshift(theme.file);
325
+ }
326
+ const stats = await collectStats(compiled, written, theme?.info && {
327
+ ...theme.file,
328
+ info: theme.info
329
+ });
330
+ console.log(formatStats(stats, current.sizes, { previousTheme: current.theme }));
331
+ current.sizes = new Map(stats.files.map(({ path, size }) => [path, size]));
332
+ current.theme = stats.theme;
333
+ log.success(`Built in ${elapsed(begin)}`);
334
+ }
335
+ /** `─── 14:32:05 · src/header.hbs +2 ───────`, dim. */
336
+ function formatDivider(files) {
337
+ const time = (/* @__PURE__ */ new Date()).toLocaleTimeString("en-GB", { hour12: false });
338
+ const [file, ...others] = files.map((item) => path.relative(process.cwd(), item));
339
+ const text = `─── ${time}${file ? ` · ${file}${others.length > 0 ? ` +${others.length}` : ""}` : ""} `;
340
+ return `\n${styleText("dim", text + "─".repeat(Math.max(3, 50 - text.length)))}`;
341
+ }
342
+ /** Renders, validates and formats the theme, as enabled, and writes it. */
343
+ async function writeTheme(current, compiled, templateFile) {
344
+ const { outDir, context } = current;
269
345
  const file = path.join(outDir, templateFile);
270
346
  const shown = path.relative(process.cwd(), file);
271
347
  let start = performance.now();
272
348
  const rendered = await createRenderer(compiled).render(context);
273
349
  log.success(`Rendered in ${elapsed(start)}`);
350
+ if (!current.validate) {
351
+ await fs.writeFile(file, rendered);
352
+ return { file: {
353
+ path: shown,
354
+ content: rendered
355
+ } };
356
+ }
274
357
  start = performance.now();
275
- let output;
358
+ let result;
276
359
  try {
277
- output = finalizeXml(current, rendered);
360
+ result = processXml(rendered, {
361
+ path: shown,
362
+ format: current.format
363
+ });
278
364
  } catch (error) {
279
365
  await fs.writeFile(file, rendered);
280
- throw new CompileError(`The rendered template ${shown} is not valid XML: ${error.message}`);
366
+ throw new CompileError("The rendered template is not valid XML", {
367
+ cause: error,
368
+ loc: error.loc
369
+ });
281
370
  }
282
- if (current.validate) log.success(`${current.format ? "Formatted" : "Validated"} in ${elapsed(start)}`);
283
- await fs.writeFile(file, output);
284
- log.success(`Written ${styleText("dim", shown)}`);
371
+ for (const { message, loc } of result.warnings) log.warn(withLocation(message, loc));
372
+ log.success(`${current.format ? "Formatted" : "Validated"} in ${elapsed(start)}`);
373
+ await fs.writeFile(file, result.xml);
374
+ return {
375
+ file: {
376
+ path: shown,
377
+ content: result.xml
378
+ },
379
+ info: result.info
380
+ };
285
381
  }
286
- /** Validates and formats the rendered template, as enabled. */
287
- function finalizeXml({ validate, format }, xml) {
288
- if (!validate) return xml;
289
- if (!format) {
290
- validateXml(xml);
291
- return xml;
382
+ /**
383
+ * Validates the theme in `file`, and with `format` formats it, written to
384
+ * `out` or to stdout. Warnings are placed in what is written, errors in
385
+ * `file`, and nothing is written when it is not valid.
386
+ */
387
+ async function processFile(file, { format, out, json }) {
388
+ if (json) {
389
+ const report = await reportOf(file, {
390
+ format,
391
+ out
392
+ });
393
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
394
+ if (!report.valid) process.exitCode = 1;
395
+ return;
292
396
  }
293
- return formatXml(xml);
397
+ const toStdout = format && out === void 0;
398
+ const print = toStdout ? logTo(console.error) : log;
399
+ if (!toStdout) printHeader(print.write);
400
+ const start = performance.now();
401
+ const { result, written } = await processTheme(file, {
402
+ format,
403
+ out
404
+ });
405
+ for (const { message, loc } of result.warnings) print.warn(withLocation(message, loc));
406
+ if (!format) print.success(`Validated in ${elapsed(start)}`);
407
+ else if (out === void 0) process.stdout.write(result.xml);
408
+ else print.success(`Formatted in ${elapsed(start)}, written to ${written}`);
409
+ }
410
+ /**
411
+ * Reads, processes and, with `out`, writes the theme in `file`. Its
412
+ * warnings and info are placed in what is written, and an error throws.
413
+ */
414
+ async function processTheme(file, { format, out }) {
415
+ const shown = path.relative(process.cwd(), path.resolve(file));
416
+ const xml = await readTheme(file, shown);
417
+ let result;
418
+ try {
419
+ result = processXml(xml, {
420
+ path: shown,
421
+ format
422
+ });
423
+ } catch (error) {
424
+ throw new Error(`${shown} is not valid XML`, { cause: error });
425
+ }
426
+ const written = !format ? shown : out === void 0 ? "<stdout>" : path.relative(process.cwd(), path.resolve(out));
427
+ const at = (item) => ({
428
+ ...item,
429
+ loc: {
430
+ ...item.loc,
431
+ path: written
432
+ }
433
+ });
434
+ result = {
435
+ ...result,
436
+ warnings: result.warnings.map(at),
437
+ info: {
438
+ ...result.info,
439
+ sections: result.info.sections.map(at),
440
+ widgets: result.info.widgets.map(at)
441
+ }
442
+ };
443
+ if (format && out !== void 0) {
444
+ await fs.mkdir(path.dirname(path.resolve(out)), { recursive: true });
445
+ await fs.writeFile(out, result.xml);
446
+ }
447
+ return {
448
+ result,
449
+ written
450
+ };
451
+ }
452
+ async function reportOf(file, { format, out }) {
453
+ try {
454
+ const { result, written } = await processTheme(file, {
455
+ format,
456
+ out
457
+ });
458
+ return {
459
+ valid: true,
460
+ warnings: result.warnings,
461
+ info: result.info,
462
+ ...format && out === void 0 && { xml: result.xml },
463
+ ...format && out !== void 0 && { written }
464
+ };
465
+ } catch (error) {
466
+ const cause = error instanceof Error ? error.cause : void 0;
467
+ return {
468
+ valid: false,
469
+ errors: cause instanceof XmlError ? [...cause.errors] : [{ message: messageOf(error) }]
470
+ };
471
+ }
472
+ }
473
+ /** The message of an error and of what caused it, on one line. */
474
+ function messageOf(error) {
475
+ const messages = [];
476
+ for (let current = error; current !== void 0;) {
477
+ messages.push(current instanceof Error ? current.message : String(current));
478
+ current = current instanceof Error ? current.cause : void 0;
479
+ }
480
+ return messages.join(": ");
481
+ }
482
+ /** The text of the theme in `file`, an error saying so when it can not be read. */
483
+ async function readTheme(file, shown) {
484
+ try {
485
+ return await fs.readFile(file, "utf-8");
486
+ } catch (error) {
487
+ throw new Error(`Can not read ${shown}`, { cause: error });
488
+ }
489
+ }
490
+ /** The version, the copyright and the license, where the project is, and the runtime. */
491
+ async function printVersion() {
492
+ printHeader();
493
+ const notice = await fs.readFile(new URL("../../LICENSE.md", import.meta.url), "utf-8").catch(() => "");
494
+ const copyright = /^Copyright .*$/m.exec(notice)?.[0];
495
+ console.log([
496
+ copyright,
497
+ `Released under the ${license} License, provided as is, without warranty.`,
498
+ repository.url,
499
+ "",
500
+ `Node.js ${process.version} on ${process.platform}-${process.arch}`
501
+ ].filter((line) => line !== void 0).join("\n"));
502
+ }
503
+ /**
504
+ * A blank line, `blogger-hbs v0.0.4` right-aligned on a cyan line under it,
505
+ * before what a command prints, with `write`.
506
+ */
507
+ function printHeader(write = console.log) {
508
+ headerPrinted = true;
509
+ const text = `blogger-hbs v${version}`;
510
+ const width = text.length + 2;
511
+ write(`\n${" ".repeat(width - text.length)}${styleText("bold", "blogger-hbs")} ${styleText("dim", `v${version}`)}\n${styleText("cyan", "─".repeat(width))}`);
294
512
  }
295
513
  function elapsed(start) {
296
514
  return styleText("yellow", `${Math.round(performance.now() - start)}ms`);
297
515
  }
298
- /** The message of the error and of everything that caused it. */
516
+ /**
517
+ * The message of the error and of everything that caused it, a cause whose
518
+ * message is already in the one before is not repeated. The lines of a
519
+ * message are indented under its first.
520
+ */
299
521
  function formatError(error) {
300
522
  const lines = [];
523
+ let previous = "";
301
524
  let current = error;
302
525
  while (current !== void 0) {
303
- lines.push(current instanceof Error ? `${lines.length === 0 ? "" : "Caused by: "}${current.message}` : String(current));
526
+ const message = current instanceof Error ? current.message : String(current);
527
+ if (lines.length === 0 || !previous.includes(message)) lines.push(`${lines.length === 0 ? "" : "Caused by: "}${message.replaceAll("\n", "\n ")}`);
528
+ previous = message;
304
529
  current = current instanceof Error ? current.cause : void 0;
305
530
  }
306
531
  return lines.join("\n ");