@svelte-lean/vite 0.1.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/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +229 -0
- package/dist/behaviors.d.ts +23 -0
- package/dist/behaviors.js +106 -0
- package/dist/filter.d.ts +31 -0
- package/dist/filter.js +103 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +5 -0
- package/dist/manifest.d.ts +13 -0
- package/dist/manifest.js +43 -0
- package/dist/plugin.d.ts +33 -0
- package/dist/plugin.js +214 -0
- package/dist/report.d.ts +13 -0
- package/dist/report.js +70 -0
- package/dist/scanner.d.ts +19 -0
- package/dist/scanner.js +394 -0
- package/dist/types.d.ts +98 -0
- package/dist/types.js +1 -0
- package/package.json +59 -0
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
2
|
+
import { BUILTIN_BEHAVIORS, modulesFor, resolveBehavior } from './behaviors.js';
|
|
3
|
+
import { createIdFilter, normalizePath, parseId, relativeToRoot } from './filter.js';
|
|
4
|
+
import { createManifest, writeManifest } from './manifest.js';
|
|
5
|
+
import { formatDynamicWarning, formatReport, formatUnknownWarning, formatUnterminatedWarning } from './report.js';
|
|
6
|
+
import { scan } from './scanner.js';
|
|
7
|
+
export const SCAN_PLUGIN_NAME = 'svelte-lean:scan';
|
|
8
|
+
export const INJECT_PLUGIN_NAME = 'svelte-lean:inject';
|
|
9
|
+
export const DEFAULT_MANIFEST_PATH = 'node_modules/.svelte-lean/manifest.json';
|
|
10
|
+
function toArray(value, fallback) {
|
|
11
|
+
if (value === undefined)
|
|
12
|
+
return fallback;
|
|
13
|
+
return Array.isArray(value) ? value : [value];
|
|
14
|
+
}
|
|
15
|
+
export function resolveOptions(options = {}) {
|
|
16
|
+
const behaviors = options.behaviors ?? {};
|
|
17
|
+
for (const [name, value] of Object.entries(behaviors)) {
|
|
18
|
+
if (value !== false && (typeof value !== 'string' || value.length === 0)) {
|
|
19
|
+
throw new Error(`svelteLean(): behaviors.${name} must be a module specifier string or false.`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const ssr = options.ssr ?? 'skip';
|
|
23
|
+
if (ssr !== 'skip' && ssr !== 'inject') {
|
|
24
|
+
throw new Error(`svelteLean(): ssr must be 'skip' or 'inject', got ${JSON.stringify(ssr)}.`);
|
|
25
|
+
}
|
|
26
|
+
const manifest = options.manifest ?? true;
|
|
27
|
+
return {
|
|
28
|
+
include: toArray(options.include, ['**/*.svelte']),
|
|
29
|
+
exclude: toArray(options.exclude, ['**/node_modules/**']),
|
|
30
|
+
package: options.package ?? '@svelte-lean/primitives',
|
|
31
|
+
behaviors,
|
|
32
|
+
manifest: manifest === true ? DEFAULT_MANIFEST_PATH : manifest === false ? false : manifest,
|
|
33
|
+
report: options.report ?? false,
|
|
34
|
+
warnDynamic: options.warnDynamic ?? true,
|
|
35
|
+
ssr
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Appends one side-effect import per registration module. Imports are hoisted by the module
|
|
40
|
+
* system, so evaluation order is identical to a prepended import, while appending keeps every
|
|
41
|
+
* existing line and column in place: `map: null` is then the correct source-map answer ("the
|
|
42
|
+
* transform did not move code"), and the compiler's map for the module stays valid.
|
|
43
|
+
*/
|
|
44
|
+
export function appendImports(code, modules) {
|
|
45
|
+
if (modules.length === 0)
|
|
46
|
+
return code;
|
|
47
|
+
const statements = modules.map((module) => `import '${escapeSpecifier(module)}';`);
|
|
48
|
+
const separator = code.length === 0 || code.endsWith('\n') ? '' : '\n';
|
|
49
|
+
return code + separator + statements.join('\n') + '\n';
|
|
50
|
+
}
|
|
51
|
+
function escapeSpecifier(specifier) {
|
|
52
|
+
// JSON escaping covers backslashes, control characters and double quotes; single quotes are
|
|
53
|
+
// the only remaining character that would end the literal.
|
|
54
|
+
return JSON.stringify(specifier).slice(1, -1).replace(/'/g, "\\'");
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Packages kept out of the dev server's dependency pre-bundling. The registration imports are
|
|
58
|
+
* added by the inject transform, after Vite's dependency scan has read the raw source, so a
|
|
59
|
+
* pre-bundled package would be discovered on the first page that uses a behavior, re-optimized,
|
|
60
|
+
* and the page fully reloaded. Excluded, the packages are served as the plain ES modules they are
|
|
61
|
+
* (no CommonJS, no dependencies outside the scope), core stays a single instance, and HMR of the
|
|
62
|
+
* registration modules behaves as it does for linked workspace packages. A package the user lists
|
|
63
|
+
* in `optimizeDeps.include` is left to the user.
|
|
64
|
+
*/
|
|
65
|
+
export function optimizeDepsExclude(options, userConfig = {}) {
|
|
66
|
+
const include = userConfig.optimizeDeps?.include ?? [];
|
|
67
|
+
const listed = (name) => include.some((entry) => entry === name || entry.startsWith(name + '/'));
|
|
68
|
+
return [...new Set([options.package, '@svelte-lean/core'])].filter((name) => !listed(name));
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Creates the two plugins. `svelte-lean:scan` (`enforce: 'pre'`) reads Svelte source before the
|
|
72
|
+
* compiler and records which behaviors each file uses; `svelte-lean:inject` (`enforce: 'post'`)
|
|
73
|
+
* adds the registration imports to the compiled module. They share state through this closure.
|
|
74
|
+
*/
|
|
75
|
+
export function svelteLean(userOptions = {}) {
|
|
76
|
+
const options = resolveOptions(userOptions);
|
|
77
|
+
const state = {
|
|
78
|
+
root: normalizePath(process.cwd()),
|
|
79
|
+
isBuild: false,
|
|
80
|
+
config: null,
|
|
81
|
+
filter: () => false,
|
|
82
|
+
usage: new Map(),
|
|
83
|
+
dynamic: new Map(),
|
|
84
|
+
warnedDynamic: new Set(),
|
|
85
|
+
warnedUnknown: new Set(),
|
|
86
|
+
warnedUnterminated: new Set()
|
|
87
|
+
};
|
|
88
|
+
const reset = () => {
|
|
89
|
+
state.usage.clear();
|
|
90
|
+
state.dynamic.clear();
|
|
91
|
+
state.warnedDynamic.clear();
|
|
92
|
+
state.warnedUnknown.clear();
|
|
93
|
+
state.warnedUnterminated.clear();
|
|
94
|
+
};
|
|
95
|
+
const configResolved = (config) => {
|
|
96
|
+
state.config = config;
|
|
97
|
+
state.root = normalizePath(config.root);
|
|
98
|
+
state.isBuild = config.command === 'build';
|
|
99
|
+
state.filter = createIdFilter({
|
|
100
|
+
root: state.root,
|
|
101
|
+
include: options.include,
|
|
102
|
+
exclude: options.exclude
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
const manifestPath = () => options.manifest === false
|
|
106
|
+
? null
|
|
107
|
+
: isAbsolute(options.manifest)
|
|
108
|
+
? options.manifest
|
|
109
|
+
: resolve(state.root, options.manifest);
|
|
110
|
+
const display = (file) => relativeToRoot(state.root, file) ?? file;
|
|
111
|
+
const scanPlugin = {
|
|
112
|
+
name: SCAN_PLUGIN_NAME,
|
|
113
|
+
enforce: 'pre',
|
|
114
|
+
config(userConfig) {
|
|
115
|
+
return { optimizeDeps: { exclude: optimizeDepsExclude(options, userConfig) } };
|
|
116
|
+
},
|
|
117
|
+
configResolved,
|
|
118
|
+
buildStart() {
|
|
119
|
+
reset();
|
|
120
|
+
},
|
|
121
|
+
watchChange(id, change) {
|
|
122
|
+
const { file } = parseId(id);
|
|
123
|
+
if (change?.event === 'delete') {
|
|
124
|
+
state.usage.delete(file);
|
|
125
|
+
state.dynamic.delete(file);
|
|
126
|
+
}
|
|
127
|
+
state.warnedDynamic.delete(file);
|
|
128
|
+
state.warnedUnterminated.delete(file);
|
|
129
|
+
},
|
|
130
|
+
transform(code, id) {
|
|
131
|
+
if (!state.filter(id))
|
|
132
|
+
return null;
|
|
133
|
+
const { file } = parseId(id);
|
|
134
|
+
if (!code.includes('data-slean')) {
|
|
135
|
+
state.usage.delete(file);
|
|
136
|
+
state.dynamic.delete(file);
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
const result = scan(code, file);
|
|
140
|
+
if (result.behaviors.size)
|
|
141
|
+
state.usage.set(file, result.behaviors);
|
|
142
|
+
else
|
|
143
|
+
state.usage.delete(file);
|
|
144
|
+
if (result.dynamic.length)
|
|
145
|
+
state.dynamic.set(file, result.dynamic);
|
|
146
|
+
else
|
|
147
|
+
state.dynamic.delete(file);
|
|
148
|
+
for (const name of result.behaviors) {
|
|
149
|
+
if (state.warnedUnknown.has(name))
|
|
150
|
+
continue;
|
|
151
|
+
if (resolveBehavior(name, options).kind !== 'unknown')
|
|
152
|
+
continue;
|
|
153
|
+
state.warnedUnknown.add(name);
|
|
154
|
+
const known = [...Object.keys(BUILTIN_BEHAVIORS), ...Object.keys(options.behaviors)];
|
|
155
|
+
this.warn(formatUnknownWarning(name, display(file), [...new Set(known)].sort()));
|
|
156
|
+
}
|
|
157
|
+
if (result.unterminated && !state.warnedUnterminated.has(file)) {
|
|
158
|
+
state.warnedUnterminated.add(file);
|
|
159
|
+
this.warn(formatUnterminatedWarning(display(file), result.unterminated), {
|
|
160
|
+
line: result.unterminated.line,
|
|
161
|
+
column: result.unterminated.column - 1
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
if (options.warnDynamic && result.dynamic.length && !state.warnedDynamic.has(file)) {
|
|
165
|
+
state.warnedDynamic.add(file);
|
|
166
|
+
const first = result.dynamic[0];
|
|
167
|
+
// Rollup and Vite expect a 0-based column here; the scanner reports 1-based columns.
|
|
168
|
+
this.warn(formatDynamicWarning(display(file), result.dynamic), {
|
|
169
|
+
line: first.line,
|
|
170
|
+
column: first.column - 1
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
const injectPlugin = {
|
|
177
|
+
name: INJECT_PLUGIN_NAME,
|
|
178
|
+
enforce: 'post',
|
|
179
|
+
configResolved,
|
|
180
|
+
transform(code, id, transformOptions) {
|
|
181
|
+
if (!state.filter(id))
|
|
182
|
+
return null;
|
|
183
|
+
// Vite 6+ passes `ssr` for compatibility; the environment consumer is the primary signal.
|
|
184
|
+
const ssr = transformOptions?.ssr ?? this.environment?.config?.consumer === 'server';
|
|
185
|
+
if (ssr && options.ssr === 'skip')
|
|
186
|
+
return null;
|
|
187
|
+
const names = state.usage.get(parseId(id).file);
|
|
188
|
+
if (!names || names.size === 0)
|
|
189
|
+
return null;
|
|
190
|
+
const modules = modulesFor(names, options);
|
|
191
|
+
if (modules.length === 0)
|
|
192
|
+
return null;
|
|
193
|
+
return { code: appendImports(code, modules), map: null };
|
|
194
|
+
},
|
|
195
|
+
async buildEnd(error) {
|
|
196
|
+
if (error || !state.isBuild)
|
|
197
|
+
return;
|
|
198
|
+
const path = manifestPath();
|
|
199
|
+
if (path === null)
|
|
200
|
+
return;
|
|
201
|
+
await writeManifest(path, createManifest({ ...state, options }));
|
|
202
|
+
},
|
|
203
|
+
closeBundle() {
|
|
204
|
+
if (!options.report || !state.isBuild || !state.config)
|
|
205
|
+
return;
|
|
206
|
+
if (state.config.build.ssr)
|
|
207
|
+
return;
|
|
208
|
+
const path = manifestPath();
|
|
209
|
+
const manifest = createManifest({ ...state, options });
|
|
210
|
+
state.config.logger.info('\n' + formatReport(manifest, path && display(normalizePath(path))));
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
return [scanPlugin, injectPlugin];
|
|
214
|
+
}
|
package/dist/report.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { BehaviorManifest, DynamicMarker } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Human-readable build report. It only states what the scan observed: native-only names, the
|
|
4
|
+
* registration modules that were injected and the files that caused each of them, unknown names
|
|
5
|
+
* and dynamic markers. It makes no size or timing claims.
|
|
6
|
+
*/
|
|
7
|
+
export declare function formatReport(manifest: BehaviorManifest, manifestPath: string | null): string;
|
|
8
|
+
export declare function formatDynamicWarning(file: string, markers: readonly DynamicMarker[]): string;
|
|
9
|
+
export declare function formatUnterminatedWarning(file: string, position: {
|
|
10
|
+
line: number;
|
|
11
|
+
column: number;
|
|
12
|
+
}): string;
|
|
13
|
+
export declare function formatUnknownWarning(name: string, file: string, known: string[]): string;
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const LABEL_WIDTH = 13;
|
|
2
|
+
function row(label, first, rest = []) {
|
|
3
|
+
const pad = ' '.repeat(LABEL_WIDTH);
|
|
4
|
+
return [
|
|
5
|
+
' ' + label.padEnd(LABEL_WIDTH) + first,
|
|
6
|
+
...rest.map((line) => ' ' + pad + ' ' + line)
|
|
7
|
+
];
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Human-readable build report. It only states what the scan observed: native-only names, the
|
|
11
|
+
* registration modules that were injected and the files that caused each of them, unknown names
|
|
12
|
+
* and dynamic markers. It makes no size or timing claims.
|
|
13
|
+
*/
|
|
14
|
+
export function formatReport(manifest, manifestPath) {
|
|
15
|
+
const native = [];
|
|
16
|
+
const runtime = [];
|
|
17
|
+
const unknown = new Set(manifest.unknown);
|
|
18
|
+
for (const [name, entry] of Object.entries(manifest.behaviors)) {
|
|
19
|
+
if (entry.module !== null)
|
|
20
|
+
runtime.push([name, { files: entry.files, module: entry.module }]);
|
|
21
|
+
else if (!unknown.has(name))
|
|
22
|
+
native.push(name);
|
|
23
|
+
}
|
|
24
|
+
const lines = ['svelte-lean build report'];
|
|
25
|
+
lines.push(...row('native-only', native.length ? native.join(', ') : 'none'));
|
|
26
|
+
if (runtime.length === 0) {
|
|
27
|
+
lines.push(...row('runtime', 'none'));
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
runtime.forEach(([name, entry], index) => {
|
|
31
|
+
const head = `${name} -> ${entry.module}`;
|
|
32
|
+
lines.push(...(index === 0 ? row('runtime', head, entry.files) : row('', head, entry.files)));
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
lines.push(...row('unknown', manifest.unknown.length
|
|
36
|
+
? manifest.unknown
|
|
37
|
+
.map((name) => `${name} (${manifest.behaviors[name]?.files.join(', ') ?? ''})`)
|
|
38
|
+
.join(', ')
|
|
39
|
+
: 'none'));
|
|
40
|
+
const dynamicLines = Object.entries(manifest.dynamic).flatMap(([file, markers]) => markers.map((marker) => `${file}:${marker.line} data-slean=${marker.expression}`));
|
|
41
|
+
lines.push(...(dynamicLines.length
|
|
42
|
+
? row('dynamic', dynamicLines[0], dynamicLines.slice(1))
|
|
43
|
+
: row('dynamic', 'none')));
|
|
44
|
+
lines.push(...row('manifest', manifestPath ?? 'not written'));
|
|
45
|
+
return lines.join('\n');
|
|
46
|
+
}
|
|
47
|
+
export function formatDynamicWarning(file, markers) {
|
|
48
|
+
const found = markers.map((marker) => ` ${file}:${marker.line}:${marker.column} data-slean=${marker.expression}`);
|
|
49
|
+
return [
|
|
50
|
+
'Dynamic behavior identifiers cannot be auto-discovered.',
|
|
51
|
+
'Found:',
|
|
52
|
+
...found,
|
|
53
|
+
'Use a static identifier such as data-slean="tabs", or import the registration module',
|
|
54
|
+
"manually (for example import '@svelte-lean/primitives/tabs/register')."
|
|
55
|
+
].join('\n');
|
|
56
|
+
}
|
|
57
|
+
export function formatUnterminatedWarning(file, position) {
|
|
58
|
+
return [
|
|
59
|
+
`Svelte Lean scanner stopped at ${file}:${position.line}:${position.column}: the { expression starting there never closes.`,
|
|
60
|
+
'Markers after that point are not discovered. Check the expression, or import the registration',
|
|
61
|
+
"module manually (for example import '@svelte-lean/primitives/tabs/register')."
|
|
62
|
+
].join('\n');
|
|
63
|
+
}
|
|
64
|
+
export function formatUnknownWarning(name, file, known) {
|
|
65
|
+
return [
|
|
66
|
+
`Unknown Svelte Lean behavior "${name}" in ${file}.`,
|
|
67
|
+
`Known behaviors: ${known.join(', ')}.`,
|
|
68
|
+
`Map it with svelteLean({ behaviors: { ${JSON.stringify(name)}: '<module specifier>' } }), or to false if it needs no runtime.`
|
|
69
|
+
].join('\n');
|
|
70
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ScanResult } from './types.js';
|
|
2
|
+
/** 1-based line and column of `index` in `source`. */
|
|
3
|
+
export declare function locate(source: string, index: number): {
|
|
4
|
+
line: number;
|
|
5
|
+
column: number;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Skips a balanced `{ … }` Svelte expression starting at `start` (which must be `{`) and
|
|
9
|
+
* returns the index after the closing brace. String, template and regular-expression literals
|
|
10
|
+
* and comments inside the expression are skipped so braces and quotes in them do not affect
|
|
11
|
+
* balancing. When the expression never closes, the end of the source is returned and, given a
|
|
12
|
+
* `result`, the position of the outermost unterminated expression is recorded once.
|
|
13
|
+
*/
|
|
14
|
+
export declare function skipExpression(source: string, start: number, result?: ScanResult): number;
|
|
15
|
+
/**
|
|
16
|
+
* Finds static `data-slean="name"` markers and dynamic `data-slean={…}` markers in Svelte source.
|
|
17
|
+
* Pure: no I/O, no plugin context. `id` is only carried into the result for diagnostics.
|
|
18
|
+
*/
|
|
19
|
+
export declare function scan(source: string, id?: string): ScanResult;
|