@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.
- package/CHANGELOG.md +77 -0
- package/dist/declarations/src/commands.d.ts +1 -1
- package/dist/declarations/src/completionContext.d.ts +1 -1
- package/dist/declarations/src/completions.d.ts +1 -1
- package/dist/declarations/src/diagnostics.d.ts +5 -3
- package/dist/declarations/src/publicValFiles.d.ts +11 -4
- package/dist/valbuild-language-server.cjs.dev.js +238 -138
- package/dist/valbuild-language-server.cjs.prod.js +238 -138
- package/dist/valbuild-language-server.esm.js +238 -138
- package/package.json +4 -4
|
@@ -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.
|
|
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 =
|
|
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 '…'")
|
|
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) :
|
|
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
|
-
|
|
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
|
|
4002
|
-
*
|
|
4003
|
-
*
|
|
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
|
-
/**
|
|
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.
|
|
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/
|
|
33
|
-
"@valbuild/
|
|
34
|
-
"@valbuild/
|
|
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",
|