@remit/web-client 0.0.157 → 0.0.159
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/package.json +18 -2
- package/spellcheck/empty-build.test.ts +160 -0
- package/spellcheck/languages.test.ts +80 -0
- package/spellcheck/languages.ts +86 -0
- package/spellcheck/vite-plugin-licence.test.ts +92 -0
- package/spellcheck/vite-plugin.test.ts +230 -0
- package/spellcheck/vite-plugin.ts +385 -0
- package/spellcheck/worker-stub.ts +36 -0
- package/src/components/compose/compose-spellcheck.test.ts +40 -5
- package/vite.base.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.159",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"files": [
|
|
18
18
|
"src",
|
|
19
19
|
"harness",
|
|
20
|
+
"spellcheck",
|
|
20
21
|
"vite.base.ts",
|
|
21
22
|
"public",
|
|
22
23
|
"index.html"
|
|
@@ -29,10 +30,13 @@
|
|
|
29
30
|
"build:dist": "npm run generate:routes && node --import tsx harness/build.mjs",
|
|
30
31
|
"preview": "vite preview",
|
|
31
32
|
"test:typecheck": "npm run generate:routes && tsgo --noEmit && tsgo --noEmit -p tsconfig.node.json",
|
|
32
|
-
"test:run": "TSX_TSCONFIG_PATH=./tsconfig.test.json node $NODE_TEST_FLAGS --import tsx --import ./test-support/register.mjs --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-exclude='src/test-support/**' --test-coverage-lines=86 --test 'src/**/*.test.ts'",
|
|
33
|
+
"test:run": "TSX_TSCONFIG_PATH=./tsconfig.test.json node $NODE_TEST_FLAGS --import tsx --import ./test-support/register.mjs --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-exclude='src/test-support/**' --test-coverage-lines=86 --test 'src/**/*.test.ts' 'spellcheck/**/*.test.ts'",
|
|
33
34
|
"test": "npm run test:typecheck && npm run test:run"
|
|
34
35
|
},
|
|
35
36
|
"peerDependencies": {
|
|
37
|
+
"dictionary-en": "^4",
|
|
38
|
+
"dictionary-en-gb": "^3",
|
|
39
|
+
"dictionary-nl": "^2",
|
|
36
40
|
"react": "^19",
|
|
37
41
|
"react-dom": "^19",
|
|
38
42
|
"vite": "^7",
|
|
@@ -43,6 +47,15 @@
|
|
|
43
47
|
"@types/react-dom": "^19"
|
|
44
48
|
},
|
|
45
49
|
"peerDependenciesMeta": {
|
|
50
|
+
"dictionary-en": {
|
|
51
|
+
"optional": true
|
|
52
|
+
},
|
|
53
|
+
"dictionary-en-gb": {
|
|
54
|
+
"optional": true
|
|
55
|
+
},
|
|
56
|
+
"dictionary-nl": {
|
|
57
|
+
"optional": true
|
|
58
|
+
},
|
|
46
59
|
"vite": {
|
|
47
60
|
"optional": true
|
|
48
61
|
},
|
|
@@ -101,6 +114,9 @@
|
|
|
101
114
|
"@types/nodemailer": "*",
|
|
102
115
|
"@types/pngjs": "^6.0.5",
|
|
103
116
|
"@vitejs/plugin-react": "^4",
|
|
117
|
+
"dictionary-en": "4.0.0",
|
|
118
|
+
"dictionary-en-gb": "3.0.0",
|
|
119
|
+
"dictionary-nl": "2.0.0",
|
|
104
120
|
"electrodb": "*",
|
|
105
121
|
"esbuild": "*",
|
|
106
122
|
"expect-env": "*",
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The self-hoster's opt-out, proven by building it.
|
|
3
|
+
*
|
|
4
|
+
* `REMIT_SPELLCHECK_LANGUAGES=` is the documented way to build an image with no
|
|
5
|
+
* spellchecker — a small box, or an operator who does not want to carry
|
|
6
|
+
* somebody else's licence obligations. What it has to produce is a *clean*
|
|
7
|
+
* build: a web client that works, with no `spellcheck/` tree to serve, and none
|
|
8
|
+
* of the worker's code shipped to browsers that can never start it. Nothing
|
|
9
|
+
* short of a real build can tell those apart from a broken one, so this runs
|
|
10
|
+
* vite twice and reads the output.
|
|
11
|
+
*
|
|
12
|
+
* The second build is the control. Asserting only that an empty list emits no
|
|
13
|
+
* worker chunk would pass just as well if the chunk were renamed, if the
|
|
14
|
+
* composer stopped reaching the worker at all, or if the build quietly produced
|
|
15
|
+
* nothing — so the same assertions are made against a build that must carry it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import { spawnSync } from "node:child_process";
|
|
20
|
+
import { mkdtempSync, readdirSync, rmSync } from "node:fs";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
23
|
+
import { after, before, describe, it } from "node:test";
|
|
24
|
+
|
|
25
|
+
const packageRoot = resolve(import.meta.dirname, "..");
|
|
26
|
+
|
|
27
|
+
const BUILD_TIMEOUT_MS = 600_000;
|
|
28
|
+
|
|
29
|
+
const outputs: string[] = [];
|
|
30
|
+
|
|
31
|
+
// A child process, because the language set is read out of the environment when
|
|
32
|
+
// the plugin configures itself and two builds in one process would share it.
|
|
33
|
+
const build = (languages: string): readonly string[] => {
|
|
34
|
+
const outDir = mkdtempSync(join(tmpdir(), "spellcheck-build-"));
|
|
35
|
+
outputs.push(outDir);
|
|
36
|
+
const result = spawnSync(
|
|
37
|
+
process.execPath,
|
|
38
|
+
[
|
|
39
|
+
"--input-type=module",
|
|
40
|
+
"-e",
|
|
41
|
+
`import { build } from "vite";
|
|
42
|
+
await build({
|
|
43
|
+
logLevel: "warn",
|
|
44
|
+
build: { outDir: ${JSON.stringify(outDir)}, emptyOutDir: true },
|
|
45
|
+
});`,
|
|
46
|
+
],
|
|
47
|
+
{
|
|
48
|
+
cwd: packageRoot,
|
|
49
|
+
encoding: "utf8",
|
|
50
|
+
env: { ...process.env, REMIT_SPELLCHECK_LANGUAGES: languages },
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
assert.equal(
|
|
54
|
+
result.status,
|
|
55
|
+
0,
|
|
56
|
+
`vite build with REMIT_SPELLCHECK_LANGUAGES="${languages}" failed:\n${result.stdout}\n${result.stderr}`,
|
|
57
|
+
);
|
|
58
|
+
return emitted(outDir);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const emitted = (root: string): readonly string[] => {
|
|
62
|
+
const found: string[] = [];
|
|
63
|
+
const walk = (current: string): void => {
|
|
64
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
65
|
+
const full = join(current, entry.name);
|
|
66
|
+
if (entry.isDirectory()) {
|
|
67
|
+
walk(full);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
found.push(relative(root, full).split(sep).join("/"));
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
walk(root);
|
|
74
|
+
return found.sort();
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The worker, the port it speaks over, and the bundle vite emits for the `new
|
|
79
|
+
* Worker(new URL(...))` that reaches it. Vite emits that bundle while
|
|
80
|
+
* transforming the module rather than while writing the output, so it survives
|
|
81
|
+
* tree-shaking: its absence means the specifier never resolved there at all.
|
|
82
|
+
*/
|
|
83
|
+
const workerChunks = (files: readonly string[]): readonly string[] =>
|
|
84
|
+
files.filter((file) => /rich-text-spellcheck-/.test(file));
|
|
85
|
+
|
|
86
|
+
const spellcheckAssets = (files: readonly string[]): readonly string[] =>
|
|
87
|
+
files.filter((file) => file.startsWith("spellcheck/"));
|
|
88
|
+
|
|
89
|
+
describe("a build that carries no dictionaries", () => {
|
|
90
|
+
let files: readonly string[] = [];
|
|
91
|
+
|
|
92
|
+
before(
|
|
93
|
+
() => {
|
|
94
|
+
files = build("");
|
|
95
|
+
},
|
|
96
|
+
{ timeout: BUILD_TIMEOUT_MS },
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
after(() => {
|
|
100
|
+
for (const outDir of outputs)
|
|
101
|
+
rmSync(outDir, { recursive: true, force: true });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("is still a web client", () => {
|
|
105
|
+
assert.ok(files.includes("index.html"), "the build emitted no index.html");
|
|
106
|
+
assert.ok(
|
|
107
|
+
files.some((file) => file.startsWith("assets/") && file.endsWith(".js")),
|
|
108
|
+
"the build emitted no JavaScript",
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("serves no spellcheck tree", () => {
|
|
113
|
+
assert.deepEqual(spellcheckAssets(files), []);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("ships no worker to browsers that could never start it", () => {
|
|
117
|
+
assert.deepEqual(workerChunks(files), []);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("a build that carries one", () => {
|
|
122
|
+
let files: readonly string[] = [];
|
|
123
|
+
|
|
124
|
+
before(
|
|
125
|
+
() => {
|
|
126
|
+
files = build("nl");
|
|
127
|
+
},
|
|
128
|
+
{ timeout: BUILD_TIMEOUT_MS },
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
it("stages the engine, the dictionary and the notice", () => {
|
|
132
|
+
// The staged directory is named after a digest of its own contents, so
|
|
133
|
+
// what is asserted is the tree under it rather than the whole path.
|
|
134
|
+
const staged = spellcheckAssets(files).map((file) =>
|
|
135
|
+
file.replace(/^spellcheck\/[0-9a-f]+\//, ""),
|
|
136
|
+
);
|
|
137
|
+
// English rides along whether or not it was asked for: it is the language
|
|
138
|
+
// every account is guaranteed to offer.
|
|
139
|
+
assert.deepEqual(staged.sort(), [
|
|
140
|
+
"LICENSE",
|
|
141
|
+
"NOTICE.txt",
|
|
142
|
+
"dictionaries/en/LICENSE",
|
|
143
|
+
"dictionaries/en/index.aff",
|
|
144
|
+
"dictionaries/en/index.dic",
|
|
145
|
+
"dictionaries/nl/LICENSE",
|
|
146
|
+
"dictionaries/nl/index.aff",
|
|
147
|
+
"dictionaries/nl/index.dic",
|
|
148
|
+
"hunspell.mjs",
|
|
149
|
+
"hunspell.wasm",
|
|
150
|
+
"license.hunspell",
|
|
151
|
+
"manifest.json",
|
|
152
|
+
]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// The control for the two assertions above: both names are real, and a build
|
|
156
|
+
// that should carry them does.
|
|
157
|
+
it("ships the worker the empty build must not", () => {
|
|
158
|
+
assert.notDeepEqual(workerChunks(files), []);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The switch, at the point where a self-hoster's `REMIT_SPELLCHECK_LANGUAGES`
|
|
3
|
+
* becomes the set the image carries. Everything downstream — the staged files,
|
|
4
|
+
* the manifest, NOTICE.txt, and what the composer believes it can check — is
|
|
5
|
+
* that set and nothing else, so a wrong answer here is wrong everywhere at once.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { describe, it } from "node:test";
|
|
10
|
+
import {
|
|
11
|
+
DEFAULT_SPELLCHECK_LANGUAGES,
|
|
12
|
+
DICTIONARY_SOURCES,
|
|
13
|
+
resolveLanguages,
|
|
14
|
+
} from "./languages.ts";
|
|
15
|
+
|
|
16
|
+
const tagsOf = (requested: string | undefined): readonly string[] =>
|
|
17
|
+
resolveLanguages(requested).map((source) => source.tag);
|
|
18
|
+
|
|
19
|
+
describe("the language set an image is built with", () => {
|
|
20
|
+
it("carries the published default when nothing is asked for", () => {
|
|
21
|
+
assert.deepEqual(
|
|
22
|
+
tagsOf(undefined),
|
|
23
|
+
DEFAULT_SPELLCHECK_LANGUAGES.split(","),
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// `defaultComposeLanguages` appends `en` to every account, so an image
|
|
28
|
+
// without it would leave the one language every account offers as the one
|
|
29
|
+
// language it cannot check.
|
|
30
|
+
it("carries English whether or not it was asked for", () => {
|
|
31
|
+
assert.deepEqual(tagsOf("nl"), ["en", "nl"]);
|
|
32
|
+
assert.equal(tagsOf("en,nl")[0], "en");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("reads a tag the same however it was typed, and carries it once", () => {
|
|
36
|
+
assert.deepEqual(tagsOf(" nl , EN-gb ,nl, en "), ["en", "nl", "en-GB"]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// The self-hoster's opt-out. An empty list is a legitimate answer and not a
|
|
40
|
+
// typo to be corrected into the default, so it does not acquire English.
|
|
41
|
+
it("carries nothing at all when the list is empty", () => {
|
|
42
|
+
assert.deepEqual(tagsOf(""), []);
|
|
43
|
+
assert.deepEqual(tagsOf(" "), []);
|
|
44
|
+
assert.deepEqual(tagsOf(",, ,"), []);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("hands back the whole row, not just the tag", () => {
|
|
48
|
+
const [english] = resolveLanguages("en");
|
|
49
|
+
assert.equal(english.package, "dictionary-en");
|
|
50
|
+
assert.ok(english.licence.length > 0);
|
|
51
|
+
assert.ok(english.source.startsWith("https://"));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// A tag nobody can ship must stop the build rather than quietly produce an
|
|
55
|
+
// image whose NOTICE.txt is a list of what the operator asked for.
|
|
56
|
+
it("stops on a tag no dictionary covers, and says which one", () => {
|
|
57
|
+
assert.throws(
|
|
58
|
+
() => resolveLanguages("en,nl,fi"),
|
|
59
|
+
(failed: Error) => {
|
|
60
|
+
assert.match(failed.message, /"fi"/);
|
|
61
|
+
assert.match(
|
|
62
|
+
failed.message,
|
|
63
|
+
/packages\/web-client\/spellcheck\/languages/,
|
|
64
|
+
);
|
|
65
|
+
// The remedy is picking a known tag, so the message lists them.
|
|
66
|
+
for (const source of DICTIONARY_SOURCES) {
|
|
67
|
+
assert.ok(
|
|
68
|
+
failed.message.includes(source.tag),
|
|
69
|
+
`the failure does not offer ${source.tag}`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return true;
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("stops on an unknown tag even when every other tag is known", () => {
|
|
78
|
+
assert.throws(() => resolveLanguages("nl,de"), /"de"/);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every dictionary this repo knows how to ship, and everything the image owes
|
|
3
|
+
* for carrying one. Adding a language is a row here, its `dictionary-*` package
|
|
4
|
+
* in `devDependencies`, and its tag in `REMIT_SPELLCHECK_LANGUAGES` — no code.
|
|
5
|
+
*
|
|
6
|
+
* A `.aff` and a `.dic` are data an engine reads at runtime, so carrying one is
|
|
7
|
+
* aggregation and reader's own licence is untouched. What each licence does
|
|
8
|
+
* oblige is that its text travels with the file and that the served bytes are
|
|
9
|
+
* the upstream source; both are build outputs, generated from this table.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface DictionarySource {
|
|
13
|
+
/** BCP 47, and the directory name the browser fetches under. */
|
|
14
|
+
readonly tag: string;
|
|
15
|
+
readonly package: string;
|
|
16
|
+
readonly project: string;
|
|
17
|
+
readonly authors: string;
|
|
18
|
+
readonly source: string;
|
|
19
|
+
/** SPDX, with the option taken where upstream offers a choice. */
|
|
20
|
+
readonly licence: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const DICTIONARY_SOURCES: readonly DictionarySource[] = [
|
|
24
|
+
{
|
|
25
|
+
tag: "en",
|
|
26
|
+
package: "dictionary-en",
|
|
27
|
+
project: "SCOWL",
|
|
28
|
+
authors: "Kevin Atkinson and contributors",
|
|
29
|
+
source: "https://github.com/wooorm/dictionaries/tree/main/dictionaries/en",
|
|
30
|
+
licence: "MIT AND BSD-3-Clause",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
tag: "en-GB",
|
|
34
|
+
package: "dictionary-en-gb",
|
|
35
|
+
project: "SCOWL",
|
|
36
|
+
authors: "Kevin Atkinson and contributors",
|
|
37
|
+
source:
|
|
38
|
+
"https://github.com/wooorm/dictionaries/tree/main/dictionaries/en-GB",
|
|
39
|
+
licence: "MIT AND BSD-3-Clause",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
tag: "nl",
|
|
43
|
+
package: "dictionary-nl",
|
|
44
|
+
project: "OpenTaal",
|
|
45
|
+
authors: "Stichting OpenTaal",
|
|
46
|
+
source: "https://github.com/wooorm/dictionaries/tree/main/dictionaries/nl",
|
|
47
|
+
licence: "CC-BY-3.0",
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* What the published image carries. Everything else in the table above is a
|
|
53
|
+
* language this repo can build and does not; the list is the switch.
|
|
54
|
+
*/
|
|
55
|
+
export const DEFAULT_SPELLCHECK_LANGUAGES = "en,en-GB,nl";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `defaultComposeLanguages` appends `en` to every account's candidate set, so a
|
|
59
|
+
* build without it would leave the one language every account is guaranteed to
|
|
60
|
+
* offer as the one with no dictionary. The switch normalises rather than
|
|
61
|
+
* validates.
|
|
62
|
+
*/
|
|
63
|
+
export const resolveLanguages = (
|
|
64
|
+
requested: string | undefined,
|
|
65
|
+
): readonly DictionarySource[] => {
|
|
66
|
+
const asked = (requested ?? DEFAULT_SPELLCHECK_LANGUAGES)
|
|
67
|
+
.split(",")
|
|
68
|
+
.map((tag) => tag.trim())
|
|
69
|
+
.filter((tag) => tag !== "");
|
|
70
|
+
if (asked.length === 0) return [];
|
|
71
|
+
|
|
72
|
+
const known = new Map(
|
|
73
|
+
DICTIONARY_SOURCES.map((source) => [source.tag.toLowerCase(), source]),
|
|
74
|
+
);
|
|
75
|
+
const wanted: DictionarySource[] = [];
|
|
76
|
+
for (const tag of ["en", ...asked]) {
|
|
77
|
+
const source = known.get(tag.toLowerCase());
|
|
78
|
+
if (!source) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`REMIT_SPELLCHECK_LANGUAGES names "${tag}", which no dictionary in packages/web-client/spellcheck/languages.ts covers. Known tags: ${DICTIONARY_SOURCES.map((entry) => entry.tag).join(", ")}.`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
if (!wanted.includes(source)) wanted.push(source);
|
|
84
|
+
}
|
|
85
|
+
return wanted;
|
|
86
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A dictionary whose licence text cannot travel with it must stop the build.
|
|
3
|
+
*
|
|
4
|
+
* Every licence in the table obliges the text to ship alongside the files, and
|
|
5
|
+
* the build generates NOTICE.txt from the same rows — so a package that lost its
|
|
6
|
+
* licence file (an upstream repackage, a pruned install) would otherwise produce
|
|
7
|
+
* an image whose notice points at a `LICENSE` nobody staged. That is the one
|
|
8
|
+
* failure mode a reader cannot see and a distributor is answerable for.
|
|
9
|
+
*
|
|
10
|
+
* The dictionary is shadowed rather than the real one damaged: node resolves
|
|
11
|
+
* from the importer outwards, so a package planted in this directory's own
|
|
12
|
+
* `node_modules` answers for `dictionary-en-gb` here and nowhere else. That
|
|
13
|
+
* makes this file its own process — node's test runner gives each file one —
|
|
14
|
+
* because module resolution is cached per process and a sibling test resolving
|
|
15
|
+
* the real package first would decide which one this sees.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { after, describe, it } from "node:test";
|
|
23
|
+
|
|
24
|
+
const shadowRoot = join(import.meta.dirname, "node_modules");
|
|
25
|
+
const shadow = join(shadowRoot, "dictionary-en-gb");
|
|
26
|
+
mkdirSync(shadow, { recursive: true });
|
|
27
|
+
writeFileSync(
|
|
28
|
+
join(shadow, "package.json"),
|
|
29
|
+
JSON.stringify({ name: "dictionary-en-gb", version: "0.0.0-shadow" }),
|
|
30
|
+
);
|
|
31
|
+
writeFileSync(join(shadow, "index.js"), "");
|
|
32
|
+
writeFileSync(join(shadow, "index.aff"), "SET UTF-8\n");
|
|
33
|
+
writeFileSync(join(shadow, "index.dic"), "1\nword\n");
|
|
34
|
+
|
|
35
|
+
const engineDir = mkdtempSync(join(tmpdir(), "spellcheck-engine-"));
|
|
36
|
+
for (const name of [
|
|
37
|
+
"hunspell.wasm",
|
|
38
|
+
"hunspell.mjs",
|
|
39
|
+
"LICENSE",
|
|
40
|
+
"license.hunspell",
|
|
41
|
+
]) {
|
|
42
|
+
writeFileSync(join(engineDir, name), "stub");
|
|
43
|
+
}
|
|
44
|
+
process.env.REMIT_SPELLCHECK_ENGINE_DIR = engineDir;
|
|
45
|
+
|
|
46
|
+
const { stageSpellcheck } = await import("./vite-plugin.ts");
|
|
47
|
+
|
|
48
|
+
after(() => {
|
|
49
|
+
rmSync(shadow, { recursive: true, force: true });
|
|
50
|
+
rmSync(shadowRoot, { recursive: true, force: true });
|
|
51
|
+
rmSync(engineDir, { recursive: true, force: true });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("a dictionary that ships no licence text", () => {
|
|
55
|
+
it("stops the build rather than staging it", () => {
|
|
56
|
+
assert.throws(() => stageSpellcheck("en-GB", "/"));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// The remedy is the whole message: which directory, and why a missing
|
|
60
|
+
// licence file is fatal rather than something the notice can paper over.
|
|
61
|
+
it("names the directory and what is wrong with it", () => {
|
|
62
|
+
assert.throws(
|
|
63
|
+
() => stageSpellcheck("en-GB", "/"),
|
|
64
|
+
(failed: Error) => {
|
|
65
|
+
assert.ok(
|
|
66
|
+
failed.message.includes(shadow),
|
|
67
|
+
`the failure does not name the dictionary directory: ${failed.message}`,
|
|
68
|
+
);
|
|
69
|
+
assert.match(failed.message, /licence/i);
|
|
70
|
+
assert.match(failed.message, /NOTICE\.txt/);
|
|
71
|
+
return true;
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// The languages before the broken one must not reach the output either: a
|
|
77
|
+
// half-staged tree with a complete-looking notice is worse than no build.
|
|
78
|
+
it("takes the whole build with it, not just that language", () => {
|
|
79
|
+
assert.throws(() => stageSpellcheck("en,en-GB", "/"));
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("a dictionary that does ship one", () => {
|
|
84
|
+
it("stages the licence text beside the files it covers", () => {
|
|
85
|
+
const build = stageSpellcheck("en", "/");
|
|
86
|
+
const licence = build.files.find(
|
|
87
|
+
(file) => file.path === "dictionaries/en/LICENSE",
|
|
88
|
+
);
|
|
89
|
+
assert.ok(licence);
|
|
90
|
+
assert.ok(licence.source.byteLength > 0);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the build stages, and whether NOTICE.txt describes it.
|
|
3
|
+
*
|
|
4
|
+
* The licence obligation this repo took on is that the notice matches the image
|
|
5
|
+
* exactly and that the served dictionary is the upstream file — which is the
|
|
6
|
+
* whole reason the language set is fixed at build time rather than chosen at
|
|
7
|
+
* run time. A notice that names a dictionary the image does not carry, or omits
|
|
8
|
+
* one it does, is a licence failure and not a cosmetic one, so the chain from
|
|
9
|
+
* the resolved set through the manifest to the notice is asserted end to end
|
|
10
|
+
* rather than at either end.
|
|
11
|
+
*
|
|
12
|
+
* This runs in the checkout, against the dictionary packages actually
|
|
13
|
+
* installed, which is what makes the byte-for-byte and licence-text assertions
|
|
14
|
+
* mean anything. `npm-scripts/lib/spellcheck-staging.test.mjs` is the other
|
|
15
|
+
* half: the same function against a synthetic tree shaped like the published
|
|
16
|
+
* package, where the question is which file gets read and what a distributor is
|
|
17
|
+
* told when one is missing. Base, `servePath` and the digest belong to that
|
|
18
|
+
* one.
|
|
19
|
+
*
|
|
20
|
+
* The engine is stubbed: what it weighs is gated where it is built
|
|
21
|
+
* (npm-scripts/build-hunspell.mjs), and staging it here would make every one of
|
|
22
|
+
* these tests wait on a WebAssembly toolchain.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import assert from "node:assert/strict";
|
|
26
|
+
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
27
|
+
import { createRequire } from "node:module";
|
|
28
|
+
import { tmpdir } from "node:os";
|
|
29
|
+
import { dirname, join } from "node:path";
|
|
30
|
+
import { describe, it } from "node:test";
|
|
31
|
+
|
|
32
|
+
const require = createRequire(import.meta.url);
|
|
33
|
+
|
|
34
|
+
const ENGINE_FILES = {
|
|
35
|
+
"hunspell.wasm": "\0asm stub",
|
|
36
|
+
"hunspell.mjs": "export default () => {};",
|
|
37
|
+
LICENSE: "Mozilla Public Licence 1.1, stub",
|
|
38
|
+
"license.hunspell": "Hunspell licence, stub",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const engineDir = mkdtempSync(join(tmpdir(), "spellcheck-engine-"));
|
|
42
|
+
for (const [name, body] of Object.entries(ENGINE_FILES)) {
|
|
43
|
+
writeFileSync(join(engineDir, name), body);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
process.env.REMIT_SPELLCHECK_ENGINE_DIR = engineDir;
|
|
47
|
+
const { stageSpellcheck } = await import("./vite-plugin.ts");
|
|
48
|
+
|
|
49
|
+
const staged = (requested: string) => stageSpellcheck(requested, "/");
|
|
50
|
+
|
|
51
|
+
const fileNamed = (
|
|
52
|
+
build: ReturnType<typeof staged>,
|
|
53
|
+
path: string,
|
|
54
|
+
): Buffer | undefined => build.files.find((file) => file.path === path)?.source;
|
|
55
|
+
|
|
56
|
+
const textOf = (build: ReturnType<typeof staged>, path: string): string => {
|
|
57
|
+
const found = fileNamed(build, path);
|
|
58
|
+
if (!found) throw new Error(`${path} was not staged`);
|
|
59
|
+
return found.toString("utf8");
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** The tags NOTICE.txt claims this image carries, read back off the notice. */
|
|
63
|
+
const tagsNoticed = (notice: string): readonly string[] => {
|
|
64
|
+
const dictionaries = notice.split("\nDictionaries\n")[1];
|
|
65
|
+
if (dictionaries === undefined) {
|
|
66
|
+
throw new Error("NOTICE.txt has no Dictionaries section");
|
|
67
|
+
}
|
|
68
|
+
return dictionaries
|
|
69
|
+
.split("\n")
|
|
70
|
+
.map((line) => /^(\S+) — /.exec(line)?.[1])
|
|
71
|
+
.filter((tag): tag is string => tag !== undefined);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** The tags the image actually carries a dictionary for. */
|
|
75
|
+
const tagsStaged = (build: ReturnType<typeof staged>): readonly string[] => [
|
|
76
|
+
...new Set(
|
|
77
|
+
build.files
|
|
78
|
+
.map((file) => /^dictionaries\/([^/]+)\//.exec(file.path)?.[1])
|
|
79
|
+
.filter((tag): tag is string => tag !== undefined),
|
|
80
|
+
),
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
describe("what a build stages", () => {
|
|
84
|
+
it("stages the engine, both dictionary files and a licence per language", () => {
|
|
85
|
+
const build = staged("en,nl");
|
|
86
|
+
const paths = build.files.map((file) => file.path).sort();
|
|
87
|
+
assert.deepEqual(paths, [
|
|
88
|
+
"LICENSE",
|
|
89
|
+
"NOTICE.txt",
|
|
90
|
+
"dictionaries/en/LICENSE",
|
|
91
|
+
"dictionaries/en/index.aff",
|
|
92
|
+
"dictionaries/en/index.dic",
|
|
93
|
+
"dictionaries/nl/LICENSE",
|
|
94
|
+
"dictionaries/nl/index.aff",
|
|
95
|
+
"dictionaries/nl/index.dic",
|
|
96
|
+
"hunspell.mjs",
|
|
97
|
+
"hunspell.wasm",
|
|
98
|
+
"license.hunspell",
|
|
99
|
+
"manifest.json",
|
|
100
|
+
]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// GPL and MPL correspondence is discharged by the served file being the
|
|
104
|
+
// upstream one. Anything that rewrote a dictionary on the way through —
|
|
105
|
+
// a normaliser, a re-encode, a line-ending fix — would break that quietly.
|
|
106
|
+
it("serves the upstream dictionary byte for byte", () => {
|
|
107
|
+
const build = staged("nl");
|
|
108
|
+
const upstream = dirname(require.resolve("dictionary-nl"));
|
|
109
|
+
for (const name of ["index.aff", "index.dic"]) {
|
|
110
|
+
assert.deepEqual(
|
|
111
|
+
fileNamed(build, `dictionaries/nl/${name}`),
|
|
112
|
+
readFileSync(join(upstream, name)),
|
|
113
|
+
`dictionaries/nl/${name} is not the upstream file`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("NOTICE.txt", () => {
|
|
120
|
+
it("names every language the image carries", () => {
|
|
121
|
+
const build = staged("en,en-GB,nl");
|
|
122
|
+
const noticed = tagsNoticed(textOf(build, "NOTICE.txt"));
|
|
123
|
+
assert.deepEqual([...noticed].sort(), ["en", "en-GB", "nl"]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Over-claiming is the failure that matters: a notice listing a dictionary
|
|
127
|
+
// the image does not carry offers a licence text nobody can find, and one
|
|
128
|
+
// missing a dictionary it does carry ships that dictionary with no licence
|
|
129
|
+
// at all. Both are the same defect from opposite sides, so the three lists
|
|
130
|
+
// the build produces are compared to each other rather than to a fixture.
|
|
131
|
+
it("names only the languages the image carries", () => {
|
|
132
|
+
for (const requested of ["en", "nl", "en,en-GB,nl"]) {
|
|
133
|
+
const build = staged(requested);
|
|
134
|
+
const notice = textOf(build, "NOTICE.txt");
|
|
135
|
+
const carried = tagsStaged(build);
|
|
136
|
+
const manifest = JSON.parse(textOf(build, "manifest.json"));
|
|
137
|
+
|
|
138
|
+
assert.deepEqual(
|
|
139
|
+
[...tagsNoticed(notice)].sort(),
|
|
140
|
+
[...carried].sort(),
|
|
141
|
+
`NOTICE.txt and the staged dictionaries disagree for "${requested}"`,
|
|
142
|
+
);
|
|
143
|
+
assert.deepEqual(
|
|
144
|
+
manifest.languages.map((language: { tag: string }) => language.tag),
|
|
145
|
+
build.languages.map((language) => language.tag),
|
|
146
|
+
);
|
|
147
|
+
assert.deepEqual(
|
|
148
|
+
[...carried].sort(),
|
|
149
|
+
build.languages.map((language) => language.tag).sort(),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("points every language at a licence text the image ships", () => {
|
|
155
|
+
const build = staged("en,en-GB,nl");
|
|
156
|
+
const notice = textOf(build, "NOTICE.txt");
|
|
157
|
+
const referenced = [...notice.matchAll(/Licence text: (\S+?),/g)]
|
|
158
|
+
.map((match) => match[1])
|
|
159
|
+
.sort();
|
|
160
|
+
assert.deepEqual(referenced, [
|
|
161
|
+
"LICENSE",
|
|
162
|
+
"dictionaries/en-GB/LICENSE",
|
|
163
|
+
"dictionaries/en/LICENSE",
|
|
164
|
+
"dictionaries/nl/LICENSE",
|
|
165
|
+
]);
|
|
166
|
+
for (const path of referenced) {
|
|
167
|
+
const licence = fileNamed(build, path);
|
|
168
|
+
assert.ok(licence, `NOTICE.txt names ${path}, which is not staged`);
|
|
169
|
+
assert.ok(licence.byteLength > 0, `${path} is empty`);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("states each licence, its author and the version that was staged", () => {
|
|
174
|
+
const build = staged("nl");
|
|
175
|
+
const notice = textOf(build, "NOTICE.txt");
|
|
176
|
+
const [dutch] = build.languages.filter((language) => language.tag === "nl");
|
|
177
|
+
assert.match(notice, /nl — OpenTaal/);
|
|
178
|
+
assert.ok(notice.includes(`Licence: ${dutch.licence}`));
|
|
179
|
+
assert.ok(notice.includes(`Authors: ${dutch.authors}`));
|
|
180
|
+
assert.ok(notice.includes(`dictionary-nl@${dutch.version}`));
|
|
181
|
+
assert.match(dutch.version, /^\d+\.\d+\.\d+/);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("says the served files are the source where a licence requires it", () => {
|
|
185
|
+
const gpl = textOf(staged("nl,en"), "NOTICE.txt");
|
|
186
|
+
// Neither English nor Dutch is under a correspondence licence here, so
|
|
187
|
+
// the sentence must not appear; German would bring it.
|
|
188
|
+
assert.ok(!gpl.includes("unmodified upstream source"));
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("carries the engine's own provenance, checksum included", () => {
|
|
192
|
+
const notice = textOf(staged("en"), "NOTICE.txt");
|
|
193
|
+
const pins = readFileSync(
|
|
194
|
+
join(
|
|
195
|
+
import.meta.dirname,
|
|
196
|
+
"..",
|
|
197
|
+
"..",
|
|
198
|
+
"..",
|
|
199
|
+
"docker",
|
|
200
|
+
"hunspell",
|
|
201
|
+
"pin.env",
|
|
202
|
+
),
|
|
203
|
+
"utf8",
|
|
204
|
+
);
|
|
205
|
+
const version = /HUNSPELL_VERSION=(\S+)/.exec(pins)?.[1];
|
|
206
|
+
const sha = /HUNSPELL_SHA256=(\S+)/.exec(pins)?.[1];
|
|
207
|
+
assert.ok(version && sha);
|
|
208
|
+
assert.ok(notice.includes(`Hunspell ${version}`));
|
|
209
|
+
assert.ok(notice.includes(sha));
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("the manifest the app reads", () => {
|
|
214
|
+
it("describes the engine and every language it staged", () => {
|
|
215
|
+
const build = staged("en,nl");
|
|
216
|
+
const manifest = JSON.parse(textOf(build, "manifest.json"));
|
|
217
|
+
assert.equal(manifest.engine.project, "Hunspell");
|
|
218
|
+
assert.equal(manifest.engine.licence, "MPL-1.1");
|
|
219
|
+
assert.deepEqual(
|
|
220
|
+
manifest.languages.map(
|
|
221
|
+
(language: { licenceFile: string }) => language.licenceFile,
|
|
222
|
+
),
|
|
223
|
+
["dictionaries/en/LICENSE", "dictionaries/nl/LICENSE"],
|
|
224
|
+
);
|
|
225
|
+
for (const language of manifest.languages) {
|
|
226
|
+
assert.ok(fileNamed(build, language.licenceFile));
|
|
227
|
+
assert.match(language.package, /@\d+\.\d+\.\d+/);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns `REMIT_SPELLCHECK_LANGUAGES` into what the browser fetches: the engine,
|
|
3
|
+
* one directory per language holding the upstream `.aff`, `.dic` and licence
|
|
4
|
+
* text byte for byte, a manifest, and the notice generated from it.
|
|
5
|
+
*
|
|
6
|
+
* Nothing here is bundled and nothing is fetched at page load. The build's own
|
|
7
|
+
* job is that the notice describes the image exactly — which is the reason the
|
|
8
|
+
* language set is fixed at build time at all — so a tag nobody can ship, or a
|
|
9
|
+
* dictionary whose licence file is missing, fails the build by name.
|
|
10
|
+
*
|
|
11
|
+
* Two audiences build this package: this repo, where `npm run build:hunspell`
|
|
12
|
+
* leaves an engine in `build/hunspell/`, and a distributor building from the
|
|
13
|
+
* published tarball, which carries neither an engine nor a dictionary. Every
|
|
14
|
+
* failure below therefore names the file it wanted and both ways past it —
|
|
15
|
+
* `REMIT_SPELLCHECK_ENGINE_DIR` for an engine built elsewhere, and an empty
|
|
16
|
+
* `REMIT_SPELLCHECK_LANGUAGES` for a build with no spellchecker at all.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
21
|
+
import { createRequire } from "node:module";
|
|
22
|
+
import { dirname, join } from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
import type { Plugin } from "vite";
|
|
25
|
+
import type { DictionarySource } from "./languages.ts";
|
|
26
|
+
import { resolveLanguages } from "./languages.ts";
|
|
27
|
+
|
|
28
|
+
const require = createRequire(import.meta.url);
|
|
29
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
// The repo root when this file is a checkout, and `node_modules` when it is an
|
|
31
|
+
// installed package — which holds neither the engine nor the pins. Nothing is
|
|
32
|
+
// read from here without a fallback and an error that names the alternative.
|
|
33
|
+
const repoRoot = join(here, "..", "..", "..");
|
|
34
|
+
// Where `npm run build:hunspell` leaves the engine. A distributor building the
|
|
35
|
+
// web client from the published package has no such tree, so the location is
|
|
36
|
+
// theirs to name.
|
|
37
|
+
const engineDir =
|
|
38
|
+
process.env.REMIT_SPELLCHECK_ENGINE_DIR ??
|
|
39
|
+
join(repoRoot, "build", "hunspell");
|
|
40
|
+
|
|
41
|
+
const WAYS_OUT = [
|
|
42
|
+
"Set REMIT_SPELLCHECK_ENGINE_DIR to a directory holding hunspell.wasm, hunspell.mjs, LICENSE, license.hunspell and pin.env — in this repo `npm run build:hunspell` writes one into build/hunspell/.",
|
|
43
|
+
"Or set REMIT_SPELLCHECK_LANGUAGES= (empty) to build a web client with no spellchecker at all; the browser's own checker stays on either way.",
|
|
44
|
+
].join("\n");
|
|
45
|
+
|
|
46
|
+
const LICENCE_NAMES = ["LICENSE", "license", "LICENCE", "licence"];
|
|
47
|
+
|
|
48
|
+
/** The engine's own files, in the order the browser needs them explained. */
|
|
49
|
+
const ENGINE_FILES = [
|
|
50
|
+
"hunspell.wasm",
|
|
51
|
+
"hunspell.mjs",
|
|
52
|
+
"LICENSE",
|
|
53
|
+
"license.hunspell",
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
interface StagedFile {
|
|
57
|
+
readonly path: string;
|
|
58
|
+
readonly source: Buffer;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface StagedLanguage extends DictionarySource {
|
|
62
|
+
readonly version: string;
|
|
63
|
+
readonly bytes: number;
|
|
64
|
+
readonly files: readonly StagedFile[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The pins describe the engine, so they are read from beside it: an engine dir
|
|
69
|
+
* a distributor points at carries the `pin.env` its own build used, and the
|
|
70
|
+
* checksum in NOTICE.txt is then the checksum of the bytes being served rather
|
|
71
|
+
* than of whatever this checkout happens to pin.
|
|
72
|
+
*/
|
|
73
|
+
const pinFile = (): string => {
|
|
74
|
+
const beside = join(engineDir, "pin.env");
|
|
75
|
+
if (existsSync(beside)) return beside;
|
|
76
|
+
const inRepo = join(repoRoot, "docker", "hunspell", "pin.env");
|
|
77
|
+
if (existsSync(inRepo)) return inRepo;
|
|
78
|
+
throw new Error(
|
|
79
|
+
`The engine at ${engineDir} carries no pin.env, and neither does ${inRepo}, so nothing here can say which Hunspell release is being served and NOTICE.txt would have to guess.\n${WAYS_OUT}`,
|
|
80
|
+
);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
let pins: Record<string, string> | null = null;
|
|
84
|
+
|
|
85
|
+
const readPin = (name: string): string => {
|
|
86
|
+
const path = pinFile();
|
|
87
|
+
pins ??= Object.fromEntries(
|
|
88
|
+
readFileSync(path, "utf8")
|
|
89
|
+
.split("\n")
|
|
90
|
+
.flatMap((line) => {
|
|
91
|
+
const match = /^([A-Z0-9_]+)=(.*)$/.exec(line.trim());
|
|
92
|
+
return match ? [[match[1], match[2]] as const] : [];
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
const found = pins[name];
|
|
96
|
+
if (found === undefined) throw new Error(`${path} has no ${name}`);
|
|
97
|
+
return found;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const licenceIn = (directory: string): string => {
|
|
101
|
+
const named = LICENCE_NAMES.find((name) => existsSync(join(directory, name)));
|
|
102
|
+
if (named) return named;
|
|
103
|
+
const found = readdirSync(directory).find((entry) =>
|
|
104
|
+
/^licen[cs]e/i.test(entry),
|
|
105
|
+
);
|
|
106
|
+
if (found) return found;
|
|
107
|
+
throw new Error(
|
|
108
|
+
`${directory} ships no licence file. A dictionary whose licence text cannot travel with it cannot ship, so the build stops here rather than producing a NOTICE.txt that claims one exists.`,
|
|
109
|
+
);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const stageEngine = (): StagedFile[] =>
|
|
113
|
+
ENGINE_FILES.map((name) => {
|
|
114
|
+
const path = join(engineDir, name);
|
|
115
|
+
if (!existsSync(path)) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`${path} is missing, so this build has no spellchecking engine to serve.\n${WAYS_OUT}`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
return { path: name, source: readFileSync(path) };
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A `dictionary-*` package is a devDependency of @remit/web-client, which a
|
|
125
|
+
* consumer of the published tarball never receives — so a missing one is the
|
|
126
|
+
* ordinary case out here, not a broken install.
|
|
127
|
+
*/
|
|
128
|
+
const dictionaryDir = (source: DictionarySource): string => {
|
|
129
|
+
try {
|
|
130
|
+
// Resolved through the package's own entry point: `dictionary-*` declares an
|
|
131
|
+
// `exports` map, so its package.json is not a resolvable subpath.
|
|
132
|
+
return dirname(require.resolve(source.package));
|
|
133
|
+
} catch {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`${source.package} is not installed, so the ${source.tag} dictionary cannot be staged. It is a devDependency of @remit/web-client and a consumer of the published package never gets it.\nInstall ${source.package} alongside @remit/web-client, or drop "${source.tag}" from REMIT_SPELLCHECK_LANGUAGES — set it empty to build with no spellchecker at all.`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const stageLanguage = (source: DictionarySource): StagedLanguage => {
|
|
141
|
+
const directory = dictionaryDir(source);
|
|
142
|
+
const version = JSON.parse(
|
|
143
|
+
readFileSync(join(directory, "package.json"), "utf8"),
|
|
144
|
+
).version;
|
|
145
|
+
const licence = licenceIn(directory);
|
|
146
|
+
const at = (name: string) => `dictionaries/${source.tag}/${name}`;
|
|
147
|
+
const aff = readFileSync(join(directory, "index.aff"));
|
|
148
|
+
const dic = readFileSync(join(directory, "index.dic"));
|
|
149
|
+
return {
|
|
150
|
+
...source,
|
|
151
|
+
version,
|
|
152
|
+
bytes: aff.byteLength + dic.byteLength,
|
|
153
|
+
files: [
|
|
154
|
+
{ path: at("index.aff"), source: aff },
|
|
155
|
+
{ path: at("index.dic"), source: dic },
|
|
156
|
+
{ path: at("LICENSE"), source: readFileSync(join(directory, licence)) },
|
|
157
|
+
],
|
|
158
|
+
};
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/** A licence that says the served form must be the source says it out loud. */
|
|
162
|
+
const correspondence = (licence: string): boolean =>
|
|
163
|
+
/GPL|MPL/.test(licence.toUpperCase());
|
|
164
|
+
|
|
165
|
+
const noticeFor = (
|
|
166
|
+
engineVersion: string,
|
|
167
|
+
languages: readonly StagedLanguage[],
|
|
168
|
+
): string => {
|
|
169
|
+
const blocks = languages.map((language) =>
|
|
170
|
+
[
|
|
171
|
+
`${language.tag} — ${language.project}`,
|
|
172
|
+
` Authors: ${language.authors}`,
|
|
173
|
+
` Source: ${language.source}`,
|
|
174
|
+
` Package: ${language.package}@${language.version}`,
|
|
175
|
+
` Licence: ${language.licence}`,
|
|
176
|
+
` Licence text: dictionaries/${language.tag}/LICENSE, beside this file`,
|
|
177
|
+
...(correspondence(language.licence)
|
|
178
|
+
? [
|
|
179
|
+
` The served index.aff and index.dic are the unmodified upstream source.`,
|
|
180
|
+
]
|
|
181
|
+
: []),
|
|
182
|
+
].join("\n"),
|
|
183
|
+
);
|
|
184
|
+
return [
|
|
185
|
+
"This build of Remit Reader carries the spelling dictionaries listed below.",
|
|
186
|
+
"Each stays under its own licence; the licence text travels with the files,",
|
|
187
|
+
"and nothing in this image modifies a dictionary. Reader itself is MIT.",
|
|
188
|
+
"",
|
|
189
|
+
"Engine",
|
|
190
|
+
"",
|
|
191
|
+
` Hunspell ${engineVersion}`,
|
|
192
|
+
" Source: https://github.com/hunspell/hunspell",
|
|
193
|
+
` Tarball: https://github.com/hunspell/hunspell/releases/download/v${engineVersion}/hunspell-${engineVersion}.tar.gz`,
|
|
194
|
+
` sha256: ${readPin("HUNSPELL_SHA256")}`,
|
|
195
|
+
" Licence: MPL-1.1 (the option taken from Hunspell's MPL-1.1/GPL-2.0/LGPL-2.1 triple)",
|
|
196
|
+
" Licence text: LICENSE, beside this file",
|
|
197
|
+
" Build recipe: docker/hunspell/build.sh",
|
|
198
|
+
"",
|
|
199
|
+
"Dictionaries",
|
|
200
|
+
"",
|
|
201
|
+
...blocks.map((block) => `${block}\n`),
|
|
202
|
+
].join("\n");
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
export interface SpellcheckBuild {
|
|
206
|
+
readonly languages: readonly StagedLanguage[];
|
|
207
|
+
/** Paths relative to `directory`, which is where all of them are emitted. */
|
|
208
|
+
readonly files: readonly StagedFile[];
|
|
209
|
+
/** `spellcheck/<digest>/`, relative to the build's output root. */
|
|
210
|
+
readonly directory: string;
|
|
211
|
+
/** What the browser resolves against the document to reach `directory`. */
|
|
212
|
+
readonly base: string;
|
|
213
|
+
/** Where the dev server answers for `directory`, always document-rooted. */
|
|
214
|
+
readonly servePath: string;
|
|
215
|
+
/** Tag → the bytes opening that language downloads, engine included. */
|
|
216
|
+
readonly bytes: Readonly<Record<string, number>>;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const EMPTY_BUILD: SpellcheckBuild = {
|
|
220
|
+
languages: [],
|
|
221
|
+
files: [],
|
|
222
|
+
directory: "",
|
|
223
|
+
base: "",
|
|
224
|
+
servePath: "",
|
|
225
|
+
bytes: {},
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* One digest over everything served, folded into the directory name. Nothing
|
|
230
|
+
* the browser fetches is versioned otherwise — `hunspell.wasm` and `index.dic`
|
|
231
|
+
* are the same two paths in every build — and an unversioned path cannot be
|
|
232
|
+
* cached, which is what left every composer open re-downloading its dictionary.
|
|
233
|
+
*/
|
|
234
|
+
const digestOf = (files: readonly StagedFile[]): string => {
|
|
235
|
+
const hash = createHash("sha256");
|
|
236
|
+
for (const file of [...files].sort((left, right) =>
|
|
237
|
+
left.path.localeCompare(right.path),
|
|
238
|
+
)) {
|
|
239
|
+
hash.update(file.path);
|
|
240
|
+
hash.update("\0");
|
|
241
|
+
hash.update(file.source);
|
|
242
|
+
}
|
|
243
|
+
return hash.digest("hex").slice(0, 16);
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const documentRooted = (viteBase: string | undefined): boolean =>
|
|
247
|
+
viteBase === undefined || viteBase.startsWith("/");
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* An absolute base names where the app is mounted, and the staged directory
|
|
251
|
+
* hangs off it. A relative one — Storybook's, always `./` — belongs to a build
|
|
252
|
+
* published under a path it cannot know here (`/reader/pr/<n>/<sha>/`), so it
|
|
253
|
+
* gets the directory alone and the document resolves it at run time. An unset
|
|
254
|
+
* base is vite's own default of `/`, not a relative one.
|
|
255
|
+
*/
|
|
256
|
+
const browserBase = (
|
|
257
|
+
viteBase: string | undefined,
|
|
258
|
+
directory: string,
|
|
259
|
+
): string =>
|
|
260
|
+
documentRooted(viteBase)
|
|
261
|
+
? `${(viteBase ?? "/").replace(/\/$/, "")}/${directory}`
|
|
262
|
+
: directory;
|
|
263
|
+
|
|
264
|
+
export const stageSpellcheck = (
|
|
265
|
+
requested: string | undefined,
|
|
266
|
+
viteBase: string | undefined,
|
|
267
|
+
): SpellcheckBuild => {
|
|
268
|
+
const wanted = resolveLanguages(requested);
|
|
269
|
+
if (wanted.length === 0) return EMPTY_BUILD;
|
|
270
|
+
|
|
271
|
+
// The engine first: it is the file a build outside this repo is most likely
|
|
272
|
+
// to be missing, and its error is the one that names both ways out.
|
|
273
|
+
const engine = stageEngine();
|
|
274
|
+
const engineVersion = readPin("HUNSPELL_VERSION");
|
|
275
|
+
const languages = wanted.map(stageLanguage);
|
|
276
|
+
const wasmBytes =
|
|
277
|
+
engine.find((file) => file.path === "hunspell.wasm")?.source.byteLength ??
|
|
278
|
+
0;
|
|
279
|
+
|
|
280
|
+
const payload = [
|
|
281
|
+
...engine,
|
|
282
|
+
...languages.flatMap((language) => language.files),
|
|
283
|
+
];
|
|
284
|
+
const directory = `spellcheck/${digestOf(payload)}/`;
|
|
285
|
+
|
|
286
|
+
const manifest = {
|
|
287
|
+
engine: {
|
|
288
|
+
project: "Hunspell",
|
|
289
|
+
version: engineVersion,
|
|
290
|
+
licence: "MPL-1.1",
|
|
291
|
+
source: "https://github.com/hunspell/hunspell",
|
|
292
|
+
recipe: "docker/hunspell/build.sh",
|
|
293
|
+
bytes: wasmBytes,
|
|
294
|
+
},
|
|
295
|
+
languages: languages.map((language) => ({
|
|
296
|
+
tag: language.tag,
|
|
297
|
+
project: language.project,
|
|
298
|
+
authors: language.authors,
|
|
299
|
+
source: language.source,
|
|
300
|
+
package: `${language.package}@${language.version}`,
|
|
301
|
+
licence: language.licence,
|
|
302
|
+
licenceFile: `dictionaries/${language.tag}/LICENSE`,
|
|
303
|
+
bytes: language.bytes,
|
|
304
|
+
})),
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
languages,
|
|
309
|
+
directory,
|
|
310
|
+
base: browserBase(viteBase, directory),
|
|
311
|
+
servePath: `${(documentRooted(viteBase) ? (viteBase ?? "/") : "/").replace(/\/$/, "")}/${directory}`,
|
|
312
|
+
bytes: Object.fromEntries(
|
|
313
|
+
languages.map((language) => [language.tag, wasmBytes + language.bytes]),
|
|
314
|
+
),
|
|
315
|
+
files: [
|
|
316
|
+
...payload,
|
|
317
|
+
{
|
|
318
|
+
path: "manifest.json",
|
|
319
|
+
source: Buffer.from(`${JSON.stringify(manifest, null, "\t")}\n`),
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
path: "NOTICE.txt",
|
|
323
|
+
source: Buffer.from(noticeFor(engineVersion, languages)),
|
|
324
|
+
},
|
|
325
|
+
],
|
|
326
|
+
};
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
export const spellcheckPlugin = (): Plugin => {
|
|
330
|
+
let staged: SpellcheckBuild | null = null;
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
name: "remit-spellcheck",
|
|
334
|
+
enforce: "pre",
|
|
335
|
+
config(userConfig) {
|
|
336
|
+
staged = stageSpellcheck(
|
|
337
|
+
process.env.REMIT_SPELLCHECK_LANGUAGES,
|
|
338
|
+
userConfig.base,
|
|
339
|
+
);
|
|
340
|
+
return {
|
|
341
|
+
define: {
|
|
342
|
+
__REMIT_SPELLCHECK_LANGUAGES__: JSON.stringify(
|
|
343
|
+
staged.languages.map((language) => language.tag),
|
|
344
|
+
),
|
|
345
|
+
__REMIT_SPELLCHECK_BASE__: JSON.stringify(staged.base),
|
|
346
|
+
__REMIT_SPELLCHECK_BYTES__: JSON.stringify(staged.bytes),
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
},
|
|
350
|
+
configureServer(server) {
|
|
351
|
+
server.middlewares.use((request, response, next) => {
|
|
352
|
+
const held = staged;
|
|
353
|
+
const url = (request.url ?? "").split("?")[0];
|
|
354
|
+
if (!held || held.servePath === "" || !url.startsWith(held.servePath))
|
|
355
|
+
return next();
|
|
356
|
+
const wanted = decodeURIComponent(url.slice(held.servePath.length));
|
|
357
|
+
const file = held.files.find((entry) => entry.path === wanted);
|
|
358
|
+
if (!file) return next();
|
|
359
|
+
response.setHeader("Content-Length", file.source.byteLength);
|
|
360
|
+
response.setHeader(
|
|
361
|
+
"Content-Type",
|
|
362
|
+
wanted.endsWith(".mjs")
|
|
363
|
+
? "text/javascript; charset=utf-8"
|
|
364
|
+
: wanted.endsWith(".wasm")
|
|
365
|
+
? "application/wasm"
|
|
366
|
+
: wanted.endsWith(".json")
|
|
367
|
+
? "application/json; charset=utf-8"
|
|
368
|
+
: "text/plain; charset=utf-8",
|
|
369
|
+
);
|
|
370
|
+
response.end(file.source);
|
|
371
|
+
});
|
|
372
|
+
},
|
|
373
|
+
generateBundle() {
|
|
374
|
+
const held = staged;
|
|
375
|
+
if (!held) return;
|
|
376
|
+
for (const file of held.files) {
|
|
377
|
+
this.emitFile({
|
|
378
|
+
type: "asset",
|
|
379
|
+
fileName: `${held.directory}${file.path}`,
|
|
380
|
+
source: file.source,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
},
|
|
384
|
+
};
|
|
385
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeps the spellchecking worker out of a build that staged no dictionaries.
|
|
3
|
+
*
|
|
4
|
+
* Vite emits a worker bundle for whatever module graph reaches a `new
|
|
5
|
+
* Worker(new URL(...))`, and it emits it while transforming that module —
|
|
6
|
+
* before tree-shaking, and whether or not anything in the output still imports
|
|
7
|
+
* it. So a dead branch around the dynamic import is not enough on its own: the
|
|
8
|
+
* bundle is already a file by the time the branch is dropped. The only way the
|
|
9
|
+
* worker never becomes an asset is for the specifier never to resolve to it.
|
|
10
|
+
*
|
|
11
|
+
* The decision is `resolveLanguages`, the same function the staging plugin
|
|
12
|
+
* asks, so there is no second answer to what this build carries.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Plugin } from "vite";
|
|
16
|
+
import { resolveLanguages } from "./languages.ts";
|
|
17
|
+
|
|
18
|
+
const WORKER_MODULE = "@remit/ui/spellcheck-worker";
|
|
19
|
+
const STUB = "\0remit-spellcheck-absent";
|
|
20
|
+
|
|
21
|
+
export const spellcheckWorkerStub = (): Plugin => ({
|
|
22
|
+
name: "remit-spellcheck-worker-stub",
|
|
23
|
+
enforce: "pre",
|
|
24
|
+
resolveId(source) {
|
|
25
|
+
if (source !== WORKER_MODULE) return null;
|
|
26
|
+
const staged = resolveLanguages(process.env.REMIT_SPELLCHECK_LANGUAGES);
|
|
27
|
+
return staged.length === 0 ? STUB : null;
|
|
28
|
+
},
|
|
29
|
+
load(id) {
|
|
30
|
+
// The provider's own contract for a language it cannot check: answer null,
|
|
31
|
+
// start no worker, and leave the browser's checking switched on.
|
|
32
|
+
return id === STUB
|
|
33
|
+
? "export const openSpellcheckWorker = async () => null;\n"
|
|
34
|
+
: null;
|
|
35
|
+
},
|
|
36
|
+
});
|
|
@@ -16,10 +16,22 @@ import { composeSpellcheck, spellcheckFailure } from "./compose-spellcheck.js";
|
|
|
16
16
|
interface WorkerMessage {
|
|
17
17
|
readonly type: string;
|
|
18
18
|
readonly language?: string;
|
|
19
|
+
readonly base?: string;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
const opened: { url: string; messages: WorkerMessage[] }[] = [];
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* What `REMIT_SPELLCHECK_LANGUAGES` writes into the bundle. Nothing defines it
|
|
26
|
+
* outside a Vite build, so a test that wants a build carrying English says so.
|
|
27
|
+
*/
|
|
28
|
+
const built = (...tags: string[]): void => {
|
|
29
|
+
Object.defineProperty(globalThis, "__REMIT_SPELLCHECK_LANGUAGES__", {
|
|
30
|
+
value: tags,
|
|
31
|
+
configurable: true,
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
|
|
23
35
|
class FakeWorker {
|
|
24
36
|
readonly messages: WorkerMessage[] = [];
|
|
25
37
|
constructor(url: URL) {
|
|
@@ -42,24 +54,42 @@ const withWorker = <T>(run: () => Promise<T>): Promise<T> => {
|
|
|
42
54
|
|
|
43
55
|
afterEach(() => {
|
|
44
56
|
opened.length = 0;
|
|
57
|
+
built();
|
|
45
58
|
});
|
|
46
59
|
|
|
47
60
|
describe("the checker the composer opens", () => {
|
|
48
61
|
it("asks the worker for the language the message is in", async () => {
|
|
62
|
+
built("en", "en-GB", "nl");
|
|
49
63
|
const provider = await withWorker(() =>
|
|
50
64
|
composeSpellcheck(() => undefined).provider("en"),
|
|
51
65
|
);
|
|
52
66
|
|
|
53
|
-
assert.equal(
|
|
67
|
+
assert.equal(
|
|
68
|
+
provider?.language,
|
|
69
|
+
"en",
|
|
70
|
+
"plain English is checked against English, not a region's",
|
|
71
|
+
);
|
|
54
72
|
assert.equal(opened.length, 1);
|
|
55
73
|
assert.match(opened[0].url, /rich-text-spellcheck-worker/);
|
|
56
|
-
assert.deepEqual(
|
|
74
|
+
assert.deepEqual(
|
|
75
|
+
opened[0].messages,
|
|
76
|
+
[
|
|
77
|
+
{
|
|
78
|
+
type: "open",
|
|
79
|
+
language: "en",
|
|
80
|
+
base: "http://localhost/spellcheck/",
|
|
81
|
+
bytesExpected: 0,
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
"the worker is told an absolute place to fetch from, resolved against the page",
|
|
85
|
+
);
|
|
57
86
|
provider?.close();
|
|
58
87
|
});
|
|
59
88
|
|
|
60
89
|
it("starts no worker for a language the build carries no words for", async () => {
|
|
90
|
+
built("en", "en-GB", "nl");
|
|
61
91
|
const provider = await withWorker(() =>
|
|
62
|
-
composeSpellcheck(() => undefined).provider("
|
|
92
|
+
composeSpellcheck(() => undefined).provider("de"),
|
|
63
93
|
);
|
|
64
94
|
|
|
65
95
|
assert.equal(provider, null);
|
|
@@ -70,9 +100,14 @@ describe("the checker the composer opens", () => {
|
|
|
70
100
|
const reported: PushErrorInput[] = [];
|
|
71
101
|
const { onStatus } = composeSpellcheck((input) => reported.push(input));
|
|
72
102
|
|
|
73
|
-
onStatus?.({
|
|
103
|
+
onStatus?.({
|
|
104
|
+
state: "opening",
|
|
105
|
+
language: "en",
|
|
106
|
+
bytesLoaded: 65_536,
|
|
107
|
+
bytesTotal: 167_936,
|
|
108
|
+
});
|
|
74
109
|
onStatus?.({ state: "ready", language: "en" });
|
|
75
|
-
onStatus?.({ state: "unavailable", language: "
|
|
110
|
+
onStatus?.({ state: "unavailable", language: "de" });
|
|
76
111
|
|
|
77
112
|
assert.deepEqual(reported, []);
|
|
78
113
|
});
|
package/vite.base.ts
CHANGED
|
@@ -4,6 +4,8 @@ import tailwindcss from "@tailwindcss/vite";
|
|
|
4
4
|
import { tanstackRouter } from "@tanstack/router-plugin/vite";
|
|
5
5
|
import react from "@vitejs/plugin-react";
|
|
6
6
|
import type { AliasOptions, BuildOptions, PluginOption } from "vite";
|
|
7
|
+
import { spellcheckPlugin } from "./spellcheck/vite-plugin.ts";
|
|
8
|
+
import { spellcheckWorkerStub } from "./spellcheck/worker-stub.ts";
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* The one place the web-client build toolchain is described. Both the dev
|
|
@@ -54,4 +56,6 @@ export const webClientPlugins = (routes: RouterPaths = {}): PluginOption[] => [
|
|
|
54
56
|
tanstackRouter({ target: "react", autoCodeSplitting: true, ...routes }),
|
|
55
57
|
react(),
|
|
56
58
|
tailwindcss(),
|
|
59
|
+
spellcheckPlugin(),
|
|
60
|
+
spellcheckWorkerStub(),
|
|
57
61
|
];
|