Skip to content

Commit 6d115f4

Browse files
fix: Work on a bundler for CSpell Dictionaries (#8532)
Signed-off-by: Jason Dent <Jason3S@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 1080f57 commit 6d115f4

33 files changed

+1416
-617
lines changed

cspell-dict.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ noreply
4242
observablehq
4343
Openbase
4444
pagekit
45+
pluginutils
4546
pnpm
4647
popd
4748
prebuilds
@@ -50,16 +51,19 @@ pwsh
5051
pycontribs
5152
résumé
5253
rolldown
54+
rspack
5355
serializers
5456
skia-canvas
5557
smol-toml
5658
specberus
59+
sxzz
5760
thistogram
5861
treeshake
5962
trieb
6063
tsbuildinfo
6164
typecheck
6265
typedoc
66+
unplugin
6367
walk-throughs
6468
webdeveric
6569
wireapp

cspell.code-workspace

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
},
77
{ "path": "packages/cspell-bundled-dicts" },
88
{ "path": "packages/cspell-config-lib" },
9+
{ "path": "packages/cspell-dictionary-bundler-plugin" },
910
{ "path": "packages/cspell-dictionary" },
1011
{ "path": "packages/cspell-eslint-plugin" },
1112
{ "path": "packages/cspell-filetypes" },

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@
109109
},
110110
"homepage": "https://streetsidesoftware.github.io/cspell/",
111111
"engines": {
112-
"node": ">=20.0.0"
112+
"node": ">=20.18.0"
113113
},
114114
"devDependencies": {
115115
"@cspell/cspell-tools": "workspace:*",
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Street Side Software
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# @cspell/dictionary-bundler-plugin
2+
3+
Starter template for [unplugin](https://github.com/unjs/unplugin).
4+
5+
## Installation
6+
7+
```bash
8+
npm i -D @cspell/dictionary-bundler-plugin
9+
```
10+
11+
<details>
12+
<summary>Vite</summary><br>
13+
14+
```ts
15+
// vite.config.ts
16+
import Starter from '@cspell/dictionary-bundler-plugin/vite';
17+
18+
export default defineConfig({
19+
plugins: [Starter()]
20+
});
21+
```
22+
23+
<br></details>
24+
25+
<details>
26+
<summary>Rollup</summary><br>
27+
28+
```ts
29+
// rollup.config.js
30+
import Starter from '@cspell/dictionary-bundler-plugin/rollup';
31+
32+
export default {
33+
plugins: [Starter()]
34+
};
35+
```
36+
37+
<br></details>
38+
39+
<details>
40+
<summary>Rolldown / tsdown</summary><br>
41+
42+
```ts
43+
// rolldown.config.ts / tsdown.config.ts
44+
import Starter from '@cspell/dictionary-bundler-plugin/rolldown';
45+
46+
export default {
47+
plugins: [Starter()]
48+
};
49+
```
50+
51+
<br></details>
52+
53+
<details>
54+
<summary>esbuild</summary><br>
55+
56+
```ts
57+
import { build } from 'esbuild';
58+
import Starter from '@cspell/dictionary-bundler-plugin/esbuild';
59+
60+
build({
61+
plugins: [Starter()]
62+
});
63+
```
64+
65+
<br></details>
66+
67+
<details>
68+
<summary>Webpack</summary><br>
69+
70+
```js
71+
// webpack.config.js
72+
import Starter from '@cspell/dictionary-bundler-plugin/webpack';
73+
74+
export default {
75+
/* ... */
76+
plugins: [Starter()]
77+
};
78+
```
79+
80+
<br></details>
81+
82+
<details>
83+
<summary>Rspack</summary><br>
84+
85+
```ts
86+
// rspack.config.js
87+
import Starter from '@cspell/dictionary-bundler-plugin/rspack';
88+
89+
export default {
90+
/* ... */
91+
plugins: [Starter()]
92+
};
93+
```
94+
95+
<br></details>
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
{
2+
"name": "@cspell/dictionary-bundler-plugin",
3+
"type": "module",
4+
"version": "9.6.4",
5+
"description": "A plugin for bundling cspell dictionaries.",
6+
"publishConfig": {
7+
"access": "public",
8+
"provenance": true
9+
},
10+
"private": true,
11+
"license": "MIT",
12+
"keywords": [
13+
"cspell",
14+
"dictionary",
15+
"bundler",
16+
"plugin"
17+
],
18+
"exports": {
19+
".": "./dist/index.js",
20+
"./api": "./dist/api.js",
21+
"./esbuild": "./dist/esbuild.js",
22+
"./farm": "./dist/farm.js",
23+
"./rolldown": "./dist/rolldown.js",
24+
"./rollup": "./dist/rollup.js",
25+
"./rspack": "./dist/rspack.js",
26+
"./vite": "./dist/vite.js",
27+
"./webpack": "./dist/webpack.js",
28+
"./package.json": "./package.json"
29+
},
30+
"files": [
31+
"dist/*.mjs",
32+
"dist/*.d.ts"
33+
],
34+
"engines": {
35+
"node": ">=20.18.0"
36+
},
37+
"scripts": {
38+
"lint": "eslint --cache .",
39+
"lint:fix": "pnpm run lint --fix",
40+
"build": "tsdown",
41+
"build:tsc": "tsc",
42+
"watch": "tsdown --watch",
43+
"test": "pnpm build:tsc && vitest run",
44+
"test:watch": "vitest",
45+
"coverage": "vitest run --coverage",
46+
"prepublishOnly": "pnpm run build"
47+
},
48+
"dependencies": {
49+
"@rollup/pluginutils": "^5.3.0",
50+
"cspell-config-lib": "workspace:*",
51+
"cspell-trie-lib": "workspace:*",
52+
"unplugin": "^3.0.0"
53+
},
54+
"devDependencies": {
55+
"@cspell/cspell-types": "workspace:*",
56+
"@cspell/dict-html-symbol-entities": "^4.0.5",
57+
"@sxzz/test-utils": "^0.5.15",
58+
"vitest": "~4.0.18"
59+
}
60+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export {};
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import fs from 'node:fs/promises';
2+
3+
import type { CSpellVFS } from '@cspell/cspell-types';
4+
import { describe, expect, test } from 'vitest';
5+
6+
import { makeVfsUrl, populateVfs } from './bundler.ts';
7+
8+
const fixturesUrl = new URL('../../tests/fixtures/file.txt', import.meta.url);
9+
10+
describe('makeVfsUrl', () => {
11+
test.each`
12+
url | hash | expected
13+
${'file:///to/file.txt'} | ${'abc123'} | ${'cspell-vfs:///abc123/to/file.txt'}
14+
${'file:///path/to/file.txt'} | ${'abc123'} | ${'cspell-vfs:///abc123/path/to/file.txt'}
15+
${'file:///one/two/three/path/to/file.txt'} | ${'abc123'} | ${'cspell-vfs:///abc123/path/to/file.txt'}
16+
${'file:///path/to/node_modules/@cspell/dict-en/file.txt'} | ${'abc123'} | ${'cspell-vfs:///abc123/@cspell/dict-en/file.txt'}
17+
`('should create a vfs url $url $hash', ({ url, hash, expected }) => {
18+
url = new URL(url);
19+
const vfsUrl = makeVfsUrl(url, hash);
20+
expect(vfsUrl.href).toBe(expected);
21+
});
22+
});
23+
24+
describe('populateVfs', () => {
25+
test('should populate the vfs with the content of the file', async () => {
26+
const vfs: CSpellVFS = {};
27+
const fileUrl = new URL('words.txt', fixturesUrl);
28+
const url = await populateVfs(vfs, fileUrl);
29+
expect(url.href).toMatch(/^cspell-vfs:\/\/\//);
30+
expect(vfs[url.href]).toBeDefined();
31+
expect(vfs[url.href].encoding).toBe('base64');
32+
const content = Buffer.from(vfs[url.href].data as string, 'base64').toString();
33+
expect(content).toBe(await fs.readFile(fileUrl, 'utf8'));
34+
});
35+
});
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { createHash } from 'node:crypto';
2+
import fs from 'node:fs/promises';
3+
4+
import type { CSpellSettings, CSpellVFS } from '@cspell/cspell-types';
5+
import { mergeConfig } from '@cspell/cspell-types';
6+
import type { CSpellConfigFile, CSpellConfigFileReaderWriter, ICSpellConfigFile } from 'cspell-config-lib';
7+
8+
export class CSpellDictionaryBundler {
9+
#loadedConfigs = new Map<string, Promise<ICSpellConfigFile>>();
10+
11+
constructor(readonly reader: CSpellConfigFileReaderWriter) {}
12+
13+
bundle(url: URL, content?: string): Promise<ICSpellConfigFile> {
14+
const found = this.#loadedConfigs.get(url.href);
15+
if (found) {
16+
return found;
17+
}
18+
const config = this.importConfig(url, content).then((config) => this.bundleConfig(config));
19+
this.#loadedConfigs.set(url.href, config);
20+
return config;
21+
}
22+
23+
async bundleConfig(config: CSpellConfigFile): Promise<ICSpellConfigFile> {
24+
const imports = await this.loadImports(config);
25+
const settings = mergeConfig(
26+
imports.map((f) => f.settings),
27+
await this.resolveDictionaries(config),
28+
);
29+
delete settings.import;
30+
return {
31+
url: config.url,
32+
settings,
33+
};
34+
}
35+
36+
async resolveDictionaries(config: ICSpellConfigFile): Promise<CSpellSettings> {
37+
const settings = { ...config.settings };
38+
if (!settings.dictionaryDefinitions) return settings;
39+
// Make a copy of the dictionary definitions and vfs to avoid mutating the original config file.
40+
const dictDefs = (settings.dictionaryDefinitions = [...settings.dictionaryDefinitions]);
41+
const vfs: CSpellVFS = (settings.vfs ??= Object.create(null));
42+
43+
for (let i = 0; i < dictDefs.length; ++i) {
44+
const def = dictDefs[i];
45+
if (!def.path) continue;
46+
const d = { ...def };
47+
dictDefs[i] = d;
48+
delete d.file;
49+
const url = new URL(def.btrie ?? def.path, config.url);
50+
if (url.protocol !== 'file:') continue;
51+
const vfsUrl = await populateVfs(vfs, url);
52+
d.path = vfsUrl.href;
53+
}
54+
55+
return settings;
56+
}
57+
58+
importConfig(url: URL, content?: string): Promise<CSpellConfigFile> {
59+
if (content) {
60+
return Promise.resolve(this.reader.parse({ url, content }));
61+
}
62+
return this.reader.readConfig(url);
63+
}
64+
65+
loadImports(config: CSpellConfigFile): Promise<ICSpellConfigFile[]> {
66+
const imports = [config.settings.import || []].flat();
67+
return Promise.all(imports.map((importPath) => this.bundle(new URL(importPath, config.url))));
68+
}
69+
}
70+
71+
/**
72+
* Load a file from the file system and populate the virtual file system with its content.
73+
*
74+
* @param vfs - The Virtual Files system data
75+
* @param url - The url to load and store.
76+
* @return The cspell-vfs url that was loaded.
77+
*/
78+
export async function populateVfs(vfs: CSpellVFS, url: URL): Promise<URL> {
79+
const content = await fs.readFile(url);
80+
81+
const hash = createHash('sha256').update(content).digest('hex');
82+
83+
const data = content.toString('base64');
84+
const vfsUrl = makeVfsUrl(url, hash.slice(0, 16));
85+
vfs[vfsUrl.href] = {
86+
data,
87+
encoding: 'base64',
88+
};
89+
return vfsUrl;
90+
}
91+
92+
/**
93+
* We want to make a url that is unique to the content of the file and indications where it came from.
94+
* @param url - the url to the source file.
95+
* @param hash - the hash of the file content
96+
* @returns A `cspell-vfs:///` url that can be used to reference the file content in the virtual file system.
97+
*/
98+
export function makeVfsUrl(url: URL, hash: string): URL {
99+
const parts = url.pathname.split('/').slice(1);
100+
let pos = -3;
101+
const i = parts.lastIndexOf('node_modules');
102+
if (i > 0) {
103+
pos = i + 1;
104+
}
105+
const path = parts.slice(pos).join('/');
106+
return new URL(`cspell-vfs:///${hash}/${path}`);
107+
}

0 commit comments

Comments
 (0)