@valbuild/language-server 0.140.2 → 0.141.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.
@@ -2,12 +2,12 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import ts from 'typescript';
4
4
  import { Internal, DEFAULT_VAL_REMOTE_HOST, DEFAULT_CONTENT_HOST } from '@valbuild/core';
5
- import { fixHandlers, createService, analyzeValModule, createFixPatch, parsePersonalAccessTokenFile, getPersonalAccessTokenPath, startValLogin, awaitValLoginConfirmation, persistPersonalAccessToken, ValLoginError, findAndEvalValConfigFile, patchSourceFile, getSettings, uploadRemoteFile, findJsonEntryFilePath, rebaseContentOp, classifyJsonValuesOp, planJsonValuesEntryExtraction, extractImageMetadata, extractFileMetadata, findDefaultExport } from '@valbuild/server';
6
5
  import { DiagnosticSeverity, ShowDocumentRequest, ApplyWorkspaceEditRequest, CodeActionKind, CodeAction, MarkupContent, CompletionItemKind, DidChangeWatchedFilesNotification } from 'vscode-languageserver';
7
6
  import { TextDocuments, TextDocumentSyncKind, createConnection, ProposedFeatures } from 'vscode-languageserver/node';
8
7
  import { TextDocument } from 'vscode-languageserver-textdocument';
9
8
  import crypto from 'crypto';
10
9
  import { createRequire } from 'node:module';
10
+ import { fixHandlers, createService, analyzeValModule, createFixPatch, parsePersonalAccessTokenFile, getPersonalAccessTokenPath, startValLogin, awaitValLoginConfirmation, persistPersonalAccessToken, ValLoginError, findAndEvalValConfigFile, patchSourceFile, getSettings, uploadRemoteFile, findJsonEntryFilePath, rebaseContentOp, classifyJsonValuesOp, planJsonValuesEntryExtraction, extractVideoMetadataFromFile, extractImageMetadata, extractFileMetadata, findDefaultExport } from '@valbuild/server';
11
11
  import { resolveSchemaSourceFixes, SCHEMA_SOURCE_FIXES, getRoutesWithModulePaths } from '@valbuild/shared/internal';
12
12
  import { result } from '@valbuild/core/fp';
13
13
  import { JSONOps, applyPatch, deepClone } from '@valbuild/core/patch';
@@ -172,7 +172,7 @@ var packageJson = {
172
172
  "lsp",
173
173
  "language-server"
174
174
  ],
175
- version: "0.140.2",
175
+ version: "0.141.1",
176
176
  bin: {
177
177
  "val-language-server": "./bin.js"
178
178
  },
@@ -1403,7 +1403,11 @@ const FALLBACK_RANGE = {
1403
1403
  * upload/download fixes stay: `*:upload-remote` is reported on a local path
1404
1404
  * and a missing file really is its problem.
1405
1405
  */
1406
- 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"];
1406
+ 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",
1407
+ // Local only: core never asks a remote video for its metadata.
1408
+ "video:add-metadata",
1409
+ // The same, for a video set's entry, whose file is its KEY.
1410
+ "videos:add-metadata"];
1407
1411
  function build(range, message, data) {
1408
1412
  return {
1409
1413
  range,
@@ -1482,7 +1486,8 @@ function createValDiagnostics({
1482
1486
  const missing = valRoot && fixes?.some(fix => FILE_FIXES.includes(fix)) ? missingFileRef({
1483
1487
  sourcePath,
1484
1488
  content,
1485
- valRoot
1489
+ valRoot,
1490
+ fixes
1486
1491
  }) : undefined;
1487
1492
  if (missing) {
1488
1493
  diagnostics.push(build(rangeOf(sourcePath, modulePathMap, "path"), `File ${missing} does not exist`, {
@@ -1567,7 +1572,8 @@ function createValDiagnostics({
1567
1572
  function missingFileRef({
1568
1573
  sourcePath,
1569
1574
  content,
1570
- valRoot
1575
+ valRoot,
1576
+ fixes
1571
1577
  }) {
1572
1578
  if (!content.source || !content.schema) {
1573
1579
  return undefined;
@@ -1575,7 +1581,9 @@ function missingFileRef({
1575
1581
  try {
1576
1582
  const [, modulePath] = Internal.splitModuleFilePathAndModulePath(sourcePath);
1577
1583
  const resolved = Internal.resolvePath(modulePath, content.source, content.schema);
1578
- const ref = resolved.source?.path;
1584
+ const ref = fixes.includes("videos:add-metadata") ?
1585
+ // A video set entry is keyed by its file; the value has no `path`.
1586
+ Internal.splitModulePath(modulePath).pop() : resolved.source?.path;
1579
1587
  if (typeof ref !== "string") {
1580
1588
  return undefined;
1581
1589
  }
@@ -1710,7 +1718,7 @@ function longestResolvedPrefixRange(modulePath, modulePathMap) {
1710
1718
  * Gallery checks core emits unconditionally.
1711
1719
  *
1712
1720
  * `RecordSchema.validate` attaches these to every `s.imageset()` / `s.fileset()`
1713
- * module whether or not anything is actually wrong (see
1721
+ * / `s.videoset()` module whether or not anything is actually wrong (see
1714
1722
  * `packages/core/src/schema/record.ts`): they are placeholders asking someone to
1715
1723
  * go and look. `val validate` looks by running the matching fix handler, which
1716
1724
  * reports success when the directory is unique and every file is accounted for.
@@ -1719,7 +1727,7 @@ function longestResolvedPrefixRange(modulePath, modulePathMap) {
1719
1727
  * on every gallery module in the project — which is worse than useless, because
1720
1728
  * a warning that is always there is one nobody reads.
1721
1729
  */
1722
- const GALLERY_CHECK_FIXES = ["images:check-unique-folder", "files:check-unique-folder", "images:check-all-files", "files:check-all-files"];
1730
+ 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"];
1723
1731
  function isGalleryCheckFix(fix) {
1724
1732
  return GALLERY_CHECK_FIXES.includes(fix);
1725
1733
  }
@@ -1819,7 +1827,9 @@ function dropDeferredPlaceholders(validation) {
1819
1827
  * A gallery-backed field pointing at something the gallery does not have.
1820
1828
  *
1821
1829
  * `ImageSchema.validate` reports this (`packages/core/src/schema/image.ts`:
1822
- * "The gallery does not have an image at '…'") but attaches no `ValidationFix`,
1830
+ * "The gallery does not have an image at '…'"), and `VideoSchema.validate` the
1831
+ * same for a field picked from an `s.videoset()` ("The set does not have a
1832
+ * video at '…'"), but neither attaches a `ValidationFix`,
1823
1833
  * because the remedy is not a change to this module: either the gallery gains an
1824
1834
  * entry, or the file moves into the gallery's directory. Both are edits to
1825
1835
  * somewhere else, which is not what a `ValidationFix` describes.
@@ -1845,7 +1855,7 @@ function galleryMembershipAt({
1845
1855
  return undefined;
1846
1856
  }
1847
1857
  const schema = resolved.schema;
1848
- if (!schema || typeof schema !== "object" || !("type" in schema) || schema.type !== "image" && schema.type !== "file") {
1858
+ if (!schema || typeof schema !== "object" || !("type" in schema) || schema.type !== "image" && schema.type !== "file" && schema.type !== "video") {
1849
1859
  return undefined;
1850
1860
  }
1851
1861
  const referencedModule = "referencedModule" in schema && typeof schema.referencedModule === "string" ? schema.referencedModule : undefined;
@@ -1912,6 +1922,100 @@ function minimalTextEdit(before, after, document) {
1912
1922
  };
1913
1923
  }
1914
1924
 
1925
+ /**
1926
+ * Conversions between LSP document URIs and the paths Val uses.
1927
+ *
1928
+ * Deliberately minimal rather than pulling in `vscode-uri`: the server only ever
1929
+ * deals with local `file:` URIs, and keeping this small makes the assumptions
1930
+ * visible.
1931
+ */
1932
+
1933
+ /** Matches the Val module files the server validates. */
1934
+ const VAL_MODULE_RE = /\.val\.(ts|js|tsx|jsx)$/;
1935
+
1936
+ /** `file:` with an optional authority, capturing authority and path apart. */
1937
+ const FILE_URI_RE = /^file:\/\/([^/?#]*)([^?#]*)/i;
1938
+
1939
+ /** A `/c:/...` prefix, i.e. a Windows drive letter as it appears in a URI. */
1940
+ const URI_DRIVE_LETTER_RE = /^\/([a-zA-Z]:)(\/|$)/;
1941
+
1942
+ /**
1943
+ * Matches the `*.val.json` files that hold `.jsonValues()` entry content. Not a
1944
+ * Val module: nothing validates one on its own, but editing it changes what a
1945
+ * module validates to, so the server has to notice.
1946
+ */
1947
+ const VAL_JSON_ENTRY_RE = /\.val\.json$/;
1948
+ function isValModuleUri(uri) {
1949
+ return VAL_MODULE_RE.test(uri);
1950
+ }
1951
+ function isValJsonEntryUri(uri) {
1952
+ return VAL_JSON_ENTRY_RE.test(uri);
1953
+ }
1954
+
1955
+ /**
1956
+ * `decodeURIComponent` throws on malformed escapes (`%zz`). A client that sends
1957
+ * one is broken, but that should not take the server down: fall back to the
1958
+ * undecoded text so the path is at worst not found.
1959
+ */
1960
+ function decodeSafely(value) {
1961
+ try {
1962
+ return decodeURIComponent(value);
1963
+ } catch {
1964
+ return value;
1965
+ }
1966
+ }
1967
+
1968
+ /**
1969
+ * `file:///a/b.val.ts` -> `/a/b.val.ts`
1970
+ *
1971
+ * Percent escapes are decoded, Windows drive letters lose the leading slash
1972
+ * (`file:///c%3A/a` -> `c:/a`), and a URI with an authority is read as a UNC
1973
+ * path (`file://host/share/a` -> `//host/share/a`). Anything that is not a
1974
+ * `file:` URI is passed through unchanged, since callers also hand us plain
1975
+ * paths.
1976
+ */
1977
+ function uriToPath(uri) {
1978
+ const match = FILE_URI_RE.exec(uri);
1979
+ if (!match) {
1980
+ return uri;
1981
+ }
1982
+ const authority = decodeSafely(match[1]);
1983
+ const fsPath = decodeSafely(match[2] || "/");
1984
+ if (authority) {
1985
+ return `//${authority}${fsPath}`;
1986
+ }
1987
+ return fsPath.replace(URI_DRIVE_LETTER_RE, "$1$2");
1988
+ }
1989
+
1990
+ /**
1991
+ * `/a/b.val.ts` -> `file:///a/b.val.ts`
1992
+ *
1993
+ * The escaping matches what VS Code produces (drive-letter colons included), so
1994
+ * that a URI built here can be looked up in the open-document map keyed by the
1995
+ * URIs the client sent.
1996
+ */
1997
+ function pathToUri(fsPath) {
1998
+ const normalized = fsPath.split(path.sep).join("/");
1999
+ const rooted = normalized.startsWith("/") ? normalized : `/${normalized}`;
2000
+ return `file://${rooted.split("/").map(segment => encodeURIComponent(segment)).join("/")}`;
2001
+ }
2002
+
2003
+ /**
2004
+ * Convert a document URI into the `ModuleFilePath` Val addresses it by: a
2005
+ * POSIX-style path relative to the Val root, with a leading slash.
2006
+ *
2007
+ * Returns `undefined` when the file lies outside the Val root — one server
2008
+ * serves exactly one root, so another root's files are not its business.
2009
+ */
2010
+ function toModuleFilePath(valRoot, uri) {
2011
+ const fsPath = uriToPath(uri);
2012
+ const relative = path.relative(valRoot, fsPath);
2013
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
2014
+ return undefined;
2015
+ }
2016
+ return `/${relative.split(path.sep).join("/")}`;
2017
+ }
2018
+
1915
2019
  /**
1916
2020
  * `workspace/executeCommand` handlers.
1917
2021
  *
@@ -1948,7 +2052,10 @@ const REMOTE_FIX_COMMANDS = {
1948
2052
  "images:upload-remote": VAL_UPLOAD_REMOTE_COMMAND,
1949
2053
  "files:upload-remote": VAL_UPLOAD_REMOTE_COMMAND,
1950
2054
  "image:download-remote": VAL_DOWNLOAD_REMOTE_COMMAND,
1951
- "file:download-remote": VAL_DOWNLOAD_REMOTE_COMMAND
2055
+ "file:download-remote": VAL_DOWNLOAD_REMOTE_COMMAND,
2056
+ "video:upload-remote": VAL_UPLOAD_REMOTE_COMMAND,
2057
+ "video:download-remote": VAL_DOWNLOAD_REMOTE_COMMAND,
2058
+ "videos:upload-remote": VAL_UPLOAD_REMOTE_COMMAND
1952
2059
  };
1953
2060
  const REMOTE_FIX_TITLES = {
1954
2061
  "image:upload-remote": "Val: upload this image to Val Remote",
@@ -1956,7 +2063,10 @@ const REMOTE_FIX_TITLES = {
1956
2063
  "images:upload-remote": "Val: upload this gallery's images to Val Remote",
1957
2064
  "files:upload-remote": "Val: upload this gallery's files to Val Remote",
1958
2065
  "image:download-remote": "Val: download this image into the project",
1959
- "file:download-remote": "Val: download this file into the project"
2066
+ "file:download-remote": "Val: download this file into the project",
2067
+ "video:upload-remote": "Val: upload this video (and its poster and captions) to Val Remote",
2068
+ "video:download-remote": "Val: download this video (and its poster and captions) into the project",
2069
+ "videos:upload-remote": "Val: upload this video to Val Remote (and point the fields using it there)"
1960
2070
  };
1961
2071
 
1962
2072
  /** Arguments a remote-fix command is invoked with. */
@@ -2173,16 +2283,67 @@ function createValCommands(deps) {
2173
2283
  if (!edit) {
2174
2284
  return;
2175
2285
  }
2286
+ const changes = {
2287
+ [args.uri]: [edit]
2288
+ };
2289
+ // A fix that renames what other modules name (a video set's key) rewrites
2290
+ // them in the same edit, so one undo takes back all of it.
2291
+ const unwritten = [];
2292
+ for (const other of outcome.otherModulePatches ?? []) {
2293
+ const otherEdit = editForModulePatch(project.valRoot, other);
2294
+ if (otherEdit) {
2295
+ (changes[otherEdit.uri] ??= []).push(otherEdit.edit);
2296
+ } else {
2297
+ unwritten.push(other.moduleFilePath);
2298
+ }
2299
+ }
2176
2300
  // Applied through the client so it lands in the editor's undo history,
2177
2301
  // rather than written to disk under the user's cursor.
2178
- await connection.sendRequest(ApplyWorkspaceEditRequest.type, {
2302
+ const applied = await connection.sendRequest(ApplyWorkspaceEditRequest.type, {
2179
2303
  label: REMOTE_FIX_TITLES[args.fix] ?? `Val: ${args.fix}`,
2180
2304
  edit: {
2181
- changes: {
2182
- [args.uri]: [edit]
2183
- }
2305
+ changes
2184
2306
  }
2185
2307
  });
2308
+ // The client may refuse the edit — a document changed under it, say. The
2309
+ // files have moved by then, but nothing names them yet, so the fix did
2310
+ // NOT happen and must not read as though it had. Running it again is
2311
+ // safe: refs are content-addressed, so the same bytes go to the same refs.
2312
+ if (!applied.applied) {
2313
+ 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.`);
2314
+ return;
2315
+ }
2316
+ if (unwritten.length > 0) {
2317
+ connection.window.showWarningMessage(`Val: ${args.fix} could not rewrite ${unwritten.join(", ")}. Run "val validate" to see what still points at the old path.`);
2318
+ }
2319
+ }
2320
+
2321
+ /**
2322
+ * The edit for a patch to another module than the one the command ran on,
2323
+ * against the editor's buffer when the file is open and the disk otherwise.
2324
+ */
2325
+ function editForModulePatch(valRoot, other) {
2326
+ const uri = pathToUri(path.join(valRoot, other.moduleFilePath));
2327
+ let otherDocument = deps.getDocument(uri);
2328
+ if (!otherDocument) {
2329
+ let text;
2330
+ try {
2331
+ text = fs.readFileSync(path.join(valRoot, other.moduleFilePath), "utf8");
2332
+ } catch {
2333
+ return undefined;
2334
+ }
2335
+ otherDocument = TextDocument.create(uri, "typescript", 0, text);
2336
+ }
2337
+ const otherBefore = otherDocument.getText();
2338
+ const otherPatched = patchSourceFile(otherBefore, other.patch);
2339
+ if (result.isErr(otherPatched)) {
2340
+ return undefined;
2341
+ }
2342
+ const otherEdit = minimalTextEdit(otherBefore, otherPatched.value.text, otherDocument);
2343
+ return otherEdit && {
2344
+ uri,
2345
+ edit: otherEdit
2346
+ };
2186
2347
  }
2187
2348
  return {
2188
2349
  async execute(command, args) {
@@ -2365,100 +2526,6 @@ function valModulesEntryText({
2365
2526
  return hasElements ? `,\n${indentation}${entry}` : `\n${indentation}${entry}\n${indentation.slice(2)}`;
2366
2527
  }
2367
2528
 
2368
- /**
2369
- * Conversions between LSP document URIs and the paths Val uses.
2370
- *
2371
- * Deliberately minimal rather than pulling in `vscode-uri`: the server only ever
2372
- * deals with local `file:` URIs, and keeping this small makes the assumptions
2373
- * visible.
2374
- */
2375
-
2376
- /** Matches the Val module files the server validates. */
2377
- const VAL_MODULE_RE = /\.val\.(ts|js|tsx|jsx)$/;
2378
-
2379
- /** `file:` with an optional authority, capturing authority and path apart. */
2380
- const FILE_URI_RE = /^file:\/\/([^/?#]*)([^?#]*)/i;
2381
-
2382
- /** A `/c:/...` prefix, i.e. a Windows drive letter as it appears in a URI. */
2383
- const URI_DRIVE_LETTER_RE = /^\/([a-zA-Z]:)(\/|$)/;
2384
-
2385
- /**
2386
- * Matches the `*.val.json` files that hold `.jsonValues()` entry content. Not a
2387
- * Val module: nothing validates one on its own, but editing it changes what a
2388
- * module validates to, so the server has to notice.
2389
- */
2390
- const VAL_JSON_ENTRY_RE = /\.val\.json$/;
2391
- function isValModuleUri(uri) {
2392
- return VAL_MODULE_RE.test(uri);
2393
- }
2394
- function isValJsonEntryUri(uri) {
2395
- return VAL_JSON_ENTRY_RE.test(uri);
2396
- }
2397
-
2398
- /**
2399
- * `decodeURIComponent` throws on malformed escapes (`%zz`). A client that sends
2400
- * one is broken, but that should not take the server down: fall back to the
2401
- * undecoded text so the path is at worst not found.
2402
- */
2403
- function decodeSafely(value) {
2404
- try {
2405
- return decodeURIComponent(value);
2406
- } catch {
2407
- return value;
2408
- }
2409
- }
2410
-
2411
- /**
2412
- * `file:///a/b.val.ts` -> `/a/b.val.ts`
2413
- *
2414
- * Percent escapes are decoded, Windows drive letters lose the leading slash
2415
- * (`file:///c%3A/a` -> `c:/a`), and a URI with an authority is read as a UNC
2416
- * path (`file://host/share/a` -> `//host/share/a`). Anything that is not a
2417
- * `file:` URI is passed through unchanged, since callers also hand us plain
2418
- * paths.
2419
- */
2420
- function uriToPath(uri) {
2421
- const match = FILE_URI_RE.exec(uri);
2422
- if (!match) {
2423
- return uri;
2424
- }
2425
- const authority = decodeSafely(match[1]);
2426
- const fsPath = decodeSafely(match[2] || "/");
2427
- if (authority) {
2428
- return `//${authority}${fsPath}`;
2429
- }
2430
- return fsPath.replace(URI_DRIVE_LETTER_RE, "$1$2");
2431
- }
2432
-
2433
- /**
2434
- * `/a/b.val.ts` -> `file:///a/b.val.ts`
2435
- *
2436
- * The escaping matches what VS Code produces (drive-letter colons included), so
2437
- * that a URI built here can be looked up in the open-document map keyed by the
2438
- * URIs the client sent.
2439
- */
2440
- function pathToUri(fsPath) {
2441
- const normalized = fsPath.split(path.sep).join("/");
2442
- const rooted = normalized.startsWith("/") ? normalized : `/${normalized}`;
2443
- return `file://${rooted.split("/").map(segment => encodeURIComponent(segment)).join("/")}`;
2444
- }
2445
-
2446
- /**
2447
- * Convert a document URI into the `ModuleFilePath` Val addresses it by: a
2448
- * POSIX-style path relative to the Val root, with a leading slash.
2449
- *
2450
- * Returns `undefined` when the file lies outside the Val root — one server
2451
- * serves exactly one root, so another root's files are not its business.
2452
- */
2453
- function toModuleFilePath(valRoot, uri) {
2454
- const fsPath = uriToPath(uri);
2455
- const relative = path.relative(valRoot, fsPath);
2456
- if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
2457
- return undefined;
2458
- }
2459
- return `/${relative.split(path.sep).join("/")}`;
2460
- }
2461
-
2462
2529
  const jsonOps = new JSONOps();
2463
2530
 
2464
2531
  /**
@@ -2636,9 +2703,19 @@ function supportsResourceOperation(capabilities, operation) {
2636
2703
  * silently required auth would just fail.
2637
2704
  */
2638
2705
  const LOCAL_FIXES = ["image:add-metadata", "image:check-metadata", "file:add-metadata", "file:check-metadata",
2706
+ // A local video's mimeType, width, height and duration, read from the file
2707
+ // (or, for an HLS stream, from its playlists) on disk.
2708
+ "video:add-metadata",
2639
2709
  // Gallery metadata: createFixPatch reads each entry's file and corrects the
2640
2710
  // stored metadata, dropping entries whose file has gone. Filesystem only.
2641
2711
  "images:check-all-files", "files:check-all-files",
2712
+ // A video set's entries against its directory: entries whose file has gone
2713
+ // are dropped, and videos in the directory the set does not list are added
2714
+ // with what is read from their bytes. Filesystem only.
2715
+ "videos:check-all-files",
2716
+ // A video set entry's mimeType, width, height and duration, read from the
2717
+ // file at its KEY. The set's twin of `video:add-metadata`.
2718
+ "videos:add-metadata",
2642
2719
  // A view pointing at the wrong module: the schema names the right one, so the
2643
2720
  // fix is a value the patch already knows. Nothing read, nothing fetched.
2644
2721
  "view:check-module"];
@@ -2661,8 +2738,11 @@ const FIX_TITLES = {
2661
2738
  "image:check-metadata": "Val: update image metadata",
2662
2739
  "file:add-metadata": "Val: add file metadata",
2663
2740
  "file:check-metadata": "Val: update file metadata",
2741
+ "video:add-metadata": "Val: add video metadata",
2664
2742
  "images:check-all-files": "Val: update gallery image metadata",
2665
2743
  "files:check-all-files": "Val: update gallery file metadata",
2744
+ "videos:check-all-files": "Val: sync the video set with its directory",
2745
+ "videos:add-metadata": "Val: add video metadata",
2666
2746
  "jsonValues:extract-entry": "Val: move entry into its own .val.json",
2667
2747
  "view:check-module": "Val: point this view at the module its schema names"
2668
2748
  };
@@ -3230,7 +3310,9 @@ function getValCompletionContext(sourceFile, offset) {
3230
3310
  }
3231
3311
 
3232
3312
  /** The properties Val computes from a file's bytes. */
3233
- const MEDIA_METADATA_KEYS = ["width", "height", "mimeType"];
3313
+ const MEDIA_METADATA_KEYS = ["width", "height", "mimeType",
3314
+ // A video's: never read for an image or a file, so never written for one.
3315
+ "duration"];
3234
3316
  /**
3235
3317
  * Re-find the media object literal whose `path` value starts at
3236
3318
  * `pathValueStart`, and report where its metadata siblings are *now*.
@@ -3412,6 +3494,24 @@ async function resolveValCompletion({
3412
3494
  */
3413
3495
  async function readMetadata(data) {
3414
3496
  try {
3497
+ if (data.mediaType === "video") {
3498
+ // Only what could be read: a video whose duration cannot be read still
3499
+ // gets its mime type, and `video:add-metadata` reports the rest. From
3500
+ // the file rather than a buffer: only its headers are read.
3501
+ const metadata = await extractVideoMetadataFromFile(data.filePath);
3502
+ const rendered = {};
3503
+ if (metadata.width !== undefined && metadata.height !== undefined) {
3504
+ rendered.width = String(metadata.width);
3505
+ rendered.height = String(metadata.height);
3506
+ }
3507
+ if (metadata.mimeType) {
3508
+ rendered.mimeType = JSON.stringify(metadata.mimeType);
3509
+ }
3510
+ if (metadata.duration !== undefined) {
3511
+ rendered.duration = String(metadata.duration);
3512
+ }
3513
+ return Object.keys(rendered).length > 0 ? rendered : undefined;
3514
+ }
3415
3515
  const buffer = fs.readFileSync(data.filePath);
3416
3516
  if (data.mediaType === "image") {
3417
3517
  const metadata = await extractImageMetadata(data.filePath, buffer);
@@ -3486,7 +3586,10 @@ function createSchemaDrivenCompletions({
3486
3586
  return [];
3487
3587
  }
3488
3588
  const directory = "dir" in container && typeof container.dir === "string" ? container.dir : undefined;
3489
- const galleryFiles = container.mediaType === "images" ? files.images(directory) : files.list(directory);
3589
+ const galleryFiles = container.mediaType === "images" ? files.images(directory) : container.mediaType === "videos" ?
3590
+ // A stream is keyed by its master playlist; its media playlists
3591
+ // and segments are part of it, never keys of their own.
3592
+ files.videos(directory) : files.list(directory);
3490
3593
  return items(galleryFiles.map(file => file.ref), CompletionItemKind.File, range);
3491
3594
  }
3492
3595
 
@@ -3498,7 +3601,7 @@ function createSchemaDrivenCompletions({
3498
3601
  // was ignored.
3499
3602
  if (!isPropertyName && valueOfProperty === "path") {
3500
3603
  const container = resolveSchemaAt(parentModulePath(modulePath), source, schema);
3501
- if (container && typeof container === "object" && "type" in container && (container.type === "image" || container.type === "file")) {
3604
+ if (container && typeof container === "object" && "type" in container && (container.type === "image" || container.type === "file" || container.type === "video")) {
3502
3605
  return mediaPathItems({
3503
3606
  document,
3504
3607
  container,
@@ -3558,7 +3661,7 @@ function mediaPathItems({
3558
3661
  directory = gallery.dir;
3559
3662
  }
3560
3663
  }
3561
- const candidates = container.type === "image" ? files.images(directory) : files.list(directory);
3664
+ const candidates = container.type === "image" ? files.images(directory) : container.type === "video" ? files.videos(directory) : files.list(directory);
3562
3665
  return candidates.map((file, index) => {
3563
3666
  const data = {
3564
3667
  kind: "media-path",
@@ -3795,7 +3898,11 @@ async function createGalleryMembershipActions({
3795
3898
  if (register) {
3796
3899
  actions.push(register);
3797
3900
  }
3798
- if (allowRename && gallery.dir && onDisk) {
3901
+
3902
+ // A stream is a directory of files its master playlist names, and renaming
3903
+ // the master alone would leave every one of them behind.
3904
+ const isStream = gallery.mediaType === "video" && gallery.path.split("?")[0].toLowerCase().endsWith(".m3u8");
3905
+ if (allowRename && gallery.dir && onDisk && !isStream) {
3799
3906
  const move = createMoveIntoGalleryDirectoryAction({
3800
3907
  document,
3801
3908
  gallery,
@@ -3848,7 +3955,7 @@ async function createRegisterInGalleryAction({
3848
3955
  },
3849
3956
  newText: insertion.hasProperties ? `,\n${insertion.indentation}${entry}` : `\n${insertion.indentation}${entry}\n${insertion.indentation.slice(2)}`
3850
3957
  };
3851
- return CodeAction.create(`Val: add ${path.posix.basename(gallery.path)} to the gallery`, {
3958
+ return CodeAction.create(`Val: add ${path.posix.basename(gallery.path)} to the ${gallery.mediaType === "video" ? "video set" : "gallery"}`, {
3852
3959
  changes: {
3853
3960
  [pathToUri(galleryFile)]: [edit]
3854
3961
  }
@@ -3970,6 +4077,16 @@ function findRecordInsertion(sourceFile) {
3970
4077
  */
3971
4078
  async function readMetadataSource(filePath, mediaType) {
3972
4079
  try {
4080
+ if (mediaType === "video") {
4081
+ // All four or nothing: a set entry missing any of them is reported by
4082
+ // `videos:add-metadata`, which says why it could not be read — a better
4083
+ // place to land than a registered entry that is already wrong.
4084
+ const metadata = await extractVideoMetadataFromFile(filePath);
4085
+ if (!metadata.mimeType || metadata.width === undefined || metadata.height === undefined || metadata.duration === undefined) {
4086
+ return undefined;
4087
+ }
4088
+ return `mimeType: ${JSON.stringify(metadata.mimeType)}, width: ${metadata.width}, height: ${metadata.height}, duration: ${metadata.duration}, alt: null`;
4089
+ }
3973
4090
  const buffer = fs.readFileSync(filePath);
3974
4091
  if (mediaType === "image") {
3975
4092
  const metadata = await extractImageMetadata(filePath, buffer);
@@ -3998,12 +4115,12 @@ function readFileOrUndefined(filePath) {
3998
4115
  /**
3999
4116
  * Lists the files a Val module may reference.
4000
4117
  *
4001
- * Val stores referenced files under `/public/val` by convention (configurable
4002
- * via `files.directory` in val.config), and a reference is written as the path
4003
- * *including* the `/public` prefix — so this returns Val-style refs directly.
4118
+ * Val stores referenced files under `/public/val` by convention, and a
4119
+ * reference is written as the path *including* the `/public` prefix — so this
4120
+ * returns Val-style refs directly.
4004
4121
  */
4005
4122
 
4006
- /** Default directory, matching `files.directory` in val.config. */
4123
+ /** Where Val stores the files a module references. */
4007
4124
  const DEFAULT_FILES_DIRECTORY = "/public/val";
4008
4125
 
4009
4126
  /**
@@ -4074,6 +4191,7 @@ function createPublicValFiles({
4074
4191
  return {
4075
4192
  list,
4076
4193
  images: dir => list(dir).filter(f => f.mimeType?.startsWith("image/")),
4194
+ videos: dir => list(dir).filter(f => f.mimeType?.startsWith("video/") || path.posix.basename(f.ref) === "master.m3u8"),
4077
4195
  invalidate: () => {
4078
4196
  cache.clear();
4079
4197
  }
@@ -4440,24 +4558,6 @@ function createValLanguageServer(connection) {
4440
4558
  publicFiles = createPublicValFiles({
4441
4559
  valRoot: options.valRoot
4442
4560
  });
4443
- // A project can point `files.directory` somewhere other than /public/val, and
4444
- // media-path completions would list nothing if we assumed the default.
4445
- // Reading val.config is async and `initialize` is not, so the default stands
4446
- // until the real value arrives — completions cannot be requested before
4447
- // `initialize` returns anyway.
4448
- void findAndEvalValConfigFile(options.valRoot).then(config => {
4449
- const directory = config?.files?.directory;
4450
- if (directory && directory !== DEFAULT_FILES_DIRECTORY) {
4451
- publicFiles = createPublicValFiles({
4452
- valRoot: options.valRoot,
4453
- directory
4454
- });
4455
- }
4456
- }).catch(e => {
4457
- // A broken or unreadable val.config is reported per module by the
4458
- // service; here it only means "keep the default directory".
4459
- connection.console.warn(`Val: could not read val.config: ${e instanceof Error ? e.message : String(e)}`);
4460
- });
4461
4561
  project = createValProject({
4462
4562
  valRoot: options.valRoot,
4463
4563
  open: {
package/package.json CHANGED
@@ -11,7 +11,7 @@
11
11
  "lsp",
12
12
  "language-server"
13
13
  ],
14
- "version": "0.140.2",
14
+ "version": "0.141.1",
15
15
  "bin": {
16
16
  "val-language-server": "./bin.js"
17
17
  },
@@ -29,9 +29,9 @@
29
29
  "typescript": "^6.0.3",
30
30
  "vscode-languageserver": "^10.1.0",
31
31
  "vscode-languageserver-textdocument": "^1.0.14",
32
- "@valbuild/core": "0.139.1",
33
- "@valbuild/server": "0.140.2",
34
- "@valbuild/shared": "0.140.2"
32
+ "@valbuild/server": "0.141.1",
33
+ "@valbuild/shared": "0.141.0",
34
+ "@valbuild/core": "0.141.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/jest": "^30.0.0",