@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/package.json +5 -5
- package/src/artifacts.ts +328 -0
- package/src/config.ts +260 -0
- package/src/contracts.ts +247 -0
- package/src/{generator.js → generator.ts} +68 -41
- package/src/media.ts +143 -0
- package/src/reactive.ts +229 -0
- package/src/{responsive-wrapper.js → responsive-wrapper.ts} +118 -74
- package/src/responsive.ts +54 -0
- package/src/scanner.ts +289 -0
- package/src/template.ts +110 -0
- package/src/{virtual.js → virtual.tsx} +16 -7
- package/LICENSE +0 -674
- package/README.md +0 -194
- package/src/artifacts.d.ts +0 -20
- package/src/artifacts.js +0 -274
- package/src/client.js +0 -8
- package/src/config.d.ts +0 -179
- package/src/config.js +0 -58
- package/src/contracts.d.ts +0 -176
- package/src/contracts.js +0 -45
- package/src/generator.d.ts +0 -2
- package/src/index.js +0 -11
- package/src/media.d.ts +0 -44
- package/src/media.js +0 -87
- package/src/reactive.d.ts +0 -53
- package/src/reactive.js +0 -160
- package/src/responsive-wrapper.d.ts +0 -18
- package/src/responsive.d.ts +0 -15
- package/src/responsive.js +0 -48
- package/src/scanner.d.ts +0 -65
- package/src/scanner.js +0 -200
- package/src/state.js +0 -1
- package/src/template.d.ts +0 -48
- package/src/template.js +0 -98
- package/src/virtual.d.ts +0 -14
- /package/src/{client.d.ts → client.ts} +0 -0
- /package/src/{index.d.ts → index.ts} +0 -0
- /package/src/{state.d.ts → state.ts} +0 -0
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
|
+
}
|
package/src/template.ts
ADDED
|
@@ -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("&", "&")
|
|
48
|
+
.replaceAll("<", "<")
|
|
49
|
+
.replaceAll(">", ">")
|
|
50
|
+
.replaceAll('"', """)
|
|
51
|
+
.replaceAll("'", "'");
|
|
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("&", "&")
|
|
61
|
+
.replaceAll("<", "<")
|
|
62
|
+
.replaceAll(">", ">")
|
|
63
|
+
.replaceAll('"', """)
|
|
64
|
+
.replaceAll("'", "'");
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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}"
|