@lithent/hmr-parser 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,2 @@
1
+ import { MarkerTransformOptions, HmrTransformResult } from './types';
2
+ export declare const transformWithMarker: (options: MarkerTransformOptions) => HmrTransformResult;
@@ -0,0 +1,2 @@
1
+ import { BaseTransformOptions, HmrTransformResult } from './types';
2
+ export declare const transformWithoutMarker: (options: BaseTransformOptions) => HmrTransformResult;
@@ -0,0 +1,4 @@
1
+ import { MarkerTransformOptions, BaseTransformOptions, MarkerAnalysisResult, BaseAnalysisResult } from './types';
2
+ export declare const analyzeMarker: (options: MarkerTransformOptions) => MarkerAnalysisResult;
3
+ export declare const analyzeNoMarker: (options: BaseTransformOptions) => BaseAnalysisResult;
4
+ export declare const shouldSkipTransform: (code: string) => boolean;
@@ -0,0 +1 @@
1
+ export declare const createHmrBootstrapBlock: (targetExports: string[], componentNames: string[]) => string;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ import { default as MagicString } from 'magic-string';
2
+ import { MountInfo } from '../utils/ast/componentCollector';
3
+ export declare const stitchComponentRegistration: (ms: MagicString, mounts: MountInfo[], code: string, importInsertionPos: number) => void;
@@ -0,0 +1,3 @@
1
+ import { default as MagicString } from 'magic-string';
2
+ import { File } from '@babel/types';
3
+ export declare const wrapRenderCalls: (ms: MagicString, ast: File, code: string) => boolean;
@@ -0,0 +1,28 @@
1
+ import { default as MagicString } from 'magic-string';
2
+ import { File } from '@babel/types';
3
+ export interface BaseTransformOptions {
4
+ code: string;
5
+ boundaryImportSpecifier: string;
6
+ tagFunctionImportSpecifier: string;
7
+ }
8
+ export interface MarkerTransformOptions extends BaseTransformOptions {
9
+ markerRegex: RegExp;
10
+ }
11
+ export interface BaseAnalysisResult {
12
+ targetExports: string[];
13
+ shouldTransform: boolean;
14
+ importInsertionPos: number;
15
+ blockInsertionPos: number;
16
+ headerSnippet: string;
17
+ ast: File;
18
+ }
19
+ export interface MarkerAnalysisResult extends BaseAnalysisResult {
20
+ match: RegExpExecArray | null;
21
+ }
22
+ export interface HmrTransformOptions extends MarkerTransformOptions {
23
+ }
24
+ export interface HmrTransformResult {
25
+ transformed: boolean;
26
+ code: string;
27
+ map: ReturnType<MagicString['generateMap']> | null;
28
+ }
@@ -0,0 +1,8 @@
1
+ import { File } from '@babel/types';
2
+ export type MountInfo = {
3
+ insertPos: number;
4
+ propsExpression: string;
5
+ componentName: string | null;
6
+ declarationEnd: number;
7
+ };
8
+ export declare const collectComponentMounts: (ast: File, code: string) => MountInfo[];
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "type": "module",
3
+ "name": "@lithent/hmr-parser",
4
+ "version": "0.1.0",
5
+ "description": "Lithent HMR parser utilities for build tools",
6
+ "packageManager": "pnpm@9.12.3",
7
+ "main": "./dist/lithentHmrParser.umd.js",
8
+ "module": "./dist/lithentHmrParser.mjs",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/lithentHmrParser.mjs",
14
+ "require": "./dist/lithentHmrParser.umd.js"
15
+ }
16
+ },
17
+ "scripts": {
18
+ "build": "vite build",
19
+ "watch": "vite build --watch",
20
+ "test": "vitest run --reporter verbose",
21
+ "test:dev": "vitest"
22
+ },
23
+ "author": "superlucky84",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/superlucky84/lithent.git"
27
+ },
28
+ "homepage": "https://github.com/superlucky84/lithent",
29
+ "license": "MIT",
30
+ "keywords": ["lithent", "hmr", "parser"],
31
+ "dependencies": {
32
+ "@babel/parser": "^7.26.2",
33
+ "@babel/types": "^7.26.0",
34
+ "magic-string": "^0.30.11"
35
+ },
36
+ "files": [
37
+ "dist",
38
+ "src"
39
+ ],
40
+ "devDependencies": {
41
+ "lithent": "workspace:*",
42
+ "@types/babel__traverse": "^7.20.6",
43
+ "vite": "^5.4.8",
44
+ "vite-plugin-checker": "^0.8.0",
45
+ "vite-plugin-dts": "^4.2.1",
46
+ "vitest": "^2.1.5"
47
+ },
48
+ "volta": {
49
+ "node": "20.3.0",
50
+ "pnpm": "9.12.3"
51
+ }
52
+ }
@@ -0,0 +1,169 @@
1
+ import type {
2
+ File,
3
+ ImportDeclaration,
4
+ ExpressionStatement,
5
+ } from '@babel/types';
6
+
7
+ export const collectExportNames = (ast: File): string[] => {
8
+ const names = new Set<string>();
9
+
10
+ for (const node of ast.program.body) {
11
+ if (node.type === 'ExportDefaultDeclaration') {
12
+ names.add('default');
13
+ }
14
+
15
+ if (node.type === 'ExportNamedDeclaration') {
16
+ const { declaration, specifiers } = node;
17
+
18
+ if (declaration) {
19
+ if (declaration.type === 'FunctionDeclaration' && declaration.id) {
20
+ names.add(declaration.id.name);
21
+ } else if (
22
+ declaration.type === 'VariableDeclaration' &&
23
+ declaration.declarations.length
24
+ ) {
25
+ for (const declarator of declaration.declarations) {
26
+ if (declarator.id.type === 'Identifier') {
27
+ names.add(declarator.id.name);
28
+ }
29
+ }
30
+ } else if (declaration.type === 'ClassDeclaration' && declaration.id) {
31
+ names.add(declaration.id.name);
32
+ }
33
+ }
34
+
35
+ if (specifiers?.length) {
36
+ specifiers.forEach(specifier => {
37
+ if (
38
+ specifier.type === 'ExportSpecifier' &&
39
+ specifier.exported.type === 'Identifier'
40
+ ) {
41
+ names.add(specifier.exported.name);
42
+ }
43
+ });
44
+ }
45
+ }
46
+ }
47
+
48
+ return Array.from(names);
49
+ };
50
+
51
+ export const detectLithentUsage = (ast: File): boolean => {
52
+ const watchedNames = new Set(['render', 'mount', 'createApp', 'createRoot']);
53
+
54
+ for (const node of ast.program.body) {
55
+ if (node.type !== 'ImportDeclaration') continue;
56
+ const source = node.source.value;
57
+ if (typeof source !== 'string') continue;
58
+
59
+ if (source === 'lithent') {
60
+ const hasWatched = node.specifiers.some(specifier => {
61
+ if (
62
+ specifier.type === 'ImportSpecifier' &&
63
+ specifier.imported.type === 'Identifier'
64
+ ) {
65
+ const importedName = specifier.imported.name;
66
+ return watchedNames.has(importedName);
67
+ }
68
+ return false;
69
+ });
70
+
71
+ if (hasWatched) return true;
72
+ }
73
+
74
+ if (source.startsWith('lithent/')) {
75
+ if ((node as ImportDeclaration).importKind === 'type') {
76
+ continue;
77
+ }
78
+ return true;
79
+ }
80
+ }
81
+
82
+ return false;
83
+ };
84
+
85
+ export const collectHeaderInsert = (
86
+ ast: File,
87
+ sourceCode: string,
88
+ boundaryImportSpecifier: string,
89
+ tagFunctionImportSpecifier: string
90
+ ) => {
91
+ const importsToPrepend: string[] = [];
92
+ let hasBoundaryImport = false;
93
+ let hasTagImport = false;
94
+ let lastImportEnd = 0;
95
+ let directiveEnd = 0;
96
+ let firstImportStart: number | null = null;
97
+
98
+ for (const node of ast.program.body) {
99
+ if (node.type === 'ImportDeclaration') {
100
+ lastImportEnd = node.end ?? lastImportEnd;
101
+ if (firstImportStart == null) {
102
+ firstImportStart = node.start ?? null;
103
+ }
104
+
105
+ if (node.source.value === boundaryImportSpecifier) {
106
+ hasBoundaryImport = true;
107
+ }
108
+
109
+ if (node.source.value === tagFunctionImportSpecifier) {
110
+ const hasTag = node.specifiers.some(specifier => {
111
+ return (
112
+ specifier.type === 'ImportSpecifier' &&
113
+ specifier.imported.type === 'Identifier' &&
114
+ specifier.imported.name === 'TagFunction'
115
+ );
116
+ });
117
+
118
+ if (hasTag) {
119
+ hasTagImport = true;
120
+ }
121
+ }
122
+
123
+ continue;
124
+ }
125
+
126
+ if (
127
+ node.type === 'ExpressionStatement' &&
128
+ node.expression.type === 'StringLiteral'
129
+ ) {
130
+ const expressionStatement = node as ExpressionStatement & {
131
+ directive?: string;
132
+ };
133
+ if (typeof expressionStatement.directive === 'string') {
134
+ directiveEnd = expressionStatement.end ?? directiveEnd;
135
+ continue;
136
+ }
137
+ }
138
+
139
+ break;
140
+ }
141
+
142
+ if (!hasBoundaryImport) {
143
+ importsToPrepend.push(
144
+ `import { createBoundary } from '${boundaryImportSpecifier}';`
145
+ );
146
+ }
147
+
148
+ if (!hasTagImport) {
149
+ importsToPrepend.push(
150
+ `import type { TagFunction } from '${tagFunctionImportSpecifier}';`
151
+ );
152
+ }
153
+
154
+ const importInsertionPos = firstImportStart ?? directiveEnd ?? 0;
155
+ const blockInsertionPos = lastImportEnd || directiveEnd || 0;
156
+
157
+ let snippet = '';
158
+ if (importsToPrepend.length) {
159
+ const precedingChar =
160
+ importInsertionPos > 0 ? sourceCode[importInsertionPos - 1] : undefined;
161
+ const needsLeadingNewline =
162
+ importInsertionPos > 0 && precedingChar !== '\n';
163
+ snippet += needsLeadingNewline ? '\n' : '';
164
+ snippet += importsToPrepend.join('\n');
165
+ snippet += '\n';
166
+ }
167
+
168
+ return { importInsertionPos, blockInsertionPos, headerSnippet: snippet };
169
+ };
package/src/index.ts ADDED
@@ -0,0 +1,33 @@
1
+ import { transformWithMarker } from './markerParse';
2
+ import { transformWithoutMarker } from './noMarkerParse';
3
+ import { analyzeMarker, analyzeNoMarker, shouldSkipTransform } from './shared';
4
+ import type {
5
+ MarkerTransformOptions,
6
+ BaseTransformOptions,
7
+ HmrTransformResult,
8
+ } from './types';
9
+
10
+ export const transformWithHmr = (
11
+ options: MarkerTransformOptions
12
+ ): HmrTransformResult => {
13
+ const markerResult = transformWithMarker(options);
14
+ if (markerResult.transformed) {
15
+ return markerResult;
16
+ }
17
+
18
+ const baseOptions: BaseTransformOptions = {
19
+ code: options.code,
20
+ boundaryImportSpecifier: options.boundaryImportSpecifier,
21
+ tagFunctionImportSpecifier: options.tagFunctionImportSpecifier,
22
+ };
23
+
24
+ return transformWithoutMarker(baseOptions);
25
+ };
26
+
27
+ export { analyzeMarker, analyzeNoMarker, shouldSkipTransform };
28
+ export { transformWithMarker, transformWithoutMarker };
29
+ export type {
30
+ MarkerTransformOptions,
31
+ BaseTransformOptions,
32
+ HmrTransformResult,
33
+ } from './types';
@@ -0,0 +1,61 @@
1
+ import MagicString from 'magic-string';
2
+ import { createHmrBootstrapBlock } from './strings';
3
+ import type { MarkerTransformOptions, HmrTransformResult } from './types';
4
+ import { analyzeMarker } from './shared';
5
+ import { stitchComponentRegistration } from './transform/componentRegister';
6
+ import { wrapRenderCalls } from './transform/renderGuard';
7
+ import { collectComponentMounts } from './utils/ast/componentCollector';
8
+
9
+ export const transformWithMarker = (
10
+ options: MarkerTransformOptions
11
+ ): HmrTransformResult => {
12
+ const analysis = analyzeMarker(options);
13
+ const {
14
+ match,
15
+ targetExports,
16
+ shouldTransform,
17
+ importInsertionPos,
18
+ headerSnippet,
19
+ } = analysis;
20
+
21
+ if (!shouldTransform || !match) {
22
+ return { transformed: false, code: options.code, map: null };
23
+ }
24
+
25
+ const mounts = collectComponentMounts(analysis.ast, options.code);
26
+ const componentNames = Array.from(
27
+ new Set(
28
+ mounts
29
+ .map(mount => mount.componentName)
30
+ .filter((name): name is string => !!name)
31
+ )
32
+ );
33
+
34
+ const ms = new MagicString(options.code);
35
+ wrapRenderCalls(ms, analysis.ast, options.code);
36
+ const transformBlock = createHmrBootstrapBlock(
37
+ targetExports,
38
+ componentNames
39
+ ).trimStart();
40
+
41
+ if (headerSnippet) {
42
+ ms.appendLeft(importInsertionPos, headerSnippet);
43
+ }
44
+
45
+ const precedingChar =
46
+ match.index > 0 ? options.code[match.index - 1] : undefined;
47
+ const needsLeadingNewline = match.index > 0 && precedingChar !== '\n';
48
+ const followingChar = options.code[match.index + match[0].length] ?? null;
49
+ const trailingNewline = followingChar === '\n' ? '\n' : '\n\n';
50
+ const blockSnippet = `${needsLeadingNewline ? '\n' : ''}${transformBlock}${trailingNewline}`;
51
+
52
+ ms.overwrite(match.index, match.index + match[0].length, blockSnippet);
53
+
54
+ stitchComponentRegistration(ms, mounts, options.code, importInsertionPos);
55
+
56
+ return {
57
+ transformed: true,
58
+ code: ms.toString(),
59
+ map: ms.generateMap({ hires: true }),
60
+ };
61
+ };
@@ -0,0 +1,70 @@
1
+ import MagicString from 'magic-string';
2
+ import { createHmrBootstrapBlock } from './strings';
3
+ import type { BaseTransformOptions, HmrTransformResult } from './types';
4
+ import { analyzeNoMarker } from './shared';
5
+ import { stitchComponentRegistration } from './transform/componentRegister';
6
+ import { wrapRenderCalls } from './transform/renderGuard';
7
+ import { collectComponentMounts } from './utils/ast/componentCollector';
8
+
9
+ export const transformWithoutMarker = (
10
+ options: BaseTransformOptions
11
+ ): HmrTransformResult => {
12
+ const analysis = analyzeNoMarker(options);
13
+ const {
14
+ targetExports,
15
+ shouldTransform,
16
+ importInsertionPos,
17
+ blockInsertionPos,
18
+ headerSnippet,
19
+ ast,
20
+ } = analysis;
21
+
22
+ if (!shouldTransform) {
23
+ return { transformed: false, code: options.code, map: null };
24
+ }
25
+
26
+ const mounts = collectComponentMounts(ast, options.code);
27
+ const componentNames = Array.from(
28
+ new Set(
29
+ mounts
30
+ .map(mount => mount.componentName)
31
+ .filter((name): name is string => !!name)
32
+ )
33
+ );
34
+
35
+ const ms = new MagicString(options.code);
36
+ wrapRenderCalls(ms, ast, options.code);
37
+ const transformBlock = createHmrBootstrapBlock(
38
+ targetExports,
39
+ componentNames
40
+ ).trimStart();
41
+
42
+ const blockPrecedingChar =
43
+ blockInsertionPos > 0 ? options.code[blockInsertionPos - 1] : undefined;
44
+ const needsLeadingNewlineForBlock =
45
+ blockInsertionPos > 0 && blockPrecedingChar !== '\n';
46
+ const blockSnippet = `${needsLeadingNewlineForBlock ? '\n' : ''}${transformBlock}\n\n`;
47
+
48
+ if (!headerSnippet && !blockSnippet.trim().length) {
49
+ return { transformed: false, code: options.code, map: null };
50
+ }
51
+
52
+ if (headerSnippet) {
53
+ if (blockInsertionPos === importInsertionPos) {
54
+ ms.appendLeft(importInsertionPos, `${headerSnippet}${blockSnippet}`);
55
+ } else {
56
+ ms.appendLeft(importInsertionPos, headerSnippet);
57
+ ms.appendLeft(blockInsertionPos, blockSnippet);
58
+ }
59
+ } else {
60
+ ms.appendLeft(blockInsertionPos, blockSnippet);
61
+ }
62
+
63
+ stitchComponentRegistration(ms, mounts, options.code, importInsertionPos);
64
+
65
+ return {
66
+ transformed: true,
67
+ code: ms.toString(),
68
+ map: ms.generateMap({ hires: true }),
69
+ };
70
+ };
package/src/shared.ts ADDED
@@ -0,0 +1,109 @@
1
+ import { parse } from '@babel/parser';
2
+ import type { File } from '@babel/types';
3
+ import type {
4
+ MarkerTransformOptions,
5
+ BaseTransformOptions,
6
+ MarkerAnalysisResult,
7
+ BaseAnalysisResult,
8
+ } from './types';
9
+ import {
10
+ collectExportNames,
11
+ detectLithentUsage,
12
+ collectHeaderInsert,
13
+ } from './helper/calculator';
14
+
15
+ const analyzeBase = (
16
+ code: string,
17
+ boundaryImportSpecifier: string,
18
+ tagFunctionImportSpecifier: string
19
+ ) => {
20
+ const ast = parse(code, {
21
+ sourceType: 'module',
22
+ plugins: ['typescript', 'jsx'],
23
+ ranges: true,
24
+ }) as File;
25
+
26
+ const inferredExports = collectExportNames(ast);
27
+ const targetExports =
28
+ inferredExports.length > 0 ? inferredExports : ['default'];
29
+ const { importInsertionPos, blockInsertionPos, headerSnippet } =
30
+ collectHeaderInsert(
31
+ ast,
32
+ code,
33
+ boundaryImportSpecifier,
34
+ tagFunctionImportSpecifier
35
+ );
36
+
37
+ return {
38
+ ast,
39
+ targetExports,
40
+ importInsertionPos,
41
+ blockInsertionPos,
42
+ headerSnippet,
43
+ } as const;
44
+ };
45
+
46
+ export const analyzeMarker = (
47
+ options: MarkerTransformOptions
48
+ ): MarkerAnalysisResult => {
49
+ const {
50
+ code,
51
+ markerRegex,
52
+ boundaryImportSpecifier,
53
+ tagFunctionImportSpecifier,
54
+ } = options;
55
+
56
+ const {
57
+ ast,
58
+ targetExports,
59
+ importInsertionPos,
60
+ blockInsertionPos,
61
+ headerSnippet,
62
+ } = analyzeBase(code, boundaryImportSpecifier, tagFunctionImportSpecifier);
63
+
64
+ const match = markerRegex.exec(code);
65
+ const rawNames = match?.[1]
66
+ ? match[1]
67
+ .split(',')
68
+ .map(token => token.trim())
69
+ .filter(Boolean)
70
+ : [];
71
+
72
+ const finalTargets = rawNames.length ? rawNames : targetExports;
73
+
74
+ return {
75
+ targetExports: finalTargets,
76
+ shouldTransform: !!match || detectLithentUsage(ast),
77
+ importInsertionPos,
78
+ blockInsertionPos,
79
+ headerSnippet,
80
+ ast,
81
+ match: match ?? null,
82
+ };
83
+ };
84
+
85
+ export const analyzeNoMarker = (
86
+ options: BaseTransformOptions
87
+ ): BaseAnalysisResult => {
88
+ const { code, boundaryImportSpecifier, tagFunctionImportSpecifier } = options;
89
+
90
+ const {
91
+ ast,
92
+ targetExports,
93
+ importInsertionPos,
94
+ blockInsertionPos,
95
+ headerSnippet,
96
+ } = analyzeBase(code, boundaryImportSpecifier, tagFunctionImportSpecifier);
97
+
98
+ return {
99
+ targetExports,
100
+ shouldTransform: detectLithentUsage(ast),
101
+ importInsertionPos,
102
+ blockInsertionPos,
103
+ headerSnippet,
104
+ ast,
105
+ };
106
+ };
107
+
108
+ export const shouldSkipTransform = (code: string): boolean =>
109
+ code.includes('__lithentModuleId');