@granite-js/plugin-router 0.0.1
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 +41 -0
- package/README.md +4 -0
- package/dist/index.cjs +278 -0
- package/dist/index.d.cts +10 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +255 -0
- package/package.json +44 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# @granite-js/plugin-router
|
|
2
|
+
|
|
3
|
+
## 0.0.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- ed4d356: changeset
|
|
8
|
+
- Updated dependencies [ed4d356]
|
|
9
|
+
- @granite-js/plugin-core@0.0.3
|
|
10
|
+
|
|
11
|
+
## 0.0.2
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- 0ae09b7: deploy guide
|
|
16
|
+
- 0ae09b7: type fix
|
|
17
|
+
- 0ae09b7: guide
|
|
18
|
+
- 0ae09b7: fix comment
|
|
19
|
+
- 0ae09b7: showcase
|
|
20
|
+
- 0ae09b7: refactor interface names
|
|
21
|
+
- 0ae09b7: plugin-router
|
|
22
|
+
- 0ae09b7: CanGoBackGuard 수정, typecheck fix, lint fix
|
|
23
|
+
- 0ae09b7: counter
|
|
24
|
+
- 0ae09b7: licenses
|
|
25
|
+
- Updated dependencies [0ae09b7]
|
|
26
|
+
- Updated dependencies [0ae09b7]
|
|
27
|
+
- Updated dependencies [0ae09b7]
|
|
28
|
+
- Updated dependencies [0ae09b7]
|
|
29
|
+
- Updated dependencies [0ae09b7]
|
|
30
|
+
- Updated dependencies [0ae09b7]
|
|
31
|
+
- Updated dependencies [0ae09b7]
|
|
32
|
+
- Updated dependencies [0ae09b7]
|
|
33
|
+
- Updated dependencies [0ae09b7]
|
|
34
|
+
- Updated dependencies [0ae09b7]
|
|
35
|
+
- @granite-js/plugin-core@0.0.2
|
|
36
|
+
|
|
37
|
+
## 0.0.1
|
|
38
|
+
|
|
39
|
+
### Patch Changes
|
|
40
|
+
|
|
41
|
+
- f47ca39: first release
|
package/README.md
ADDED
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
//#region rolldown:runtime
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __copyProps = (to, from, except, desc) => {
|
|
9
|
+
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
10
|
+
key = keys[i];
|
|
11
|
+
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
12
|
+
get: ((k) => from[k]).bind(null, key),
|
|
13
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
|
|
19
|
+
value: mod,
|
|
20
|
+
enumerable: true
|
|
21
|
+
}) : target, mod));
|
|
22
|
+
|
|
23
|
+
//#endregion
|
|
24
|
+
const fs = __toESM(require("fs"));
|
|
25
|
+
const path = __toESM(require("path"));
|
|
26
|
+
const __swc_core = __toESM(require("@swc/core"));
|
|
27
|
+
const es_toolkit = __toESM(require("es-toolkit"));
|
|
28
|
+
const fs_promises = __toESM(require("fs/promises"));
|
|
29
|
+
const chokidar = __toESM(require("chokidar"));
|
|
30
|
+
|
|
31
|
+
//#region src/checkExportRoute.ts
|
|
32
|
+
/**
|
|
33
|
+
* Checks if Route is exported from the file.
|
|
34
|
+
*/
|
|
35
|
+
function checkExportRoute(path$1) {
|
|
36
|
+
try {
|
|
37
|
+
const ast = (0, __swc_core.parseFileSync)(path$1, {
|
|
38
|
+
syntax: "typescript",
|
|
39
|
+
tsx: true
|
|
40
|
+
});
|
|
41
|
+
const hasExportSpecifiers = ast.body.some((node) => {
|
|
42
|
+
if (node.type !== "ExportNamedDeclaration") return false;
|
|
43
|
+
return node.specifiers?.some((specifier) => {
|
|
44
|
+
if (specifier.type !== "ExportSpecifier") return false;
|
|
45
|
+
return specifier.orig?.value === "Route";
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
if (hasExportSpecifiers) return true;
|
|
49
|
+
const hasExportNamedVariable = ast.body.some((node) => {
|
|
50
|
+
if (node.type !== "ExportDeclaration") return false;
|
|
51
|
+
if (node.declaration.type !== "VariableDeclaration") return false;
|
|
52
|
+
return node.declaration.declarations.some((declaration) => declaration.id.type === "Identifier" && declaration.id.value === "Route");
|
|
53
|
+
});
|
|
54
|
+
return hasExportNamedVariable;
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
//#endregion
|
|
61
|
+
//#region src/getComponentName.ts
|
|
62
|
+
function getComponentName(filePath) {
|
|
63
|
+
const path$1 = filePath.replace(/^pages\//, "").replace(/\.(tsx|ts)$/, "");
|
|
64
|
+
const segments = path$1.split("/").filter(Boolean);
|
|
65
|
+
if (segments[segments.length - 1] === "index") segments.pop();
|
|
66
|
+
const componentName = segments.map((segment) => (0, es_toolkit.pascalCase)(segment)).join("");
|
|
67
|
+
return componentName || "Index";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
//#endregion
|
|
71
|
+
//#region src/getPath.ts
|
|
72
|
+
function getPath(filePath) {
|
|
73
|
+
let path$1 = filePath.replace(/^pages\//, "").replace(/\.[^/.]+$/, "");
|
|
74
|
+
if (path$1.endsWith("/index")) path$1 = path$1.replace(/\/index$/, "");
|
|
75
|
+
else if (path$1 === "index") path$1 = "";
|
|
76
|
+
return `/${path$1}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
//#endregion
|
|
80
|
+
//#region src/template.ts
|
|
81
|
+
const NEW_ROUTE_FILE_TEMPLATE = `import React from 'react';
|
|
82
|
+
import { Text, View } from 'react-native';
|
|
83
|
+
import { createRoute } from '@granite-js/react-native';
|
|
84
|
+
|
|
85
|
+
export const Route = createRoute('%%path%%', {
|
|
86
|
+
validateParams: (params) => params,
|
|
87
|
+
component: %%componentName%%,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
function %%componentName%%() {
|
|
91
|
+
return (
|
|
92
|
+
<View>
|
|
93
|
+
<Text>Hello %%componentName%%</Text>
|
|
94
|
+
</View>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
`;
|
|
98
|
+
const ROUTER_GEN_TEMPLATE = `/* eslint-disable */
|
|
99
|
+
// This file is auto-generated by @granite-js/react-native. DO NOT EDIT.
|
|
100
|
+
%%pageImports%%
|
|
101
|
+
|
|
102
|
+
declare module '@granite-js/react-native' {
|
|
103
|
+
interface RegisterScreen {
|
|
104
|
+
%%pageRoutes%%
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
`;
|
|
108
|
+
const NEW_LAYOUT_FILE_TEMPLATE = `import React, { PropsWithChildren } from 'react';
|
|
109
|
+
|
|
110
|
+
export default function %%componentName%%Layout({ children }: PropsWithChildren) {
|
|
111
|
+
return (
|
|
112
|
+
<>
|
|
113
|
+
{children}
|
|
114
|
+
</>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
`;
|
|
118
|
+
|
|
119
|
+
//#endregion
|
|
120
|
+
//#region src/utils/transformTemplate.ts
|
|
121
|
+
/**
|
|
122
|
+
* 템플릿 문자열에서 %%key%% 형식의 플레이스홀더를 values 객체의 값으로 대체합니다.
|
|
123
|
+
* 제네릭 타입 T를 통해 템플릿 문자열의 플레이스홀더 키를 자동으로 추론하여 타입 안정성을 보장합니다.
|
|
124
|
+
*
|
|
125
|
+
* @example
|
|
126
|
+
* const str = "안녕하세요 %%name%%님, 당신의 나이는 %%age%%살 입니다."
|
|
127
|
+
* const result = transformTemplate(str, { name: "홍길동", age: "20" })
|
|
128
|
+
* // 결과: "안녕하세요 홍길동님, 당신의 나이는 20살 입니다."
|
|
129
|
+
*/
|
|
130
|
+
function transformTemplate(templateString, values) {
|
|
131
|
+
let result = templateString;
|
|
132
|
+
for (const key in values) {
|
|
133
|
+
const placeholder = `%%${key}%%`;
|
|
134
|
+
result = result.replace(new RegExp(placeholder, "g"), values[key]);
|
|
135
|
+
}
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
//#endregion
|
|
140
|
+
//#region src/generateRouterFile.ts
|
|
141
|
+
function generateRouterFile() {
|
|
142
|
+
const cwd = process.cwd();
|
|
143
|
+
function getPageFiles(dir, prefix = "") {
|
|
144
|
+
const files = (0, fs.readdirSync)((0, path.join)(cwd, dir), { withFileTypes: true });
|
|
145
|
+
return files.reduce((acc, file) => {
|
|
146
|
+
if (file.isDirectory()) return [...acc, ...getPageFiles(`${dir}/${file.name}`, `${prefix}${file.name}/`)];
|
|
147
|
+
if (file.name.endsWith(".tsx") || file.name.endsWith(".ts")) {
|
|
148
|
+
const name = (0, path.parse)(file.name).name;
|
|
149
|
+
const ext = (0, path.parse)(file.name).ext;
|
|
150
|
+
return [...acc, `${prefix}${name}${ext}`];
|
|
151
|
+
}
|
|
152
|
+
return acc;
|
|
153
|
+
}, []);
|
|
154
|
+
}
|
|
155
|
+
const allPages = getPageFiles("pages");
|
|
156
|
+
const exportRouteMap = new Map(allPages.map((page) => [page, checkExportRoute((0, path.join)(cwd, "pages", page))]));
|
|
157
|
+
const pageFiles = allPages.filter((page) => !page.startsWith("_") && exportRouteMap.get(page));
|
|
158
|
+
const pageImports = pageFiles.map((page) => {
|
|
159
|
+
const componentName = getComponentName(page);
|
|
160
|
+
const pagePath = getPath(page);
|
|
161
|
+
return transformTemplate("import { Route as _%%componentName%%Route } from '../pages%%pagePath%%';", {
|
|
162
|
+
componentName,
|
|
163
|
+
pagePath
|
|
164
|
+
});
|
|
165
|
+
}).join("\n");
|
|
166
|
+
const pageRoutes = pageFiles.map((page) => {
|
|
167
|
+
const componentName = getComponentName(page);
|
|
168
|
+
const pagePath = getPath(page);
|
|
169
|
+
return transformTemplate(" '%%pagePath%%': ReturnType<typeof _%%componentName%%Route.useParams>;", {
|
|
170
|
+
componentName,
|
|
171
|
+
pagePath
|
|
172
|
+
});
|
|
173
|
+
}).join("\n");
|
|
174
|
+
const generatedContent = transformTemplate(ROUTER_GEN_TEMPLATE, {
|
|
175
|
+
pageImports,
|
|
176
|
+
pageRoutes
|
|
177
|
+
});
|
|
178
|
+
const routerFilePath = (0, path.join)(cwd, "src", "router.gen.ts");
|
|
179
|
+
if ((0, fs.existsSync)(routerFilePath)) {
|
|
180
|
+
const existingContent = (0, fs.readFileSync)(routerFilePath, "utf-8");
|
|
181
|
+
if (existingContent === generatedContent) return;
|
|
182
|
+
}
|
|
183
|
+
(0, fs.writeFileSync)(routerFilePath, generatedContent);
|
|
184
|
+
console.log("✅ Router file generated successfully!");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
//#endregion
|
|
188
|
+
//#region src/getPageName.ts
|
|
189
|
+
function getPageName(filePath) {
|
|
190
|
+
const parts = filePath.split("/");
|
|
191
|
+
const indexOfPages = parts.indexOf("pages");
|
|
192
|
+
if (indexOfPages === -1) return "";
|
|
193
|
+
const relevantParts = parts.slice(indexOfPages + 1);
|
|
194
|
+
if (relevantParts.at(-1)?.startsWith("_")) relevantParts.pop();
|
|
195
|
+
const lastPart = relevantParts.at(-1);
|
|
196
|
+
if (!lastPart) return "";
|
|
197
|
+
return (0, es_toolkit.pascalCase)(lastPart);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
//#endregion
|
|
201
|
+
//#region src/transformNewRouteFile.ts
|
|
202
|
+
async function transformNewRouteFile(path$1) {
|
|
203
|
+
return transformTemplate(NEW_ROUTE_FILE_TEMPLATE, {
|
|
204
|
+
path: getPath(path$1),
|
|
205
|
+
componentName: getComponentName(path$1)
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
async function transformNewLayoutFile(path$1) {
|
|
209
|
+
return transformTemplate(NEW_LAYOUT_FILE_TEMPLATE, { componentName: getPageName(path$1) });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
//#endregion
|
|
213
|
+
//#region src/watchRouter.ts
|
|
214
|
+
function watchRouter() {
|
|
215
|
+
const watcher = chokidar.default.watch("./pages", {
|
|
216
|
+
ignored: (path$1, stats) => {
|
|
217
|
+
return Boolean(stats?.isFile() && !path$1.endsWith(".ts") && !path$1.endsWith(".tsx"));
|
|
218
|
+
},
|
|
219
|
+
ignoreInitial: true,
|
|
220
|
+
persistent: true,
|
|
221
|
+
cwd: process.cwd()
|
|
222
|
+
});
|
|
223
|
+
const handleAdd = async (path$1) => {
|
|
224
|
+
const file = (0, path.join)(process.cwd(), path$1);
|
|
225
|
+
const code = await (0, fs_promises.readFile)(file, "utf8");
|
|
226
|
+
if (code !== "") return;
|
|
227
|
+
const filename = (0, path.parse)(path$1).name;
|
|
228
|
+
if (filename.startsWith("_")) switch (filename) {
|
|
229
|
+
case "_layout":
|
|
230
|
+
console.log("👀 Layout file has been added");
|
|
231
|
+
await (0, fs_promises.writeFile)(path$1, await transformNewLayoutFile(path$1));
|
|
232
|
+
return;
|
|
233
|
+
default: return;
|
|
234
|
+
}
|
|
235
|
+
const componentName = (0, es_toolkit.kebabCase)(filename);
|
|
236
|
+
if (componentName !== filename) {
|
|
237
|
+
console.log(`❌ File name should be in kebab-case format. Would you like to rename ${filename} to ${componentName}?`);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
console.log(`👀 File ${path$1} has been added`);
|
|
241
|
+
await (0, fs_promises.writeFile)(path$1, await transformNewRouteFile(path$1));
|
|
242
|
+
await generateRouterFile();
|
|
243
|
+
};
|
|
244
|
+
watcher.on("add", handleAdd);
|
|
245
|
+
watcher.on("change", generateRouterFile);
|
|
246
|
+
watcher.on("unlink", generateRouterFile);
|
|
247
|
+
return () => {
|
|
248
|
+
watcher.off("add", handleAdd);
|
|
249
|
+
watcher.off("change", generateRouterFile);
|
|
250
|
+
watcher.off("unlink", generateRouterFile);
|
|
251
|
+
watcher.close();
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
//#endregion
|
|
256
|
+
//#region src/routerPlugin.ts
|
|
257
|
+
const DEFAULT_OPTIONS = { watch: true };
|
|
258
|
+
const router = (options = DEFAULT_OPTIONS) => {
|
|
259
|
+
return {
|
|
260
|
+
name: "router-plugin",
|
|
261
|
+
build: {
|
|
262
|
+
order: "pre",
|
|
263
|
+
handler: () => {
|
|
264
|
+
generateRouterFile();
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
dev: {
|
|
268
|
+
order: "pre",
|
|
269
|
+
handler: () => {
|
|
270
|
+
generateRouterFile();
|
|
271
|
+
if (options.watch) watchRouter();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
//#endregion
|
|
278
|
+
exports.router = router;
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { GranitePluginCore } from "@granite-js/plugin-core";
|
|
2
|
+
|
|
3
|
+
//#region src/routerPlugin.d.ts
|
|
4
|
+
interface RouterPluginOptions {
|
|
5
|
+
watch?: boolean;
|
|
6
|
+
}
|
|
7
|
+
declare const router: (options?: RouterPluginOptions) => GranitePluginCore;
|
|
8
|
+
|
|
9
|
+
//#endregion
|
|
10
|
+
export { router };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { GranitePluginCore } from "@granite-js/plugin-core";
|
|
2
|
+
|
|
3
|
+
//#region src/routerPlugin.d.ts
|
|
4
|
+
interface RouterPluginOptions {
|
|
5
|
+
watch?: boolean;
|
|
6
|
+
}
|
|
7
|
+
declare const router: (options?: RouterPluginOptions) => GranitePluginCore;
|
|
8
|
+
|
|
9
|
+
//#endregion
|
|
10
|
+
export { router };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync } from "fs";
|
|
2
|
+
import { join, parse } from "path";
|
|
3
|
+
import { parseFileSync } from "@swc/core";
|
|
4
|
+
import { kebabCase, pascalCase } from "es-toolkit";
|
|
5
|
+
import { readFile, writeFile } from "fs/promises";
|
|
6
|
+
import chokidar from "chokidar";
|
|
7
|
+
|
|
8
|
+
//#region src/checkExportRoute.ts
|
|
9
|
+
/**
|
|
10
|
+
* Checks if Route is exported from the file.
|
|
11
|
+
*/
|
|
12
|
+
function checkExportRoute(path) {
|
|
13
|
+
try {
|
|
14
|
+
const ast = parseFileSync(path, {
|
|
15
|
+
syntax: "typescript",
|
|
16
|
+
tsx: true
|
|
17
|
+
});
|
|
18
|
+
const hasExportSpecifiers = ast.body.some((node) => {
|
|
19
|
+
if (node.type !== "ExportNamedDeclaration") return false;
|
|
20
|
+
return node.specifiers?.some((specifier) => {
|
|
21
|
+
if (specifier.type !== "ExportSpecifier") return false;
|
|
22
|
+
return specifier.orig?.value === "Route";
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
if (hasExportSpecifiers) return true;
|
|
26
|
+
const hasExportNamedVariable = ast.body.some((node) => {
|
|
27
|
+
if (node.type !== "ExportDeclaration") return false;
|
|
28
|
+
if (node.declaration.type !== "VariableDeclaration") return false;
|
|
29
|
+
return node.declaration.declarations.some((declaration) => declaration.id.type === "Identifier" && declaration.id.value === "Route");
|
|
30
|
+
});
|
|
31
|
+
return hasExportNamedVariable;
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
//#endregion
|
|
38
|
+
//#region src/getComponentName.ts
|
|
39
|
+
function getComponentName(filePath) {
|
|
40
|
+
const path = filePath.replace(/^pages\//, "").replace(/\.(tsx|ts)$/, "");
|
|
41
|
+
const segments = path.split("/").filter(Boolean);
|
|
42
|
+
if (segments[segments.length - 1] === "index") segments.pop();
|
|
43
|
+
const componentName = segments.map((segment) => pascalCase(segment)).join("");
|
|
44
|
+
return componentName || "Index";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region src/getPath.ts
|
|
49
|
+
function getPath(filePath) {
|
|
50
|
+
let path = filePath.replace(/^pages\//, "").replace(/\.[^/.]+$/, "");
|
|
51
|
+
if (path.endsWith("/index")) path = path.replace(/\/index$/, "");
|
|
52
|
+
else if (path === "index") path = "";
|
|
53
|
+
return `/${path}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
//#endregion
|
|
57
|
+
//#region src/template.ts
|
|
58
|
+
const NEW_ROUTE_FILE_TEMPLATE = `import React from 'react';
|
|
59
|
+
import { Text, View } from 'react-native';
|
|
60
|
+
import { createRoute } from '@granite-js/react-native';
|
|
61
|
+
|
|
62
|
+
export const Route = createRoute('%%path%%', {
|
|
63
|
+
validateParams: (params) => params,
|
|
64
|
+
component: %%componentName%%,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
function %%componentName%%() {
|
|
68
|
+
return (
|
|
69
|
+
<View>
|
|
70
|
+
<Text>Hello %%componentName%%</Text>
|
|
71
|
+
</View>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
`;
|
|
75
|
+
const ROUTER_GEN_TEMPLATE = `/* eslint-disable */
|
|
76
|
+
// This file is auto-generated by @granite-js/react-native. DO NOT EDIT.
|
|
77
|
+
%%pageImports%%
|
|
78
|
+
|
|
79
|
+
declare module '@granite-js/react-native' {
|
|
80
|
+
interface RegisterScreen {
|
|
81
|
+
%%pageRoutes%%
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
`;
|
|
85
|
+
const NEW_LAYOUT_FILE_TEMPLATE = `import React, { PropsWithChildren } from 'react';
|
|
86
|
+
|
|
87
|
+
export default function %%componentName%%Layout({ children }: PropsWithChildren) {
|
|
88
|
+
return (
|
|
89
|
+
<>
|
|
90
|
+
{children}
|
|
91
|
+
</>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
`;
|
|
95
|
+
|
|
96
|
+
//#endregion
|
|
97
|
+
//#region src/utils/transformTemplate.ts
|
|
98
|
+
/**
|
|
99
|
+
* 템플릿 문자열에서 %%key%% 형식의 플레이스홀더를 values 객체의 값으로 대체합니다.
|
|
100
|
+
* 제네릭 타입 T를 통해 템플릿 문자열의 플레이스홀더 키를 자동으로 추론하여 타입 안정성을 보장합니다.
|
|
101
|
+
*
|
|
102
|
+
* @example
|
|
103
|
+
* const str = "안녕하세요 %%name%%님, 당신의 나이는 %%age%%살 입니다."
|
|
104
|
+
* const result = transformTemplate(str, { name: "홍길동", age: "20" })
|
|
105
|
+
* // 결과: "안녕하세요 홍길동님, 당신의 나이는 20살 입니다."
|
|
106
|
+
*/
|
|
107
|
+
function transformTemplate(templateString, values) {
|
|
108
|
+
let result = templateString;
|
|
109
|
+
for (const key in values) {
|
|
110
|
+
const placeholder = `%%${key}%%`;
|
|
111
|
+
result = result.replace(new RegExp(placeholder, "g"), values[key]);
|
|
112
|
+
}
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
//#endregion
|
|
117
|
+
//#region src/generateRouterFile.ts
|
|
118
|
+
function generateRouterFile() {
|
|
119
|
+
const cwd = process.cwd();
|
|
120
|
+
function getPageFiles(dir, prefix = "") {
|
|
121
|
+
const files = readdirSync(join(cwd, dir), { withFileTypes: true });
|
|
122
|
+
return files.reduce((acc, file) => {
|
|
123
|
+
if (file.isDirectory()) return [...acc, ...getPageFiles(`${dir}/${file.name}`, `${prefix}${file.name}/`)];
|
|
124
|
+
if (file.name.endsWith(".tsx") || file.name.endsWith(".ts")) {
|
|
125
|
+
const name = parse(file.name).name;
|
|
126
|
+
const ext = parse(file.name).ext;
|
|
127
|
+
return [...acc, `${prefix}${name}${ext}`];
|
|
128
|
+
}
|
|
129
|
+
return acc;
|
|
130
|
+
}, []);
|
|
131
|
+
}
|
|
132
|
+
const allPages = getPageFiles("pages");
|
|
133
|
+
const exportRouteMap = new Map(allPages.map((page) => [page, checkExportRoute(join(cwd, "pages", page))]));
|
|
134
|
+
const pageFiles = allPages.filter((page) => !page.startsWith("_") && exportRouteMap.get(page));
|
|
135
|
+
const pageImports = pageFiles.map((page) => {
|
|
136
|
+
const componentName = getComponentName(page);
|
|
137
|
+
const pagePath = getPath(page);
|
|
138
|
+
return transformTemplate("import { Route as _%%componentName%%Route } from '../pages%%pagePath%%';", {
|
|
139
|
+
componentName,
|
|
140
|
+
pagePath
|
|
141
|
+
});
|
|
142
|
+
}).join("\n");
|
|
143
|
+
const pageRoutes = pageFiles.map((page) => {
|
|
144
|
+
const componentName = getComponentName(page);
|
|
145
|
+
const pagePath = getPath(page);
|
|
146
|
+
return transformTemplate(" '%%pagePath%%': ReturnType<typeof _%%componentName%%Route.useParams>;", {
|
|
147
|
+
componentName,
|
|
148
|
+
pagePath
|
|
149
|
+
});
|
|
150
|
+
}).join("\n");
|
|
151
|
+
const generatedContent = transformTemplate(ROUTER_GEN_TEMPLATE, {
|
|
152
|
+
pageImports,
|
|
153
|
+
pageRoutes
|
|
154
|
+
});
|
|
155
|
+
const routerFilePath = join(cwd, "src", "router.gen.ts");
|
|
156
|
+
if (existsSync(routerFilePath)) {
|
|
157
|
+
const existingContent = readFileSync(routerFilePath, "utf-8");
|
|
158
|
+
if (existingContent === generatedContent) return;
|
|
159
|
+
}
|
|
160
|
+
writeFileSync(routerFilePath, generatedContent);
|
|
161
|
+
console.log("✅ Router file generated successfully!");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
//#endregion
|
|
165
|
+
//#region src/getPageName.ts
|
|
166
|
+
function getPageName(filePath) {
|
|
167
|
+
const parts = filePath.split("/");
|
|
168
|
+
const indexOfPages = parts.indexOf("pages");
|
|
169
|
+
if (indexOfPages === -1) return "";
|
|
170
|
+
const relevantParts = parts.slice(indexOfPages + 1);
|
|
171
|
+
if (relevantParts.at(-1)?.startsWith("_")) relevantParts.pop();
|
|
172
|
+
const lastPart = relevantParts.at(-1);
|
|
173
|
+
if (!lastPart) return "";
|
|
174
|
+
return pascalCase(lastPart);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
//#endregion
|
|
178
|
+
//#region src/transformNewRouteFile.ts
|
|
179
|
+
async function transformNewRouteFile(path) {
|
|
180
|
+
return transformTemplate(NEW_ROUTE_FILE_TEMPLATE, {
|
|
181
|
+
path: getPath(path),
|
|
182
|
+
componentName: getComponentName(path)
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
async function transformNewLayoutFile(path) {
|
|
186
|
+
return transformTemplate(NEW_LAYOUT_FILE_TEMPLATE, { componentName: getPageName(path) });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
//#endregion
|
|
190
|
+
//#region src/watchRouter.ts
|
|
191
|
+
function watchRouter() {
|
|
192
|
+
const watcher = chokidar.watch("./pages", {
|
|
193
|
+
ignored: (path, stats) => {
|
|
194
|
+
return Boolean(stats?.isFile() && !path.endsWith(".ts") && !path.endsWith(".tsx"));
|
|
195
|
+
},
|
|
196
|
+
ignoreInitial: true,
|
|
197
|
+
persistent: true,
|
|
198
|
+
cwd: process.cwd()
|
|
199
|
+
});
|
|
200
|
+
const handleAdd = async (path) => {
|
|
201
|
+
const file = join(process.cwd(), path);
|
|
202
|
+
const code = await readFile(file, "utf8");
|
|
203
|
+
if (code !== "") return;
|
|
204
|
+
const filename = parse(path).name;
|
|
205
|
+
if (filename.startsWith("_")) switch (filename) {
|
|
206
|
+
case "_layout":
|
|
207
|
+
console.log("👀 Layout file has been added");
|
|
208
|
+
await writeFile(path, await transformNewLayoutFile(path));
|
|
209
|
+
return;
|
|
210
|
+
default: return;
|
|
211
|
+
}
|
|
212
|
+
const componentName = kebabCase(filename);
|
|
213
|
+
if (componentName !== filename) {
|
|
214
|
+
console.log(`❌ File name should be in kebab-case format. Would you like to rename ${filename} to ${componentName}?`);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
console.log(`👀 File ${path} has been added`);
|
|
218
|
+
await writeFile(path, await transformNewRouteFile(path));
|
|
219
|
+
await generateRouterFile();
|
|
220
|
+
};
|
|
221
|
+
watcher.on("add", handleAdd);
|
|
222
|
+
watcher.on("change", generateRouterFile);
|
|
223
|
+
watcher.on("unlink", generateRouterFile);
|
|
224
|
+
return () => {
|
|
225
|
+
watcher.off("add", handleAdd);
|
|
226
|
+
watcher.off("change", generateRouterFile);
|
|
227
|
+
watcher.off("unlink", generateRouterFile);
|
|
228
|
+
watcher.close();
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
//#endregion
|
|
233
|
+
//#region src/routerPlugin.ts
|
|
234
|
+
const DEFAULT_OPTIONS = { watch: true };
|
|
235
|
+
const router = (options = DEFAULT_OPTIONS) => {
|
|
236
|
+
return {
|
|
237
|
+
name: "router-plugin",
|
|
238
|
+
build: {
|
|
239
|
+
order: "pre",
|
|
240
|
+
handler: () => {
|
|
241
|
+
generateRouterFile();
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
dev: {
|
|
245
|
+
order: "pre",
|
|
246
|
+
handler: () => {
|
|
247
|
+
generateRouterFile();
|
|
248
|
+
if (options.watch) watchRouter();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
//#endregion
|
|
255
|
+
export { router };
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@granite-js/plugin-router",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.0.1",
|
|
5
|
+
"description": "A Route Generator for Granite project",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"require": {
|
|
16
|
+
"types": "./dist/index.d.cts",
|
|
17
|
+
"default": "./dist/index.cjs"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"prepack": "yarn build",
|
|
23
|
+
"typecheck": "tsc --noEmit",
|
|
24
|
+
"lint": "eslint .",
|
|
25
|
+
"test": "vitest --no-watch",
|
|
26
|
+
"build": "tsdown"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist"
|
|
30
|
+
],
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/node": "^22.10.2",
|
|
33
|
+
"tsdown": "^0.11.12",
|
|
34
|
+
"typescript": "5.8.3",
|
|
35
|
+
"vitest": "^2.1.8"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@granite-js/plugin-core": "0.0.1",
|
|
39
|
+
"@swc/core": "1.5.24",
|
|
40
|
+
"chokidar": "4.0.1",
|
|
41
|
+
"es-toolkit": "^1.26.1"
|
|
42
|
+
},
|
|
43
|
+
"sideEffects": false
|
|
44
|
+
}
|