@jxsuite/content-mapper 0.0.0-stage → 0.0.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.
- package/LICENSE +21 -0
- package/README.md +74 -2
- package/bin/server.js +4 -0
- package/dist/server.js +9067 -0
- package/package.json +47 -4
- package/src/mapper.ts +266 -0
- package/src/server.ts +20 -0
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/content-mapper",
|
|
3
|
-
"version": "0.0.0
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "A TypeScript content mapper for Jx Notation: tsc and an editor check the code in a .jxn document",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/jxsuite/jx.git",
|
|
9
|
+
"directory": "packages/content-mapper"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"bin/",
|
|
13
|
+
"src/",
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
16
|
+
"type": "module",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./src/mapper.ts",
|
|
19
|
+
"./package.json": "./package.json"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"provenance": false
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"prepare": "bun build src/server.ts --outdir=dist --format=esm --target=node",
|
|
26
|
+
"build": "bun run prepare",
|
|
27
|
+
"test": "bun test --isolate",
|
|
28
|
+
"test:coverage": "bun test --isolate --coverage"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@jxsuite/schema": "workspace:^",
|
|
32
|
+
"ts-content-mapper": "0.1.1"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"typescript": ">=7.1.0-beta"
|
|
36
|
+
},
|
|
37
|
+
"typescript": {
|
|
38
|
+
"contentMapper": {
|
|
39
|
+
"dynamicConfig": true,
|
|
40
|
+
"exec": [
|
|
41
|
+
"node",
|
|
42
|
+
"bin/server.js"
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": "^22.18.0 || >=24.11.0"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/mapper.ts
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The content mapper TypeScript runs for `.jxn` files (`spec.md` §23.3).
|
|
3
|
+
*
|
|
4
|
+
* TypeScript 7.1 can be told that a file it does not read is written by a program that does: a
|
|
5
|
+
* content mapper, which answers each file with the TypeScript it stands for and a map between the
|
|
6
|
+
* two. This is that program's answer for Jx Notation. What a document's types are is not decided
|
|
7
|
+
* here: `@jxsuite/schema/virtual-document` writes the modules, as it does for every other reader,
|
|
8
|
+
* and this hands them to the compiler in the form its protocol takes.
|
|
9
|
+
*
|
|
10
|
+
* What it adds is the one thing a file cannot say about itself: what its project gives it. A page
|
|
11
|
+
* in a project with `state` of its own, or with a default layout, reads state the page never names,
|
|
12
|
+
* so its `State` is open, and only the project file says which. And a document uses the components
|
|
13
|
+
* its project keeps without declaring them, so only the project's folder says what a tag names.
|
|
14
|
+
*
|
|
15
|
+
* @docs framework/concepts/type-checking
|
|
16
|
+
*/
|
|
17
|
+
import { readdirSync } from "node:fs";
|
|
18
|
+
import type { Dirent } from "node:fs";
|
|
19
|
+
import { dirname, join, relative, sep } from "node:path";
|
|
20
|
+
|
|
21
|
+
import { baseName, isProjectFileName, PROJECT_FILE_NAMES } from "@jxsuite/schema/project-file";
|
|
22
|
+
import {
|
|
23
|
+
findEnclosingProjectOnDisk,
|
|
24
|
+
isSameStamp,
|
|
25
|
+
projectFileStamp,
|
|
26
|
+
readProjectFile,
|
|
27
|
+
} from "@jxsuite/schema/project-file/node";
|
|
28
|
+
import type { EnclosingProject } from "@jxsuite/schema/project-file";
|
|
29
|
+
import type { ProjectFileStamp } from "@jxsuite/schema/project-file/node";
|
|
30
|
+
import type { JsonObject } from "@jxsuite/schema/types";
|
|
31
|
+
import {
|
|
32
|
+
generateVirtualDocument,
|
|
33
|
+
givenByProject,
|
|
34
|
+
outsideAt,
|
|
35
|
+
} from "@jxsuite/schema/virtual-document";
|
|
36
|
+
import type { OutsideState, ProjectGives, VirtualUnit } from "@jxsuite/schema/virtual-document";
|
|
37
|
+
import { SpanMapKind, stringIndexToPosition } from "ts-content-mapper";
|
|
38
|
+
import type {
|
|
39
|
+
ContentMapperDefinition,
|
|
40
|
+
MappedOutput,
|
|
41
|
+
PositionEncoding,
|
|
42
|
+
SpanMapping,
|
|
43
|
+
} from "ts-content-mapper";
|
|
44
|
+
|
|
45
|
+
/** The prefix TypeScript puts before a code of ours: `error jx1`. */
|
|
46
|
+
export const DIAGNOSTIC_SOURCE = "jx";
|
|
47
|
+
|
|
48
|
+
/** The file is not Jx Notation, so nothing in it could be read. */
|
|
49
|
+
export const NOT_NOTATION = 1;
|
|
50
|
+
|
|
51
|
+
/** What is remembered between files of one compile. */
|
|
52
|
+
export interface MapperProject {
|
|
53
|
+
/** Each project folder met, as its file last read. */
|
|
54
|
+
facts: Map<string, { facts: ProjectGives; stamp: ProjectFileStamp }>;
|
|
55
|
+
/** The folder holding the `tsconfig`, above which no project is looked for. */
|
|
56
|
+
root: string | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A project file that cannot be read gives nothing away, so a document in its folder is assumed to
|
|
61
|
+
* inherit everything: the file's own error is the one to report, not a hundred about `state`.
|
|
62
|
+
*/
|
|
63
|
+
const UNREADABLE: ProjectGives = { layout: true, state: true };
|
|
64
|
+
|
|
65
|
+
/** Read what a project gives its documents, from its file as it is now. */
|
|
66
|
+
function readFacts(root: string): ProjectGives {
|
|
67
|
+
const read = readProjectFile(root);
|
|
68
|
+
return read === null || "error" in read ? UNREADABLE : givenByProject(read.config);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** What a project gives its documents, read again only when its file has changed. */
|
|
72
|
+
function factsOf(root: string, project: MapperProject): ProjectGives {
|
|
73
|
+
const stamp = projectFileStamp(root);
|
|
74
|
+
if (stamp === null) {
|
|
75
|
+
// A folder holding both project files, which no host chooses between.
|
|
76
|
+
return UNREADABLE;
|
|
77
|
+
}
|
|
78
|
+
const cached = project.facts.get(root);
|
|
79
|
+
if (cached && isSameStamp(cached.stamp, stamp)) {
|
|
80
|
+
return cached.facts;
|
|
81
|
+
}
|
|
82
|
+
const facts = readFacts(root);
|
|
83
|
+
project.facts.set(root, { facts, stamp });
|
|
84
|
+
return facts;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** What a project found on disk gives its documents: nothing is known of one that cannot be read. */
|
|
88
|
+
function givenBy(found: EnclosingProject, project: MapperProject): ProjectGives {
|
|
89
|
+
return found.verdict === "project" ? factsOf(found.root, project) : UNREADABLE;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The folders at or under one that hold a file named as a project file is.
|
|
94
|
+
*
|
|
95
|
+
* A `tsconfig` may sit above several projects, and the compiler says which files it reads only as
|
|
96
|
+
* it reads them, so the projects are looked for: in every folder but `node_modules` and one whose
|
|
97
|
+
* name begins with a dot, and through no link.
|
|
98
|
+
*
|
|
99
|
+
* @param root The folder holding the `tsconfig`.
|
|
100
|
+
* @returns Each folder, in the order of their paths.
|
|
101
|
+
*/
|
|
102
|
+
function projectFoldersUnder(root: string): string[] {
|
|
103
|
+
const found: string[] = [];
|
|
104
|
+
const pending = [root];
|
|
105
|
+
for (let at = pending.pop(); at !== undefined; at = pending.pop()) {
|
|
106
|
+
let entries: Dirent[];
|
|
107
|
+
try {
|
|
108
|
+
entries = readdirSync(at, { withFileTypes: true });
|
|
109
|
+
} catch {
|
|
110
|
+
// A folder that cannot be listed holds no project a document could be read from.
|
|
111
|
+
entries = [];
|
|
112
|
+
}
|
|
113
|
+
for (const entry of entries) {
|
|
114
|
+
if (entry.isDirectory() && entry.name !== "node_modules" && !entry.name.startsWith(".")) {
|
|
115
|
+
pending.push(join(at, entry.name));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (entries.some((entry) => isProjectFileName(entry.name))) {
|
|
119
|
+
found.push(at);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return found.toSorted();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A component a project keeps, by its file: a tag, which has a hyphen, and `.jxn`. */
|
|
126
|
+
const KEPT_COMPONENT = /^(?<tag>.+-.*)\.jxn$/u;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The components a project keeps, which every document of it uses without declaring them
|
|
130
|
+
* (`imports.md` §1.4): each `.jxn` file directly in `components/` that is named for its tag.
|
|
131
|
+
*
|
|
132
|
+
* It is read from the folder each time, and not remembered, because a component is added by writing
|
|
133
|
+
* a file, which nothing here is told of.
|
|
134
|
+
*
|
|
135
|
+
* @param root The project's folder.
|
|
136
|
+
* @param fileName The document they are for.
|
|
137
|
+
* @returns How the document imports each one, by its tag.
|
|
138
|
+
*/
|
|
139
|
+
function componentsOf(root: string, fileName: string): Record<string, string> {
|
|
140
|
+
const folder = join(root, "components");
|
|
141
|
+
let names: string[];
|
|
142
|
+
try {
|
|
143
|
+
names = readdirSync(folder);
|
|
144
|
+
} catch {
|
|
145
|
+
// A project with no components has no folder for them.
|
|
146
|
+
return {};
|
|
147
|
+
}
|
|
148
|
+
const from = dirname(fileName);
|
|
149
|
+
return Object.fromEntries(
|
|
150
|
+
names.toSorted().flatMap((name) => {
|
|
151
|
+
const tag = KEPT_COMPONENT.exec(name)?.groups?.tag;
|
|
152
|
+
const path = relative(from, join(folder, name)).split(sep).join("/");
|
|
153
|
+
return tag === undefined ? [] : [[tag, path.startsWith(".") ? path : `./${path}`]];
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* What a document is given by the project it is in.
|
|
160
|
+
*
|
|
161
|
+
* @param fileName The document's absolute path.
|
|
162
|
+
* @param project What this compile remembers.
|
|
163
|
+
*/
|
|
164
|
+
export function outsideOf(fileName: string, project: MapperProject): OutsideState {
|
|
165
|
+
const found = findEnclosingProjectOnDisk(dirname(fileName), project.root ?? undefined);
|
|
166
|
+
if (!found) {
|
|
167
|
+
return {};
|
|
168
|
+
}
|
|
169
|
+
const [folder] = relative(found.root, fileName).split(sep);
|
|
170
|
+
return {
|
|
171
|
+
...outsideAt(folder, givenBy(found, project)),
|
|
172
|
+
components: componentsOf(found.root, fileName),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** One module in the form the protocol takes, with its offsets in the encoding agreed on. */
|
|
177
|
+
function mappedOutput(unit: VirtualUnit, source: string, encoding: PositionEncoding): MappedOutput {
|
|
178
|
+
const mappings: SpanMapping[] = unit.spans.map((span) => {
|
|
179
|
+
const virtualStart = stringIndexToPosition(unit.text, span.virtualStart, encoding);
|
|
180
|
+
const virtualEnd = stringIndexToPosition(
|
|
181
|
+
unit.text,
|
|
182
|
+
span.virtualStart + span.virtualLength,
|
|
183
|
+
encoding,
|
|
184
|
+
);
|
|
185
|
+
const originalStart = stringIndexToPosition(source, span.originalStart, encoding);
|
|
186
|
+
const originalEnd = stringIndexToPosition(
|
|
187
|
+
source,
|
|
188
|
+
span.originalStart + span.originalLength,
|
|
189
|
+
encoding,
|
|
190
|
+
);
|
|
191
|
+
return [
|
|
192
|
+
virtualStart,
|
|
193
|
+
virtualEnd - virtualStart,
|
|
194
|
+
originalStart,
|
|
195
|
+
originalEnd - originalStart,
|
|
196
|
+
span.kind === "verbatim" ? SpanMapKind.Verbatim : SpanMapKind.Atom,
|
|
197
|
+
];
|
|
198
|
+
});
|
|
199
|
+
return { extension: unit.extension, mappings, text: unit.text };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* What is said of a file no `tsconfig` reads, which an editor opens on its own: nothing a project
|
|
204
|
+
* gives is known before the file is. It is not the empty string, because the language server reads
|
|
205
|
+
* that as no answer and then refuses every file the mapper is given.
|
|
206
|
+
*/
|
|
207
|
+
const INFERRED_IDENTITY = "inferred";
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The mapper, as `ts-content-mapper` runs one.
|
|
211
|
+
*
|
|
212
|
+
* @returns A definition holding no state of its own: what it remembers is kept per project, which
|
|
213
|
+
* the compiler opens and closes.
|
|
214
|
+
*/
|
|
215
|
+
export function createMapper(): ContentMapperDefinition<JsonObject, MapperProject> {
|
|
216
|
+
return {
|
|
217
|
+
diagnosticSource: DIAGNOSTIC_SOURCE,
|
|
218
|
+
openProject(params) {
|
|
219
|
+
const root = params.configFileName === "" ? null : dirname(params.configFileName);
|
|
220
|
+
const project: MapperProject = { facts: new Map(), root };
|
|
221
|
+
if (root === null) {
|
|
222
|
+
return { configIdentity: INFERRED_IDENTITY, project };
|
|
223
|
+
}
|
|
224
|
+
const folders = projectFoldersUnder(root);
|
|
225
|
+
/* The compiler reads every file again when this changes, so it says only what would change
|
|
226
|
+
one: what each project at or under the `tsconfig` gives its documents, and not when it
|
|
227
|
+
was saved. A file with a project file's name that is a page gives nothing, and is not
|
|
228
|
+
said. */
|
|
229
|
+
const given = new Map<string, ProjectGives>();
|
|
230
|
+
for (const folder of folders) {
|
|
231
|
+
const found = findEnclosingProjectOnDisk(folder, folder);
|
|
232
|
+
if (found) {
|
|
233
|
+
const name = relative(root, folder).split(sep).join("/");
|
|
234
|
+
given.set(name === "" ? "." : name, givenBy(found, project));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/* Either name in each folder, so that a project changing its format is seen, and beside the
|
|
238
|
+
`tsconfig` whether one is there or not, so that one appearing is. */
|
|
239
|
+
const watched = new Set([root, ...folders]);
|
|
240
|
+
return {
|
|
241
|
+
configIdentity: JSON.stringify(Object.fromEntries(given)),
|
|
242
|
+
project,
|
|
243
|
+
watchedFiles: [...watched].flatMap((folder) =>
|
|
244
|
+
PROJECT_FILE_NAMES.map((name) => join(folder, name)),
|
|
245
|
+
),
|
|
246
|
+
};
|
|
247
|
+
},
|
|
248
|
+
transform(params, context) {
|
|
249
|
+
const { content, fileName } = params;
|
|
250
|
+
const written = generateVirtualDocument(content, {
|
|
251
|
+
outside: outsideOf(fileName, context.project),
|
|
252
|
+
specifier: `./${baseName(fileName)}`,
|
|
253
|
+
});
|
|
254
|
+
const encoding = context.positionEncoding;
|
|
255
|
+
return {
|
|
256
|
+
...mappedOutput(written.main, content, encoding),
|
|
257
|
+
diagnostics: written.problems.map((problem) => {
|
|
258
|
+
const start = stringIndexToPosition(content, problem.offset, encoding);
|
|
259
|
+
const end = stringIndexToPosition(content, problem.offset + problem.length, encoding);
|
|
260
|
+
return { code: NOT_NOTATION, length: end - start, messageText: problem.message, start };
|
|
261
|
+
}),
|
|
262
|
+
supplemental: written.supplemental.map((unit) => mappedOutput(unit, content, encoding)),
|
|
263
|
+
};
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The process TypeScript starts for `.jxn` files (`spec.md` §23.3): the mapper, answering over the
|
|
3
|
+
* streams the compiler holds the other ends of.
|
|
4
|
+
*
|
|
5
|
+
* TypeScript finds it through this package's `typescript.contentMapper` manifest and starts it only
|
|
6
|
+
* when run with `--runExternalCode`, because starting it is running a project's dependency.
|
|
7
|
+
*/
|
|
8
|
+
import { runContentMapper } from "ts-content-mapper";
|
|
9
|
+
import type { RunContentMapperOptions, RunningContentMapper } from "ts-content-mapper";
|
|
10
|
+
|
|
11
|
+
import { createMapper } from "./mapper.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Start answering.
|
|
15
|
+
*
|
|
16
|
+
* @param options The streams to answer over. Standard input and output, without any.
|
|
17
|
+
*/
|
|
18
|
+
export function run(options?: RunContentMapperOptions): RunningContentMapper {
|
|
19
|
+
return runContentMapper(createMapper(), options);
|
|
20
|
+
}
|