@valbuild/language-server 0.140.2 → 0.141.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,12 +6,12 @@ var fs = require('fs');
6
6
  var path = require('path');
7
7
  var ts = require('typescript');
8
8
  var core = require('@valbuild/core');
9
- var server = require('@valbuild/server');
10
9
  var vscodeLanguageserver = require('vscode-languageserver');
11
10
  var node = require('vscode-languageserver/node');
12
11
  var vscodeLanguageserverTextdocument = require('vscode-languageserver-textdocument');
13
12
  var crypto = require('crypto');
14
13
  var node_module = require('node:module');
14
+ var server = require('@valbuild/server');
15
15
  var internal = require('@valbuild/shared/internal');
16
16
  var fp = require('@valbuild/core/fp');
17
17
  var patch = require('@valbuild/core/patch');
@@ -183,7 +183,7 @@ var packageJson = {
183
183
  "lsp",
184
184
  "language-server"
185
185
  ],
186
- version: "0.140.2",
186
+ version: "0.141.0",
187
187
  bin: {
188
188
  "val-language-server": "./bin.js"
189
189
  },
@@ -1414,7 +1414,11 @@ const FALLBACK_RANGE = {
1414
1414
  * upload/download fixes stay: `*:upload-remote` is reported on a local path
1415
1415
  * and a missing file really is its problem.
1416
1416
  */
1417
- const FILE_FIXES = ["image:add-metadata", "image:check-metadata", "image:upload-remote", "image:download-remote", "file:add-metadata", "file:check-metadata", "file:upload-remote", "file:download-remote"];
1417
+ const FILE_FIXES = ["image:add-metadata", "image:check-metadata", "image:upload-remote", "image:download-remote", "file:add-metadata", "file:check-metadata", "file:upload-remote", "file:download-remote",
1418
+ // Local only: core never asks a remote video for its metadata.
1419
+ "video:add-metadata",
1420
+ // The same, for a video set's entry, whose file is its KEY.
1421
+ "videos:add-metadata"];
1418
1422
  function build(range, message, data) {
1419
1423
  return {
1420
1424
  range,
@@ -1493,7 +1497,8 @@ function createValDiagnostics({
1493
1497
  const missing = valRoot && fixes?.some(fix => FILE_FIXES.includes(fix)) ? missingFileRef({
1494
1498
  sourcePath,
1495
1499
  content,
1496
- valRoot
1500
+ valRoot,
1501
+ fixes
1497
1502
  }) : undefined;
1498
1503
  if (missing) {
1499
1504
  diagnostics.push(build(rangeOf(sourcePath, modulePathMap, "path"), `File ${missing} does not exist`, {
@@ -1578,7 +1583,8 @@ function createValDiagnostics({
1578
1583
  function missingFileRef({
1579
1584
  sourcePath,
1580
1585
  content,
1581
- valRoot
1586
+ valRoot,
1587
+ fixes
1582
1588
  }) {
1583
1589
  if (!content.source || !content.schema) {
1584
1590
  return undefined;
@@ -1586,7 +1592,9 @@ function missingFileRef({
1586
1592
  try {
1587
1593
  const [, modulePath] = core.Internal.splitModuleFilePathAndModulePath(sourcePath);
1588
1594
  const resolved = core.Internal.resolvePath(modulePath, content.source, content.schema);
1589
- const ref = resolved.source?.path;
1595
+ const ref = fixes.includes("videos:add-metadata") ?
1596
+ // A video set entry is keyed by its file; the value has no `path`.
1597
+ core.Internal.splitModulePath(modulePath).pop() : resolved.source?.path;
1590
1598
  if (typeof ref !== "string") {
1591
1599
  return undefined;
1592
1600
  }
@@ -1721,7 +1729,7 @@ function longestResolvedPrefixRange(modulePath, modulePathMap) {
1721
1729
  * Gallery checks core emits unconditionally.
1722
1730
  *
1723
1731
  * `RecordSchema.validate` attaches these to every `s.imageset()` / `s.fileset()`
1724
- * module whether or not anything is actually wrong (see
1732
+ * / `s.videoset()` module whether or not anything is actually wrong (see
1725
1733
  * `packages/core/src/schema/record.ts`): they are placeholders asking someone to
1726
1734
  * go and look. `val validate` looks by running the matching fix handler, which
1727
1735
  * reports success when the directory is unique and every file is accounted for.
@@ -1730,7 +1738,7 @@ function longestResolvedPrefixRange(modulePath, modulePathMap) {
1730
1738
  * on every gallery module in the project — which is worse than useless, because
1731
1739
  * a warning that is always there is one nobody reads.
1732
1740
  */
1733
- const GALLERY_CHECK_FIXES = ["images:check-unique-folder", "files:check-unique-folder", "images:check-all-files", "files:check-all-files"];
1741
+ const GALLERY_CHECK_FIXES = ["images:check-unique-folder", "files:check-unique-folder", "videos:check-unique-folder", "images:check-all-files", "files:check-all-files", "videos:check-all-files"];
1734
1742
  function isGalleryCheckFix(fix) {
1735
1743
  return GALLERY_CHECK_FIXES.includes(fix);
1736
1744
  }
@@ -1830,7 +1838,9 @@ function dropDeferredPlaceholders(validation) {
1830
1838
  * A gallery-backed field pointing at something the gallery does not have.
1831
1839
  *
1832
1840
  * `ImageSchema.validate` reports this (`packages/core/src/schema/image.ts`:
1833
- * "The gallery does not have an image at '…'") but attaches no `ValidationFix`,
1841
+ * "The gallery does not have an image at '…'"), and `VideoSchema.validate` the
1842
+ * same for a field picked from an `s.videoset()` ("The set does not have a
1843
+ * video at '…'"), but neither attaches a `ValidationFix`,
1834
1844
  * because the remedy is not a change to this module: either the gallery gains an
1835
1845
  * entry, or the file moves into the gallery's directory. Both are edits to
1836
1846
  * somewhere else, which is not what a `ValidationFix` describes.
@@ -1856,7 +1866,7 @@ function galleryMembershipAt({
1856
1866
  return undefined;
1857
1867
  }
1858
1868
  const schema = resolved.schema;
1859
- if (!schema || typeof schema !== "object" || !("type" in schema) || schema.type !== "image" && schema.type !== "file") {
1869
+ if (!schema || typeof schema !== "object" || !("type" in schema) || schema.type !== "image" && schema.type !== "file" && schema.type !== "video") {
1860
1870
  return undefined;
1861
1871
  }
1862
1872
  const referencedModule = "referencedModule" in schema && typeof schema.referencedModule === "string" ? schema.referencedModule : undefined;
@@ -1923,6 +1933,100 @@ function minimalTextEdit(before, after, document) {
1923
1933
  };
1924
1934
  }
1925
1935
 
1936
+ /**
1937
+ * Conversions between LSP document URIs and the paths Val uses.
1938
+ *
1939
+ * Deliberately minimal rather than pulling in `vscode-uri`: the server only ever
1940
+ * deals with local `file:` URIs, and keeping this small makes the assumptions
1941
+ * visible.
1942
+ */
1943
+
1944
+ /** Matches the Val module files the server validates. */
1945
+ const VAL_MODULE_RE = /\.val\.(ts|js|tsx|jsx)$/;
1946
+
1947
+ /** `file:` with an optional authority, capturing authority and path apart. */
1948
+ const FILE_URI_RE = /^file:\/\/([^/?#]*)([^?#]*)/i;
1949
+
1950
+ /** A `/c:/...` prefix, i.e. a Windows drive letter as it appears in a URI. */
1951
+ const URI_DRIVE_LETTER_RE = /^\/([a-zA-Z]:)(\/|$)/;
1952
+
1953
+ /**
1954
+ * Matches the `*.val.json` files that hold `.jsonValues()` entry content. Not a
1955
+ * Val module: nothing validates one on its own, but editing it changes what a
1956
+ * module validates to, so the server has to notice.
1957
+ */
1958
+ const VAL_JSON_ENTRY_RE = /\.val\.json$/;
1959
+ function isValModuleUri(uri) {
1960
+ return VAL_MODULE_RE.test(uri);
1961
+ }
1962
+ function isValJsonEntryUri(uri) {
1963
+ return VAL_JSON_ENTRY_RE.test(uri);
1964
+ }
1965
+
1966
+ /**
1967
+ * `decodeURIComponent` throws on malformed escapes (`%zz`). A client that sends
1968
+ * one is broken, but that should not take the server down: fall back to the
1969
+ * undecoded text so the path is at worst not found.
1970
+ */
1971
+ function decodeSafely(value) {
1972
+ try {
1973
+ return decodeURIComponent(value);
1974
+ } catch {
1975
+ return value;
1976
+ }
1977
+ }
1978
+
1979
+ /**
1980
+ * `file:///a/b.val.ts` -> `/a/b.val.ts`
1981
+ *
1982
+ * Percent escapes are decoded, Windows drive letters lose the leading slash
1983
+ * (`file:///c%3A/a` -> `c:/a`), and a URI with an authority is read as a UNC
1984
+ * path (`file://host/share/a` -> `//host/share/a`). Anything that is not a
1985
+ * `file:` URI is passed through unchanged, since callers also hand us plain
1986
+ * paths.
1987
+ */
1988
+ function uriToPath(uri) {
1989
+ const match = FILE_URI_RE.exec(uri);
1990
+ if (!match) {
1991
+ return uri;
1992
+ }
1993
+ const authority = decodeSafely(match[1]);
1994
+ const fsPath = decodeSafely(match[2] || "/");
1995
+ if (authority) {
1996
+ return `//${authority}${fsPath}`;
1997
+ }
1998
+ return fsPath.replace(URI_DRIVE_LETTER_RE, "$1$2");
1999
+ }
2000
+
2001
+ /**
2002
+ * `/a/b.val.ts` -> `file:///a/b.val.ts`
2003
+ *
2004
+ * The escaping matches what VS Code produces (drive-letter colons included), so
2005
+ * that a URI built here can be looked up in the open-document map keyed by the
2006
+ * URIs the client sent.
2007
+ */
2008
+ function pathToUri(fsPath) {
2009
+ const normalized = fsPath.split(path__default["default"].sep).join("/");
2010
+ const rooted = normalized.startsWith("/") ? normalized : `/${normalized}`;
2011
+ return `file://${rooted.split("/").map(segment => encodeURIComponent(segment)).join("/")}`;
2012
+ }
2013
+
2014
+ /**
2015
+ * Convert a document URI into the `ModuleFilePath` Val addresses it by: a
2016
+ * POSIX-style path relative to the Val root, with a leading slash.
2017
+ *
2018
+ * Returns `undefined` when the file lies outside the Val root — one server
2019
+ * serves exactly one root, so another root's files are not its business.
2020
+ */
2021
+ function toModuleFilePath(valRoot, uri) {
2022
+ const fsPath = uriToPath(uri);
2023
+ const relative = path__default["default"].relative(valRoot, fsPath);
2024
+ if (!relative || relative.startsWith("..") || path__default["default"].isAbsolute(relative)) {
2025
+ return undefined;
2026
+ }
2027
+ return `/${relative.split(path__default["default"].sep).join("/")}`;
2028
+ }
2029
+
1926
2030
  /**
1927
2031
  * `workspace/executeCommand` handlers.
1928
2032
  *
@@ -1959,7 +2063,10 @@ const REMOTE_FIX_COMMANDS = {
1959
2063
  "images:upload-remote": VAL_UPLOAD_REMOTE_COMMAND,
1960
2064
  "files:upload-remote": VAL_UPLOAD_REMOTE_COMMAND,
1961
2065
  "image:download-remote": VAL_DOWNLOAD_REMOTE_COMMAND,
1962
- "file:download-remote": VAL_DOWNLOAD_REMOTE_COMMAND
2066
+ "file:download-remote": VAL_DOWNLOAD_REMOTE_COMMAND,
2067
+ "video:upload-remote": VAL_UPLOAD_REMOTE_COMMAND,
2068
+ "video:download-remote": VAL_DOWNLOAD_REMOTE_COMMAND,
2069
+ "videos:upload-remote": VAL_UPLOAD_REMOTE_COMMAND
1963
2070
  };
1964
2071
  const REMOTE_FIX_TITLES = {
1965
2072
  "image:upload-remote": "Val: upload this image to Val Remote",
@@ -1967,7 +2074,10 @@ const REMOTE_FIX_TITLES = {
1967
2074
  "images:upload-remote": "Val: upload this gallery's images to Val Remote",
1968
2075
  "files:upload-remote": "Val: upload this gallery's files to Val Remote",
1969
2076
  "image:download-remote": "Val: download this image into the project",
1970
- "file:download-remote": "Val: download this file into the project"
2077
+ "file:download-remote": "Val: download this file into the project",
2078
+ "video:upload-remote": "Val: upload this video (and its poster and captions) to Val Remote",
2079
+ "video:download-remote": "Val: download this video (and its poster and captions) into the project",
2080
+ "videos:upload-remote": "Val: upload this video to Val Remote (and point the fields using it there)"
1971
2081
  };
1972
2082
 
1973
2083
  /** Arguments a remote-fix command is invoked with. */
@@ -2184,16 +2294,67 @@ function createValCommands(deps) {
2184
2294
  if (!edit) {
2185
2295
  return;
2186
2296
  }
2297
+ const changes = {
2298
+ [args.uri]: [edit]
2299
+ };
2300
+ // A fix that renames what other modules name (a video set's key) rewrites
2301
+ // them in the same edit, so one undo takes back all of it.
2302
+ const unwritten = [];
2303
+ for (const other of outcome.otherModulePatches ?? []) {
2304
+ const otherEdit = editForModulePatch(project.valRoot, other);
2305
+ if (otherEdit) {
2306
+ (changes[otherEdit.uri] ??= []).push(otherEdit.edit);
2307
+ } else {
2308
+ unwritten.push(other.moduleFilePath);
2309
+ }
2310
+ }
2187
2311
  // Applied through the client so it lands in the editor's undo history,
2188
2312
  // rather than written to disk under the user's cursor.
2189
- await connection.sendRequest(vscodeLanguageserver.ApplyWorkspaceEditRequest.type, {
2313
+ const applied = await connection.sendRequest(vscodeLanguageserver.ApplyWorkspaceEditRequest.type, {
2190
2314
  label: REMOTE_FIX_TITLES[args.fix] ?? `Val: ${args.fix}`,
2191
2315
  edit: {
2192
- changes: {
2193
- [args.uri]: [edit]
2194
- }
2316
+ changes
2195
2317
  }
2196
2318
  });
2319
+ // The client may refuse the edit — a document changed under it, say. The
2320
+ // files have moved by then, but nothing names them yet, so the fix did
2321
+ // NOT happen and must not read as though it had. Running it again is
2322
+ // safe: refs are content-addressed, so the same bytes go to the same refs.
2323
+ if (!applied.applied) {
2324
+ connection.window.showErrorMessage(`Val: ${args.fix} moved the files, but the editor did not apply the change${applied.failureReason ? ` (${applied.failureReason})` : ""}. Nothing was rewritten: run the fix again.`);
2325
+ return;
2326
+ }
2327
+ if (unwritten.length > 0) {
2328
+ connection.window.showWarningMessage(`Val: ${args.fix} could not rewrite ${unwritten.join(", ")}. Run "val validate" to see what still points at the old path.`);
2329
+ }
2330
+ }
2331
+
2332
+ /**
2333
+ * The edit for a patch to another module than the one the command ran on,
2334
+ * against the editor's buffer when the file is open and the disk otherwise.
2335
+ */
2336
+ function editForModulePatch(valRoot, other) {
2337
+ const uri = pathToUri(path__default["default"].join(valRoot, other.moduleFilePath));
2338
+ let otherDocument = deps.getDocument(uri);
2339
+ if (!otherDocument) {
2340
+ let text;
2341
+ try {
2342
+ text = fs__default["default"].readFileSync(path__default["default"].join(valRoot, other.moduleFilePath), "utf8");
2343
+ } catch {
2344
+ return undefined;
2345
+ }
2346
+ otherDocument = vscodeLanguageserverTextdocument.TextDocument.create(uri, "typescript", 0, text);
2347
+ }
2348
+ const otherBefore = otherDocument.getText();
2349
+ const otherPatched = server.patchSourceFile(otherBefore, other.patch);
2350
+ if (fp.result.isErr(otherPatched)) {
2351
+ return undefined;
2352
+ }
2353
+ const otherEdit = minimalTextEdit(otherBefore, otherPatched.value.text, otherDocument);
2354
+ return otherEdit && {
2355
+ uri,
2356
+ edit: otherEdit
2357
+ };
2197
2358
  }
2198
2359
  return {
2199
2360
  async execute(command, args) {
@@ -2376,100 +2537,6 @@ function valModulesEntryText({
2376
2537
  return hasElements ? `,\n${indentation}${entry}` : `\n${indentation}${entry}\n${indentation.slice(2)}`;
2377
2538
  }
2378
2539
 
2379
- /**
2380
- * Conversions between LSP document URIs and the paths Val uses.
2381
- *
2382
- * Deliberately minimal rather than pulling in `vscode-uri`: the server only ever
2383
- * deals with local `file:` URIs, and keeping this small makes the assumptions
2384
- * visible.
2385
- */
2386
-
2387
- /** Matches the Val module files the server validates. */
2388
- const VAL_MODULE_RE = /\.val\.(ts|js|tsx|jsx)$/;
2389
-
2390
- /** `file:` with an optional authority, capturing authority and path apart. */
2391
- const FILE_URI_RE = /^file:\/\/([^/?#]*)([^?#]*)/i;
2392
-
2393
- /** A `/c:/...` prefix, i.e. a Windows drive letter as it appears in a URI. */
2394
- const URI_DRIVE_LETTER_RE = /^\/([a-zA-Z]:)(\/|$)/;
2395
-
2396
- /**
2397
- * Matches the `*.val.json` files that hold `.jsonValues()` entry content. Not a
2398
- * Val module: nothing validates one on its own, but editing it changes what a
2399
- * module validates to, so the server has to notice.
2400
- */
2401
- const VAL_JSON_ENTRY_RE = /\.val\.json$/;
2402
- function isValModuleUri(uri) {
2403
- return VAL_MODULE_RE.test(uri);
2404
- }
2405
- function isValJsonEntryUri(uri) {
2406
- return VAL_JSON_ENTRY_RE.test(uri);
2407
- }
2408
-
2409
- /**
2410
- * `decodeURIComponent` throws on malformed escapes (`%zz`). A client that sends
2411
- * one is broken, but that should not take the server down: fall back to the
2412
- * undecoded text so the path is at worst not found.
2413
- */
2414
- function decodeSafely(value) {
2415
- try {
2416
- return decodeURIComponent(value);
2417
- } catch {
2418
- return value;
2419
- }
2420
- }
2421
-
2422
- /**
2423
- * `file:///a/b.val.ts` -> `/a/b.val.ts`
2424
- *
2425
- * Percent escapes are decoded, Windows drive letters lose the leading slash
2426
- * (`file:///c%3A/a` -> `c:/a`), and a URI with an authority is read as a UNC
2427
- * path (`file://host/share/a` -> `//host/share/a`). Anything that is not a
2428
- * `file:` URI is passed through unchanged, since callers also hand us plain
2429
- * paths.
2430
- */
2431
- function uriToPath(uri) {
2432
- const match = FILE_URI_RE.exec(uri);
2433
- if (!match) {
2434
- return uri;
2435
- }
2436
- const authority = decodeSafely(match[1]);
2437
- const fsPath = decodeSafely(match[2] || "/");
2438
- if (authority) {
2439
- return `//${authority}${fsPath}`;
2440
- }
2441
- return fsPath.replace(URI_DRIVE_LETTER_RE, "$1$2");
2442
- }
2443
-
2444
- /**
2445
- * `/a/b.val.ts` -> `file:///a/b.val.ts`
2446
- *
2447
- * The escaping matches what VS Code produces (drive-letter colons included), so
2448
- * that a URI built here can be looked up in the open-document map keyed by the
2449
- * URIs the client sent.
2450
- */
2451
- function pathToUri(fsPath) {
2452
- const normalized = fsPath.split(path__default["default"].sep).join("/");
2453
- const rooted = normalized.startsWith("/") ? normalized : `/${normalized}`;
2454
- return `file://${rooted.split("/").map(segment => encodeURIComponent(segment)).join("/")}`;
2455
- }
2456
-
2457
- /**
2458
- * Convert a document URI into the `ModuleFilePath` Val addresses it by: a
2459
- * POSIX-style path relative to the Val root, with a leading slash.
2460
- *
2461
- * Returns `undefined` when the file lies outside the Val root — one server
2462
- * serves exactly one root, so another root's files are not its business.
2463
- */
2464
- function toModuleFilePath(valRoot, uri) {
2465
- const fsPath = uriToPath(uri);
2466
- const relative = path__default["default"].relative(valRoot, fsPath);
2467
- if (!relative || relative.startsWith("..") || path__default["default"].isAbsolute(relative)) {
2468
- return undefined;
2469
- }
2470
- return `/${relative.split(path__default["default"].sep).join("/")}`;
2471
- }
2472
-
2473
2540
  const jsonOps = new patch.JSONOps();
2474
2541
 
2475
2542
  /**
@@ -2647,9 +2714,19 @@ function supportsResourceOperation(capabilities, operation) {
2647
2714
  * silently required auth would just fail.
2648
2715
  */
2649
2716
  const LOCAL_FIXES = ["image:add-metadata", "image:check-metadata", "file:add-metadata", "file:check-metadata",
2717
+ // A local video's mimeType, width, height and duration, read from the file
2718
+ // (or, for an HLS stream, from its playlists) on disk.
2719
+ "video:add-metadata",
2650
2720
  // Gallery metadata: createFixPatch reads each entry's file and corrects the
2651
2721
  // stored metadata, dropping entries whose file has gone. Filesystem only.
2652
2722
  "images:check-all-files", "files:check-all-files",
2723
+ // A video set's entries against its directory: entries whose file has gone
2724
+ // are dropped, and videos in the directory the set does not list are added
2725
+ // with what is read from their bytes. Filesystem only.
2726
+ "videos:check-all-files",
2727
+ // A video set entry's mimeType, width, height and duration, read from the
2728
+ // file at its KEY. The set's twin of `video:add-metadata`.
2729
+ "videos:add-metadata",
2653
2730
  // A view pointing at the wrong module: the schema names the right one, so the
2654
2731
  // fix is a value the patch already knows. Nothing read, nothing fetched.
2655
2732
  "view:check-module"];
@@ -2672,8 +2749,11 @@ const FIX_TITLES = {
2672
2749
  "image:check-metadata": "Val: update image metadata",
2673
2750
  "file:add-metadata": "Val: add file metadata",
2674
2751
  "file:check-metadata": "Val: update file metadata",
2752
+ "video:add-metadata": "Val: add video metadata",
2675
2753
  "images:check-all-files": "Val: update gallery image metadata",
2676
2754
  "files:check-all-files": "Val: update gallery file metadata",
2755
+ "videos:check-all-files": "Val: sync the video set with its directory",
2756
+ "videos:add-metadata": "Val: add video metadata",
2677
2757
  "jsonValues:extract-entry": "Val: move entry into its own .val.json",
2678
2758
  "view:check-module": "Val: point this view at the module its schema names"
2679
2759
  };
@@ -3241,7 +3321,9 @@ function getValCompletionContext(sourceFile, offset) {
3241
3321
  }
3242
3322
 
3243
3323
  /** The properties Val computes from a file's bytes. */
3244
- const MEDIA_METADATA_KEYS = ["width", "height", "mimeType"];
3324
+ const MEDIA_METADATA_KEYS = ["width", "height", "mimeType",
3325
+ // A video's: never read for an image or a file, so never written for one.
3326
+ "duration"];
3245
3327
  /**
3246
3328
  * Re-find the media object literal whose `path` value starts at
3247
3329
  * `pathValueStart`, and report where its metadata siblings are *now*.
@@ -3423,6 +3505,24 @@ async function resolveValCompletion({
3423
3505
  */
3424
3506
  async function readMetadata(data) {
3425
3507
  try {
3508
+ if (data.mediaType === "video") {
3509
+ // Only what could be read: a video whose duration cannot be read still
3510
+ // gets its mime type, and `video:add-metadata` reports the rest. From
3511
+ // the file rather than a buffer: only its headers are read.
3512
+ const metadata = await server.extractVideoMetadataFromFile(data.filePath);
3513
+ const rendered = {};
3514
+ if (metadata.width !== undefined && metadata.height !== undefined) {
3515
+ rendered.width = String(metadata.width);
3516
+ rendered.height = String(metadata.height);
3517
+ }
3518
+ if (metadata.mimeType) {
3519
+ rendered.mimeType = JSON.stringify(metadata.mimeType);
3520
+ }
3521
+ if (metadata.duration !== undefined) {
3522
+ rendered.duration = String(metadata.duration);
3523
+ }
3524
+ return Object.keys(rendered).length > 0 ? rendered : undefined;
3525
+ }
3426
3526
  const buffer = fs__default["default"].readFileSync(data.filePath);
3427
3527
  if (data.mediaType === "image") {
3428
3528
  const metadata = await server.extractImageMetadata(data.filePath, buffer);
@@ -3497,7 +3597,10 @@ function createSchemaDrivenCompletions({
3497
3597
  return [];
3498
3598
  }
3499
3599
  const directory = "dir" in container && typeof container.dir === "string" ? container.dir : undefined;
3500
- const galleryFiles = container.mediaType === "images" ? files.images(directory) : files.list(directory);
3600
+ const galleryFiles = container.mediaType === "images" ? files.images(directory) : container.mediaType === "videos" ?
3601
+ // A stream is keyed by its master playlist; its media playlists
3602
+ // and segments are part of it, never keys of their own.
3603
+ files.videos(directory) : files.list(directory);
3501
3604
  return items(galleryFiles.map(file => file.ref), vscodeLanguageserver.CompletionItemKind.File, range);
3502
3605
  }
3503
3606
 
@@ -3509,7 +3612,7 @@ function createSchemaDrivenCompletions({
3509
3612
  // was ignored.
3510
3613
  if (!isPropertyName && valueOfProperty === "path") {
3511
3614
  const container = resolveSchemaAt(parentModulePath(modulePath), source, schema);
3512
- if (container && typeof container === "object" && "type" in container && (container.type === "image" || container.type === "file")) {
3615
+ if (container && typeof container === "object" && "type" in container && (container.type === "image" || container.type === "file" || container.type === "video")) {
3513
3616
  return mediaPathItems({
3514
3617
  document,
3515
3618
  container,
@@ -3569,7 +3672,7 @@ function mediaPathItems({
3569
3672
  directory = gallery.dir;
3570
3673
  }
3571
3674
  }
3572
- const candidates = container.type === "image" ? files.images(directory) : files.list(directory);
3675
+ const candidates = container.type === "image" ? files.images(directory) : container.type === "video" ? files.videos(directory) : files.list(directory);
3573
3676
  return candidates.map((file, index) => {
3574
3677
  const data = {
3575
3678
  kind: "media-path",
@@ -3806,7 +3909,11 @@ async function createGalleryMembershipActions({
3806
3909
  if (register) {
3807
3910
  actions.push(register);
3808
3911
  }
3809
- if (allowRename && gallery.dir && onDisk) {
3912
+
3913
+ // A stream is a directory of files its master playlist names, and renaming
3914
+ // the master alone would leave every one of them behind.
3915
+ const isStream = gallery.mediaType === "video" && gallery.path.split("?")[0].toLowerCase().endsWith(".m3u8");
3916
+ if (allowRename && gallery.dir && onDisk && !isStream) {
3810
3917
  const move = createMoveIntoGalleryDirectoryAction({
3811
3918
  document,
3812
3919
  gallery,
@@ -3859,7 +3966,7 @@ async function createRegisterInGalleryAction({
3859
3966
  },
3860
3967
  newText: insertion.hasProperties ? `,\n${insertion.indentation}${entry}` : `\n${insertion.indentation}${entry}\n${insertion.indentation.slice(2)}`
3861
3968
  };
3862
- return vscodeLanguageserver.CodeAction.create(`Val: add ${path__default["default"].posix.basename(gallery.path)} to the gallery`, {
3969
+ return vscodeLanguageserver.CodeAction.create(`Val: add ${path__default["default"].posix.basename(gallery.path)} to the ${gallery.mediaType === "video" ? "video set" : "gallery"}`, {
3863
3970
  changes: {
3864
3971
  [pathToUri(galleryFile)]: [edit]
3865
3972
  }
@@ -3981,6 +4088,16 @@ function findRecordInsertion(sourceFile) {
3981
4088
  */
3982
4089
  async function readMetadataSource(filePath, mediaType) {
3983
4090
  try {
4091
+ if (mediaType === "video") {
4092
+ // All four or nothing: a set entry missing any of them is reported by
4093
+ // `videos:add-metadata`, which says why it could not be read — a better
4094
+ // place to land than a registered entry that is already wrong.
4095
+ const metadata = await server.extractVideoMetadataFromFile(filePath);
4096
+ if (!metadata.mimeType || metadata.width === undefined || metadata.height === undefined || metadata.duration === undefined) {
4097
+ return undefined;
4098
+ }
4099
+ return `mimeType: ${JSON.stringify(metadata.mimeType)}, width: ${metadata.width}, height: ${metadata.height}, duration: ${metadata.duration}, alt: null`;
4100
+ }
3984
4101
  const buffer = fs__default["default"].readFileSync(filePath);
3985
4102
  if (mediaType === "image") {
3986
4103
  const metadata = await server.extractImageMetadata(filePath, buffer);
@@ -4009,12 +4126,12 @@ function readFileOrUndefined(filePath) {
4009
4126
  /**
4010
4127
  * Lists the files a Val module may reference.
4011
4128
  *
4012
- * Val stores referenced files under `/public/val` by convention (configurable
4013
- * via `files.directory` in val.config), and a reference is written as the path
4014
- * *including* the `/public` prefix — so this returns Val-style refs directly.
4129
+ * Val stores referenced files under `/public/val` by convention, and a
4130
+ * reference is written as the path *including* the `/public` prefix — so this
4131
+ * returns Val-style refs directly.
4015
4132
  */
4016
4133
 
4017
- /** Default directory, matching `files.directory` in val.config. */
4134
+ /** Where Val stores the files a module references. */
4018
4135
  const DEFAULT_FILES_DIRECTORY = "/public/val";
4019
4136
 
4020
4137
  /**
@@ -4085,6 +4202,7 @@ function createPublicValFiles({
4085
4202
  return {
4086
4203
  list,
4087
4204
  images: dir => list(dir).filter(f => f.mimeType?.startsWith("image/")),
4205
+ videos: dir => list(dir).filter(f => f.mimeType?.startsWith("video/") || path__default["default"].posix.basename(f.ref) === "master.m3u8"),
4088
4206
  invalidate: () => {
4089
4207
  cache.clear();
4090
4208
  }
@@ -4451,24 +4569,6 @@ function createValLanguageServer(connection) {
4451
4569
  publicFiles = createPublicValFiles({
4452
4570
  valRoot: options.valRoot
4453
4571
  });
4454
- // A project can point `files.directory` somewhere other than /public/val, and
4455
- // media-path completions would list nothing if we assumed the default.
4456
- // Reading val.config is async and `initialize` is not, so the default stands
4457
- // until the real value arrives — completions cannot be requested before
4458
- // `initialize` returns anyway.
4459
- void server.findAndEvalValConfigFile(options.valRoot).then(config => {
4460
- const directory = config?.files?.directory;
4461
- if (directory && directory !== DEFAULT_FILES_DIRECTORY) {
4462
- publicFiles = createPublicValFiles({
4463
- valRoot: options.valRoot,
4464
- directory
4465
- });
4466
- }
4467
- }).catch(e => {
4468
- // A broken or unreadable val.config is reported per module by the
4469
- // service; here it only means "keep the default directory".
4470
- connection.console.warn(`Val: could not read val.config: ${e instanceof Error ? e.message : String(e)}`);
4471
- });
4472
4572
  project = createValProject({
4473
4573
  valRoot: options.valRoot,
4474
4574
  open: {