#!/usr/bin/env node // Spielt die Pixel-Oberfläche „Hustler“ aus der Claude-Design-Zeichenfläche (pixelwelt-v2) in WOMS ein. // // Aufruf: node scripts/hustler-import.mjs [] // // Erzeugt (alles deterministisch, ein zweiter Lauf ändert keine Datei): // src/assets/hustler/ die benutzten PNGs (Vite hängt beim Bauen einen Hash an) // src/styles/hustler/{hochhaus,keller,pult}.css das CSS der drei Artboards, auf WOMS zugeschnitten // src/lib/hustlerWelt.js Geometrie des Hochhauses (reine Daten, eingefroren) // // Der optionale zweite Ordner (pixelwelt-woms) enthält das neu gezeichnete Haus. Eine Datei dort unter // haus/ ersetzt die gleichnamige aus pixelwelt-v2/haus/. Nennt dort haus/manifest.json „graueRaeume“, // entstehen zusätzlich die Ebenen für abgedunkelte Räume (CSS-Klassen hk-raum-grau- und GRAUE_RAEUME). // Erwartetes Format: graueRaeume = { "": { "file": "raum-grau-.png", "x": …, "y": …, "w": …, "h": … } } // (x/y in Weltkoordinaten wie alle Werte der Haus-Manifeste; file, w und h sind optional – ohne Angabe gilt // raum-grau-.png in seiner Pixelgröße). Statt eines Objekts geht auch eine Liste mit „raum“ je Eintrag. // // Nur Node-Bordmittel. Schreibt ausschließlich in die drei Zielorte oben; gelöscht werden höchstens alte // Ebenen src/assets/hustler/haus/raum-grau-*.png, die der aktuelle Lauf nicht mehr erzeugt. import { createHash } from 'node:crypto' import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs' import path from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' const HIER = path.dirname(fileURLToPath(import.meta.url)) const REPO = path.resolve(HIER, '..') const ROSTER_DATEI = path.join(REPO, 'src/lib/workersRoster.js') const QUELLEN_DATEI = path.join(HIER, 'hustler-import/quellen.json') // Ausgabeorte (relativ zur Repo-Wurzel, mit /) const ASSETS = 'src/assets/hustler' const STYLES = 'src/styles/hustler' const WELT_DATEI = 'src/lib/hustlerWelt.js' // Die drei Bildschirme: Artboard, Blob-Zuordnung, Wurzelklasse, Keyframe-Vorsatz, Ausgabedatei export const BILDSCHIRME = Object.freeze({ hochhaus: { artboard: 'Main.dc.html', titel: 'Hochhaus', html: 'entwurf/Main.dc.html', blobs: 'entwurf/blobs.json', wurzel: 'hk-hochhaus', kfVorsatz: 'hk-h-', css: 'hochhaus.css' }, keller: { artboard: 'BotLeads.dc.html', titel: 'Keller', html: 'keller/entwurf/BotLeads.dc.html', blobs: 'keller/entwurf/blobs.json', wurzel: 'hk-keller', kfVorsatz: 'hk-k-', css: 'keller.css' }, pult: { artboard: 'Freigabe.dc.html', titel: 'Pult', html: 'pult/entwurf/Freigabe.dc.html', blobs: 'pult/entwurf/blobs.json', wurzel: 'hk-pult', kfVorsatz: 'hk-p-', css: 'pult.css' }, }) // Raum-Schlüssel im Haus-Manifest -> Raum-Schlüssel in ROOM_TITLES (workersRoster.js) const RAUM_NACH_ROSTER = Object.freeze({ bruecke: 'bruecke', radar: 'netz', klein: 'kleinanzeigen', portale: 'portale', foren: 'foren', aerzte: 'aerzte', handwerk: 'handwerk', selbst: 'selbst', laeden: 'laden', waage: 'waage', mail: 'mail', forum: 'forum', post: 'posteingang', abschluss: 'abschluss', tisch1: 'tisch1', tisch2: 'tisch2', tisch3: 'tisch3', pruef: 'pruefung', freigabe: 'pult', tresor: 'tresor', keller: 'archiv', }) export class ImportFehler extends Error { constructor(text) { super(text) this.name = 'ImportFehler' } } const fehler = (text) => new ImportFehler(text) const sha256 = (daten) => createHash('sha256').update(daten).digest('hex') const kurz = (text, n = 70) => { const t = String(text).replace(/\s+/g, ' ').trim() return t.length > n ? `${t.slice(0, n)}…` : t } // ───────────────────────────── CSS-Tokenizer ───────────────────────────── // Kleiner, strenger Zerleger: Kommentare, Zeichenketten, url(…), verschachtelte @media/@supports, @keyframes. function stringEnde(s, i) { const q = s[i] let j = i + 1 while (j < s.length) { const c = s[j] if (c === '\\') { j += 2; continue } if (c === q) return j + 1 if (c === '\n') break j += 1 } throw fehler(`CSS: Zeichenkette ohne Ende bei „${kurz(s.slice(i, i + 40))}“.`) } // Entfernt /* … */ (Zeichenketten und ungequotetes url(…) bleiben unberührt) function ohneKommentare(s) { let aus = '' let i = 0 while (i < s.length) { const c = s[i] if (c === '"' || c === "'") { const e = stringEnde(s, i); aus += s.slice(i, e); i = e; continue } if (c === '\\') { aus += s.slice(i, i + 2); i += 2; continue } if (c === '/' && s[i + 1] === '*') { const e = s.indexOf('*/', i + 2) if (e < 0) throw fehler('CSS: Kommentar ohne Ende.') aus += ' ' i = e + 2 continue } if ((c === 'u' || c === 'U') && /^url\(\s*[^\s"')]/i.test(s.slice(i, i + 12)) && !/[\w-]/.test(s[i - 1] ?? ' ')) { const e = s.indexOf(')', i) if (e < 0) throw fehler('CSS: url( ohne schließende Klammer.') aus += s.slice(i, e + 1) i = e + 1 continue } aus += c i += 1 } return aus } // Erste Stelle ab i, an der ein Zeichen aus `stopps` auf oberster Ebene steht (außerhalb von (), [] und Zeichenketten) function sucheOben(s, i, stopps) { let klammer = 0 let eckig = 0 while (i < s.length) { const c = s[i] if (c === '"' || c === "'") { i = stringEnde(s, i); continue } if (c === '\\') { i += 2; continue } if (c === '(') klammer += 1 else if (c === ')') klammer = Math.max(0, klammer - 1) else if (c === '[') eckig += 1 else if (c === ']') eckig = Math.max(0, eckig - 1) else if (klammer === 0 && eckig === 0 && stopps.includes(c)) return i i += 1 } return -1 } function teileOben(s, trenner) { const teile = [] let start = 0 for (;;) { const e = sucheOben(s, start, trenner) if (e < 0) { teile.push(s.slice(start)); return teile } teile.push(s.slice(start, e)) start = e + 1 } } // Index der Klammer, die zu s[offen] ('(' oder '[') gehört; Zeichenketten und Escapes zählen nicht mit function passende(s, offen) { const auf = s[offen] const zu = auf === '(' ? ')' : ']' let tiefe = 0 for (let i = offen; i < s.length; i += 1) { const c = s[i] if (c === '"' || c === "'") { i = stringEnde(s, i) - 1; continue } if (c === '\\') { i += 1; continue } if (c === auf) tiefe += 1 else if (c === zu) { tiefe -= 1 if (tiefe === 0) return i } } throw fehler(`CSS: „${auf}“ ohne passende schließende Klammer in „${kurz(s.slice(offen))}“.`) } const GRUPPEN_REGELN = new Set(['media', 'supports', 'layer', 'container']) // → { knoten, ende }; knoten: { art: 'regel'|'gruppe'|'keyframes'|'weg', … } function parseListe(s, start, innen) { const knoten = [] let i = start for (;;) { while (i < s.length && /\s/.test(s[i])) i += 1 if (i >= s.length) { if (innen) throw fehler('CSS: Block ohne schließende Klammer.') return { knoten, ende: i } } if (s[i] === '}') { if (!innen) throw fehler('CSS: unerwartete schließende Klammer.') return { knoten, ende: i + 1 } } const stopp = sucheOben(s, i, ';{}') if (stopp < 0) throw fehler(`CSS: Regel „${kurz(s.slice(i))}“ endet nicht.`) const kopf = s.slice(i, stopp).trim() if (s[stopp] === '}') throw fehler(`CSS: „${kurz(kopf)}“ steht ohne Block.`) if (s[stopp] === ';') { // @import/@charset ohne Block entfallen, andere Anweisungen kennen wir nicht if (/^@(import|charset)\b/i.test(kopf)) { knoten.push({ art: 'weg' }); i = stopp + 1; continue } throw fehler(`CSS: nicht unterstützte Anweisung „${kurz(kopf)}“.`) } // Block if (kopf.startsWith('@')) { const name = (kopf.match(/^@([A-Za-z-]+)/)?.[1] ?? '').toLowerCase() if (name === 'font-face' || name === 'import') { const e = blockEnde(s, stopp) knoten.push({ art: 'weg' }) i = e + 1 continue } if (/(^|-)keyframes$/.test(name)) { const kfName = kopf.slice(kopf.indexOf(name) + name.length).trim() if (!/^[A-Za-z_][\w-]*$/.test(kfName)) throw fehler(`CSS: ungewöhnlicher Keyframe-Name „${kurz(kfName)}“.`) const { knoten: bilder, ende } = parseListe(s, stopp + 1, true) knoten.push({ art: 'keyframes', at: `@${name}`, name: kfName, bilder }) i = ende continue } if (GRUPPEN_REGELN.has(name)) { const { knoten: kinder, ende } = parseListe(s, stopp + 1, true) knoten.push({ art: 'gruppe', kopf, kinder }) i = ende continue } throw fehler(`CSS: nicht unterstützte At-Regel „${kurz(kopf)}“.`) } const e = blockEnde(s, stopp) knoten.push({ art: 'regel', selektor: kopf, koerper: s.slice(stopp + 1, e) }) i = e + 1 } } // Index der schließenden Klammer zum Block, der bei s[offen] === '{' beginnt (ohne Verschachtelung) function blockEnde(s, offen) { const e = sucheOben(s, offen + 1, '{}') if (e < 0) throw fehler('CSS: Block ohne schließende Klammer.') if (s[e] === '{') throw fehler(`CSS: verschachtelte Regel in „${kurz(s.slice(offen + 1, e))}“ wird nicht unterstützt.`) return e } // ───────────────────────────── Selektoren ───────────────────────────── function istIdentStart(s, j) { const c = s[j] if (c === undefined) return false if (/[A-Za-z_\\]/.test(c) || c.charCodeAt(0) > 127) return true return c === '-' && /[A-Za-z_\\-]/.test(s[j + 1] ?? '') } // Jede Klasse (.name) bekommt hk- davor; Zeichenketten und [attribute] bleiben unberührt function praefixKlassen(s) { let aus = '' let i = 0 while (i < s.length) { const c = s[i] if (c === '"' || c === "'") { const e = stringEnde(s, i); aus += s.slice(i, e); i = e; continue } if (c === '\\') { aus += s.slice(i, i + 2); i += 2; continue } if (c === '[') { const ende = passende(s, i) aus += s.slice(i, ende + 1) i = ende + 1 continue } if (c === '.' && istIdentStart(s, i + 1)) { aus += '.hk-'; i += 1; continue } aus += c i += 1 } return aus } const WURZEL_ANFANG = /^(?:html|body|:root)(?![\w-])/i const WURZEL_FOLGE = /^\s*(?:>\s*)?(?=(?:html|body|:root)(?![\w-]))/i export function wandleSelektor(selektor, wurzel) { let rest = selektor.trim() if (!rest) throw fehler('CSS: leerer Selektor.') let istWurzel = false while (WURZEL_ANFANG.test(rest)) { istWurzel = true rest = rest.replace(WURZEL_ANFANG, '') const folge = rest.match(WURZEL_FOLGE) if (!folge) break rest = rest.slice(folge[0].length) } if (/(^|[\s>+~(,])(?:html|body|:root)(?![\w-])/i.test(rest)) { throw fehler(`CSS: html/body/:root mitten im Selektor „${kurz(selektor)}“ lässt sich nicht sicher umformen.`) } const mitKlassen = praefixKlassen(rest) return istWurzel ? `.${wurzel}${mitKlassen}` : `.${wurzel} ${mitKlassen}` } function wandleSelektorListe(liste, wurzel) { const neu = [] for (const teil of teileOben(liste, ',')) { const s = wandleSelektor(teil, wurzel) if (!neu.includes(s)) neu.push(s) } return neu.join(',') } // ───────────────────────────── Werte ───────────────────────────── const BLOB_ADRESSE = /^\/_blob\/([0-9A-Za-z]+)$/ function blobInhalt(inhalt, blobPfad) { const m = inhalt.trim().match(BLOB_ADRESSE) if (m) return blobPfad(m[1]) if (inhalt.includes('/_blob/')) throw fehler(`CSS: unerwartete Blob-Adresse „${kurz(inhalt)}“.`) return inhalt } // url(/_blob/…) und Zeichenketten mit Blob-Adresse -> Asset-Pfad function ersetzeBlobs(wert, blobPfad) { let aus = '' let i = 0 while (i < wert.length) { const c = wert[i] if (c === '"' || c === "'") { const e = stringEnde(wert, i) aus += c + blobInhalt(wert.slice(i + 1, e - 1), blobPfad) + c i = e continue } if (c === '\\') { aus += wert.slice(i, i + 2); i += 2; continue } if ((c === 'u' || c === 'U') && /^url\(/i.test(wert.slice(i, i + 4)) && !/[\w-]/.test(wert[i - 1] ?? ' ')) { let j = i + 4 while (j < wert.length && /\s/.test(wert[j])) j += 1 if (wert[j] === '"' || wert[j] === "'") { aus += wert.slice(i, j); i = j; continue } // die Zeichenkette folgt in der Schleife const e = wert.indexOf(')', j) if (e < 0) throw fehler(`CSS: url( ohne schließende Klammer in „${kurz(wert)}“.`) aus += `url(${blobInhalt(wert.slice(j, e), blobPfad)})` i = e + 1 continue } aus += c i += 1 } return aus } const ZAHL = /[+-]?(?:\d*\.\d+|\d+\.?)(?:[eE][+-]?\d+)?[A-Za-z%]*/y const IDENT = /(?:[A-Za-z_]|[^\u0000-\u007f]|\\.)(?:[\w-]|[^\u0000-\u007f]|\\.)*|-(?:[A-Za-z_-]|[^\u0000-\u007f]|\\.)(?:[\w-]|[^\u0000-\u007f]|\\.)*/y // Bekannte Keyframe-Namen in animation/animation-name umbenennen; Schlüsselwörter, Zahlen mit Einheit // (3200ms, -1.3s, .5s) und Funktionen (steps(1,end)) bleiben unberührt function benenneAnimationen(wert, umbenennen) { let aus = '' let i = 0 while (i < wert.length) { const c = wert[i] if (c === '"' || c === "'") { const e = stringEnde(wert, i); aus += wert.slice(i, e); i = e; continue } ZAHL.lastIndex = i const zahl = ZAHL.exec(wert) if (zahl) { aus += zahl[0] i += zahl[0].length continue } IDENT.lastIndex = i const ident = IDENT.exec(wert) if (ident) { const name = ident[0] i += name.length if (wert[i] === '(') { // Funktion (steps, cubic-bezier, var …): unverändert bis zur passenden Klammer const ende = passende(wert, i) aus += name + wert.slice(i, ende + 1) i = ende + 1 } else { aus += umbenennen.has(name) ? umbenennen.get(name) : name } continue } aus += c i += 1 } return aus } function wandleDeklarationen(koerper, ctx) { const teile = [] for (const roh of teileOben(koerper, ';')) { const d = roh.trim() if (!d) continue const k = sucheOben(d, 0, ':') if (k < 0) throw fehler(`CSS: Deklaration ohne „:“ in „${kurz(d)}“.`) const eigenschaft = d.slice(0, k).trim() let wert = d.slice(k + 1).trim().replace(/\s*\n\s*/g, ' ') wert = ersetzeBlobs(wert, ctx.blobPfad) if (/^(?:-webkit-)?animation(?:-name)?$/i.test(eigenschaft)) wert = benenneAnimationen(wert, ctx.kfNamen) teile.push(`${eigenschaft}:${wert}`) } return teile.join('; ') } function sammleKeyframes(knoten, namen) { for (const k of knoten) { if (k.art === 'keyframes') namen.add(k.name) else if (k.art === 'gruppe') sammleKeyframes(k.kinder, namen) } } function schreibeKnoten(knoten, ctx, zeilen) { for (const k of knoten) { if (k.art === 'weg') continue if (k.art === 'regel') { zeilen.push(`${wandleSelektorListe(k.selektor, ctx.wurzel)}{${wandleDeklarationen(k.koerper, ctx)}}`) } else if (k.art === 'keyframes') { const bilder = k.bilder.map((b) => { if (b.art !== 'regel') throw fehler(`CSS: unerwarteter Inhalt in @keyframes ${k.name}.`) return `${b.selektor.replace(/\s+/g, ' ')}{${wandleDeklarationen(b.koerper, ctx)}}` }) zeilen.push(`${k.at} ${ctx.kfNamen.get(k.name)}{${bilder.join('')}}`) } else if (k.art === 'gruppe') { const innen = [] schreibeKnoten(k.kinder, ctx, innen) zeilen.push(`${k.kopf.replace(/\s+/g, ' ')}{`, ...innen, '}') } } } /** * Formt das CSS eines Artboards für WOMS um. * @param {string} css zusammengesetzter Inhalt der