@fiyuu/core 0.1.0 → 0.1.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/src/scanner.ts ADDED
@@ -0,0 +1,289 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const FIXED_FILES = ["page.tsx", "action.ts", "query.ts", "schema.ts", "meta.ts"] as const;
5
+ const REQUIRED_FILES = ["schema.ts", "meta.ts"] as const;
6
+ const SUPPLEMENTARY_FILES = ["middleware.ts", "layout.tsx", "layout.meta.ts", "route.ts", "not-found.tsx", "error.tsx"] as const;
7
+ const GENERATED_FILE_PATTERN = /\.(js|jsx|d\.ts|map)$/;
8
+
9
+ export interface FeatureRecord {
10
+ route: string;
11
+ feature: string;
12
+ directory: string;
13
+ files: Partial<Record<(typeof FIXED_FILES)[number], string>>;
14
+ missingRequiredFiles: string[];
15
+ intent: string | null;
16
+ pageIntent: string | null;
17
+ descriptions: string[];
18
+ render: "ssr" | "csr" | "ssg";
19
+ warnings: string[];
20
+ /** Names of dynamic segments in order, e.g. ["id"] for /blog/[id] */
21
+ params: string[];
22
+ /** True if route has any dynamic [param] or [...slug] segments */
23
+ isDynamic: boolean;
24
+ /**
25
+ * Human-readable pattern string, e.g. "/blog/:id" or "/docs/:slug*"
26
+ * Stored for AI docs and devtools display only — not used for matching.
27
+ */
28
+ routePattern: string;
29
+ }
30
+
31
+ export interface ProjectGraph {
32
+ routes: Array<{
33
+ path: string;
34
+ feature: string;
35
+ hasPage: boolean;
36
+ }>;
37
+ actions: Array<{
38
+ route: string;
39
+ file: string;
40
+ }>;
41
+ queries: Array<{
42
+ route: string;
43
+ file: string;
44
+ }>;
45
+ schemas: Array<{
46
+ route: string;
47
+ file: string;
48
+ descriptions: string[];
49
+ render: "ssr" | "csr" | "ssg";
50
+ }>;
51
+ relations: Array<{
52
+ from: string;
53
+ to: string;
54
+ type: "uses" | "renders" | "describes";
55
+ }>;
56
+ features: FeatureRecord[];
57
+ }
58
+
59
+ export async function scanApp(appDirectory: string): Promise<FeatureRecord[]> {
60
+ const features = await walkFeatureDirectories(appDirectory);
61
+ const records = await Promise.all(features.map((directory) => scanFeature(appDirectory, directory)));
62
+ return records.sort((left, right) => left.route.localeCompare(right.route));
63
+ }
64
+
65
+ export async function createProjectGraph(appDirectory: string): Promise<ProjectGraph> {
66
+ const features = await scanApp(appDirectory);
67
+
68
+ return {
69
+ routes: features.map((feature) => ({
70
+ path: feature.route,
71
+ feature: feature.feature,
72
+ hasPage: Boolean(feature.files["page.tsx"]),
73
+ })),
74
+ actions: features
75
+ .filter((feature) => feature.files["action.ts"])
76
+ .map((feature) => ({ route: feature.route, file: feature.files["action.ts"]! })),
77
+ queries: features
78
+ .filter((feature) => feature.files["query.ts"])
79
+ .map((feature) => ({ route: feature.route, file: feature.files["query.ts"]! })),
80
+ schemas: features
81
+ .filter((feature) => feature.files["schema.ts"])
82
+ .map((feature) => ({
83
+ route: feature.route,
84
+ file: feature.files["schema.ts"]!,
85
+ descriptions: feature.descriptions,
86
+ render: feature.render,
87
+ })),
88
+ relations: features.flatMap((feature) => {
89
+ const relations: ProjectGraph["relations"] = [];
90
+
91
+ if (feature.files["schema.ts"]) {
92
+ relations.push({ from: feature.route, to: feature.files["schema.ts"]!, type: "uses" });
93
+ }
94
+
95
+ if (feature.files["action.ts"]) {
96
+ relations.push({ from: feature.route, to: feature.files["action.ts"]!, type: "uses" });
97
+ }
98
+
99
+ if (feature.files["query.ts"]) {
100
+ relations.push({ from: feature.route, to: feature.files["query.ts"]!, type: "uses" });
101
+ }
102
+
103
+ if (feature.files["page.tsx"]) {
104
+ relations.push({ from: feature.route, to: feature.files["page.tsx"]!, type: "renders" });
105
+ }
106
+
107
+ if (feature.files["meta.ts"]) {
108
+ relations.push({ from: feature.route, to: feature.files["meta.ts"]!, type: "describes" });
109
+ }
110
+
111
+ return relations;
112
+ }),
113
+ features,
114
+ };
115
+ }
116
+
117
+ async function walkFeatureDirectories(root: string): Promise<string[]> {
118
+ const directories: string[] = [];
119
+ await visit(root, directories, root);
120
+ return directories;
121
+ }
122
+
123
+ async function visit(currentDirectory: string, directories: string[], appDirectory: string): Promise<void> {
124
+ const entries = await fs.readdir(currentDirectory, { withFileTypes: true });
125
+ const fileNames = new Set(entries.filter((entry) => entry.isFile()).map((entry) => entry.name));
126
+ const hasFeatureFiles = FIXED_FILES.some((fileName) => fileNames.has(fileName));
127
+
128
+ if (hasFeatureFiles) {
129
+ directories.push(currentDirectory);
130
+ }
131
+
132
+ await Promise.all(
133
+ entries
134
+ .filter((entry) => entry.isDirectory())
135
+ .map((entry) => visit(path.join(currentDirectory, entry.name), directories, appDirectory)),
136
+ );
137
+ }
138
+
139
+ /**
140
+ * Parses dynamic segment syntax from a route string.
141
+ *
142
+ * Supported formats (mirrors Next.js conventions):
143
+ * [param] — single dynamic segment /blog/[id]
144
+ * [...slug] — required catch-all /docs/[...slug]
145
+ * [[...slug]] — optional catch-all /docs/[[...slug]]
146
+ */
147
+ export function parseRouteSegments(route: string): {
148
+ params: string[];
149
+ isDynamic: boolean;
150
+ routePattern: string;
151
+ } {
152
+ const params: string[] = [];
153
+ const patternParts = route
154
+ .split("/")
155
+ .filter(Boolean)
156
+ .map((segment) => {
157
+ const optionalCatchAll = segment.match(/^\[\[\.\.\.(\w+)\]\]$/);
158
+ if (optionalCatchAll) {
159
+ params.push(optionalCatchAll[1]);
160
+ return `:${optionalCatchAll[1]}?*`;
161
+ }
162
+ const catchAll = segment.match(/^\[\.\.\.(\w+)\]$/);
163
+ if (catchAll) {
164
+ params.push(catchAll[1]);
165
+ return `:${catchAll[1]}*`;
166
+ }
167
+ const dynamic = segment.match(/^\[(\w+)\]$/);
168
+ if (dynamic) {
169
+ params.push(dynamic[1]);
170
+ return `:${dynamic[1]}`;
171
+ }
172
+ return segment;
173
+ });
174
+
175
+ return {
176
+ params,
177
+ isDynamic: params.length > 0,
178
+ routePattern: `/${patternParts.join("/")}`,
179
+ };
180
+ }
181
+
182
+ async function scanFeature(appDirectory: string, featureDirectory: string): Promise<FeatureRecord> {
183
+ const relativeDirectory = path.relative(appDirectory, featureDirectory);
184
+ const feature = relativeDirectory.split(path.sep).join("/");
185
+ const route = `/${feature}`;
186
+ const files = Object.fromEntries(
187
+ await Promise.all(
188
+ FIXED_FILES.map(async (fileName) => {
189
+ const filePath = path.join(featureDirectory, fileName);
190
+ try {
191
+ await fs.access(filePath);
192
+ return [fileName, normalizePath(filePath)];
193
+ } catch {
194
+ return [fileName, undefined];
195
+ }
196
+ }),
197
+ ),
198
+ ) as FeatureRecord["files"];
199
+
200
+ const metaSource = files["meta.ts"] ? await fs.readFile(path.join(featureDirectory, "meta.ts"), "utf8") : "";
201
+ const pageSource = files["page.tsx"] ? await fs.readFile(path.join(featureDirectory, "page.tsx"), "utf8") : "";
202
+ const schemaSource = files["schema.ts"] ? await fs.readFile(path.join(featureDirectory, "schema.ts"), "utf8") : "";
203
+ const featureEntries = await fs.readdir(featureDirectory, { withFileTypes: true });
204
+ const extraFiles = featureEntries
205
+ .filter((entry) => entry.isFile())
206
+ .map((entry) => entry.name)
207
+ .filter(
208
+ (fileName) =>
209
+ !FIXED_FILES.includes(fileName as (typeof FIXED_FILES)[number]) &&
210
+ !SUPPLEMENTARY_FILES.includes(fileName as (typeof SUPPLEMENTARY_FILES)[number]) &&
211
+ !GENERATED_FILE_PATTERN.test(fileName),
212
+ );
213
+ const warnings = [
214
+ ...REQUIRED_FILES.filter((fileName) => !files[fileName]).map((fileName) => `Missing required file: ${fileName}`),
215
+ ...extraFiles.map((fileName) => `Non-standard file in feature directory: ${fileName}`),
216
+ ];
217
+
218
+ if (pageSource.length > 0) {
219
+ if (/<img\b/i.test(pageSource)) {
220
+ warnings.push("Raw <img> usage detected. Prefer optimizedImage() for lazy loading and responsive sources.");
221
+ if (countMatches(pageSource, /<img\b(?![^>]*\balt\s*=)[^>]*>/gi) > 0) {
222
+ warnings.push("Some <img> tags are missing alt attributes.");
223
+ }
224
+ if (countMatches(pageSource, /<img\b(?![^>]*\bloading\s*=)[^>]*>/gi) > 0) {
225
+ warnings.push("Some <img> tags are missing loading attribute (use loading=\"lazy\" when appropriate).");
226
+ }
227
+ if (countMatches(pageSource, /<img\b(?![^>]*\b(?:width|height)\s*=)[^>]*>/gi) > 0) {
228
+ warnings.push("Some <img> tags are missing intrinsic width/height which can cause layout shift.");
229
+ }
230
+ }
231
+
232
+ if (/<video\b/i.test(pageSource)) {
233
+ warnings.push("Raw <video> usage detected. Prefer optimizedVideo() for preload and source hints.");
234
+ if (countMatches(pageSource, /<video\b(?![^>]*\bpreload\s*=)[^>]*>/gi) > 0) {
235
+ warnings.push("Some <video> tags are missing preload strategy (recommended: preload=\"metadata\").");
236
+ }
237
+ if (countMatches(pageSource, /<video\b(?![^>]*\bposter\s*=)[^>]*>/gi) > 0) {
238
+ warnings.push("Some <video> tags are missing poster image, which hurts perceived loading performance.");
239
+ }
240
+ }
241
+ }
242
+
243
+ const { params, isDynamic, routePattern } = parseRouteSegments(route);
244
+
245
+ return {
246
+ route,
247
+ feature,
248
+ directory: normalizePath(featureDirectory),
249
+ files,
250
+ missingRequiredFiles: REQUIRED_FILES.filter((fileName) => !files[fileName]),
251
+ intent: extractStringValue(metaSource, "intent"),
252
+ pageIntent: extractStringValue(pageSource, "intent"),
253
+ descriptions: extractStringList(schemaSource, "description"),
254
+ render: extractRenderMode(metaSource),
255
+ warnings,
256
+ params,
257
+ isDynamic,
258
+ routePattern,
259
+ };
260
+ }
261
+
262
+ function extractStringValue(source: string, key: string): string | null {
263
+ const match = source.match(new RegExp(`${key}\\s*(?::|=)\\s*["'\`]([^"'\`]+)["'\`]`));
264
+ return match?.[1] ?? null;
265
+ }
266
+
267
+ function extractStringList(source: string, key: string): string[] {
268
+ return Array.from(source.matchAll(new RegExp(`${key}\\s*(?::|=)\\s*["'\`]([^"'\`]+)["'\`]`, "g"))).map((match) => match[1]);
269
+ }
270
+
271
+ function normalizePath(filePath: string): string {
272
+ return filePath.split(path.sep).join("/");
273
+ }
274
+
275
+ function countMatches(source: string, pattern: RegExp): number {
276
+ const matches = source.match(pattern);
277
+ return matches ? matches.length : 0;
278
+ }
279
+
280
+ function extractRenderMode(source: string): "ssr" | "csr" | "ssg" {
281
+ const render = extractStringValue(source, "render");
282
+ if (render === "csr") {
283
+ return "csr";
284
+ }
285
+ if (render === "ssg") {
286
+ return "ssg";
287
+ }
288
+ return "ssr";
289
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Embeds server-side data as a JSON script tag for client access via fiyuu.data(id).
3
+ *
4
+ * @example
5
+ * // In page.tsx template:
6
+ * ${clientData('my-posts', posts.map(p => ({ id: p.id, title: p.title })))}
7
+ *
8
+ * // In inline script:
9
+ * const posts = fiyuu.data('my-posts');
10
+ */
11
+ export function clientData<T>(id: string, data: T): string {
12
+ return `<script type="application/json" id="${id}">${JSON.stringify(data)}</script>`;
13
+ }
14
+
15
+ /**
16
+ * RawHtml — marks a string as already-safe HTML (bypasses auto-escaping).
17
+ */
18
+ export class RawHtml {
19
+ readonly value: string;
20
+ constructor(value: string) {
21
+ this.value = value;
22
+ }
23
+ toString(): string {
24
+ return this.value;
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Marks a string as trusted HTML (bypasses auto-escaping).
30
+ */
31
+ export function raw(value: string | RawHtml): RawHtml {
32
+ return value instanceof RawHtml ? value : new RawHtml(value);
33
+ }
34
+
35
+ /**
36
+ * Alias for raw() — more explicit about intent.
37
+ */
38
+ export const unsafeHtml = raw;
39
+
40
+ /**
41
+ * Internal HTML escaping — used by media.ts and responsive-wrapper.ts.
42
+ * Not intended for direct user consumption; html`` auto-escapes.
43
+ */
44
+ export function escapeHtml(value: unknown): string {
45
+ const text = value == null ? "" : String(value);
46
+ return text
47
+ .replaceAll("&", "&amp;")
48
+ .replaceAll("<", "&lt;")
49
+ .replaceAll(">", "&gt;")
50
+ .replaceAll('"', "&quot;")
51
+ .replaceAll("'", "&#39;");
52
+ }
53
+
54
+ function autoEscape(value: unknown): string {
55
+ if (value instanceof RawHtml) {
56
+ return value.value;
57
+ }
58
+ const text = value == null ? "" : String(value);
59
+ return text
60
+ .replaceAll("&", "&amp;")
61
+ .replaceAll("<", "&lt;")
62
+ .replaceAll(">", "&gt;")
63
+ .replaceAll('"', "&quot;")
64
+ .replaceAll("'", "&#39;");
65
+ }
66
+
67
+ /**
68
+ * Tagged template for building HTML strings.
69
+ *
70
+ * All interpolations are auto-escaped by default (XSS-safe).
71
+ * Use raw() or unsafeHtml() for intentional raw HTML.
72
+ * null / undefined / false render as empty string.
73
+ * Arrays are auto-flattened and joined.
74
+ *
75
+ * @example
76
+ * html`<p>${user.bio}</p>` // auto-escaped
77
+ * html`<div>${unsafeHtml(someHtml)}</div>` // intentional raw HTML
78
+ * html`<ul>${items.map(i => html`<li>${i}</li>`)}</ul>` // auto-flattened
79
+ */
80
+ export function html(strings: TemplateStringsArray, ...values: unknown[]): string {
81
+ let output = "";
82
+ for (let index = 0; index < strings.length; index += 1) {
83
+ output += strings[index] ?? "";
84
+ if (index < values.length) {
85
+ output += serializeTemplateValue(values[index]);
86
+ }
87
+ }
88
+ return output;
89
+ }
90
+
91
+ function serializeTemplateValue(value: unknown): string {
92
+ if (value == null || value === false) {
93
+ return "";
94
+ }
95
+ if (value instanceof RawHtml) {
96
+ return value.value;
97
+ }
98
+ if (Array.isArray(value)) {
99
+ return value.map(serializeTemplateValue).join("");
100
+ }
101
+ return autoEscape(value);
102
+ }
103
+
104
+ export type ComponentProps = Record<string, unknown>;
105
+
106
+ export function component<Props extends ComponentProps = ComponentProps>(
107
+ render: (props: Props) => string,
108
+ ): (props: Props) => string {
109
+ return render;
110
+ }
@@ -1,4 +1,12 @@
1
1
  import { html } from "./template.js";
2
+
3
+ export interface VirtualListProps {
4
+ items: unknown[];
5
+ itemHeight: number;
6
+ height: number;
7
+ renderItem: (item: unknown, index: number) => string;
8
+ }
9
+
2
10
  /**
3
11
  * renderVirtualList — server-side HTML generator for virtualized lists.
4
12
  *
@@ -6,13 +14,14 @@ import { html } from "./template.js";
6
14
  * virtualization can be layered on top by reading the `data-fiyuu-virtual`
7
15
  * attribute and the `data-item-height` / `data-total-height` values.
8
16
  */
9
- export function renderVirtualList(props) {
10
- const { items, itemHeight, height, renderItem } = props;
11
- const totalHeight = items.length * itemHeight;
12
- const itemsHtml = items
13
- .map((item, index) => `<div style="height:${itemHeight}px;overflow:hidden">${renderItem(item, index)}</div>`)
14
- .join("");
15
- return html `<div
17
+ export function renderVirtualList(props: VirtualListProps): string {
18
+ const { items, itemHeight, height, renderItem } = props;
19
+ const totalHeight = items.length * itemHeight;
20
+ const itemsHtml = items
21
+ .map((item, index) => `<div style="height:${itemHeight}px;overflow:hidden">${renderItem(item, index)}</div>`)
22
+ .join("");
23
+
24
+ return html`<div
16
25
  data-fiyuu-virtual
17
26
  data-item-height="${itemHeight}"
18
27
  data-total-height="${totalHeight}"