@fiyuu/core 0.1.0 → 0.2.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,247 @@
1
+ import { z } from "zod";
2
+
3
+ export type AnyZodSchema = z.ZodTypeAny;
4
+
5
+ export interface SchemaContract<TInput extends AnyZodSchema = AnyZodSchema, TOutput extends AnyZodSchema = AnyZodSchema> {
6
+ input: TInput;
7
+ output: TOutput;
8
+ description: string;
9
+ }
10
+
11
+ export interface ActionDefinition<TInput extends AnyZodSchema = AnyZodSchema, TOutput extends AnyZodSchema = AnyZodSchema>
12
+ extends SchemaContract<TInput, TOutput> {
13
+ kind: "action";
14
+ }
15
+
16
+ export interface QueryDefinition<TInput extends AnyZodSchema = AnyZodSchema, TOutput extends AnyZodSchema = AnyZodSchema>
17
+ extends SchemaContract<TInput, TOutput> {
18
+ kind: "query";
19
+ }
20
+
21
+ export interface QueryCacheConfig {
22
+ /** Cache TTL in seconds. Set to 0 to disable. */
23
+ ttl: number;
24
+ /** Cache key varies by these URL query string parameters. */
25
+ vary?: string[];
26
+ }
27
+
28
+ export interface MetaDefinition {
29
+ intent: string;
30
+ title?: string;
31
+ render?: RenderMode;
32
+ /**
33
+ * Mark this page as zero-JS.
34
+ * `fiyuu doctor` will warn if <script> tags are detected in page.tsx.
35
+ */
36
+ noJs?: boolean;
37
+ /**
38
+ * Revalidate interval in seconds for `render: "ssg"` pages.
39
+ * Works like ISR: stale HTML is served immediately and refreshed in background.
40
+ */
41
+ revalidate?: number;
42
+ seo?: {
43
+ title?: string;
44
+ description?: string;
45
+ };
46
+ }
47
+
48
+ export interface PageDefinition {
49
+ kind: "page";
50
+ intent: string;
51
+ }
52
+
53
+ export interface LayoutDefinition {
54
+ kind: "layout";
55
+ name?: string;
56
+ }
57
+
58
+ export type RenderMode = "ssr" | "csr" | "ssg";
59
+
60
+ export interface PageProps<TData = unknown> {
61
+ data: TData | null;
62
+ route: string;
63
+ intent: string;
64
+ render: RenderMode;
65
+ /** Dynamic route parameters extracted from the URL, e.g. { id: "42" } for /blog/[id] */
66
+ params: Record<string, string>;
67
+ }
68
+
69
+ export interface LayoutProps {
70
+ children: string;
71
+ route: string;
72
+ }
73
+
74
+ export function defineAction<TInput extends AnyZodSchema, TOutput extends AnyZodSchema>(config: SchemaContract<TInput, TOutput>): ActionDefinition<TInput, TOutput> {
75
+ return {
76
+ kind: "action",
77
+ ...config,
78
+ };
79
+ }
80
+
81
+ export function defineQuery<TInput extends AnyZodSchema, TOutput extends AnyZodSchema>(config: SchemaContract<TInput, TOutput>): QueryDefinition<TInput, TOutput> {
82
+ return {
83
+ kind: "query",
84
+ ...config,
85
+ };
86
+ }
87
+
88
+ export function definePage(config: { intent: string }): PageDefinition {
89
+ return {
90
+ kind: "page",
91
+ ...config,
92
+ };
93
+ }
94
+
95
+ export function defineLayout(config: { name?: string } = {}): LayoutDefinition {
96
+ return {
97
+ kind: "layout",
98
+ ...config,
99
+ };
100
+ }
101
+
102
+ export function defineMeta(config: MetaDefinition): MetaDefinition {
103
+ return config;
104
+ }
105
+
106
+ // ── Type inference helpers ─────────────────────────────────────────────────
107
+ //
108
+ // Eliminate type duplication between Zod schemas and TypeScript code.
109
+ // Define your schema once in query.ts / action.ts — infer everywhere else.
110
+ //
111
+ // @example page.tsx
112
+ // import { query } from "./query.js";
113
+ // type PageData = InferQueryOutput<typeof query>; // no manual type needed
114
+ //
115
+ // @example action.ts execute()
116
+ // export async function execute({ input, request }: ActionContext<typeof action>) { ... }
117
+ // // input is fully typed from the action's Zod input schema
118
+
119
+ /** TypeScript output type inferred from a QueryDefinition's output Zod schema. */
120
+ export type InferQueryOutput<T extends QueryDefinition> = z.infer<T["output"]>;
121
+
122
+ /** TypeScript input type inferred from a QueryDefinition's input Zod schema. */
123
+ export type InferQueryInput<T extends QueryDefinition> = z.infer<T["input"]>;
124
+
125
+ /** TypeScript input type inferred from an ActionDefinition's input Zod schema. */
126
+ export type InferActionInput<T extends ActionDefinition> = z.infer<T["input"]>;
127
+
128
+ /** TypeScript output type inferred from an ActionDefinition's output Zod schema. */
129
+ export type InferActionOutput<T extends ActionDefinition> = z.infer<T["output"]>;
130
+
131
+ /**
132
+ * Context passed to query `execute()` functions.
133
+ *
134
+ * @example
135
+ * export async function execute({ request, params }: QueryContext) {
136
+ * const sessionId = getSessionIdFromRequest(request as any);
137
+ * ...
138
+ * }
139
+ */
140
+ export interface QueryContext {
141
+ /** Incoming HTTP request (Node.js IncomingMessage). Cast to `any` if you need raw access. */
142
+ request: unknown;
143
+ /** Dynamic URL parameters, e.g. { id: "42" } for route /blog/[id] */
144
+ params: Record<string, string>;
145
+ }
146
+
147
+ /**
148
+ * Context passed to action `execute()` functions.
149
+ * Pass `typeof action` as the generic to get fully typed `input`.
150
+ *
151
+ * @example
152
+ * export const action = defineAction({ input: z.object({ title: z.string() }), ... });
153
+ *
154
+ * export async function execute({ input, request }: ActionContext<typeof action>) {
155
+ * // input.title is typed as string — no manual type annotation needed
156
+ * }
157
+ */
158
+ export interface ActionContext<T extends ActionDefinition = ActionDefinition> {
159
+ /** Action input, typed from the action's Zod input schema. */
160
+ input: z.infer<T["input"]>;
161
+ /** Incoming HTTP request (Node.js IncomingMessage). Cast to `any` if you need raw access. */
162
+ request: unknown;
163
+ /** Dynamic URL parameters, e.g. { id: "42" } for route /blog/[id] */
164
+ params: Record<string, string>;
165
+ }
166
+
167
+ // ── Middleware ─────────────────────────────────────────────────────────────
168
+ //
169
+ // Use `defineMiddleware` to get full type inference without importing from
170
+ // @fiyuu/runtime. All types use `unknown` for Node.js primitives so that
171
+ // @fiyuu/core stays free of @types/node as a required peer dependency.
172
+
173
+ /**
174
+ * Context object passed to every middleware handler.
175
+ *
176
+ * @example
177
+ * export const middleware = defineMiddleware(async ({ url, request }, next) => {
178
+ * if (url.pathname === "/secret") { ... }
179
+ * await next();
180
+ * });
181
+ */
182
+ export interface FiyuuMiddlewareContext {
183
+ /** Incoming HTTP request — Node.js `IncomingMessage`. */
184
+ request: unknown;
185
+ /** Parsed request URL. */
186
+ url: URL;
187
+ /** Mutable response headers that will be sent with the final response. */
188
+ responseHeaders: Record<string, string>;
189
+ /** Unique ID for this request (useful for logging). */
190
+ requestId: string;
191
+ /** Non-fatal warnings accumulated during request handling. */
192
+ warnings: string[];
193
+ }
194
+
195
+ /**
196
+ * Return this from a middleware to short-circuit the request.
197
+ *
198
+ * @example
199
+ * // Redirect to login
200
+ * return {
201
+ * headers: { Location: "/auth" },
202
+ * response: { status: 302, body: "" },
203
+ * };
204
+ */
205
+ export interface FiyuuMiddlewareResult {
206
+ /** Extra response headers to send (e.g. `Location` for redirects). */
207
+ headers?: Record<string, string>;
208
+ response?: {
209
+ /** HTTP status code. */
210
+ status?: number;
211
+ /** JSON body — serialised automatically. */
212
+ json?: unknown;
213
+ /** Raw string body. */
214
+ body?: string;
215
+ };
216
+ }
217
+
218
+ /** Call this to continue to the next middleware or the route handler. */
219
+ export type FiyuuMiddlewareNext = () => Promise<void>;
220
+
221
+ /**
222
+ * A single middleware function.
223
+ * Return `FiyuuMiddlewareResult` to short-circuit, or call `next()` to continue.
224
+ */
225
+ export type FiyuuMiddlewareHandler = (
226
+ context: FiyuuMiddlewareContext,
227
+ next: FiyuuMiddlewareNext,
228
+ ) => Promise<FiyuuMiddlewareResult | void> | FiyuuMiddlewareResult | void;
229
+
230
+ /**
231
+ * Wraps your middleware function and gives it full type inference.
232
+ * Import from `@fiyuu/core` — no need to touch `@fiyuu/runtime`.
233
+ *
234
+ * @example app/middleware.ts
235
+ * import { defineMiddleware } from "@fiyuu/core";
236
+ *
237
+ * export const middleware = defineMiddleware(async ({ url, request }, next) => {
238
+ * if (url.pathname.startsWith("/dashboard")) {
239
+ * const user = await getSessionUser(request);
240
+ * if (!user) return { headers: { Location: "/auth" }, response: { status: 302, body: "" } };
241
+ * }
242
+ * await next();
243
+ * });
244
+ */
245
+ export function defineMiddleware(handler: FiyuuMiddlewareHandler): FiyuuMiddlewareHandler {
246
+ return handler;
247
+ }
@@ -1,5 +1,6 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
+
3
4
  const PAGE_TEMPLATE = `import { Component } from "@geajs/core";
4
5
  import { definePage, html, optimizedImage, responsiveStyle, fluid, type PageProps } from "fiyuu/client";
5
6
 
@@ -28,6 +29,7 @@ export default class {{Title}}Page extends Component<PageProps<{{Title}}Data>> {
28
29
  }
29
30
  }
30
31
  `;
32
+
31
33
  const ACTION_TEMPLATE = `import { z } from "zod";
32
34
  import { defineAction } from "fiyuu/client";
33
35
 
@@ -41,6 +43,7 @@ export async function execute(input: Record<string, unknown>) {
41
43
  return { success: true };
42
44
  }
43
45
  `;
46
+
44
47
  const QUERY_TEMPLATE = `import { z } from "zod";
45
48
  import { defineQuery } from "fiyuu/client";
46
49
 
@@ -54,6 +57,7 @@ export async function execute() {
54
57
  return {};
55
58
  }
56
59
  `;
60
+
57
61
  const SCHEMA_TEMPLATE = `import { z } from "zod";
58
62
 
59
63
  export const input = z.object({});
@@ -64,6 +68,7 @@ export const output = z.object({
64
68
 
65
69
  export const description = "{{description}}";
66
70
  `;
71
+
67
72
  const META_TEMPLATE = `import { defineMeta } from "fiyuu/client";
68
73
 
69
74
  export default defineMeta({
@@ -76,49 +81,71 @@ export default defineMeta({
76
81
  },
77
82
  });
78
83
  `;
79
- export async function generatePageFeature(appDirectory, featureName) {
80
- const featureDirectory = path.join(appDirectory, featureName);
81
- await fs.mkdir(featureDirectory, { recursive: true });
82
- const title = titleCase(featureName);
83
- const intent = `${title} page for user-facing interactions`;
84
- const description = `Fetches data for ${title}`;
85
- const files = [
86
- ["page.tsx", PAGE_TEMPLATE],
87
- ["query.ts", QUERY_TEMPLATE],
88
- ["schema.ts", SCHEMA_TEMPLATE],
89
- ["meta.ts", META_TEMPLATE],
90
- ];
91
- await Promise.all(files.map(([fileName, template]) => fs.writeFile(path.join(featureDirectory, fileName), hydrate(template, { title, intent, description, Title: pascalCase(featureName) }))));
92
- return files.map(([fileName]) => path.join(featureDirectory, fileName));
84
+
85
+ export async function generatePageFeature(appDirectory: string, featureName: string): Promise<string[]> {
86
+ const featureDirectory = path.join(appDirectory, featureName);
87
+ await fs.mkdir(featureDirectory, { recursive: true });
88
+
89
+ const title = titleCase(featureName);
90
+ const intent = `${title} page for user-facing interactions`;
91
+ const description = `Fetches data for ${title}`;
92
+ const files = [
93
+ ["page.tsx", PAGE_TEMPLATE],
94
+ ["query.ts", QUERY_TEMPLATE],
95
+ ["schema.ts", SCHEMA_TEMPLATE],
96
+ ["meta.ts", META_TEMPLATE],
97
+ ] as const;
98
+
99
+ await Promise.all(
100
+ files.map(([fileName, template]) =>
101
+ fs.writeFile(path.join(featureDirectory, fileName), hydrate(template, { title, intent, description, Title: pascalCase(featureName) })),
102
+ ),
103
+ );
104
+
105
+ return files.map(([fileName]) => path.join(featureDirectory, fileName));
93
106
  }
94
- export async function generateActionFeature(appDirectory, featureName) {
95
- const featureDirectory = path.join(appDirectory, featureName);
96
- await fs.mkdir(featureDirectory, { recursive: true });
97
- const title = titleCase(featureName);
98
- const intent = `${title} workflow for server-side mutations`;
99
- const description = `Performs ${title} mutation`;
100
- const files = [
101
- ["action.ts", ACTION_TEMPLATE],
102
- ["schema.ts", SCHEMA_TEMPLATE],
103
- ["meta.ts", META_TEMPLATE],
104
- ];
105
- await Promise.all(files.map(([fileName, template]) => fs.writeFile(path.join(featureDirectory, fileName), hydrate(template, { title, intent, description, Title: pascalCase(featureName) }))));
106
- return files.map(([fileName]) => path.join(featureDirectory, fileName));
107
+
108
+ export async function generateActionFeature(appDirectory: string, featureName: string): Promise<string[]> {
109
+ const featureDirectory = path.join(appDirectory, featureName);
110
+ await fs.mkdir(featureDirectory, { recursive: true });
111
+
112
+ const title = titleCase(featureName);
113
+ const intent = `${title} workflow for server-side mutations`;
114
+ const description = `Performs ${title} mutation`;
115
+ const files = [
116
+ ["action.ts", ACTION_TEMPLATE],
117
+ ["schema.ts", SCHEMA_TEMPLATE],
118
+ ["meta.ts", META_TEMPLATE],
119
+ ] as const;
120
+
121
+ await Promise.all(
122
+ files.map(([fileName, template]) =>
123
+ fs.writeFile(path.join(featureDirectory, fileName), hydrate(template, { title, intent, description, Title: pascalCase(featureName) })),
124
+ ),
125
+ );
126
+
127
+ return files.map(([fileName]) => path.join(featureDirectory, fileName));
107
128
  }
108
- function hydrate(template, replacements) {
109
- return Object.entries(replacements).reduce((content, [key, value]) => content.replaceAll(`{{${key}}}`, value), template);
129
+
130
+ function hydrate(template: string, replacements: Record<string, string>): string {
131
+ return Object.entries(replacements).reduce(
132
+ (content, [key, value]) => content.replaceAll(`{{${key}}}`, value),
133
+ template,
134
+ );
110
135
  }
111
- function titleCase(value) {
112
- return value
113
- .split(/[\/-]/)
114
- .filter(Boolean)
115
- .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
116
- .join(" ");
136
+
137
+ function titleCase(value: string): string {
138
+ return value
139
+ .split(/[\/-]/)
140
+ .filter(Boolean)
141
+ .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
142
+ .join(" ");
117
143
  }
118
- function pascalCase(value) {
119
- return value
120
- .split(/[\/-_]/)
121
- .filter(Boolean)
122
- .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
123
- .join("");
144
+
145
+ function pascalCase(value: string): string {
146
+ return value
147
+ .split(/[\/-_]/)
148
+ .filter(Boolean)
149
+ .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
150
+ .join("");
124
151
  }
package/src/media.ts ADDED
@@ -0,0 +1,143 @@
1
+ import { escapeHtml } from "./template.js";
2
+
3
+ export interface OptimizedImageSource {
4
+ srcSet: string;
5
+ media?: string;
6
+ type?: string;
7
+ sizes?: string;
8
+ }
9
+
10
+ export interface OptimizedImageProps {
11
+ src: string;
12
+ alt: string;
13
+ width?: number;
14
+ height?: number;
15
+ sizes?: string;
16
+ srcSet?: string;
17
+ sources?: OptimizedImageSource[];
18
+ loading?: "lazy" | "eager";
19
+ decoding?: "async" | "sync" | "auto";
20
+ fetchPriority?: "high" | "low" | "auto";
21
+ class?: string;
22
+ style?: string;
23
+ id?: string;
24
+ }
25
+
26
+ export interface OptimizedVideoSource {
27
+ src: string;
28
+ type?: string;
29
+ media?: string;
30
+ }
31
+
32
+ export interface OptimizedVideoProps {
33
+ src?: string;
34
+ sources?: OptimizedVideoSource[];
35
+ poster?: string;
36
+ preload?: "none" | "metadata" | "auto";
37
+ controls?: boolean;
38
+ muted?: boolean;
39
+ loop?: boolean;
40
+ autoPlay?: boolean;
41
+ playsInline?: boolean;
42
+ width?: number;
43
+ height?: number;
44
+ class?: string;
45
+ style?: string;
46
+ id?: string;
47
+ }
48
+
49
+ export function optimizedImage(props: OptimizedImageProps): string {
50
+ const loading = props.loading ?? "lazy";
51
+ const decoding = props.decoding ?? "async";
52
+ const fetchPriority = props.fetchPriority ?? "auto";
53
+ const imageAttributes = [
54
+ createAttribute("src", props.src),
55
+ createAttribute("alt", props.alt),
56
+ createAttribute("loading", loading),
57
+ createAttribute("decoding", decoding),
58
+ createAttribute("fetchpriority", fetchPriority),
59
+ createAttribute("sizes", props.sizes),
60
+ createAttribute("srcset", props.srcSet),
61
+ createAttribute("width", props.width),
62
+ createAttribute("height", props.height),
63
+ createAttribute("class", props.class),
64
+ createAttribute("style", props.style),
65
+ createAttribute("id", props.id),
66
+ ]
67
+ .filter(Boolean)
68
+ .join(" ");
69
+
70
+ const sourceTags = (props.sources ?? [])
71
+ .map((source) => {
72
+ const attributes = [
73
+ createAttribute("srcset", source.srcSet),
74
+ createAttribute("media", source.media),
75
+ createAttribute("type", source.type),
76
+ createAttribute("sizes", source.sizes),
77
+ ]
78
+ .filter(Boolean)
79
+ .join(" ");
80
+ return `<source ${attributes}>`;
81
+ })
82
+ .join("");
83
+
84
+ if (sourceTags.length > 0) {
85
+ return `<picture>${sourceTags}<img ${imageAttributes}></picture>`;
86
+ }
87
+
88
+ return `<img ${imageAttributes}>`;
89
+ }
90
+
91
+ export function optimizedVideo(props: OptimizedVideoProps): string {
92
+ const preload = props.preload ?? "metadata";
93
+ const controls = props.controls ?? true;
94
+ const playsInline = props.playsInline ?? true;
95
+ const sources = props.sources && props.sources.length > 0
96
+ ? props.sources
97
+ : props.src
98
+ ? [{ src: props.src }]
99
+ : [];
100
+
101
+ if (sources.length === 0) {
102
+ throw new Error("optimizedVideo requires either `src` or `sources`.");
103
+ }
104
+
105
+ const videoAttributes = [
106
+ createAttribute("poster", props.poster),
107
+ createAttribute("preload", preload),
108
+ createAttribute("width", props.width),
109
+ createAttribute("height", props.height),
110
+ createAttribute("class", props.class),
111
+ createAttribute("style", props.style),
112
+ createAttribute("id", props.id),
113
+ controls ? "controls" : "",
114
+ props.muted ? "muted" : "",
115
+ props.loop ? "loop" : "",
116
+ props.autoPlay ? "autoplay" : "",
117
+ playsInline ? "playsinline" : "",
118
+ ]
119
+ .filter(Boolean)
120
+ .join(" ");
121
+
122
+ const sourceTags = sources
123
+ .map((source) => {
124
+ const attributes = [
125
+ createAttribute("src", source.src),
126
+ createAttribute("type", source.type),
127
+ createAttribute("media", source.media),
128
+ ]
129
+ .filter(Boolean)
130
+ .join(" ");
131
+ return `<source ${attributes}>`;
132
+ })
133
+ .join("");
134
+
135
+ return `<video ${videoAttributes}>${sourceTags}</video>`;
136
+ }
137
+
138
+ function createAttribute(name: string, value: string | number | undefined): string {
139
+ if (value === undefined || value === null || value === "") {
140
+ return "";
141
+ }
142
+ return `${name}="${escapeHtml(value)}"`;
143
+ }