@fiyuu/core 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,48 @@
1
+ export const BREAKPOINTS = {
2
+ xs: 480,
3
+ sm: 640,
4
+ md: 768,
5
+ lg: 1024,
6
+ xl: 1280,
7
+ "2xl": 1536,
8
+ };
9
+ export function mediaUp(name, css) {
10
+ return `@media (min-width:${BREAKPOINTS[name]}px){${css}}`;
11
+ }
12
+ export function mediaDown(name, css) {
13
+ return `@media (max-width:${BREAKPOINTS[name] - 0.02}px){${css}}`;
14
+ }
15
+ export function mediaBetween(min, max, css) {
16
+ return `@media (min-width:${BREAKPOINTS[min]}px) and (max-width:${BREAKPOINTS[max] - 0.02}px){${css}}`;
17
+ }
18
+ export function fluid(minSizePx, maxSizePx, minViewportPx = 360, maxViewportPx = 1440) {
19
+ if (maxViewportPx <= minViewportPx) {
20
+ throw new Error("fluid requires maxViewportPx to be larger than minViewportPx.");
21
+ }
22
+ const slope = ((maxSizePx - minSizePx) / (maxViewportPx - minViewportPx)) * 100;
23
+ const intercept = minSizePx - (slope / 100) * minViewportPx;
24
+ return `clamp(${minSizePx}px, ${intercept.toFixed(4)}px + ${slope.toFixed(4)}vw, ${maxSizePx}px)`;
25
+ }
26
+ export function responsiveSizes(config, fallback = "100vw") {
27
+ const ordered = Object.entries(BREAKPOINTS)
28
+ .sort((a, b) => b[1] - a[1])
29
+ .map(([name, width]) => {
30
+ const value = config[name];
31
+ if (!value)
32
+ return "";
33
+ return `(min-width: ${width}px) ${value}`;
34
+ })
35
+ .filter(Boolean);
36
+ ordered.push(fallback);
37
+ return ordered.join(", ");
38
+ }
39
+ export function responsiveStyle(selector, baseCss, overrides) {
40
+ const blocks = [`${selector}{${baseCss}}`];
41
+ for (const [name] of Object.entries(BREAKPOINTS)) {
42
+ const css = overrides[name];
43
+ if (!css)
44
+ continue;
45
+ blocks.push(mediaUp(name, `${selector}{${css}}`));
46
+ }
47
+ return `<style>${blocks.join("")}</style>`;
48
+ }
@@ -0,0 +1,65 @@
1
+ declare const FIXED_FILES: readonly ["page.tsx", "action.ts", "query.ts", "schema.ts", "meta.ts"];
2
+ export interface FeatureRecord {
3
+ route: string;
4
+ feature: string;
5
+ directory: string;
6
+ files: Partial<Record<(typeof FIXED_FILES)[number], string>>;
7
+ missingRequiredFiles: string[];
8
+ intent: string | null;
9
+ pageIntent: string | null;
10
+ descriptions: string[];
11
+ render: "ssr" | "csr" | "ssg";
12
+ warnings: string[];
13
+ /** Names of dynamic segments in order, e.g. ["id"] for /blog/[id] */
14
+ params: string[];
15
+ /** True if route has any dynamic [param] or [...slug] segments */
16
+ isDynamic: boolean;
17
+ /**
18
+ * Human-readable pattern string, e.g. "/blog/:id" or "/docs/:slug*"
19
+ * Stored for AI docs and devtools display only — not used for matching.
20
+ */
21
+ routePattern: string;
22
+ }
23
+ export interface ProjectGraph {
24
+ routes: Array<{
25
+ path: string;
26
+ feature: string;
27
+ hasPage: boolean;
28
+ }>;
29
+ actions: Array<{
30
+ route: string;
31
+ file: string;
32
+ }>;
33
+ queries: Array<{
34
+ route: string;
35
+ file: string;
36
+ }>;
37
+ schemas: Array<{
38
+ route: string;
39
+ file: string;
40
+ descriptions: string[];
41
+ render: "ssr" | "csr" | "ssg";
42
+ }>;
43
+ relations: Array<{
44
+ from: string;
45
+ to: string;
46
+ type: "uses" | "renders" | "describes";
47
+ }>;
48
+ features: FeatureRecord[];
49
+ }
50
+ export declare function scanApp(appDirectory: string): Promise<FeatureRecord[]>;
51
+ export declare function createProjectGraph(appDirectory: string): Promise<ProjectGraph>;
52
+ /**
53
+ * Parses dynamic segment syntax from a route string.
54
+ *
55
+ * Supported formats (mirrors Next.js conventions):
56
+ * [param] — single dynamic segment /blog/[id]
57
+ * [...slug] — required catch-all /docs/[...slug]
58
+ * [[...slug]] — optional catch-all /docs/[[...slug]]
59
+ */
60
+ export declare function parseRouteSegments(route: string): {
61
+ params: string[];
62
+ isDynamic: boolean;
63
+ routePattern: string;
64
+ };
65
+ export {};
package/src/scanner.js ADDED
@@ -0,0 +1,200 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ const FIXED_FILES = ["page.tsx", "action.ts", "query.ts", "schema.ts", "meta.ts"];
4
+ const REQUIRED_FILES = ["schema.ts", "meta.ts"];
5
+ const SUPPLEMENTARY_FILES = ["middleware.ts", "layout.tsx", "layout.meta.ts", "route.ts", "not-found.tsx", "error.tsx"];
6
+ const GENERATED_FILE_PATTERN = /\.(js|jsx|d\.ts|map)$/;
7
+ export async function scanApp(appDirectory) {
8
+ const features = await walkFeatureDirectories(appDirectory);
9
+ const records = await Promise.all(features.map((directory) => scanFeature(appDirectory, directory)));
10
+ return records.sort((left, right) => left.route.localeCompare(right.route));
11
+ }
12
+ export async function createProjectGraph(appDirectory) {
13
+ const features = await scanApp(appDirectory);
14
+ return {
15
+ routes: features.map((feature) => ({
16
+ path: feature.route,
17
+ feature: feature.feature,
18
+ hasPage: Boolean(feature.files["page.tsx"]),
19
+ })),
20
+ actions: features
21
+ .filter((feature) => feature.files["action.ts"])
22
+ .map((feature) => ({ route: feature.route, file: feature.files["action.ts"] })),
23
+ queries: features
24
+ .filter((feature) => feature.files["query.ts"])
25
+ .map((feature) => ({ route: feature.route, file: feature.files["query.ts"] })),
26
+ schemas: features
27
+ .filter((feature) => feature.files["schema.ts"])
28
+ .map((feature) => ({
29
+ route: feature.route,
30
+ file: feature.files["schema.ts"],
31
+ descriptions: feature.descriptions,
32
+ render: feature.render,
33
+ })),
34
+ relations: features.flatMap((feature) => {
35
+ const relations = [];
36
+ if (feature.files["schema.ts"]) {
37
+ relations.push({ from: feature.route, to: feature.files["schema.ts"], type: "uses" });
38
+ }
39
+ if (feature.files["action.ts"]) {
40
+ relations.push({ from: feature.route, to: feature.files["action.ts"], type: "uses" });
41
+ }
42
+ if (feature.files["query.ts"]) {
43
+ relations.push({ from: feature.route, to: feature.files["query.ts"], type: "uses" });
44
+ }
45
+ if (feature.files["page.tsx"]) {
46
+ relations.push({ from: feature.route, to: feature.files["page.tsx"], type: "renders" });
47
+ }
48
+ if (feature.files["meta.ts"]) {
49
+ relations.push({ from: feature.route, to: feature.files["meta.ts"], type: "describes" });
50
+ }
51
+ return relations;
52
+ }),
53
+ features,
54
+ };
55
+ }
56
+ async function walkFeatureDirectories(root) {
57
+ const directories = [];
58
+ await visit(root, directories, root);
59
+ return directories;
60
+ }
61
+ async function visit(currentDirectory, directories, appDirectory) {
62
+ const entries = await fs.readdir(currentDirectory, { withFileTypes: true });
63
+ const fileNames = new Set(entries.filter((entry) => entry.isFile()).map((entry) => entry.name));
64
+ const hasFeatureFiles = FIXED_FILES.some((fileName) => fileNames.has(fileName));
65
+ if (hasFeatureFiles) {
66
+ directories.push(currentDirectory);
67
+ }
68
+ await Promise.all(entries
69
+ .filter((entry) => entry.isDirectory())
70
+ .map((entry) => visit(path.join(currentDirectory, entry.name), directories, appDirectory)));
71
+ }
72
+ /**
73
+ * Parses dynamic segment syntax from a route string.
74
+ *
75
+ * Supported formats (mirrors Next.js conventions):
76
+ * [param] — single dynamic segment /blog/[id]
77
+ * [...slug] — required catch-all /docs/[...slug]
78
+ * [[...slug]] — optional catch-all /docs/[[...slug]]
79
+ */
80
+ export function parseRouteSegments(route) {
81
+ const params = [];
82
+ const patternParts = route
83
+ .split("/")
84
+ .filter(Boolean)
85
+ .map((segment) => {
86
+ const optionalCatchAll = segment.match(/^\[\[\.\.\.(\w+)\]\]$/);
87
+ if (optionalCatchAll) {
88
+ params.push(optionalCatchAll[1]);
89
+ return `:${optionalCatchAll[1]}?*`;
90
+ }
91
+ const catchAll = segment.match(/^\[\.\.\.(\w+)\]$/);
92
+ if (catchAll) {
93
+ params.push(catchAll[1]);
94
+ return `:${catchAll[1]}*`;
95
+ }
96
+ const dynamic = segment.match(/^\[(\w+)\]$/);
97
+ if (dynamic) {
98
+ params.push(dynamic[1]);
99
+ return `:${dynamic[1]}`;
100
+ }
101
+ return segment;
102
+ });
103
+ return {
104
+ params,
105
+ isDynamic: params.length > 0,
106
+ routePattern: `/${patternParts.join("/")}`,
107
+ };
108
+ }
109
+ async function scanFeature(appDirectory, featureDirectory) {
110
+ const relativeDirectory = path.relative(appDirectory, featureDirectory);
111
+ const feature = relativeDirectory.split(path.sep).join("/");
112
+ const route = `/${feature}`;
113
+ const files = Object.fromEntries(await Promise.all(FIXED_FILES.map(async (fileName) => {
114
+ const filePath = path.join(featureDirectory, fileName);
115
+ try {
116
+ await fs.access(filePath);
117
+ return [fileName, normalizePath(filePath)];
118
+ }
119
+ catch {
120
+ return [fileName, undefined];
121
+ }
122
+ })));
123
+ const metaSource = files["meta.ts"] ? await fs.readFile(path.join(featureDirectory, "meta.ts"), "utf8") : "";
124
+ const pageSource = files["page.tsx"] ? await fs.readFile(path.join(featureDirectory, "page.tsx"), "utf8") : "";
125
+ const schemaSource = files["schema.ts"] ? await fs.readFile(path.join(featureDirectory, "schema.ts"), "utf8") : "";
126
+ const featureEntries = await fs.readdir(featureDirectory, { withFileTypes: true });
127
+ const extraFiles = featureEntries
128
+ .filter((entry) => entry.isFile())
129
+ .map((entry) => entry.name)
130
+ .filter((fileName) => !FIXED_FILES.includes(fileName) &&
131
+ !SUPPLEMENTARY_FILES.includes(fileName) &&
132
+ !GENERATED_FILE_PATTERN.test(fileName));
133
+ const warnings = [
134
+ ...REQUIRED_FILES.filter((fileName) => !files[fileName]).map((fileName) => `Missing required file: ${fileName}`),
135
+ ...extraFiles.map((fileName) => `Non-standard file in feature directory: ${fileName}`),
136
+ ];
137
+ if (pageSource.length > 0) {
138
+ if (/<img\b/i.test(pageSource)) {
139
+ warnings.push("Raw <img> usage detected. Prefer optimizedImage() for lazy loading and responsive sources.");
140
+ if (countMatches(pageSource, /<img\b(?![^>]*\balt\s*=)[^>]*>/gi) > 0) {
141
+ warnings.push("Some <img> tags are missing alt attributes.");
142
+ }
143
+ if (countMatches(pageSource, /<img\b(?![^>]*\bloading\s*=)[^>]*>/gi) > 0) {
144
+ warnings.push("Some <img> tags are missing loading attribute (use loading=\"lazy\" when appropriate).");
145
+ }
146
+ if (countMatches(pageSource, /<img\b(?![^>]*\b(?:width|height)\s*=)[^>]*>/gi) > 0) {
147
+ warnings.push("Some <img> tags are missing intrinsic width/height which can cause layout shift.");
148
+ }
149
+ }
150
+ if (/<video\b/i.test(pageSource)) {
151
+ warnings.push("Raw <video> usage detected. Prefer optimizedVideo() for preload and source hints.");
152
+ if (countMatches(pageSource, /<video\b(?![^>]*\bpreload\s*=)[^>]*>/gi) > 0) {
153
+ warnings.push("Some <video> tags are missing preload strategy (recommended: preload=\"metadata\").");
154
+ }
155
+ if (countMatches(pageSource, /<video\b(?![^>]*\bposter\s*=)[^>]*>/gi) > 0) {
156
+ warnings.push("Some <video> tags are missing poster image, which hurts perceived loading performance.");
157
+ }
158
+ }
159
+ }
160
+ const { params, isDynamic, routePattern } = parseRouteSegments(route);
161
+ return {
162
+ route,
163
+ feature,
164
+ directory: normalizePath(featureDirectory),
165
+ files,
166
+ missingRequiredFiles: REQUIRED_FILES.filter((fileName) => !files[fileName]),
167
+ intent: extractStringValue(metaSource, "intent"),
168
+ pageIntent: extractStringValue(pageSource, "intent"),
169
+ descriptions: extractStringList(schemaSource, "description"),
170
+ render: extractRenderMode(metaSource),
171
+ warnings,
172
+ params,
173
+ isDynamic,
174
+ routePattern,
175
+ };
176
+ }
177
+ function extractStringValue(source, key) {
178
+ const match = source.match(new RegExp(`${key}\\s*(?::|=)\\s*["'\`]([^"'\`]+)["'\`]`));
179
+ return match?.[1] ?? null;
180
+ }
181
+ function extractStringList(source, key) {
182
+ return Array.from(source.matchAll(new RegExp(`${key}\\s*(?::|=)\\s*["'\`]([^"'\`]+)["'\`]`, "g"))).map((match) => match[1]);
183
+ }
184
+ function normalizePath(filePath) {
185
+ return filePath.split(path.sep).join("/");
186
+ }
187
+ function countMatches(source, pattern) {
188
+ const matches = source.match(pattern);
189
+ return matches ? matches.length : 0;
190
+ }
191
+ function extractRenderMode(source) {
192
+ const render = extractStringValue(source, "render");
193
+ if (render === "csr") {
194
+ return "csr";
195
+ }
196
+ if (render === "ssg") {
197
+ return "ssg";
198
+ }
199
+ return "ssr";
200
+ }
package/src/state.d.ts ADDED
@@ -0,0 +1 @@
1
+ export { createFiyuuStore, type FiyuuStore } from "./reactive.js";
package/src/state.js ADDED
@@ -0,0 +1 @@
1
+ export { createFiyuuStore } from "./reactive.js";
@@ -0,0 +1,48 @@
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 declare function clientData<T>(id: string, data: T): string;
12
+ /**
13
+ * RawHtml — marks a string as already-safe HTML (bypasses auto-escaping).
14
+ */
15
+ export declare class RawHtml {
16
+ readonly value: string;
17
+ constructor(value: string);
18
+ toString(): string;
19
+ }
20
+ /**
21
+ * Marks a string as trusted HTML (bypasses auto-escaping).
22
+ */
23
+ export declare function raw(value: string | RawHtml): RawHtml;
24
+ /**
25
+ * Alias for raw() — more explicit about intent.
26
+ */
27
+ export declare const unsafeHtml: typeof raw;
28
+ /**
29
+ * Internal HTML escaping — used by media.ts and responsive-wrapper.ts.
30
+ * Not intended for direct user consumption; html`` auto-escapes.
31
+ */
32
+ export declare function escapeHtml(value: unknown): string;
33
+ /**
34
+ * Tagged template for building HTML strings.
35
+ *
36
+ * All interpolations are auto-escaped by default (XSS-safe).
37
+ * Use raw() or unsafeHtml() for intentional raw HTML.
38
+ * null / undefined / false render as empty string.
39
+ * Arrays are auto-flattened and joined.
40
+ *
41
+ * @example
42
+ * html`<p>${user.bio}</p>` // auto-escaped
43
+ * html`<div>${unsafeHtml(someHtml)}</div>` // intentional raw HTML
44
+ * html`<ul>${items.map(i => html`<li>${i}</li>`)}</ul>` // auto-flattened
45
+ */
46
+ export declare function html(strings: TemplateStringsArray, ...values: unknown[]): string;
47
+ export type ComponentProps = Record<string, unknown>;
48
+ export declare function component<Props extends ComponentProps = ComponentProps>(render: (props: Props) => string): (props: Props) => string;
@@ -0,0 +1,98 @@
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(id, data) {
12
+ return `<script type="application/json" id="${id}">${JSON.stringify(data)}</script>`;
13
+ }
14
+ /**
15
+ * RawHtml — marks a string as already-safe HTML (bypasses auto-escaping).
16
+ */
17
+ export class RawHtml {
18
+ value;
19
+ constructor(value) {
20
+ this.value = value;
21
+ }
22
+ toString() {
23
+ return this.value;
24
+ }
25
+ }
26
+ /**
27
+ * Marks a string as trusted HTML (bypasses auto-escaping).
28
+ */
29
+ export function raw(value) {
30
+ return value instanceof RawHtml ? value : new RawHtml(value);
31
+ }
32
+ /**
33
+ * Alias for raw() — more explicit about intent.
34
+ */
35
+ export const unsafeHtml = raw;
36
+ /**
37
+ * Internal HTML escaping — used by media.ts and responsive-wrapper.ts.
38
+ * Not intended for direct user consumption; html`` auto-escapes.
39
+ */
40
+ export function escapeHtml(value) {
41
+ const text = value == null ? "" : String(value);
42
+ return text
43
+ .replaceAll("&", "&amp;")
44
+ .replaceAll("<", "&lt;")
45
+ .replaceAll(">", "&gt;")
46
+ .replaceAll('"', "&quot;")
47
+ .replaceAll("'", "&#39;");
48
+ }
49
+ function autoEscape(value) {
50
+ if (value instanceof RawHtml) {
51
+ return value.value;
52
+ }
53
+ const text = value == null ? "" : String(value);
54
+ return text
55
+ .replaceAll("&", "&amp;")
56
+ .replaceAll("<", "&lt;")
57
+ .replaceAll(">", "&gt;")
58
+ .replaceAll('"', "&quot;")
59
+ .replaceAll("'", "&#39;");
60
+ }
61
+ /**
62
+ * Tagged template for building HTML strings.
63
+ *
64
+ * All interpolations are auto-escaped by default (XSS-safe).
65
+ * Use raw() or unsafeHtml() for intentional raw HTML.
66
+ * null / undefined / false render as empty string.
67
+ * Arrays are auto-flattened and joined.
68
+ *
69
+ * @example
70
+ * html`<p>${user.bio}</p>` // auto-escaped
71
+ * html`<div>${unsafeHtml(someHtml)}</div>` // intentional raw HTML
72
+ * html`<ul>${items.map(i => html`<li>${i}</li>`)}</ul>` // auto-flattened
73
+ */
74
+ export function html(strings, ...values) {
75
+ let output = "";
76
+ for (let index = 0; index < strings.length; index += 1) {
77
+ output += strings[index] ?? "";
78
+ if (index < values.length) {
79
+ output += serializeTemplateValue(values[index]);
80
+ }
81
+ }
82
+ return output;
83
+ }
84
+ function serializeTemplateValue(value) {
85
+ if (value == null || value === false) {
86
+ return "";
87
+ }
88
+ if (value instanceof RawHtml) {
89
+ return value.value;
90
+ }
91
+ if (Array.isArray(value)) {
92
+ return value.map(serializeTemplateValue).join("");
93
+ }
94
+ return autoEscape(value);
95
+ }
96
+ export function component(render) {
97
+ return render;
98
+ }
@@ -0,0 +1,14 @@
1
+ export interface VirtualListProps {
2
+ items: unknown[];
3
+ itemHeight: number;
4
+ height: number;
5
+ renderItem: (item: unknown, index: number) => string;
6
+ }
7
+ /**
8
+ * renderVirtualList — server-side HTML generator for virtualized lists.
9
+ *
10
+ * On the server all items are rendered (no real windowing). Client-side
11
+ * virtualization can be layered on top by reading the `data-fiyuu-virtual`
12
+ * attribute and the `data-item-height` / `data-total-height` values.
13
+ */
14
+ export declare function renderVirtualList(props: VirtualListProps): string;
package/src/virtual.js ADDED
@@ -0,0 +1,21 @@
1
+ import { html } from "./template.js";
2
+ /**
3
+ * renderVirtualList — server-side HTML generator for virtualized lists.
4
+ *
5
+ * On the server all items are rendered (no real windowing). Client-side
6
+ * virtualization can be layered on top by reading the `data-fiyuu-virtual`
7
+ * attribute and the `data-item-height` / `data-total-height` values.
8
+ */
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
16
+ data-fiyuu-virtual
17
+ data-item-height="${itemHeight}"
18
+ data-total-height="${totalHeight}"
19
+ style="height:${height}px;overflow-y:auto;position:relative"
20
+ ><div style="height:${totalHeight}px;position:relative">${itemsHtml}</div></div>`;
21
+ }