diff --git a/README.md b/README.md index 31830bb..98305bd 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ For the smallest development import graph, use a component subpath: import { AtTheHorizon } from "@designcodeio/threeui/components/AtTheHorizon"; ``` -Components that render full HTML documents expect their runtime files at the same root-relative URLs used by the ThreeUI preview. Copy the needed files from `node_modules/@designcodeio/threeui/lib-dist/assets/` into your app's public directory, or override the component's `sourceUrl` or `assetBaseUrl` prop where available. +Components that render full HTML documents expect their runtime files at the same root-relative URLs used by the ThreeUI preview. Copy the needed files from `node_modules/@designcodeio/threeui/lib-dist/assets/` into your app's public directory, or override the component's `sourceUrl` or `assetBaseUrl` prop where available. `SylvaLivingWorldScene` inlines its Lexend font so its sandboxed scene does not require a cross-origin font request; pass `assetBaseUrl` when serving additional relative assets from a different location. ## Pro source access diff --git a/package.json b/package.json index a2fdca0..8bb1cd3 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "sync:community": "node scripts/sync-community-from-main.mjs", "generate:lib": "node scripts/generate-library-entry.mjs", "typecheck": "tsc --noEmit", - "test": "node --test scripts/community-sanitizers.test.mjs scripts/public-boundary.test.mjs scripts/library-package.test.mjs scripts/prepare-community-release.test.mjs packages/cli/test/*.test.mjs", + "test": "node --test scripts/community-sanitizers.test.mjs scripts/public-boundary.test.mjs scripts/library-package.test.mjs scripts/prepare-community-release.test.mjs scripts/sylva-living-world.test.mjs packages/cli/test/*.test.mjs", "audit:public": "node scripts/audit-public.mjs", "build:site": "npm run typecheck && npm run audit:public && vite build && node scripts/audit-build.mjs", "build:lib": "npm run generate:lib && vite build --config vite.lib.config.js && tsc -p tsconfig.lib.json && node scripts/copy-library-assets.mjs", diff --git a/packages/cli/src/install.mjs b/packages/cli/src/install.mjs index 1e126b3..73a0f95 100644 --- a/packages/cli/src/install.mjs +++ b/packages/cli/src/install.mjs @@ -83,5 +83,5 @@ export async function installBundle(bundle, { directory = process.cwd(), force = await mkdir(dirname(target.destination), { recursive: true }); await writeFile(target.destination, target.bytes); } - return targets.map((target) => relative(root, target.destination)); + return targets.map((target) => relative(root, target.destination).split(sep).join("/")); } diff --git a/scripts/package-install-smoke.mjs b/scripts/package-install-smoke.mjs index d09e3e8..eef5d78 100644 --- a/scripts/package-install-smoke.mjs +++ b/scripts/package-install-smoke.mjs @@ -5,6 +5,8 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const npmCommand = process.platform === "win32" ? process.env.ComSpec ?? "cmd.exe" : "npm"; +const npmPrefix = process.platform === "win32" ? ["/d", "/s", "/c", "npm.cmd"] : []; const temporaryRoot = await mkdtemp(join(tmpdir(), "threeui-package-smoke-")); const packDirectory = join(temporaryRoot, "pack"); const consumerDirectory = join(temporaryRoot, "consumer"); @@ -28,15 +30,15 @@ try { writeFile(emptyGlobalConfig, ""), ]); const packResult = JSON.parse(execFileSync( - "npm", - ["pack", "--ignore-scripts", "--json", "--pack-destination", packDirectory], + npmCommand, + [...npmPrefix, "pack", "--ignore-scripts", "--json", "--pack-destination", packDirectory], { cwd: root, encoding: "utf8", env: environment }, )); const tarball = join(packDirectory, packResult[0].filename); await writeFile(join(consumerDirectory, "package.json"), `${JSON.stringify({ private: true, type: "module" }, null, 2)}\n`); execFileSync( - "npm", - ["install", "--ignore-scripts", tarball, "react@19", "react-dom@19", "three@0.149.0"], + npmCommand, + [...npmPrefix, "install", "--ignore-scripts", tarball, "react@19", "react-dom@19", "three@0.149.0"], { cwd: consumerDirectory, stdio: "inherit", env: environment }, ); execFileSync( diff --git a/scripts/public-boundary.test.mjs b/scripts/public-boundary.test.mjs index aa1b617..41bdb90 100644 --- a/scripts/public-boundary.test.mjs +++ b/scripts/public-boundary.test.mjs @@ -12,6 +12,10 @@ const sourceRegistry = JSON.parse(await readFile(join(root, "public", "source-co const styles = await readFile(join(root, "src", "styles.css")); const rendererStyles = await readFile(join(root, "src", "shaders", "community.css")); +function snapshotHash(value) { + return createHash("sha256").update(value.toString("utf8").replace(/\r\n/g, "\n")).digest("hex"); +} + const vite = await createServer({ root, server: { middlewareMode: true }, appType: "custom", logLevel: "silent" }); let catalog; try { @@ -104,11 +108,11 @@ test("all Community parents include their implementation source", () => { }); test("the main layout stylesheet remains byte-identical to the synchronized snapshot", () => { - assert.equal(createHash("sha256").update(styles).digest("hex"), report.layoutStylesSha256); + assert.equal(snapshotHash(styles), report.layoutStylesSha256); }); test("the main Community renderer styles are present without Pro or Beta selectors", async () => { - assert.equal(createHash("sha256").update(rendererStyles).digest("hex"), report.rendererStylesSha256); + assert.equal(snapshotHash(rendererStyles), report.rendererStylesSha256); const css = rendererStyles.toString("utf8"); for (const selector of [ ".threeui-background", diff --git a/scripts/sylva-living-world.test.mjs b/scripts/sylva-living-world.test.mjs new file mode 100644 index 0000000..9cac4f6 --- /dev/null +++ b/scripts/sylva-living-world.test.mjs @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import { readFile, stat } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const componentPath = join(root, "src", "shaders", "sylva-living-world", "SylvaLivingWorldScene.tsx"); +const source = await readFile(componentPath, "utf8"); +const syncSource = await readFile(join(root, "scripts", "sync-community-from-main.mjs"), "utf8"); + +test("Sylva keeps its sandboxed scene font self-contained", async () => { + assert.match(source, /lexend-latin\.woff2\?inline/); + assert.match(source, /assetBaseUrl\?: string/); + assert.match(source, / { + assert.match(source, /SCENE_ONLY_MARKUP/); + assert.match(source, /data-threeui-three-runtime/); + assert.match(source, /prefers-reduced-motion/); + assert.match(source, /requestAnimationFrame\(loop\)/); +}); + +test("community sync preserves the self-contained Sylva font", () => { + assert.match(syncSource, /copySylvaLivingWorldFont/); + assert.match(syncSource, /lexend-latin\.woff2/); + assert.match(syncSource, /generatePatchedSylvaLivingWorldModule/); +}); diff --git a/scripts/sync-community-from-main.mjs b/scripts/sync-community-from-main.mjs index 4c2136d..190a526 100644 --- a/scripts/sync-community-from-main.mjs +++ b/scripts/sync-community-from-main.mjs @@ -106,6 +106,20 @@ async function copyFromSource(path) { await copyFile(from, to); } +async function copySylvaLivingWorldFont() { + const targetPath = "src/shaders/sylva-living-world/sources/inner-green-assets/lexend-latin.woff2"; + try { + await copyFromSource(targetPath); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + await mkdir(dirname(join(projectRoot, targetPath)), { recursive: true }); + await copyFile( + join(sourceRoot, "public", "landing-pages", "inner-green-assets", "lexend-latin.woff2"), + join(projectRoot, targetPath), + ); + } +} + async function writeGenerated(path, contents) { const target = join(projectRoot, path); await mkdir(dirname(target), { recursive: true }); @@ -368,10 +382,12 @@ export function TempleNightScene({ className = "" }: TempleNightSceneProps) { function generateSylvaLivingWorldModule() { return `import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; import innerGreenSource from "./sources/inner-green-3d.html?raw"; +import lexendFont from "./sources/inner-green-assets/lexend-latin.woff2?inline"; import threeRuntime from "./sources/inner-green-assets/three.min.js?raw"; export const SYLVA_LIVING_WORLD_VARIANTS = ["living-green"] as const; export type SylvaLivingWorldVariant = "living-green"; -export type SylvaLivingWorldSceneProps = { variant?: SylvaLivingWorldVariant; className?: string; style?: CSSProperties }; +export type SylvaLivingWorldSceneProps = { variant?: SylvaLivingWorldVariant; assetBaseUrl?: string; className?: string; style?: CSSProperties }; +const DEFAULT_ASSET_BASE_URL = "/landing-pages/"; const SCENE_ONLY_MARKUP = \`
\`; const SCENE_ONLY_STYLE = \`\`; function buildSceneDocument(reducedMotion: boolean) { const presentationStart = innerGreenSource.indexOf('
'); const runtimeStart = innerGreenSource.indexOf(''); if (presentationStart < 0 || runtimeStart < 0 || runtimeStart <= presentationStart) throw new Error("Sylva scene adapter could not isolate the authored Three.js scene."); let source = \`\${innerGreenSource.slice(0, presentationStart)}\${SCENE_ONLY_MARKUP}\n\n\${innerGreenSource.slice(runtimeStart)}\`.replace("", \`\${SCENE_ONLY_STYLE}\`).replace('', \`\`); if (reducedMotion) source = source.replace("(function loop() { requestAnimationFrame(loop); tick(); })();", "(function loop() { if (!REDUCED) requestAnimationFrame(loop); tick(); })();"); return source; } @@ -379,6 +395,35 @@ export function SylvaLivingWorldScene({ className = "", style }: SylvaLivingWorl `; } +function generatePatchedSylvaLivingWorldModule() { + let source = generateSylvaLivingWorldModule(); + source = source.replace( + "function buildSceneDocument(reducedMotion: boolean) {", + [ + "function normalizeAssetBaseUrl(assetBaseUrl: string) { const value = assetBaseUrl.trim(); if (!value) return DEFAULT_ASSET_BASE_URL; return value.endsWith(\"/\") ? value : value + \"/\"; }", + 'function escapeHtmlAttribute(value: string) { return value.replaceAll("&", "&").replaceAll(\'"\', """).replaceAll("<", "<").replaceAll(">", ">"); }', + "function buildSceneDocument(reducedMotion: boolean, assetBaseUrl: string) {", + ].join("\n"), + ); + source = source.replace( + "if (reducedMotion) source = source.replace", + "source = source.replace(\"url('inner-green-assets/lexend-latin.woff2')\", \"url(\\\"\" + lexendFont + \"\\\")\"); source = source.replace(\"\", \"\"); if (reducedMotion) source = source.replace", + ); + source = source.replace( + 'export function SylvaLivingWorldScene({ className = "", style }', + 'export function SylvaLivingWorldScene({ assetBaseUrl = DEFAULT_ASSET_BASE_URL, className = "", style }', + ); + source = source.replace( + "buildSceneDocument(reducedMotion), [reducedMotion]", + "buildSceneDocument(reducedMotion, assetBaseUrl), [assetBaseUrl, reducedMotion]", + ); + source = source.replace( + "setReady(false), [mounted, reducedMotion]", + "setReady(false), [mounted, reducedMotion, assetBaseUrl]", + ); + return source; +} + function sanitizeSparkBadgeHtml(source) { const replaceBetween = (value, start, end, replacement = "") => { const from = value.indexOf(start); @@ -592,6 +637,7 @@ for (const path of componentPaths) { for (const path of assetPaths) { if (!excludedAssetPaths.has(path)) await copyFromSource(path); } +if (freeIds.has("sylva-living-world")) await copySylvaLivingWorldFont(); await writeGenerated("src/data/shaders.tsx", generateShadersModule(freeShaders, registryById, shaderModule.getShaderAccess)); const mainSidebarSource = await readFile(join(sourceRoot, "src/components/Sidebar.tsx"), "utf8"); @@ -633,7 +679,7 @@ await writeGenerated( ); await writeGenerated("src/shaders/spark-badge/SparkBadge.tsx", generateSparkBadgeModule()); await writeGenerated("src/shaders/temple-night/TempleNightScene.tsx", generateTempleNightModule()); -await writeGenerated("src/shaders/sylva-living-world/SylvaLivingWorldScene.tsx", generateSylvaLivingWorldModule()); +await writeGenerated("src/shaders/sylva-living-world/SylvaLivingWorldScene.tsx", generatePatchedSylvaLivingWorldModule()); const sparkHtml = sanitizeSparkBadgeHtml(await readFile(join(sourceRoot, "public", "spark-badge.html"), "utf8")); await writeFile(join(projectRoot, "public", "spark-badge.html"), sparkHtml); diff --git a/src/shaders/sylva-living-world/SylvaLivingWorldScene.tsx b/src/shaders/sylva-living-world/SylvaLivingWorldScene.tsx index 8b240d8..6b10fbb 100644 --- a/src/shaders/sylva-living-world/SylvaLivingWorldScene.tsx +++ b/src/shaders/sylva-living-world/SylvaLivingWorldScene.tsx @@ -1,12 +1,147 @@ import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; + import innerGreenSource from "./sources/inner-green-3d.html?raw"; +import lexendFont from "./sources/inner-green-assets/lexend-latin.woff2?inline"; import threeRuntime from "./sources/inner-green-assets/three.min.js?raw"; + export const SYLVA_LIVING_WORLD_VARIANTS = ["living-green"] as const; export type SylvaLivingWorldVariant = "living-green"; -export type SylvaLivingWorldSceneProps = { variant?: SylvaLivingWorldVariant; className?: string; style?: CSSProperties }; + +export type SylvaLivingWorldSceneProps = { + variant?: SylvaLivingWorldVariant; + assetBaseUrl?: string; + className?: string; + style?: CSSProperties; +}; + +const DEFAULT_ASSET_BASE_URL = "/landing-pages/"; const SCENE_ONLY_MARKUP = `
`; const SCENE_ONLY_STYLE = ``; -function buildSceneDocument(reducedMotion: boolean) { const presentationStart = innerGreenSource.indexOf('
'); const runtimeStart = innerGreenSource.indexOf(''); if (presentationStart < 0 || runtimeStart < 0 || runtimeStart <= presentationStart) throw new Error("Sylva scene adapter could not isolate the authored Three.js scene."); let source = `${innerGreenSource.slice(0, presentationStart)}${SCENE_ONLY_MARKUP} -${innerGreenSource.slice(runtimeStart)}`.replace("", `${SCENE_ONLY_STYLE}`).replace('', ``); if (reducedMotion) source = source.replace("(function loop() { requestAnimationFrame(loop); tick(); })();", "(function loop() { if (!REDUCED) requestAnimationFrame(loop); tick(); })();"); return source; } -export function SylvaLivingWorldScene({ className = "", style }: SylvaLivingWorldSceneProps) { const hostRef = useRef(null); const [hostVisible, setHostVisible] = useState(true); const [documentVisible, setDocumentVisible] = useState(() => typeof document === "undefined" || !document.hidden); const [reducedMotion, setReducedMotion] = useState(() => typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches); const [ready, setReady] = useState(false); useEffect(() => { const host = hostRef.current; if (!host || typeof IntersectionObserver === "undefined") return; const observer = new IntersectionObserver(([entry]) => setHostVisible(entry?.isIntersecting ?? true)); observer.observe(host); return () => observer.disconnect(); }, []); useEffect(() => { if (typeof document === "undefined") return; const update = () => setDocumentVisible(!document.hidden); document.addEventListener("visibilitychange", update); return () => document.removeEventListener("visibilitychange", update); }, []); useEffect(() => { const media = window.matchMedia("(prefers-reduced-motion: reduce)"); const update = () => setReducedMotion(media.matches); media.addEventListener("change", update); return () => media.removeEventListener("change", update); }, []); const source = useMemo(() => buildSceneDocument(reducedMotion), [reducedMotion]); const mounted = hostVisible && documentVisible; useEffect(() => setReady(false), [mounted, reducedMotion]); return
{mounted ?