Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 182 additions & 0 deletions src/lib/component-templates/codegen-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import type { SchemaField } from "~/lib/faker-options";

function normalize(s: string): string {
return s.toLowerCase().replace(/[_\s-]/g, "");
}

export function safeGet(k: string | undefined): string {
return k ? `(item[${JSON.stringify(k)}] ?? '')` : "''";
}

/**
* Finds the user's actual fieldName matching any of the given candidates
* (case + separator insensitive). Returns null if none found so the
* template can decide on a fallback.
*/
export function findField(fields: SchemaField[], candidates: string[]): string | null {
const normalizedCandidates = new Set(candidates.map(normalize));
const match = fields.find((f) => normalizedCandidates.has(normalize(f.fieldName)));
return match ? match.fieldName : null;
}

/**
* Builds a fetch hook that handles both response shapes from the mock
* endpoint: a single object (count === 1) or an array (count > 1).
*/
export function buildFetchHook(
hookName: string,
endpointUrl: string,
itemTypeName: string,
): string {
return `function use${hookName}Data() {
const [data, setData] = useState<${itemTypeName}[] | ${itemTypeName} | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
fetch("${endpointUrl}")
.then((res) => {
if (!res.ok) throw new Error(\`Request failed: \${res.status}\`);
return res.json();
})
.then((json) => setData(json))
.catch((err) => setError(err instanceof Error ? err.message : "Unknown error"))
.finally(() => setLoading(false));
}, []);

return { data, loading, error };
}`;
}

export function buildInterface(name: string, fieldLines: string[]): string {
return `interface ${name} {
${fieldLines.map((l) => ` ${l}`).join("\n")}
}`;
}

/**
* Builds a vanilla-JS fetch + DOM-render script block for HTML templates.
* `renderFn` is a JS expression (string) that maps a single item to an
* HTML string — the caller provides the item-level template string.
*/
export function buildHtmlFetchScript(endpointUrl: string, renderFn: string): string {
return ` async function loadData() {
const root = document.getElementById('root');
const err = document.getElementById('error');
root.innerHTML = '<p class="loading">Loading…</p>';
try {
const res = await fetch("${endpointUrl}");
if (!res.ok) throw new Error('HTTP ' + res.status);
const json = await res.json();
const items = Array.isArray(json) ? json : [json];
root.innerHTML = items.map(${renderFn}).join('');
} catch (e) {
root.innerHTML = '';
err.textContent = 'Error: ' + e.message;
}
}
loadData();`;
}

/**
* Returns a baseline CSS string used by all HTML templates:
* custom properties for color, card grid, avatar, etc.
*/
export function buildHtmlStyles(): string {
return ` :root {
--bg: #ffffff;
--surface: #f4f4f5;
--border: #e4e4e7;
--text: #18181b;
--muted: #71717a;
--primary: #6366f1;
--danger: #ef4444;
--success: #22c55e;
--radius: 12px;
--font: system-ui, -apple-system, sans-serif;
--mono: 'Fira Code', 'Cascadia Code', monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #09090b;
--surface: #18181b;
--border: #27272a;
--text: #fafafa;
--muted: #a1a1aa;
}
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font);
background: var(--bg);
color: var(--text);
padding: 1.5rem;
}
#error { color: var(--danger); font-family: var(--mono); font-size: .85rem; margin-bottom: 1rem; }
.loading { color: var(--muted); font-size: .9rem; }
.grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem;
display: flex;
flex-direction: column;
gap: .75rem;
transition: box-shadow .2s;
}
.card:hover { box-shadow: 0 4px 16px rgba(0,0,0,.12); }
.card-header { display: flex; align-items: center; gap: .75rem; }
.avatar {
width: 44px; height: 44px; border-radius: 50%;
object-fit: cover; border: 1px solid var(--border); flex-shrink: 0;
}
.avatar-placeholder {
width: 44px; height: 44px; border-radius: 50%; flex-shrink: 0;
background: var(--border); display: flex; align-items: center;
justify-content: center; font-weight: 700; font-size: 1.1rem; color: var(--muted);
}
.card-title { font-weight: 600; font-size: .95rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.card-sub { font-size: .78rem; color: var(--muted); font-family: var(--mono); margin-top: .15rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.card-desc { font-size: .8rem; color: var(--muted); line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.details { margin-top: .5rem; padding-top: .75rem; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: .35rem; }
.detail-row { display: flex; justify-content: space-between; font-size: .75rem; font-family: var(--mono); }
.detail-key { color: var(--muted); }
.badge { display: inline-block; padding: .15rem .5rem; border-radius: 4px; font-size: .68rem; font-weight: 600; font-family: var(--mono); text-transform: uppercase; letter-spacing: .05em; }
.badge-amber { background: #fef3c7; color: #92400e; }
.badge-emerald { background: #d1fae5; color: #065f46; }
.badge-rose { background: #ffe4e6; color: #9f1239; }
.badge-zinc { background: var(--border); color: var(--muted); }
img.cover { width: 100%; height: 160px; object-fit: cover; border-radius: calc(var(--radius) - 4px) calc(var(--radius) - 4px) 0 0; }
table { width: 100%; border-collapse: collapse; font-size: .85rem; }
thead tr { background: var(--surface); border-bottom: 1px solid var(--border); }
th, td { padding: .65rem 1rem; text-align: left; }
th { font-family: var(--mono); font-size: .7rem; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
tbody tr { border-bottom: 1px solid var(--border); }
tbody tr:hover { background: var(--surface); }
td.amount { text-align: right; font-weight: 700; font-family: var(--mono); }
.todo-list { list-style: none; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
.todo-item { display: flex; flex-direction: column; gap: .4rem; padding: .75rem 1rem; border-bottom: 1px solid var(--border); }
.todo-item:last-child { border-bottom: none; }
.todo-item:hover { background: var(--surface); }
.todo-row { display: flex; align-items: center; gap: .6rem; }
.todo-title { flex: 1; font-size: .9rem; font-weight: 500; }
.todo-title.done { text-decoration: line-through; color: var(--muted); }
.comment-list { display: flex; flex-direction: column; gap: .75rem; }
.comment { display: flex; gap: .75rem; }
.comment-body { flex: 1; border: 1px solid var(--border); border-radius: var(--radius); padding: .75rem; }
.comment-meta { display: flex; align-items: center; justify-content: space-between; margin-bottom: .35rem; }
.comment-author { font-weight: 600; font-size: .9rem; }
.comment-date { font-size: .75rem; color: var(--muted); }
.comment-text { font-size: .85rem; color: var(--muted); line-height: 1.55; }
.product-img { width: 100%; height: 176px; object-fit: cover; border-radius: calc(var(--radius) - 4px) calc(var(--radius) - 4px) 0 0; }
.product-img-placeholder { width: 100%; height: 96px; display: flex; align-items: center; justify-content: center; font-size: 2rem; background: var(--surface); border-radius: calc(var(--radius) - 4px) calc(var(--radius) - 4px) 0 0; }
.product-body { padding: .85rem; display: flex; flex-direction: column; gap: .4rem; }
.product-footer { padding: .65rem .85rem; border-top: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; }
.price { font-size: 1.1rem; font-weight: 700; }
.article { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
.article-body { padding: 1rem; }
.article-title { font-weight: 600; font-size: 1rem; margin-bottom: .35rem; }
.article-excerpt { font-size: .82rem; color: var(--muted); line-height: 1.55; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.article-footer { margin-top: .65rem; display: flex; justify-content: space-between; font-size: .75rem; color: var(--muted); }`;
}
113 changes: 113 additions & 0 deletions src/lib/component-templates/comment-item.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import type { ComponentTemplate } from "./types";
import {
findField,
buildFetchHook,
buildInterface,
buildHtmlFetchScript,
buildHtmlStyles,
safeGet,
} from "./codegen-utils";

export const commentItemTemplate: ComponentTemplate = {
id: "comment-item",
name: "Comment Item",
description: "Avatar + author + comment text list. Handles single or list response.",
requiredFields: ["comment", "author"],
optionalFields: ["avatar", "createdAt", "id"],
code: (fields, endpointUrl) => {
const idField = findField(fields, ["id", "commentid", "uuid"]) ?? "id";
const authorField =
findField(fields, ["author", "fullname", "username", "name"]) ?? "author";
const commentField =
findField(fields, ["comment", "message", "content", "text"]) ?? "comment";
const avatarField = findField(fields, ["avatar", "image", "photo"]);
const createdAtField = findField(fields, ["createdat", "date", "timestamp"]);

const interfaceLines = [
`${idField}: string;`,
`${authorField}: string;`,
`${commentField}: string;`,
];
if (avatarField) interfaceLines.push(`${avatarField}: string;`);
if (createdAtField) interfaceLines.push(`${createdAtField}: string;`);

return `import { useEffect, useState } from "react";

${buildInterface("Comment", interfaceLines)}

${buildFetchHook("CommentItem", endpointUrl, "Comment")}

export function CommentList() {
const { data, loading, error } = useCommentItemData();

if (loading) return <div className="p-4 text-sm text-gray-500">Loading...</div>;
if (error) return <div className="p-4 text-sm text-red-500">Error: {error}</div>;
if (!data) return null;

const comments = Array.isArray(data) ? data : [data];

return (
<div className="space-y-3">
{comments.map((comment) => (
<div key={comment.${idField}} className="flex gap-3">
${avatarField ? ` <img src={comment.${avatarField}} alt={comment.${authorField}} className="h-8 w-8 rounded-full object-cover" />\n` : ""} <div className="min-w-0 flex-1 rounded-lg border p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{comment.${authorField}}</span>
${createdAtField ? ` <span className="text-xs text-gray-400">{comment.${createdAtField}}</span>\n` : ""} </div>
<p className="mt-1 text-sm text-gray-600">{comment.${commentField}}</p>
</div>
</div>
))}
</div>
);
}
`;
},
htmlCode: (fields, endpointUrl) => {
const authorField =
findField(fields, ["author", "fullname", "username", "name"]) ?? "author";
const commentField =
findField(fields, ["comment", "message", "content", "text"]) ?? "comment";
const avatarField = findField(fields, ["avatar", "image", "photo"]);
const createdAtField = findField(fields, ["createdat", "date", "timestamp"]);

const avatarHtml = avatarField
? `\${${safeGet(avatarField)} ? \`<img class="avatar" style="flex-shrink:0;align-self:flex-start" src="\${${safeGet(avatarField)}}" alt="avatar">\` : \`<div class="avatar-placeholder" style="flex-shrink:0;align-self:flex-start">\${String(${safeGet(authorField)}).charAt(0).toUpperCase() || 'U'}</div>\`}`
: `<div class="avatar-placeholder" style="flex-shrink:0;align-self:flex-start">\${String(${safeGet(authorField)}).charAt(0).toUpperCase() || 'U'}</div>`;

const dateHtml = createdAtField
? `\${${safeGet(createdAtField)} ? \`<span class="comment-date">\${${safeGet(createdAtField)}}</span>\` : ''}`
: "";

const renderFn = `item => \`<div class="comment">
${avatarHtml}
<div class="comment-body">
<div class="comment-meta">
<span class="comment-author">\${${safeGet(authorField)}}</span>
${dateHtml}
</div>
<p class="comment-text">\${${safeGet(commentField)}}</p>
</div>
</div>\``;

return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Comments</title>
<style>
${buildHtmlStyles()}
</style>
</head>
<body>
<p id="error"></p>
<div id="root" class="comment-list"></div>
<script type="module">
${buildHtmlFetchScript(endpointUrl, renderFn)}
</script>
</body>
</html>
`;
},
};
Loading
Loading