since3-portal/modules/templates/export/project.ts
2026-10-10 02:38:50 +02:00

393 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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/<kunde>/
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<string, string> = { ...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<string, string>) {
const files: Record<string, string> = {}
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<string, Record<string, FontPair>> = ${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
const hero = content.data.hero
const description = hero && typeof hero.text === "string" ? plain(hero.text) : ""
const theme = themeColors(def, content)
const unsplash = JSON.stringify(content.data).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,
}
const project: Record<string, string> = {
...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 (",
` <html lang="de">`,
" <body>{children}</body>",
" </html>",
" )",
"}",
"",
].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.",
"",
"## 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))) {
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 }) }
}