import "server-only" import { statSync } from "node:fs" import { readFile } from "node:fs/promises" import path from "node:path" import { strToU8, zipSync, type Zippable } from "fflate" import portal from "@/package.json" import { brandName } from "../lib/content" import { getTemplate } from "../lib/registry" import { themeColors } from "../lib/theme" import type { TemplateContent, TemplateDefinition } from "../lib/types" // Exportiert eine Variante als eigenständiges Next.js-Projekt: Die Quelltexte der Vorlage werden ab template.tsx // über ihre Imports eingesammelt (nichts muss doppelt gepflegt werden), Inhalte landen in content/site.json. // Die Dateien liest der Server zur Laufzeit – siehe outputFileTracingIncludes in next.config.ts. Alle Pfade beginnen mit // einem festen Ordner, sonst bezieht Turbopack beim Build das ganze Projekt in die Server-Dateien ein. const SOURCE = "modules/templates/" const SOURCE_DIR = path.join(process.cwd(), "modules/templates") const UTILS_FILE = path.join(process.cwd(), "lib/utils.ts") // Eigene Bilder der Varianten liegen unter /pictures/templates// const IMAGE_PREFIX = "/pictures/templates/" const IMAGE_DIR = path.join(process.cwd(), "public/pictures/templates") const TARGET = "site/" // Bündeln alle Vorlagen bzw. Schriften – im Export entstehen sie neu, nur mit dem, was die Variante nutzt const REGISTRY = `${SOURCE}lib/registry.ts` const FONT_LIBRARY = `${SOURCE}catalog/font-library.ts` const SPECIFIER = /\bfrom\s+"([^"]+)"|\bimport\s*\(?\s*"([^"]+)"/g const DEV_DEPENDENCIES = ["@tailwindcss/postcss", "@types/node", "@types/react", "@types/react-dom", "tailwindcss", "typescript"] const versions: Record = { ...portal.dependencies, ...portal.devDependencies } // Nur die Vorlagen-Bausteine und cn() gehören ins Projekt – KI, Export und sonstiger Portal-Code nicht (null) function diskPath(file: string) { if (file === "lib/utils.ts") return UTILS_FILE if (file.startsWith(SOURCE) && !file.startsWith(`${SOURCE}ai/`) && !file.startsWith(`${SOURCE}export/`)) { return path.join(SOURCE_DIR, file.slice(SOURCE.length)) } return null } function exportPath(file: string) { return file.startsWith(SOURCE) ? TARGET + file.slice(SOURCE.length) : file } function isFile(absolute: string | null) { if (!absolute) return false try { return statSync(absolute).isFile() } catch { return false } } // Import ohne Endung ("./motion", "@/lib/utils") → erlaubte Datei auf der Platte function resolveModule(base: string) { const candidates = ["", ".ts", ".tsx", "/index.ts", "/index.tsx"].map((extension) => base + extension) const file = candidates.find((candidate) => isFile(diskPath(candidate))) if (file) return file throw new Error(candidates.some((candidate) => diskPath(candidate)) ? `Export: ${base} nicht gefunden` : `Export: ${base} gehört nicht in ein eigenständiges Projekt`) } async function readSource(file: string) { const absolute = diskPath(file) if (!absolute) throw new Error(`Export: ${file} gehört nicht in ein eigenständiges Projekt`) return readFile(absolute, "utf8") } function packageName(specifier: string) { const parts = specifier.split("/") return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0] } async function collectSources(entries: string[], generated: Record) { const files: Record = {} const packages = new Set(["next", "react", "react-dom"]) const queue = [...entries] while (queue.length > 0) { const file = queue.pop()! const target = exportPath(file) if (target in files) continue const source = generated[file] ?? (await readSource(file)) files[target] = source for (const match of source.matchAll(SPECIFIER)) { const specifier = match[1] ?? match[2] if (specifier.startsWith(".")) queue.push(resolveModule(path.posix.join(path.posix.dirname(file), specifier))) else if (specifier.startsWith("@/")) queue.push(resolveModule(specifier.slice(2))) else packages.add(packageName(specifier)) } } return { files, packages } } function registrySource(keys: string[]) { return [ ...keys.map((key) => `import { ${key} } from "../catalog/${key}/definition"`), `import type { TemplateDefinition } from "./types"`, "", "// Beim Export erzeugt: nur die Vorlagen, deren Inhalte bzw. Paletten diese Seite nutzt", `export const templates: TemplateDefinition[] = [${keys.join(", ")}]`, "", "export function getTemplate(key: string) {", " return templates.find((template) => template.key === key)", "}", "", ].join("\n") } function fontLibrarySource(keys: string[]) { return [ `import type { FontPair } from "../lib/types"`, ...keys.map((key) => `import { fonts as ${key} } from "./${key}/fonts"`), "", "// Beim Export erzeugt: Schriftpaare anderer Vorlagen, die diese Seite nutzt (\"vorlage:schlüssel\")", `const library: Record> = ${keys.length > 0 ? `{ ${keys.join(", ")} }` : "{}"}`, "", "export function libraryFont(key: string): FontPair | undefined {", ` const [template, pair] = key.split(":")`, " return library[template]?.[pair]", "}", "", ].join("\n") } // "vorlage:schlüssel" aus der Bibliothek → Schlüssel der fremden Vorlage function libraryTemplate(value: string) { const [key] = value.split(":") return value.includes(":") && getTemplate(key) ? [key] : [] } // Eigene Bilder, die in den Inhalten vorkommen (Pfad relativ zu IMAGE_DIR) function localImages(value: unknown): string[] { if (typeof value === "string") { if (!value.startsWith(IMAGE_PREFIX)) return [] const file = path.posix.normalize(value.split(/[?#]/)[0].slice(IMAGE_PREFIX.length)) return !file.startsWith("..") && isFile(path.join(IMAGE_DIR, file)) ? [file] : [] } if (Array.isArray(value)) return value.flatMap(localImages) if (value && typeof value === "object") return Object.values(value).flatMap(localImages) return [] } export function exportSlug(name: string, fallback: string) { const slug = name .toLowerCase() .replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss") .normalize("NFKD") .replace(/[̀-ͯ]/g, "") .replace(/[^a-z0-9]+/g, "-") .slice(0, 60) .replace(/^-+|-+$/g, "") return slug || fallback } const plain = (text: string) => text.replace(/\*/g, "").replace(/\s+/g, " ").trim() export async function buildProject(def: TemplateDefinition, content: TemplateContent, variantName: string) { const slug = exportSlug(variantName, def.key) const component = `${def.key[0].toUpperCase()}${def.key.slice(1)}Template` const templateFile = `${SOURCE}catalog/${def.key}/template.tsx` const { files, packages } = await collectSources( [templateFile, `${SOURCE}catalog/${def.key}/definition.ts`, `${SOURCE}lib/content.ts`], { [REGISTRY]: registrySource([...new Set([def.key, ...libraryTemplate(content.palette)])]), [FONT_LIBRARY]: fontLibrarySource(libraryTemplate(content.fonts)), } ) if (!files[exportPath(templateFile)].includes(`export function ${component}(`)) { throw new Error(`Export: ${component} fehlt in ${templateFile}`) } const pick = (names: string[]) => Object.fromEntries( names.toSorted().map((name) => { if (!versions[name]) throw new Error(`Export: Version für ${name} unbekannt`) return [name, versions[name]] }) ) const title = brandName(content) ?? def.name // Baukasten-Seiten haben kein festes „hero“: dann der Text des ersten Start-Bausteins, sonst der Untertitel const hero = content.data.hero ?? content.blocks.find((block) => !block.hidden && block.type.startsWith("hero-"))?.data const heroText = hero && typeof hero.text === "string" ? hero.text : content.data.brand?.tagline const description = typeof heroText === "string" ? plain(heroText) : "" const theme = themeColors(def, content) const unsplash = JSON.stringify([content.data, content.blocks]).includes("images.unsplash.com") // Ohne KI-Einstellungen: Chat & Co. brauchen das Portal-Backend const site = { palette: content.palette, fonts: content.fonts, colors: content.colors, hidden: content.hidden, order: content.order, style: content.style, data: content.data, ...(def.builder ? { blocks: content.blocks } : {}), } const project: Record = { ...files, "package.json": `${JSON.stringify( { name: slug, version: "0.1.0", private: true, scripts: { dev: "next dev", build: "next build", start: "next start" }, dependencies: pick([...packages]), devDependencies: pick(DEV_DEPENDENCIES), }, null, 2 )}\n`, "content/site.json": `${JSON.stringify(site, null, 2)}\n`, "app/page.tsx": [ `import site from "@/content/site.json"`, `import { ${def.key} } from "@/site/catalog/${def.key}/definition"`, `import { ${component} } from "@/site/catalog/${def.key}/template"`, `import { resolveContent } from "@/site/lib/content"`, "", "// Texte, Bilder, Farben und Reihenfolge der Sektionen stehen in content/site.json", "export default function Home() {", ` return <${component} content={resolveContent(${def.key}, site)} />`, "}", "", ].join("\n"), "app/layout.tsx": [ `import type { Metadata } from "next"`, `import "./globals.css"`, "", "export const metadata: Metadata = {", ` title: ${JSON.stringify(title)},`, ...(description ? [` description: ${JSON.stringify(description)},`] : []), "}", "", "export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {", " return (", ` `, " {children}", " ", " )", "}", "", ].join("\n"), "app/globals.css": `@import "tailwindcss"; /* Farben und Schriften der Vorlage – site/catalog/shared/shell.tsx setzt die Variablen (bg-t-accent, font-t-display, …) */ @theme inline { --color-t-bg: var(--t-bg); --color-t-fg: var(--t-fg); --color-t-muted: var(--t-muted); --color-t-surface: var(--t-surface); --color-t-accent: var(--t-accent); --color-t-accent-fg: var(--t-accent-fg); --font-t-display: var(--t-display); --font-t-body: var(--t-body); } @layer base { html { background: ${theme.bg}; color-scheme: ${theme.dark ? "dark" : "light"}; } * { border-color: color-mix(in oklab, var(--t-fg, currentColor) 12%, transparent); } h1, h2, h3, h4, h5, h6 { line-height: 1.2; } :where(a, button, input, textarea, select, summary, [tabindex]):focus-visible { outline: 2px solid var(--t-accent); outline-offset: 2px; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } } /* iOS zoomt beim Fokussieren in Felder mit < 16px Schrift */ @media (pointer: coarse) { input:not([type="checkbox"], [type="radio"], [type="file"]), select, textarea { font-size: max(16px, 1em); } } `, "next.config.ts": `import type { NextConfig } from "next" const nextConfig: NextConfig = { images: { // Platzhalterbilder; Bild-Links anderer Seiten lädt site/catalog/shared/media.tsx unoptimiert remotePatterns: [{ protocol: "https", hostname: "images.unsplash.com", pathname: "/**" }], }, } export default nextConfig `, "postcss.config.mjs": `const config = { plugins: { "@tailwindcss/postcss": {}, }, }; export default config; `, "tsconfig.json": `${JSON.stringify( { compilerOptions: { target: "ES2017", lib: ["dom", "dom.iterable", "esnext"], allowJs: true, skipLibCheck: true, strict: true, noEmit: true, esModuleInterop: true, module: "esnext", moduleResolution: "bundler", resolveJsonModule: true, isolatedModules: true, jsx: "react-jsx", incremental: true, plugins: [{ name: "next" }], paths: { "@/*": ["./*"] }, }, include: ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts"], exclude: ["node_modules"], }, null, 2 )}\n`, ".gitignore": ["node_modules", ".next", "out", "next-env.d.ts", "*.tsbuildinfo", ".env*", ".DS_Store", ""].join("\n"), "README.md": [ `# ${title}`, "", `Eigenständiges Next.js-Projekt, exportiert aus dem Since3-Portal (Vorlage „${def.name}“, Variante „${variantName}“).`, "", "## Starten", "", "```bash", "npm install", "npm run dev # http://localhost:3000", "npm run build && npm start", "```", "", "## Inhalte bearbeiten", "", "Alle Texte, Bilder, Farben und die Reihenfolge der Sektionen stehen in `content/site.json`.", "", "- `*Wort*` hebt ein Wort hervor, `\\n` erzeugt einen Zeilenumbruch.", "- `hidden` blendet optionale Sektionen aus, `order` legt ihre Reihenfolge fest.", "- `palette` und `fonts` wählen Farbpalette und Schriftpaar (siehe `definition.ts`), `colors` überschreibt einzelne Farben (`#rrggbb`).", "- `style.images` (original, mono, warm, soft, contrast) und `style.motion` (full, subtle) steuern Bildstil und Animationen.", ...(def.builder ? ["- `blocks` ist die Liste der Bausteine in Seitenreihenfolge (`type`, `hidden`, `data`); verfügbare Typen und Felder stehen in `site/catalog/blocks/definitions.ts`."] : []), "", "## Aufbau", "", "| Pfad | Inhalt |", "| --- | --- |", "| `app/` | Seite, Layout und globale Styles |", "| `content/site.json` | Inhalte der Seite |", `| \`site/catalog/${def.key}/\` | Die Vorlage: \`template.tsx\` (Aufbau), \`definition.ts\` (Standardinhalte, Paletten), \`fonts.ts\` (Schriften) |`, "| `site/catalog/shared/` | Gemeinsame Bausteine: Animationen, Bilder, Formularfelder |", "| `site/lib/` | Typen und Inhalts-Logik |", "| `public/` | Eigene Bilder |", "", "## Vor dem Livegang", "", "- [ ] **Formulare anbinden:** Sie zeigen nach dem Absenden nur eine Bestätigung und senden noch nichts (`site/catalog/shared/demo-form.tsx` und Formulare in `template.tsx`), z. B. per Server Action mit E-Mail-Versand.", ...(unsplash ? ["- [ ] **Platzhalterbilder ersetzen:** Bilder von images.unsplash.com durch eigene Fotos ersetzen."] : []), "- [ ] **Impressum und Datenschutzerklärung** anlegen und im Footer verlinken.", "- [ ] **Titel und Beschreibung** für Suchmaschinen in `app/layout.tsx` prüfen.", "", "KI-Funktionen aus der Demo (Chat, Telefon) sind nicht enthalten – sie laufen über das Since3-Portal.", "", "## Deployment", "", "Läuft auf jedem Hosting mit Node.js-Unterstützung für Next.js, z. B. Vercel: Repository verbinden und deployen.", "", ].join("\n"), } const zip: Zippable = {} for (const [file, source] of Object.entries(project)) zip[`${slug}/${file}`] = strToU8(source) for (const image of new Set(localImages([content.data, content.blocks]))) { zip[`${slug}/public${IMAGE_PREFIX}${image}`] = [new Uint8Array(await readFile(path.join(IMAGE_DIR, image))), { level: 0 }] } return { fileName: `${slug}.zip`, zip: zipSync(zip, { level: 6 }) } }