mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 19:51:17 +00:00
399 lines
16 KiB
TypeScript
399 lines
16 KiB
TypeScript
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
|
||
// 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<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.",
|
||
...(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 }) }
|
||
}
|