@valbuild/cli 0.143.1 → 0.144.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,48 @@
1
1
  # @valbuild/cli
2
2
 
3
+ ## 0.144.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [[`a641bb0`](https://github.com/valbuild/val/commit/a641bb0286756339252c53e9230d766c23fcccc6)]:
8
+ - @valbuild/server@0.144.1
9
+ - @valbuild/language-server@0.144.1
10
+
11
+ ## 0.144.0
12
+
13
+ ### Patch Changes
14
+
15
+ - [#788](https://github.com/valbuild/val/pull/788) [`a84271c`](https://github.com/valbuild/val/commit/a84271c534822071c1e428cf9990602459682d25) Thanks [@freekh](https://github.com/freekh)! - Fixes to `npx val validate` for `s.imagedir()` and `s.filedir()`, and the `s.image(galleryVal)` / `s.file(filesVal)` fields that use them:
16
+
17
+ - **`--fix` now updates every field that uses an uploaded image or file.** When `--fix` moves an entry of an `s.imagedir(dir).remote()` or `s.filedir(dir, options).remote()` to Val Remote, the entry's key changes from the local path to the remote URL. Fields that held the old path were left pointing at it, and reported "The gallery does not have an image at …" with nothing to fix it. Now `s.image(galleryVal)` and `s.file(filesVal)` fields, and images in rich text (`s.richtext({ img: s.image(galleryVal) })`), are updated in the same run. The VS Code extension's "upload to Val Remote" updates them in the same edit, including fields inside `.jsonValues()` entries, which live in their own `*.val.json` files.
18
+ - **A field that uses an image or file from a `.remote()` gallery is no longer reported as "Expected local image, but found remote".** The gallery decides whether its files are on Val Remote; the field only has to name a file the gallery has. Before, `--fix` would download the file and point the field at a path the gallery did not have.
19
+ - **Images uploaded through a field into a `.remote()` gallery.** When you upload an image through an `s.image(galleryVal)` field in Val Studio, the field stores the remote URL and the gallery stores the image under its local path. Validation now accepts this, and `useVal` / `fetchVal` now fill in the image's `width`, `height` and `mimeType` from the gallery for it, as they do for every other gallery image.
20
+ - **Errors in a gallery's keys are reported.** A remote URL in a gallery that is not `.remote()`, a remote URL that is not valid, or one outside the gallery's `dir` was silently ignored by `npx val validate`. It is now reported as an error. `--fix` does not change it: you decide whether the key or the gallery's options are wrong.
21
+ - **A file is no longer reported as valid when it still has an error.** When `--fix` fixed one error in a file and another error was left, the file could be printed as "✔ valid".
22
+
23
+ - [#827](https://github.com/valbuild/val/pull/827) [`1272b14`](https://github.com/valbuild/val/commit/1272b1408cf55fefccdce442352c208a7f69f44b) Thanks [@freekh](https://github.com/freekh)! - Publishing to Val's platform, through `val publish` or the Studio, now handles a stylesheet that imports other stylesheets, and fonts installed as packages:
24
+
25
+ ```css
26
+ /* src/styles.css */
27
+ @import "tailwindcss";
28
+ @import "./theme/theme.css";
29
+
30
+ /* src/theme/theme.css */
31
+ @import "@fontsource-variable/inter";
32
+ ```
33
+
34
+ Before this, the platform's build passed `@import "./theme/theme.css"` on to Tailwind without resolving it, and publishing stopped with `Tailwind asked for './theme/theme.css', which this build does not carry`. A font package such as `@fontsource-variable/inter` was built as code, failed, and was skipped.
35
+
36
+ - A stylesheet's own `@import`s are inlined where they were written, the same way `vite build` handles them. That covers relative files and stylesheets that dependencies ship. `layer()`, `supports()` and media conditions are kept, and a stylesheet imported twice is only included once.
37
+ - The files a dependency's stylesheet points at with `url(...)`, such as a font package's `.woff2` files, are published with the site and served from it.
38
+
39
+ - Updated dependencies [[`a84271c`](https://github.com/valbuild/val/commit/a84271c534822071c1e428cf9990602459682d25), [`860ab2b`](https://github.com/valbuild/val/commit/860ab2b87333daf95a48215b0e4a1b44f441c878), [`1272b14`](https://github.com/valbuild/val/commit/1272b1408cf55fefccdce442352c208a7f69f44b)]:
40
+ - @valbuild/core@0.144.0
41
+ - @valbuild/shared@0.144.0
42
+ - @valbuild/server@0.144.0
43
+ - @valbuild/language-server@0.144.0
44
+ - @valbuild/tanstack-build@0.144.0
45
+
3
46
  ## 0.143.1
4
47
 
5
48
  ### Patch Changes
@@ -243,8 +243,9 @@ async function* runValidation({
243
243
  };
244
244
  return;
245
245
  } else {
246
+ // What is still wrong with this module once every handler has run: an
247
+ // error that was fixed is not counted, one that was not is.
246
248
  let fileErrors = 0;
247
- let fixedErrors = 0;
248
249
  if (valModule.errors) {
249
250
  if (valModule.errors.validation) {
250
251
  // Resolve schema/source fixes (keyof:check-keys, router:check-route)
@@ -331,7 +332,6 @@ async function* runValidation({
331
332
  continue;
332
333
  }
333
334
  if (result.appliedFix) {
334
- fixedErrors += 1;
335
335
  yield {
336
336
  type: "fix-applied",
337
337
  file,
@@ -371,7 +371,6 @@ async function* runValidation({
371
371
  sourcePath: other.moduleFilePath
372
372
  };
373
373
  }
374
- fixedErrors += 1;
375
374
  yield {
376
375
  type: "fix-applied",
377
376
  file,
@@ -407,7 +406,10 @@ async function* runValidation({
407
406
  }
408
407
  }
409
408
  }
410
- if (fixedErrors === fileErrors && (!valModule.errors.fatal || valModule.errors.fatal.length == 0)) {
409
+ // Valid means nothing is left, not that as many errors were fixed as
410
+ // are left: one fix and one error that no fix touches add up to the
411
+ // same count, and printed a module with an error in it as valid.
412
+ if (fileErrors === 0 && (!valModule.errors.fatal || valModule.errors.fatal.length == 0)) {
411
413
  yield {
412
414
  type: "file-valid",
413
415
  file: moduleFilePath,
@@ -1029,7 +1031,7 @@ async function listUnusedFiles({
1029
1031
  }
1030
1032
  });
1031
1033
  }
1032
- // A media collection (`s.imageset()`, `s.fileset()`, `s.videoset()`) is
1034
+ // A media collection (`s.imagedir()`, `s.filedir()`, `s.videodir()`) is
1033
1035
  // keyed by its files, and an entry that is fine reports no error either —
1034
1036
  // so, as for a video, they are found by walking the source. A video set's
1035
1037
  // stream holds every playlist and segment its master names, and an entry
@@ -2616,7 +2618,10 @@ async function buildArtifacts(options) {
2616
2618
  }
2617
2619
  })();
2618
2620
  const deps = node.layerDepsOf(manifest, sources);
2619
- const css = await node.collectDependencyCss(root, node.cssSpecifiersOf(sources));
2621
+ // The files those stylesheets point at -- a font package's `.woff2`s --
2622
+ // published with the site under the names their `url()`s now use.
2623
+ const dependencyAssets = {};
2624
+ const css = await node.collectDependencyCss(root, node.cssSpecifiersOf(sources), dependencyAssets);
2620
2625
  const layer = await node.buildVendorLayer(root, deps, css, {
2621
2626
  base: target.base,
2622
2627
  // A project on the platform layers its own copies; only the platform's
@@ -2644,6 +2649,7 @@ async function buildArtifacts(options) {
2644
2649
  env: publicEnv(options.env ?? process.env),
2645
2650
  publicFiles: readPublicFiles(root),
2646
2651
  assets,
2652
+ dependencyAssets,
2647
2653
  // The project's own files, without the generated tree or the
2648
2654
  // stylesheets: what the next publish -- the Studio's included -- starts
2649
2655
  // from. See `withoutStylesheets`.
@@ -243,8 +243,9 @@ async function* runValidation({
243
243
  };
244
244
  return;
245
245
  } else {
246
+ // What is still wrong with this module once every handler has run: an
247
+ // error that was fixed is not counted, one that was not is.
246
248
  let fileErrors = 0;
247
- let fixedErrors = 0;
248
249
  if (valModule.errors) {
249
250
  if (valModule.errors.validation) {
250
251
  // Resolve schema/source fixes (keyof:check-keys, router:check-route)
@@ -331,7 +332,6 @@ async function* runValidation({
331
332
  continue;
332
333
  }
333
334
  if (result.appliedFix) {
334
- fixedErrors += 1;
335
335
  yield {
336
336
  type: "fix-applied",
337
337
  file,
@@ -371,7 +371,6 @@ async function* runValidation({
371
371
  sourcePath: other.moduleFilePath
372
372
  };
373
373
  }
374
- fixedErrors += 1;
375
374
  yield {
376
375
  type: "fix-applied",
377
376
  file,
@@ -407,7 +406,10 @@ async function* runValidation({
407
406
  }
408
407
  }
409
408
  }
410
- if (fixedErrors === fileErrors && (!valModule.errors.fatal || valModule.errors.fatal.length == 0)) {
409
+ // Valid means nothing is left, not that as many errors were fixed as
410
+ // are left: one fix and one error that no fix touches add up to the
411
+ // same count, and printed a module with an error in it as valid.
412
+ if (fileErrors === 0 && (!valModule.errors.fatal || valModule.errors.fatal.length == 0)) {
411
413
  yield {
412
414
  type: "file-valid",
413
415
  file: moduleFilePath,
@@ -1029,7 +1031,7 @@ async function listUnusedFiles({
1029
1031
  }
1030
1032
  });
1031
1033
  }
1032
- // A media collection (`s.imageset()`, `s.fileset()`, `s.videoset()`) is
1034
+ // A media collection (`s.imagedir()`, `s.filedir()`, `s.videodir()`) is
1033
1035
  // keyed by its files, and an entry that is fine reports no error either —
1034
1036
  // so, as for a video, they are found by walking the source. A video set's
1035
1037
  // stream holds every playlist and segment its master names, and an entry
@@ -2616,7 +2618,10 @@ async function buildArtifacts(options) {
2616
2618
  }
2617
2619
  })();
2618
2620
  const deps = node.layerDepsOf(manifest, sources);
2619
- const css = await node.collectDependencyCss(root, node.cssSpecifiersOf(sources));
2621
+ // The files those stylesheets point at -- a font package's `.woff2`s --
2622
+ // published with the site under the names their `url()`s now use.
2623
+ const dependencyAssets = {};
2624
+ const css = await node.collectDependencyCss(root, node.cssSpecifiersOf(sources), dependencyAssets);
2620
2625
  const layer = await node.buildVendorLayer(root, deps, css, {
2621
2626
  base: target.base,
2622
2627
  // A project on the platform layers its own copies; only the platform's
@@ -2644,6 +2649,7 @@ async function buildArtifacts(options) {
2644
2649
  env: publicEnv(options.env ?? process.env),
2645
2650
  publicFiles: readPublicFiles(root),
2646
2651
  assets,
2652
+ dependencyAssets,
2647
2653
  // The project's own files, without the generated tree or the
2648
2654
  // stylesheets: what the next publish -- the Studio's included -- starts
2649
2655
  // from. See `withoutStylesheets`.
@@ -208,8 +208,9 @@ async function* runValidation({
208
208
  };
209
209
  return;
210
210
  } else {
211
+ // What is still wrong with this module once every handler has run: an
212
+ // error that was fixed is not counted, one that was not is.
211
213
  let fileErrors = 0;
212
- let fixedErrors = 0;
213
214
  if (valModule.errors) {
214
215
  if (valModule.errors.validation) {
215
216
  // Resolve schema/source fixes (keyof:check-keys, router:check-route)
@@ -296,7 +297,6 @@ async function* runValidation({
296
297
  continue;
297
298
  }
298
299
  if (result.appliedFix) {
299
- fixedErrors += 1;
300
300
  yield {
301
301
  type: "fix-applied",
302
302
  file,
@@ -336,7 +336,6 @@ async function* runValidation({
336
336
  sourcePath: other.moduleFilePath
337
337
  };
338
338
  }
339
- fixedErrors += 1;
340
339
  yield {
341
340
  type: "fix-applied",
342
341
  file,
@@ -372,7 +371,10 @@ async function* runValidation({
372
371
  }
373
372
  }
374
373
  }
375
- if (fixedErrors === fileErrors && (!valModule.errors.fatal || valModule.errors.fatal.length == 0)) {
374
+ // Valid means nothing is left, not that as many errors were fixed as
375
+ // are left: one fix and one error that no fix touches add up to the
376
+ // same count, and printed a module with an error in it as valid.
377
+ if (fileErrors === 0 && (!valModule.errors.fatal || valModule.errors.fatal.length == 0)) {
376
378
  yield {
377
379
  type: "file-valid",
378
380
  file: moduleFilePath,
@@ -994,7 +996,7 @@ async function listUnusedFiles({
994
996
  }
995
997
  });
996
998
  }
997
- // A media collection (`s.imageset()`, `s.fileset()`, `s.videoset()`) is
999
+ // A media collection (`s.imagedir()`, `s.filedir()`, `s.videodir()`) is
998
1000
  // keyed by its files, and an entry that is fine reports no error either —
999
1001
  // so, as for a video, they are found by walking the source. A video set's
1000
1002
  // stream holds every playlist and segment its master names, and an entry
@@ -2581,7 +2583,10 @@ async function buildArtifacts(options) {
2581
2583
  }
2582
2584
  })();
2583
2585
  const deps = node.layerDepsOf(manifest, sources);
2584
- const css = await node.collectDependencyCss(root, node.cssSpecifiersOf(sources));
2586
+ // The files those stylesheets point at -- a font package's `.woff2`s --
2587
+ // published with the site under the names their `url()`s now use.
2588
+ const dependencyAssets = {};
2589
+ const css = await node.collectDependencyCss(root, node.cssSpecifiersOf(sources), dependencyAssets);
2585
2590
  const layer = await node.buildVendorLayer(root, deps, css, {
2586
2591
  base: target.base,
2587
2592
  // A project on the platform layers its own copies; only the platform's
@@ -2609,6 +2614,7 @@ async function buildArtifacts(options) {
2609
2614
  env: publicEnv(options.env ?? process.env),
2610
2615
  publicFiles: readPublicFiles(root),
2611
2616
  assets,
2617
+ dependencyAssets,
2612
2618
  // The project's own files, without the generated tree or the
2613
2619
  // stylesheets: what the next publish -- the Studio's included -- starts
2614
2620
  // from. See `withoutStylesheets`.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@valbuild/cli",
3
3
  "private": false,
4
- "version": "0.143.1",
4
+ "version": "0.144.1",
5
5
  "description": "Val CLI tools",
6
6
  "repository": {
7
7
  "type": "git",
@@ -26,11 +26,11 @@
26
26
  "picocolors": "^1.1.1",
27
27
  "zod": "^4.4.3",
28
28
  "zod-validation-error": "^5.0.0",
29
- "@valbuild/core": "0.143.0",
30
- "@valbuild/language-server": "0.143.1",
31
- "@valbuild/shared": "0.143.0",
29
+ "@valbuild/core": "0.144.0",
30
+ "@valbuild/language-server": "0.144.1",
31
+ "@valbuild/server": "0.144.1",
32
32
  "@valbuild/eslint-plugin": "0.125.0",
33
- "@valbuild/server": "0.143.1"
33
+ "@valbuild/shared": "0.144.0"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "prettier": "*",
@@ -38,7 +38,7 @@
38
38
  "rolldown": ">=1.2.5",
39
39
  "@tanstack/router-generator": ">=1.167.0",
40
40
  "@tanstack/router-plugin": ">=1.168.0",
41
- "@valbuild/tanstack-build": "^0.142.0"
41
+ "@valbuild/tanstack-build": "^0.144.0"
42
42
  },
43
43
  "peerDependenciesMeta": {
44
44
  "@valbuild/tanstack-build": {
@@ -70,7 +70,7 @@
70
70
  "prettier": "~3.8.5",
71
71
  "rolldown": "^1.2.5",
72
72
  "tsx": "^4.23.12",
73
- "@valbuild/tanstack-build": "0.142.0"
73
+ "@valbuild/tanstack-build": "0.144.0"
74
74
  },
75
75
  "scripts": {
76
76
  "start": "tsx src/cli.ts --",
@@ -2,10 +2,7 @@ import { c, s } from "../val.config";
2
2
 
3
3
  export default c.define(
4
4
  "/content/basic-files.val.ts",
5
- s.fileset({
6
- dir: "/public/val/files",
7
- accept: "*/*",
8
- }),
5
+ s.filedir("/public/val/files", { accept: "*/*" }),
9
6
  {
10
7
  "/public/val/files/tracked.txt": {
11
8
  mimeType: "text/plain",
@@ -0,0 +1,12 @@
1
+ import { c, s } from "../val.config";
2
+
3
+ // One error `--fix` repairs (the image's metadata) and one it cannot (the
4
+ // title is too short), in the same module.
5
+ export default c.define(
6
+ "/content/basic-fixable-and-unfixable.val.ts",
7
+ s.object({ image: s.image(), title: s.string().minLength(30) }),
8
+ {
9
+ image: { path: "/public/val/image.png" },
10
+ title: "Too short",
11
+ },
12
+ );
@@ -2,10 +2,7 @@ import { c, s } from "../val.config";
2
2
 
3
3
  export default c.define(
4
4
  "/content/basic-gallery-2.val.ts",
5
- s.imageset({
6
- dir: "/public/val/images2",
7
- accept: "image/*",
8
- }),
5
+ s.imagedir("/public/val/images2", { accept: "image/*" }),
9
6
  {
10
7
  "/public/val/images2/image.png": {
11
8
  width: 1,
@@ -2,10 +2,7 @@ import { c, s } from "../val.config";
2
2
 
3
3
  export default c.define(
4
4
  "/content/basic-gallery-fail-on-non-unique-dir.val.ts",
5
- s.imageset({
6
- dir: "/public/val/images",
7
- accept: "image/*",
8
- }),
5
+ s.imagedir("/public/val/images", { accept: "image/*" }),
9
6
  {
10
7
  "/public/val/images/image.png": {
11
8
  width: 1,
@@ -2,10 +2,7 @@ import { c, s } from "../val.config";
2
2
 
3
3
  export default c.define(
4
4
  "/content/basic-gallery-missing-tracked.val.ts",
5
- s.imageset({
6
- dir: "/public/val/images4",
7
- accept: "image/*",
8
- }),
5
+ s.imagedir("/public/val/images4", { accept: "image/*" }),
9
6
  {
10
7
  "/public/val/images4/missing.png": {
11
8
  width: 1,
@@ -2,12 +2,7 @@ import { c, s } from "../val.config";
2
2
 
3
3
  export default c.define(
4
4
  "/content/basic-gallery-remote-existing.val.ts",
5
- s
6
- .imageset({
7
- dir: "/public/val/images-remote2",
8
- accept: "image/*",
9
- })
10
- .remote(),
5
+ s.imagedir("/public/val/images-remote2", { accept: "image/*" }).remote(),
11
6
 
12
7
  {
13
8
  "https://remote.val.build/file/p/proj123/b/01/v/1.0.0/h/abc1/f/def4/p/public/val/images-remote2/image.png":
@@ -2,12 +2,7 @@ import { c, s } from "../val.config";
2
2
 
3
3
  export default c.define(
4
4
  "/content/basic-gallery-remote.val.ts",
5
- s
6
- .imageset({
7
- dir: "/public/val/images-remote",
8
- accept: "image/*",
9
- })
10
- .remote(),
5
+ s.imagedir("/public/val/images-remote", { accept: "image/*" }).remote(),
11
6
 
12
7
  {
13
8
  "/public/val/images-remote/image.png": {
@@ -2,10 +2,7 @@ import { c, s } from "../val.config";
2
2
 
3
3
  export default c.define(
4
4
  "/content/basic-gallery-wrong-metadata.val.ts",
5
- s.imageset({
6
- dir: "/public/val/images3",
7
- accept: "image/*",
8
- }),
5
+ s.imagedir("/public/val/images3", { accept: "image/*" }),
9
6
  {
10
7
  "/public/val/images3/image.png": {
11
8
  width: 999,
@@ -2,10 +2,7 @@ import { c, s } from "../val.config";
2
2
 
3
3
  export default c.define(
4
4
  "/content/basic-gallery.val.ts",
5
- s.imageset({
6
- dir: "/public/val/images",
7
- accept: "image/*",
8
- }),
5
+ s.imagedir("/public/val/images", { accept: "image/*" }),
9
6
 
10
7
  {
11
8
  "/public/val/images/image.png": {
@@ -6,6 +6,7 @@ export default modules(config, [
6
6
  { def: () => import("./content/basic-errors.val") },
7
7
  { def: () => import("./content/basic-files.val") },
8
8
  { def: () => import("./content/basic-image.val") },
9
+ { def: () => import("./content/basic-fixable-and-unfixable.val") },
9
10
  { def: () => import("./content/basic-image-from-gallery.val") },
10
11
  { def: () => import("./content/basic-image-from-galleries.val") },
11
12
  { def: () => import("./content/basic-gallery.val") },
@@ -56,7 +56,7 @@ export async function listUnusedFiles({ root }: { root?: string }) {
56
56
  },
57
57
  );
58
58
  }
59
- // A media collection (`s.imageset()`, `s.fileset()`, `s.videoset()`) is
59
+ // A media collection (`s.imagedir()`, `s.filedir()`, `s.videodir()`) is
60
60
  // keyed by its files, and an entry that is fine reports no error either —
61
61
  // so, as for a video, they are found by walking the source. A video set's
62
62
  // stream holds every playlist and segment its master names, and an entry
@@ -279,9 +279,13 @@ export async function buildArtifacts(options: {
279
279
  }
280
280
  })();
281
281
  const deps = node.layerDepsOf(manifest, sources);
282
+ // The files those stylesheets point at -- a font package's `.woff2`s --
283
+ // published with the site under the names their `url()`s now use.
284
+ const dependencyAssets: Record<string, string> = {};
282
285
  const css = await node.collectDependencyCss(
283
286
  root,
284
287
  node.cssSpecifiersOf(sources),
288
+ dependencyAssets,
285
289
  );
286
290
  const layer = await node.buildVendorLayer(root, deps, css, {
287
291
  base: target.base,
@@ -319,6 +323,7 @@ export async function buildArtifacts(options: {
319
323
  env: publicEnv(options.env ?? process.env),
320
324
  publicFiles: readPublicFiles(root),
321
325
  assets,
326
+ dependencyAssets,
322
327
  // The project's own files, without the generated tree or the
323
328
  // stylesheets: what the next publish -- the Studio's included -- starts
324
329
  // from. See `withoutStylesheets`.
@@ -0,0 +1,340 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "@jest/globals";
2
+ import path from "path";
3
+ import fs from "fs";
4
+ import {
5
+ DEFAULT_VAL_REMOTE_HOST,
6
+ type ModuleFilePath,
7
+ type ModulePath,
8
+ } from "@valbuild/core";
9
+ import { createService } from "@valbuild/server";
10
+ import {
11
+ createDefaultValFSHost,
12
+ runValidation,
13
+ ValidationEvent,
14
+ IValRemote,
15
+ } from "./runValidation";
16
+
17
+ /**
18
+ * `val validate` over a project with `s.imagedir().remote()` and
19
+ * `s.filedir().remote()` collections, and a page that picks from them — as a
20
+ * field, and as an image inside rich text. End to end: module loading, the fix
21
+ * handlers, the patches and the `.val.ts` rewrite.
22
+ */
23
+
24
+ const BASIC_FIXTURE = path.resolve(__dirname, "__fixtures__/basic");
25
+
26
+ // An 8x8 solid-colour PNG, so the metadata extractor has real bytes to read.
27
+ const PNG = Buffer.from(
28
+ "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX/AAD//" +
29
+ "/+l2Z/dAAAAAWJLR0QAiAUdSAAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9oIBRELLnJqTAAAAAxJREFUCNdjYGBgAAAABAABJzQnCgAAAABJRU5ErkJggg==",
30
+ "base64",
31
+ );
32
+
33
+ const LOGO = "/public/val/images/logo_abcde.png";
34
+ /**
35
+ * What the Studio stores in a field when an image is uploaded through it into
36
+ * a remote gallery: a ref, while the gallery keys the entry by the local path
37
+ * inside it (here, LOGO).
38
+ */
39
+ const LOGO_UPLOADED_THROUGH_FIELD = `https://remote.val.build/file/p/pubproj/b/01/v/0.0.0/h/${"c".repeat(
40
+ 64,
41
+ )}/f/${"d".repeat(12)}/p${LOGO}`;
42
+ const DOC = "/public/val/files/doc_12345.pdf";
43
+
44
+ const PROJECT: Record<string, string> = {
45
+ "val.modules.ts": `import { modules } from "@valbuild/core";
46
+ import { config } from "./val.config";
47
+
48
+ export default modules(config, [
49
+ { def: () => import("./content/images.val") },
50
+ { def: () => import("./content/files.val") },
51
+ { def: () => import("./content/page.val") },
52
+ { def: () => import("./content/cards.val") },
53
+ { def: () => import("./content/local-images.val") },
54
+ { def: () => import("./content/remote-image.val") },
55
+ ]);
56
+ `,
57
+ "content/images.val.ts": `import { c, s } from "../val.config";
58
+
59
+ export default c.define(
60
+ "/content/images.val.ts",
61
+ s.imagedir("/public/val/images").remote(),
62
+ {
63
+ "${LOGO}": { width: 8, height: 8, mimeType: "image/png", alt: "Logo" },
64
+ },
65
+ );
66
+ `,
67
+ "content/files.val.ts": `import { c, s } from "../val.config";
68
+
69
+ export default c.define(
70
+ "/content/files.val.ts",
71
+ s.filedir("/public/val/files", { accept: "application/pdf" }).remote(),
72
+ {
73
+ "${DOC}": { mimeType: "application/pdf" },
74
+ },
75
+ );
76
+ `,
77
+ // A field inside a `.jsonValues()` entry: its value is in the entry's
78
+ // `*.val.json`, which is where the rename has to land.
79
+ "content/cards.val.ts": `import { c, s } from "../val.config";
80
+ import imagesVal from "./images.val";
81
+
82
+ export default c.define(
83
+ "/content/cards.val.ts",
84
+ s.record(s.object({ image: s.image(imagesVal) })).jsonValues(),
85
+ {
86
+ "/a": c.json(() => import("./cards/a.val.json")),
87
+ },
88
+ );
89
+ `,
90
+ "content/cards/a.val.json": JSON.stringify({ image: { path: LOGO } }),
91
+ "content/page.val.ts": `import { c, s } from "../val.config";
92
+ import imagesVal from "./images.val";
93
+ import filesVal from "./files.val";
94
+
95
+ export default c.define(
96
+ "/content/page.val.ts",
97
+ s.object({
98
+ hero: s.image(imagesVal),
99
+ card: s.image(imagesVal),
100
+ body: s.richtext({ img: s.image(imagesVal) }),
101
+ attachment: s.file(filesVal),
102
+ }),
103
+ {
104
+ hero: { path: "${LOGO}", alt: "Kept" },
105
+ card: { path: "${LOGO_UPLOADED_THROUGH_FIELD}" },
106
+ body: [
107
+ {
108
+ tag: "p",
109
+ children: ["Look: ", { tag: "img", src: { path: "${LOGO}" } }],
110
+ },
111
+ ],
112
+ attachment: { path: "${DOC}" },
113
+ },
114
+ );
115
+ `,
116
+ // Not \`.remote()\`, and keyed by a ref anyway: an error for a person.
117
+ "content/local-images.val.ts": `import { c, s } from "../val.config";
118
+
119
+ export default c.define(
120
+ "/content/local-images.val.ts",
121
+ s.imagedir("/public/val/local"),
122
+ {
123
+ "https://remote.val.build/file/p/pubproj/b/01/v/0.0.0/h/${"a".repeat(64)}/f/${"b".repeat(12)}/p/public/val/local/x_00000.png":
124
+ { width: 8, height: 8, mimeType: "image/png", alt: null },
125
+ },
126
+ );
127
+ `,
128
+ // A field of its own, not from a gallery, with a ref that does not parse.
129
+ "content/remote-image.val.ts": `import { c, s } from "../val.config";
130
+
131
+ export default c.define(
132
+ "/content/remote-image.val.ts",
133
+ s.image().remote(),
134
+ { path: "https://remote.val.build/file/not-a-ref.png", width: 8, height: 8, mimeType: "image/png" },
135
+ );
136
+ `,
137
+ };
138
+
139
+ const notExpected: IValRemote = {
140
+ remoteHost: DEFAULT_VAL_REMOTE_HOST,
141
+ getSettings: async () => {
142
+ throw new Error("Not expected to be called");
143
+ },
144
+ uploadFile: async () => {
145
+ throw new Error("Not expected to be called");
146
+ },
147
+ };
148
+
149
+ async function run(
150
+ root: string,
151
+ valFiles: string[],
152
+ fix: boolean,
153
+ remote: IValRemote = notExpected,
154
+ ): Promise<ValidationEvent[]> {
155
+ const events: ValidationEvent[] = [];
156
+ for await (const event of runValidation({
157
+ root,
158
+ fix,
159
+ valFiles,
160
+ project: fix ? "test/project" : undefined,
161
+ remote,
162
+ fs: createDefaultValFSHost(),
163
+ })) {
164
+ events.push(event);
165
+ }
166
+ return events;
167
+ }
168
+
169
+ async function sourceOf(root: string, moduleFilePath: string) {
170
+ const service = await createService(root, createDefaultValFSHost());
171
+ try {
172
+ const result = await service.get(
173
+ moduleFilePath as ModuleFilePath,
174
+ "" as ModulePath,
175
+ { validate: false },
176
+ );
177
+ return result.source;
178
+ } finally {
179
+ service.dispose();
180
+ }
181
+ }
182
+
183
+ function errorsOf(events: ValidationEvent[]) {
184
+ return events.filter(
185
+ (e) =>
186
+ e.type === "validation-error" || e.type === "validation-fixable-error",
187
+ );
188
+ }
189
+
190
+ describe("val validate with remote image and file collections", () => {
191
+ let root: string;
192
+
193
+ beforeEach(() => {
194
+ const tmpBase = path.join(__dirname, ".tmp");
195
+ fs.mkdirSync(tmpBase, { recursive: true });
196
+ root = fs.mkdtempSync(path.join(tmpBase, "imageset-"));
197
+ for (const file of ["val.config.ts", "tsconfig.json"]) {
198
+ fs.copyFileSync(path.join(BASIC_FIXTURE, file), path.join(root, file));
199
+ }
200
+ const files: Record<string, string | Buffer> = {
201
+ ...PROJECT,
202
+ [LOGO.slice(1)]: PNG,
203
+ [DOC.slice(1)]: "%PDF-1.4\n",
204
+ "public/val/local/x_00000.png": PNG,
205
+ };
206
+ for (const [file, content] of Object.entries(files)) {
207
+ fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
208
+ fs.writeFileSync(path.join(root, file), content);
209
+ }
210
+ });
211
+
212
+ afterEach(() => {
213
+ fs.rmSync(root, { recursive: true, force: true });
214
+ });
215
+
216
+ test("--fix uploads the entries, renames their keys, and points the page's field and rich text image at them", async () => {
217
+ fs.mkdirSync(path.join(root, ".val"), { recursive: true });
218
+ fs.writeFileSync(
219
+ path.join(root, ".val", "pat.json"),
220
+ JSON.stringify({ pat: "test-pat" }),
221
+ );
222
+ const uploaded: string[] = [];
223
+ const remote: IValRemote = {
224
+ remoteHost: DEFAULT_VAL_REMOTE_HOST,
225
+ getSettings: async () => ({
226
+ success: true,
227
+ data: {
228
+ publicProjectId: "pubproj",
229
+ remoteFileBuckets: [{ bucket: "01" }],
230
+ },
231
+ }),
232
+ uploadFile: async (_project, _bucket, fileHash) => {
233
+ uploaded.push(fileHash);
234
+ return { success: true };
235
+ },
236
+ };
237
+ const modules = [
238
+ "content/cards.val.ts",
239
+ "content/files.val.ts",
240
+ "content/images.val.ts",
241
+ "content/page.val.ts",
242
+ ];
243
+ // Before: the page is valid against both collections, and the two
244
+ // entries are fixable.
245
+ expect(
246
+ errorsOf(await run(root, modules, false)).map((e) => e.sourcePath),
247
+ ).toEqual([
248
+ `/content/files.val.ts?p="${DOC}"`,
249
+ `/content/images.val.ts?p="${LOGO}"`,
250
+ ]);
251
+
252
+ await run(root, modules, true, remote);
253
+ expect(uploaded).toHaveLength(2);
254
+
255
+ const images = await sourceOf(root, "/content/images.val.ts");
256
+ const [logoRef] = Object.keys(images ?? {});
257
+ expect(logoRef).toMatch(
258
+ /^https:\/\/.*pubproj.*public\/val\/images\/logo_abcde\.png$/,
259
+ );
260
+ const files = await sourceOf(root, "/content/files.val.ts");
261
+ const [docRef] = Object.keys(files ?? {});
262
+ expect(docRef).toMatch(
263
+ /^https:\/\/.*pubproj.*public\/val\/files\/doc_12345\.pdf$/,
264
+ );
265
+ expect(await sourceOf(root, "/content/page.val.ts")).toEqual({
266
+ hero: { path: logoRef, alt: "Kept" },
267
+ card: { path: logoRef },
268
+ body: [
269
+ {
270
+ tag: "p",
271
+ children: ["Look: ", { tag: "img", src: { path: logoRef } }],
272
+ },
273
+ ],
274
+ attachment: { path: docRef },
275
+ });
276
+ expect(
277
+ JSON.parse(
278
+ fs.readFileSync(path.join(root, "content/cards/a.val.json"), "utf-8"),
279
+ ),
280
+ ).toEqual({ image: { path: logoRef } });
281
+
282
+ // And all of them are valid against each other afterwards.
283
+ const again = await run(root, modules, false);
284
+ expect(errorsOf(again)).toEqual([]);
285
+ expect(again.at(-1)).toEqual({ type: "summary-success" });
286
+ });
287
+
288
+ describe("a key that is wrong as a key", () => {
289
+ test("is reported with what core says, and the module is not valid", async () => {
290
+ const events = await run(root, ["content/local-images.val.ts"], false);
291
+ expect(errorsOf(events)).toEqual([
292
+ expect.objectContaining({
293
+ type: "validation-error",
294
+ message: expect.stringContaining("Remote URLs are not allowed"),
295
+ keyError: true,
296
+ }),
297
+ ]);
298
+ expect(events.filter((e) => e.type === "file-valid")).toEqual([]);
299
+ expect(events.at(-1)).toEqual({ type: "summary-errors", count: 1 });
300
+ });
301
+
302
+ test("--fix leaves it to a person, and still reports it", async () => {
303
+ const before = fs.readFileSync(
304
+ path.join(root, "content/local-images.val.ts"),
305
+ "utf-8",
306
+ );
307
+ const events = await run(root, ["content/local-images.val.ts"], true);
308
+ expect(errorsOf(events)).toEqual([
309
+ expect.objectContaining({
310
+ type: "validation-error",
311
+ message: expect.stringContaining("Remote URLs are not allowed"),
312
+ }),
313
+ ]);
314
+ expect(events.at(-1)).toEqual({ type: "summary-errors", count: 1 });
315
+ expect(
316
+ fs.readFileSync(
317
+ path.join(root, "content/local-images.val.ts"),
318
+ "utf-8",
319
+ ),
320
+ ).toBe(before);
321
+ });
322
+ });
323
+
324
+ /**
325
+ * The singular `image:check-remote` is core's "was not checked" marker, put
326
+ * on every remote image whether or not it is wrong. It stays a hand-off to
327
+ * `createFixPatch`, which checks the ref and reports only what is wrong —
328
+ * this pins that the hand-off does report it.
329
+ */
330
+ test("a field's malformed remote ref is still reported, through the check createFixPatch makes", async () => {
331
+ const events = await run(root, ["content/remote-image.val.ts"], false);
332
+ expect(errorsOf(events)).toEqual([
333
+ expect.objectContaining({
334
+ sourcePath: "/content/remote-image.val.ts",
335
+ message: expect.stringContaining("not-a-ref.png"),
336
+ }),
337
+ ]);
338
+ expect(events.at(-1)).toEqual({ type: "summary-errors", count: 1 });
339
+ });
340
+ });
@@ -125,6 +125,52 @@ describe("runValidation", () => {
125
125
  });
126
126
  });
127
127
 
128
+ describe("a module where --fix repairs one error and not the other", () => {
129
+ const FILE = "content/basic-fixable-and-unfixable.val.ts";
130
+ const runOn = async (fix: boolean) => {
131
+ const events: ValidationEvent[] = [];
132
+ for await (const event of runValidation({
133
+ root: tmpDir,
134
+ fix,
135
+ valFiles: [FILE],
136
+ project: undefined,
137
+ remote: mockRemote,
138
+ fs: createDefaultValFSHost(),
139
+ })) {
140
+ events.push(event);
141
+ }
142
+ return events;
143
+ };
144
+
145
+ test("is not reported valid after --fix, and counts only what is left", async () => {
146
+ const events = await runOn(true);
147
+
148
+ expect(events.filter((e) => e.type === "fix-applied")).toHaveLength(1);
149
+ expect(events.filter((e) => e.type === "validation-error")).toEqual([
150
+ expect.objectContaining({
151
+ sourcePath: `/${FILE}?p="title"`,
152
+ }),
153
+ ]);
154
+ // One fixed and one left must not add up to "valid".
155
+ expect(events.filter((e) => e.type === "file-valid")).toEqual([]);
156
+ expect(events).toContainEqual(
157
+ expect.objectContaining({
158
+ type: "file-error-count",
159
+ file: `/${FILE}`,
160
+ errorCount: 1,
161
+ }),
162
+ );
163
+ expect(events.at(-1)).toEqual({ type: "summary-errors", count: 1 });
164
+ });
165
+
166
+ test("without --fix, reports both", async () => {
167
+ const events = await runOn(false);
168
+
169
+ expect(events.filter((e) => e.type === "file-valid")).toEqual([]);
170
+ expect(events.at(-1)).toEqual({ type: "summary-errors", count: 2 });
171
+ });
172
+ });
173
+
128
174
  test("handles module with both s.image and s.images", async () => {
129
175
  const events: ValidationEvent[] = [];
130
176
 
@@ -175,8 +175,9 @@ export async function* runValidation({
175
175
  };
176
176
  return;
177
177
  } else {
178
+ // What is still wrong with this module once every handler has run: an
179
+ // error that was fixed is not counted, one that was not is.
178
180
  let fileErrors = 0;
179
- let fixedErrors = 0;
180
181
  if (valModule.errors) {
181
182
  if (valModule.errors.validation) {
182
183
  // Resolve schema/source fixes (keyof:check-keys, router:check-route)
@@ -265,7 +266,6 @@ export async function* runValidation({
265
266
  }
266
267
 
267
268
  if (result.appliedFix) {
268
- fixedErrors += 1;
269
269
  yield { type: "fix-applied", file, sourcePath };
270
270
  }
271
271
 
@@ -306,7 +306,6 @@ export async function* runValidation({
306
306
  sourcePath: other.moduleFilePath,
307
307
  };
308
308
  }
309
- fixedErrors += 1;
310
309
  yield { type: "fix-applied", file, sourcePath };
311
310
  } else if (
312
311
  !fix &&
@@ -339,8 +338,11 @@ export async function* runValidation({
339
338
  }
340
339
  }
341
340
  }
341
+ // Valid means nothing is left, not that as many errors were fixed as
342
+ // are left: one fix and one error that no fix touches add up to the
343
+ // same count, and printed a module with an error in it as valid.
342
344
  if (
343
- fixedErrors === fileErrors &&
345
+ fileErrors === 0 &&
344
346
  (!valModule.errors.fatal || valModule.errors.fatal.length == 0)
345
347
  ) {
346
348
  yield {
@@ -15,7 +15,7 @@ import {
15
15
  } from "./runValidation";
16
16
 
17
17
  /**
18
- * `val validate` over a project with an `s.videoset()`, end to end: module
18
+ * `val validate` over a project with an `s.videodir()`, end to end: module
19
19
  * loading, the fix handlers, the patches and the `.val.ts` rewrite.
20
20
  *
21
21
  * The project is written here rather than kept as a fixture because its whole
@@ -55,7 +55,7 @@ export default modules(config, [
55
55
 
56
56
  export default c.define(
57
57
  "/content/videos.val.ts",
58
- s.videoset({ dir: "/public/val/videos" }),
58
+ s.videodir("/public/val/videos"),
59
59
  {
60
60
  "/public/val/videos/clip_abcde.mp4": { alt: "A clip" },
61
61
  "/public/val/videos/intro_05198/master.m3u8": {
@@ -72,7 +72,7 @@ export default c.define(
72
72
 
73
73
  export default c.define(
74
74
  "/content/videos-remote.val.ts",
75
- s.videoset({ dir: "/public/val/remote-videos" }).remote(),
75
+ s.videodir("/public/val/remote-videos").remote(),
76
76
  {
77
77
  "/public/val/remote-videos/clip_fghij.mp4": {
78
78
  mimeType: "video/mp4",
@@ -155,7 +155,7 @@ async function sourceOf(root: string, moduleFilePath: string) {
155
155
  }
156
156
  }
157
157
 
158
- describe("val validate with an s.videoset()", () => {
158
+ describe("val validate with an s.videodir()", () => {
159
159
  let root: string;
160
160
 
161
161
  beforeEach(() => {