@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.
@@ -0,0 +1,394 @@
1
+ /**
2
+ * Lexical scanner for Svelte Lean behavior markers.
3
+ *
4
+ * It understands exactly enough Svelte markup to avoid false positives:
5
+ * `<script>` / `<style>` raw text, HTML comments, quoted attribute values (including the
6
+ * `{expression}` parts Svelte allows inside them) and `{expression}` tags in text. It does not
7
+ * build an AST and does not depend on the Svelte compiler; block tags such as `{#if}` are skipped
8
+ * like any other expression because discovery is about markup only. Inside an expression,
9
+ * string, template and regular-expression literals and comments are skipped so their quotes and
10
+ * braces cannot unbalance it; an expression that never closes is recorded in
11
+ * `ScanResult.unterminated` so a miss is never silent.
12
+ */
13
+ const ATTRIBUTE = 'data-slean';
14
+ const RAW_TEXT_ELEMENTS = new Set(['script', 'style']);
15
+ const TAB = 9;
16
+ const NL = 10;
17
+ const FF = 12;
18
+ const CR = 13;
19
+ const SPACE = 32;
20
+ const BANG = 33;
21
+ const DQUOTE = 34;
22
+ const DOLLAR = 36;
23
+ const SQUOTE = 39;
24
+ const STAR = 42;
25
+ const SLASH = 47;
26
+ const LT = 60;
27
+ const EQ = 61;
28
+ const GT = 62;
29
+ const QUESTION = 63;
30
+ const BACKSLASH = 92;
31
+ const BACKTICK = 96;
32
+ const LBRACE = 123;
33
+ const RBRACE = 125;
34
+ const LBRACKET = 91;
35
+ const RBRACKET = 93;
36
+ const RPAREN = 41;
37
+ const UNDERSCORE = 95;
38
+ /** Identifiers after which a `/` starts a regular expression rather than a division. */
39
+ const REGEX_AFTER_KEYWORD = new Set([
40
+ 'return',
41
+ 'typeof',
42
+ 'instanceof',
43
+ 'in',
44
+ 'of',
45
+ 'new',
46
+ 'delete',
47
+ 'void',
48
+ 'throw',
49
+ 'case',
50
+ 'do',
51
+ 'else',
52
+ 'yield',
53
+ 'await',
54
+ 'if',
55
+ 'while',
56
+ 'each'
57
+ ]);
58
+ function isWhitespace(c) {
59
+ return c === SPACE || c === NL || c === TAB || c === CR || c === FF;
60
+ }
61
+ function isAsciiLetter(c) {
62
+ return (c >= 65 && c <= 90) || (c >= 97 && c <= 122);
63
+ }
64
+ function isIdentifierChar(c) {
65
+ return isAsciiLetter(c) || (c >= 48 && c <= 57) || c === UNDERSCORE || c === DOLLAR || c > 127;
66
+ }
67
+ /** 1-based line and column of `index` in `source`. */
68
+ export function locate(source, index) {
69
+ let line = 1;
70
+ let lineStart = 0;
71
+ for (let i = 0; i < index; i++) {
72
+ if (source.charCodeAt(i) === NL) {
73
+ line++;
74
+ lineStart = i + 1;
75
+ }
76
+ }
77
+ return { line, column: index - lineStart + 1 };
78
+ }
79
+ /** ASCII case-insensitive `indexOf`; never lowercases the source (that can change its length). */
80
+ function indexOfIgnoreCase(source, needle, from) {
81
+ const n = source.length - needle.length;
82
+ outer: for (let i = from; i <= n; i++) {
83
+ for (let j = 0; j < needle.length; j++) {
84
+ let c = source.charCodeAt(i + j);
85
+ if (c >= 65 && c <= 90)
86
+ c += 32;
87
+ if (c !== needle.charCodeAt(j))
88
+ continue outer;
89
+ }
90
+ return i;
91
+ }
92
+ return -1;
93
+ }
94
+ function skipUntilChar(source, from, char) {
95
+ for (let i = from; i < source.length; i++) {
96
+ if (source.charCodeAt(i) === char)
97
+ return i + 1;
98
+ }
99
+ return source.length;
100
+ }
101
+ function skipStringLiteral(source, start, quote) {
102
+ for (let i = start + 1; i < source.length; i++) {
103
+ const c = source.charCodeAt(i);
104
+ if (c === BACKSLASH) {
105
+ i++;
106
+ }
107
+ else if (c === quote) {
108
+ return i + 1;
109
+ }
110
+ else if (c === NL) {
111
+ // Unterminated string: stop at the line end instead of swallowing the rest of the file.
112
+ return i;
113
+ }
114
+ }
115
+ return source.length;
116
+ }
117
+ function skipTemplateLiteral(source, start, result) {
118
+ for (let i = start + 1; i < source.length; i++) {
119
+ const c = source.charCodeAt(i);
120
+ if (c === BACKSLASH) {
121
+ i++;
122
+ }
123
+ else if (c === BACKTICK) {
124
+ return i + 1;
125
+ }
126
+ else if (c === DOLLAR && source.charCodeAt(i + 1) === LBRACE) {
127
+ i = skipExpression(source, i + 1, result) - 1;
128
+ }
129
+ }
130
+ return source.length;
131
+ }
132
+ /**
133
+ * Whether a `/` at `index` starts a regular expression literal. Decided from the previous
134
+ * significant character, as JavaScript tokenizers do: after an operand (identifier, number,
135
+ * `)` or `]`) it is a division; after an operator, an opening bracket or a keyword such as
136
+ * `return` it is a regular expression. A slash directly after the expression's `{` is a Svelte
137
+ * block close (`{/if}`, `{/each}`), so it is never treated as a literal.
138
+ */
139
+ function isRegexStart(source, index, start) {
140
+ let i = index - 1;
141
+ while (i > start && isWhitespace(source.charCodeAt(i)))
142
+ i--;
143
+ // Directly after the opening brace it is a Svelte block close (`{/if}`), not a literal.
144
+ if (i <= start)
145
+ return false;
146
+ const c = source.charCodeAt(i);
147
+ if (c === RPAREN || c === RBRACKET)
148
+ return false;
149
+ if (!isIdentifierChar(c))
150
+ return true;
151
+ let wordStart = i;
152
+ while (wordStart > start && isIdentifierChar(source.charCodeAt(wordStart - 1)))
153
+ wordStart--;
154
+ return REGEX_AFTER_KEYWORD.has(source.slice(wordStart, i + 1));
155
+ }
156
+ /** Skips a regular expression literal starting at `start` (`/`), including its flags. */
157
+ function skipRegexLiteral(source, start) {
158
+ let inClass = false;
159
+ let i = start + 1;
160
+ for (; i < source.length; i++) {
161
+ const c = source.charCodeAt(i);
162
+ if (c === BACKSLASH) {
163
+ i++;
164
+ }
165
+ else if (c === NL) {
166
+ // Unterminated literal: stop at the line end instead of swallowing the rest of the file.
167
+ return i;
168
+ }
169
+ else if (inClass) {
170
+ if (c === RBRACKET)
171
+ inClass = false;
172
+ }
173
+ else if (c === LBRACKET) {
174
+ inClass = true;
175
+ }
176
+ else if (c === SLASH) {
177
+ i++;
178
+ break;
179
+ }
180
+ }
181
+ while (i < source.length && isAsciiLetter(source.charCodeAt(i)))
182
+ i++;
183
+ return i;
184
+ }
185
+ /**
186
+ * Skips a balanced `{ … }` Svelte expression starting at `start` (which must be `{`) and
187
+ * returns the index after the closing brace. String, template and regular-expression literals
188
+ * and comments inside the expression are skipped so braces and quotes in them do not affect
189
+ * balancing. When the expression never closes, the end of the source is returned and, given a
190
+ * `result`, the position of the outermost unterminated expression is recorded once.
191
+ */
192
+ export function skipExpression(source, start, result) {
193
+ const n = source.length;
194
+ let depth = 0;
195
+ let i = start;
196
+ while (i < n) {
197
+ const c = source.charCodeAt(i);
198
+ if (c === LBRACE) {
199
+ depth++;
200
+ i++;
201
+ }
202
+ else if (c === RBRACE) {
203
+ depth--;
204
+ i++;
205
+ if (depth === 0)
206
+ return i;
207
+ }
208
+ else if (c === DQUOTE || c === SQUOTE) {
209
+ i = skipStringLiteral(source, i, c);
210
+ }
211
+ else if (c === BACKTICK) {
212
+ i = skipTemplateLiteral(source, i, result);
213
+ }
214
+ else if (c === SLASH && source.charCodeAt(i + 1) === SLASH) {
215
+ i = skipUntilChar(source, i, NL);
216
+ }
217
+ else if (c === SLASH && source.charCodeAt(i + 1) === STAR) {
218
+ const end = source.indexOf('*/', i + 2);
219
+ i = end === -1 ? n : end + 2;
220
+ }
221
+ else if (c === SLASH && isRegexStart(source, i, start)) {
222
+ i = skipRegexLiteral(source, i);
223
+ }
224
+ else {
225
+ i++;
226
+ }
227
+ }
228
+ if (result && !result.unterminated)
229
+ result.unterminated = locate(source, start);
230
+ return n;
231
+ }
232
+ function readAttributeValue(source, start, result) {
233
+ const n = source.length;
234
+ const first = source.charCodeAt(start);
235
+ if (first === DQUOTE || first === SQUOTE) {
236
+ let dynamic = false;
237
+ let i = start + 1;
238
+ while (i < n) {
239
+ const c = source.charCodeAt(i);
240
+ if (c === first)
241
+ break;
242
+ if (c === LBRACE) {
243
+ dynamic = true;
244
+ i = skipExpression(source, i, result);
245
+ }
246
+ else {
247
+ i++;
248
+ }
249
+ }
250
+ return { text: source.slice(start + 1, i), dynamic, end: Math.min(i + 1, n) };
251
+ }
252
+ if (first === LBRACE) {
253
+ const end = skipExpression(source, start, result);
254
+ return { text: source.slice(start, end), dynamic: true, end };
255
+ }
256
+ let dynamic = false;
257
+ let i = start;
258
+ while (i < n) {
259
+ const c = source.charCodeAt(i);
260
+ if (isWhitespace(c) || c === GT)
261
+ break;
262
+ if (c === SLASH && source.charCodeAt(i + 1) === GT)
263
+ break;
264
+ if (c === LBRACE) {
265
+ dynamic = true;
266
+ i = skipExpression(source, i, result);
267
+ }
268
+ else {
269
+ i++;
270
+ }
271
+ }
272
+ return { text: source.slice(start, i), dynamic, end: i };
273
+ }
274
+ function isAttributeNameEnd(source, i) {
275
+ const c = source.charCodeAt(i);
276
+ return (isWhitespace(c) ||
277
+ c === EQ ||
278
+ c === GT ||
279
+ c === LBRACE ||
280
+ c === DQUOTE ||
281
+ c === SQUOTE ||
282
+ (c === SLASH && source.charCodeAt(i + 1) === GT));
283
+ }
284
+ /** Scans one opening tag starting at `start` (`<`). Returns the index after the tag's content. */
285
+ function scanTag(source, start, result) {
286
+ const n = source.length;
287
+ let i = start + 1;
288
+ const nameStart = i;
289
+ while (i < n && !isAttributeNameEnd(source, i))
290
+ i++;
291
+ const tagName = source.slice(nameStart, i).toLowerCase();
292
+ let selfClosing = false;
293
+ while (i < n) {
294
+ const c = source.charCodeAt(i);
295
+ if (isWhitespace(c)) {
296
+ i++;
297
+ }
298
+ else if (c === GT) {
299
+ i++;
300
+ break;
301
+ }
302
+ else if (c === SLASH) {
303
+ if (source.charCodeAt(i + 1) === GT) {
304
+ selfClosing = true;
305
+ i += 2;
306
+ break;
307
+ }
308
+ i++;
309
+ }
310
+ else if (c === LBRACE) {
311
+ // `{shorthand}` or `{...spread}` attribute.
312
+ i = skipExpression(source, i, result);
313
+ }
314
+ else if (c === DQUOTE || c === SQUOTE) {
315
+ // A stray quote is not valid Svelte; skip the quoted run so the tag still ends.
316
+ i = readAttributeValue(source, i, result).end;
317
+ }
318
+ else {
319
+ const attrStart = i;
320
+ while (i < n && !isAttributeNameEnd(source, i))
321
+ i++;
322
+ if (i === attrStart) {
323
+ i++;
324
+ continue;
325
+ }
326
+ const attrName = source.slice(attrStart, i);
327
+ let j = i;
328
+ while (j < n && isWhitespace(source.charCodeAt(j)))
329
+ j++;
330
+ if (source.charCodeAt(j) !== EQ)
331
+ continue;
332
+ j++;
333
+ while (j < n && isWhitespace(source.charCodeAt(j)))
334
+ j++;
335
+ const value = readAttributeValue(source, j, result);
336
+ i = value.end;
337
+ if (attrName.toLowerCase() !== ATTRIBUTE)
338
+ continue;
339
+ if (value.dynamic) {
340
+ const { line, column } = locate(source, attrStart);
341
+ const marker = { line, column, expression: source.slice(j, value.end) };
342
+ result.dynamic.push(marker);
343
+ }
344
+ else {
345
+ const name = value.text.trim();
346
+ // A bare `<select data-slean="select">` is the Tier 0 styled native select (ADR 0007):
347
+ // the owned select is a `<div data-slean="select">`, so only that marker needs the runtime.
348
+ if (name && !(tagName === 'select' && name === 'select'))
349
+ result.behaviors.add(name);
350
+ }
351
+ }
352
+ }
353
+ if (!selfClosing && RAW_TEXT_ELEMENTS.has(tagName)) {
354
+ const close = indexOfIgnoreCase(source, '</' + tagName, i);
355
+ i = close === -1 ? n : skipUntilChar(source, close, GT);
356
+ }
357
+ return i;
358
+ }
359
+ /**
360
+ * Finds static `data-slean="name"` markers and dynamic `data-slean={…}` markers in Svelte source.
361
+ * Pure: no I/O, no plugin context. `id` is only carried into the result for diagnostics.
362
+ */
363
+ export function scan(source, id) {
364
+ const result = { id, behaviors: new Set(), dynamic: [], unterminated: null };
365
+ const n = source.length;
366
+ let i = 0;
367
+ while (i < n) {
368
+ const c = source.charCodeAt(i);
369
+ if (c === LT) {
370
+ if (source.startsWith('<!--', i)) {
371
+ const end = source.indexOf('-->', i + 4);
372
+ i = end === -1 ? n : end + 3;
373
+ continue;
374
+ }
375
+ const next = source.charCodeAt(i + 1);
376
+ if (next === SLASH || next === BANG || next === QUESTION) {
377
+ i = skipUntilChar(source, i, GT);
378
+ }
379
+ else if (isAsciiLetter(next)) {
380
+ i = scanTag(source, i, result);
381
+ }
382
+ else {
383
+ i++;
384
+ }
385
+ }
386
+ else if (c === LBRACE) {
387
+ i = skipExpression(source, i, result);
388
+ }
389
+ else {
390
+ i++;
391
+ }
392
+ }
393
+ return result;
394
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Options accepted by `svelteLean()`.
3
+ */
4
+ export interface SvelteLeanOptions {
5
+ /**
6
+ * Files to scan. Globs are matched against the path relative to the Vite root and against
7
+ * the absolute path; `RegExp` values are tested against both as well.
8
+ * Default: `['**\/*.svelte']`.
9
+ */
10
+ include?: string | RegExp | Array<string | RegExp>;
11
+ /**
12
+ * Files never scanned or injected into. Default: `['**\/node_modules/**']`.
13
+ */
14
+ exclude?: string | RegExp | Array<string | RegExp>;
15
+ /**
16
+ * Package that provides the built-in registration modules. Built-in specifiers that start with
17
+ * `@svelte-lean/primitives/` are rewritten to start with this value instead.
18
+ * Default: `'@svelte-lean/primitives'`.
19
+ */
20
+ package?: string;
21
+ /**
22
+ * Behavior name → registration module specifier, or `false` for a behavior that needs no
23
+ * runtime. Entries override `BUILTIN_BEHAVIORS`.
24
+ */
25
+ behaviors?: Record<string, string | false>;
26
+ /**
27
+ * Write the behavior manifest at the end of a production build. `true` writes
28
+ * `node_modules/.svelte-lean/manifest.json` under the Vite root; a string is a path
29
+ * (resolved against the root). Default: `true`.
30
+ */
31
+ manifest?: boolean | string;
32
+ /**
33
+ * Print a build report after the client bundle is written. Default: `false`.
34
+ */
35
+ report?: boolean;
36
+ /**
37
+ * Warn (once per file and transform) about `data-slean={expression}` markers, which cannot be
38
+ * discovered statically. Default: `true`.
39
+ */
40
+ warnDynamic?: boolean;
41
+ /**
42
+ * `'skip'` (default) leaves server-side transforms untouched: the runtime is browser-only and
43
+ * registration modules are only needed in the client graph. `'inject'` also adds the imports
44
+ * to server modules; registration modules are SSR-safe, so this is only a bundle-size choice.
45
+ */
46
+ ssr?: 'skip' | 'inject';
47
+ }
48
+ export interface ResolvedOptions {
49
+ include: Array<string | RegExp>;
50
+ exclude: Array<string | RegExp>;
51
+ package: string;
52
+ behaviors: Record<string, string | false>;
53
+ /** `false`, or the manifest path relative to (or absolute from) the Vite root. */
54
+ manifest: string | false;
55
+ report: boolean;
56
+ warnDynamic: boolean;
57
+ ssr: 'skip' | 'inject';
58
+ }
59
+ /** A `data-slean` attribute whose value is not a static string. */
60
+ export interface DynamicMarker {
61
+ /** 1-based line of the attribute in the Svelte source. */
62
+ line: number;
63
+ /** 1-based column of the attribute in the Svelte source. */
64
+ column: number;
65
+ /** The attribute value exactly as written, for example `{kind}` or `"tabs-{suffix}"`. */
66
+ expression: string;
67
+ }
68
+ export interface ScanResult {
69
+ /** The module id passed to `scan()`, if any. */
70
+ id: string | undefined;
71
+ /** Static behavior names found in markup, in first-seen order. */
72
+ behaviors: Set<string>;
73
+ /** Dynamic markers, in source order. */
74
+ dynamic: DynamicMarker[];
75
+ /**
76
+ * 1-based position of the first `{` expression that never closed, or `null`. Markup after
77
+ * that point was not scanned, so any marker in it is not discovered.
78
+ */
79
+ unterminated: {
80
+ line: number;
81
+ column: number;
82
+ } | null;
83
+ }
84
+ export interface ManifestBehavior {
85
+ /** Files that use the behavior, relative to the Vite root, sorted. */
86
+ files: string[];
87
+ /** Injected registration module, or `null` for native and unknown behaviors. */
88
+ module: string | null;
89
+ }
90
+ export interface BehaviorManifest {
91
+ version: 1;
92
+ behaviors: Record<string, ManifestBehavior>;
93
+ dynamic: Record<string, Array<{
94
+ line: number;
95
+ expression: string;
96
+ }>>;
97
+ unknown: string[];
98
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@svelte-lean/vite",
3
+ "version": "0.1.0",
4
+ "description": "Vite integration for Svelte Lean: discovers data-slean behaviors in Svelte markup at build time and injects only the registration modules that are used.",
5
+ "license": "MIT",
6
+ "author": "Ömer Say",
7
+ "type": "module",
8
+ "engines": {
9
+ "node": "^20.19.0 || >=22.12.0"
10
+ },
11
+ "sideEffects": false,
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "types": "./dist/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "default": "./dist/index.js"
20
+ },
21
+ "./package.json": "./package.json"
22
+ },
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "scripts": {
27
+ "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && yarn run -T tsc -p tsconfig.build.json && yarn run -T publint",
28
+ "check": "yarn run -T tsc -p tsconfig.json --noEmit",
29
+ "test": "yarn run -T vitest run"
30
+ },
31
+ "peerDependencies": {
32
+ "@svelte-lean/primitives": "^0.1.0",
33
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
34
+ },
35
+ "peerDependenciesMeta": {
36
+ "@svelte-lean/primitives": {
37
+ "optional": true
38
+ }
39
+ },
40
+ "homepage": "https://svelte-lean.vercel.app/docs/architecture/build-time-discovery",
41
+ "bugs": {
42
+ "url": "https://github.com/say16/svelte-lean/issues"
43
+ },
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "git+https://github.com/say16/svelte-lean.git",
47
+ "directory": "packages/vite"
48
+ },
49
+ "keywords": [
50
+ "vite",
51
+ "vite-plugin",
52
+ "svelte",
53
+ "svelte5",
54
+ "svelte-lean",
55
+ "sveltekit",
56
+ "tree-shaking",
57
+ "code-splitting"
58
+ ]
59
+ }