Website: seedance 2.5 page (#1674)

This commit is contained in:
BFlat
2026-06-22 13:09:09 +07:00
committed by GitHub
parent 0c11a95669
commit ef255f4ee6
9 changed files with 1900 additions and 20 deletions
+25 -5
View File
@@ -51,8 +51,11 @@
/media/ /index.html 200
/media/* /index.html 200
/pricing /index.html 200
/pricing/ /index.html 200
# Prerendered: serve the per-route index.html so social crawlers get the
# correct OG/title tags (see scripts/prerender-meta.mjs). The SPA still boots
# from this file and React Router takes over for users.
/pricing /pricing/index.html 200
/pricing/ /pricing/index.html 200
/pricing/* /index.html 200
/faq /index.html 200
@@ -92,8 +95,17 @@
/password-reset/ /index.html 200
/password-reset/* /index.html 200
/seedance-2 /index.html 200
/seedance-2/ /index.html 200
# Prerendered: exact paths serve their per-route index.html (correct OG tags
# for social crawlers); deep sub-paths fall back to the SPA shell. See
# scripts/prerender-meta.mjs. NOTE: these exact-match serving rules MUST come
# before the /seedance2* alias below, otherwise that wildcard would 301
# /seedance2-5 away to /seedance-2 (first match wins in _redirects).
/seedance2-5 /seedance2-5/index.html 200
/seedance2-5/ /seedance2-5/index.html 200
/seedance2-5/* /index.html 200
/seedance-2 /seedance-2/index.html 200
/seedance-2/ /seedance-2/index.html 200
/seedance-2/* /index.html 200
# Google ads breakage
@@ -102,7 +114,15 @@
/seedance-2%7Bignore%7D /seedance-2 301
/seedance-2%7Bignore%7D* /seedance-2 301
# Similar URLs
# Similar URLs — Seedance 2.5 aliases (kept above the broad /seedance2* rule)
/seedance-2-5 /seedance2-5 301
/seedance-2-5* /seedance2-5 301
/seedance-2.5 /seedance2-5 301
/seedance-2.5* /seedance2-5 301
/seedance25 /seedance2-5 301
/seedance25* /seedance2-5 301
# Similar URLs — Seedance 2.0 aliases
/seedance2 /seedance-2 301
/seedance2* /seedance-2 301
/seedance-2.0 /seedance-2 301
@@ -5,6 +5,9 @@ set -euxo pipefail
echo "Run build script (TODO: Make strict)"
nx build artcraft-website
echo "Prerender per-route social-share (OG/Twitter) meta tags into dist"
node scripts/prerender-meta.mjs
echo "Change to project dir"
pushd apps/artcraft-website/
@@ -18,6 +18,7 @@ import CreateVFX from "../pages/create-vfx";
//import Landing2 from "../pages/landing2";
import Landing3 from "../pages/landing3";
import LandingSD2 from "../pages/landing-sd2";
import LandingSD25 from "../pages/landing-sd25";
import TutorialsPage from "../pages/tutorials";
import TutorialsArticle from "../pages/tutorials/article";
import FaqIndex from "../pages/faq/index";
@@ -67,6 +68,7 @@ export function App() {
<Route path="/" element={<Landing3 />} />
<Route path="/landing3" element={<Landing3 />} />
<Route path="/seedance-2" element={<LandingSD2 />} />
<Route path="/seedance2-5" element={<LandingSD25 />} />
<Route path="/download" element={<Download />} />
<Route path="/media" element={<Media />} />
<Route path="/media/:id" element={<Media />} />
@@ -1,26 +1,38 @@
import { useEffect } from "react";
import { DEFAULT_OG_IMAGE, SITE_ORIGIN, toAbsoluteUrl } from "../config/page-meta";
type SeoProps = {
title: string;
description?: string;
/** Share-card title (og:/twitter:). Defaults to `title` when omitted. */
ogTitle?: string;
/** Site-root-relative or absolute share image. Defaults to DEFAULT_OG_IMAGE. */
ogImage?: string;
jsonLd?: object | object[];
};
const Seo = ({ title, description, jsonLd }: SeoProps) => {
// Keeps the document's title + social-share meta tags in sync as the user
// navigates the SPA client-side. NOTE: crawlers don't run this — they read the
// prerendered static HTML (scripts/prerender-meta.mjs). Both pull from the same
// config/page-meta source so the user-visible and crawler-visible tags agree.
const Seo = ({ title, description, ogTitle, ogImage, jsonLd }: SeoProps) => {
useEffect(() => {
document.title = title;
if (description) {
let meta = document.querySelector(
'meta[name="description"]'
) as HTMLMetaElement | null;
if (!meta) {
meta = document.createElement("meta");
meta.setAttribute("name", "description");
document.head.appendChild(meta);
}
meta.setAttribute("content", description);
}
const shareTitle = ogTitle ?? title;
const image = toAbsoluteUrl(ogImage ?? DEFAULT_OG_IMAGE);
const url = SITE_ORIGIN + window.location.pathname;
setMetaByName("description", description);
setMetaByProperty("og:title", shareTitle);
setMetaByProperty("og:description", description);
setMetaByProperty("og:type", "website");
setMetaByProperty("og:url", url);
setMetaByProperty("og:image", image);
setMetaByName("twitter:card", "summary_large_image");
setMetaByName("twitter:title", shareTitle);
setMetaByName("twitter:description", description);
setMetaByName("twitter:image", image);
let script: HTMLScriptElement | null = null;
if (jsonLd) {
@@ -33,9 +45,34 @@ const Seo = ({ title, description, jsonLd }: SeoProps) => {
return () => {
if (script && script.parentNode) script.parentNode.removeChild(script);
};
}, [title, description, jsonLd]);
}, [title, description, ogTitle, ogImage, jsonLd]);
return null;
};
export default Seo;
// Upsert a <meta name="..."> tag. Skips no-op when content is undefined so we
// don't clobber an existing tag with an empty value.
function setMetaByName(name: string, content: string | undefined) {
if (content === undefined) return;
upsertMeta("name", name, content);
}
// Upsert a <meta property="..."> tag (Open Graph uses `property`, not `name`).
function setMetaByProperty(property: string, content: string | undefined) {
if (content === undefined) return;
upsertMeta("property", property, content);
}
function upsertMeta(attr: "name" | "property", key: string, content: string) {
let meta = document.head.querySelector<HTMLMetaElement>(
`meta[${attr}="${key}"]`,
);
if (!meta) {
meta = document.createElement("meta");
meta.setAttribute(attr, key);
document.head.appendChild(meta);
}
meta.setAttribute("content", content);
}
@@ -0,0 +1,91 @@
// Single source of truth for per-page SEO + social-share metadata.
//
// This is consumed in TWO places, so it must stay framework-agnostic (no
// React, no Vite `import.meta`, no DOM access):
// 1. Runtime — the <Seo> component reads it to set document.title and meta
// tags after the SPA hydrates (good for users navigating client-side).
// 2. Build time — scripts/prerender-meta.mjs reads it to bake a static
// index.html per route with real <title>/<meta>/og:/twitter: tags, so
// social crawlers (Discord, Slack, X, iMessage…) that DON'T run JS still
// get the correct preview for each page.
//
// To give a new marketing route its own share preview: add an entry to
// PAGE_META keyed by its pathname, then make sure scripts/prerender-meta.mjs
// emits it (it iterates PAGE_META) and _redirects points the route at the
// prerendered file.
// Canonical production origin (no trailing slash). Used to build absolute
// og:url / og:image URLs, which crawlers require — relative paths are ignored.
export const SITE_ORIGIN = "https://getartcraft.com";
// Default share image, served from /public. Pages may override per-route.
export const DEFAULT_OG_IMAGE = "/images/3d-interface-preview.jpg";
export interface PageMeta {
title: string;
description: string;
/**
* Short title used for the social share card (og:title / twitter:title) when
* it should differ from the full, SEO-oriented <title>. Defaults to `title`.
*/
ogTitle?: string;
/** Absolute or site-root-relative image path. Defaults to DEFAULT_OG_IMAGE. */
ogImage?: string;
}
// Homepage / fallback metadata. Used for "/" and as the base any page meta is
// merged onto, so partial overrides still produce a complete tag set.
export const DEFAULT_META: PageMeta = {
title: "ArtCraft. AI Video and Images. Fast and Open Desktop App.",
description:
"ArtCraft is an Open Desktop app for generating AI Video and Images. You own ArtCraft!",
ogImage: DEFAULT_OG_IMAGE,
};
// Keyed by pathname (leading slash, no trailing slash). Only marketing pages
// that benefit from a tailored social preview need an entry — everything else
// falls back to DEFAULT_META.
export const PAGE_META: Record<string, PageMeta> = {
"/": DEFAULT_META,
"/seedance-2": {
title:
"Seedance 2.0 in ArtCraft. AI Video Generation. Fast and Open Desktop App.",
description:
"Seedance 2.0 is available now in ArtCraft. Generate jaw-dropping AI videos with the fastest open desktop app for artists.",
},
"/seedance2-5": {
title:
"Seedance 2.5 in ArtCraft. AI Video Generation. Fast and Open Desktop App.",
ogTitle: "Seedance 2.5",
description:
"Seedance 2.5 is ByteDance's anticipated next-generation AI video model — reports point to 4K, real-time generation, longer clips, and persistent characters. See what's expected, and create with Seedance in ArtCraft today.",
},
"/pricing": {
title: "Pricing - ArtCraft. AI Video and Images.",
description:
"Simple pricing for ArtCraft. Generate AI video and images with every model in one open desktop app. No subscriptions needed — you own ArtCraft.",
},
};
// Resolve full metadata for a pathname, filling any missing fields from the
// defaults. Trailing slashes are normalized so "/pricing" and "/pricing/" match.
export function getPageMeta(pathname: string): PageMeta {
const normalized =
pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
const entry = PAGE_META[normalized];
const title = entry?.title ?? DEFAULT_META.title;
return {
title,
description: entry?.description ?? DEFAULT_META.description,
ogTitle: entry?.ogTitle ?? title,
ogImage: entry?.ogImage ?? DEFAULT_META.ogImage,
};
}
// Build an absolute URL from a site-root-relative path (or pass through an
// already-absolute URL). Crawlers need absolute og:image / og:url values.
export function toAbsoluteUrl(pathOrUrl: string): string {
if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
const path = pathOrUrl.startsWith("/") ? pathOrUrl : `/${pathOrUrl}`;
return `${SITE_ORIGIN}${path}`;
}
@@ -0,0 +1 @@
export { default } from "./landing-sd25";
File diff suppressed because it is too large Load Diff
@@ -834,10 +834,9 @@ const Landing3 = () => {
<span
key={i}
data-manifesto-word
className="inline-block opacity-15 will-change-[opacity,transform]"
className="inline-block mr-[0.25em] opacity-15 will-change-[opacity,transform]"
>
{w === "control" ? <MarkerHighlight>{w}</MarkerHighlight> : w}
{i < MANIFESTO_WORDS.length - 1 ? " " : ""}
</span>
))}
</h2>
+150
View File
@@ -0,0 +1,150 @@
// Build-time per-route social-share prerendering.
//
// WHY: artcraft-website is a Vite SPA. The single dist/index.html that every
// route falls back to carries only homepage <title>/<description> and no Open
// Graph tags, so when any URL (e.g. /seedance-2) is shared on Discord/Slack/X,
// the crawler — which does NOT run JS — sees the homepage preview.
//
// WHAT: after `nx build`, this reads dist/index.html and, for every route in
// src/config/page-meta.ts, writes dist/<route>/index.html with that route's
// <title>, <meta description>, Open Graph and Twitter Card tags injected into
// <head>. It also rewrites the root dist/index.html with the default OG tags.
// _redirects then points each route at its prerendered file so the crawler
// gets the right tags; the SPA still boots from that HTML and React Router
// takes over for users.
//
// HOW IT STAYS DRY: page metadata lives only in page-meta.ts. This script
// transpiles that TS module on the fly (via the installed `typescript`
// compiler — no extra dependency) and imports it, so titles/descriptions are
// never duplicated here.
import fs from "fs";
import os from "os";
import path from "path";
import { fileURLToPath, pathToFileURL } from "url";
import ts from "typescript";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const APP_DIR = path.resolve(__dirname, "../apps/artcraft-website");
const DIST_DIR = path.join(APP_DIR, "dist");
const ROOT_HTML = path.join(DIST_DIR, "index.html");
const META_TS = path.join(APP_DIR, "src/config/page-meta.ts");
const main = async () => {
if (!fs.existsSync(ROOT_HTML)) {
console.error(`prerender-meta: ${ROOT_HTML} not found — run the build first.`);
process.exit(1);
}
const { PAGE_META, DEFAULT_META, DEFAULT_OG_IMAGE, getPageMeta, toAbsoluteUrl } =
await importTsModule(META_TS);
const baseHtml = fs.readFileSync(ROOT_HTML, "utf8");
// Rewrite the root index.html (the "/" route and SPA fallback) so the
// homepage and any non-prerendered route at least carry default OG tags.
const homeMeta = getPageMeta("/");
fs.writeFileSync(ROOT_HTML, injectMeta(baseHtml, homeMeta, "/", toAbsoluteUrl, DEFAULT_OG_IMAGE), "utf8");
console.log("prerender-meta: rewrote / (root index.html)");
// Emit dist/<route>/index.html for every configured route except "/".
for (const route of Object.keys(PAGE_META)) {
if (route === "/") continue;
const meta = getPageMeta(route);
const outDir = path.join(DIST_DIR, route.replace(/^\//, ""));
fs.mkdirSync(outDir, { recursive: true });
const outFile = path.join(outDir, "index.html");
fs.writeFileSync(outFile, injectMeta(baseHtml, meta, route, toAbsoluteUrl, DEFAULT_OG_IMAGE), "utf8");
console.log(`prerender-meta: wrote ${route} -> ${path.relative(DIST_DIR, outFile)}`);
}
// Sanity: DEFAULT_META.title should still be the value baked into root.
if (!DEFAULT_META.title) {
console.warn("prerender-meta: DEFAULT_META.title is empty — check page-meta.ts");
}
};
// Replace the homepage <title>/<meta description> in the source HTML and inject
// a full Open Graph + Twitter Card block for the given route. Idempotent: an
// existing injected block (between the marker comments) is replaced, so running
// twice doesn't stack duplicate tags.
const injectMeta = (html, meta, route, toAbsoluteUrl, defaultImage) => {
const url = toAbsoluteUrl(route === "/" ? "/" : route);
const image = toAbsoluteUrl(meta.ogImage ?? defaultImage);
// Share card title may be a shorter override; <title> stays the full SEO one.
const ogTitle = meta.ogTitle ?? meta.title;
let out = html;
// Swap the existing <title> and <meta name="description">.
out = out.replace(/<title>[\s\S]*?<\/title>/i, `<title>${esc(meta.title)}</title>`);
out = out.replace(
/<meta\s+name="description"\s+content="[\s\S]*?"\s*\/?>/i,
`<meta name="description" content="${esc(meta.description)}" />`,
);
const block = [
"<!-- prerender-meta:start -->",
`<link rel="canonical" href="${esc(url)}" />`,
`<meta property="og:type" content="website" />`,
`<meta property="og:site_name" content="ArtCraft" />`,
`<meta property="og:title" content="${esc(ogTitle)}" />`,
`<meta property="og:description" content="${esc(meta.description)}" />`,
`<meta property="og:url" content="${esc(url)}" />`,
`<meta property="og:image" content="${esc(image)}" />`,
`<meta name="twitter:card" content="summary_large_image" />`,
`<meta name="twitter:title" content="${esc(ogTitle)}" />`,
`<meta name="twitter:description" content="${esc(meta.description)}" />`,
`<meta name="twitter:image" content="${esc(image)}" />`,
"<!-- prerender-meta:end -->",
].join("\n ");
// Replace a previously-injected block if present (idempotent), else insert
// right before </head>.
const existing = /<!-- prerender-meta:start -->[\s\S]*?<!-- prerender-meta:end -->/;
if (existing.test(out)) {
out = out.replace(existing, block);
} else {
out = out.replace(/<\/head>/i, ` ${block}\n </head>`);
}
return out;
};
// Minimal HTML-attribute escaping for values placed inside double-quoted
// attributes / element text.
const esc = (s) =>
String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
// Transpile a TS module to ESM via the installed `typescript` compiler and
// dynamic-import it. Avoids adding tsx/esbuild just for the build step.
const importTsModule = async (tsPath) => {
const source = fs.readFileSync(tsPath, "utf8");
const { outputText } = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.ESNext,
target: ts.ScriptTarget.ES2020,
},
fileName: tsPath,
});
const tmpFile = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), "prerender-meta-")),
"page-meta.mjs",
);
fs.writeFileSync(tmpFile, outputText, "utf8");
try {
return await import(pathToFileURL(tmpFile).href);
} finally {
fs.rmSync(path.dirname(tmpFile), { recursive: true, force: true });
}
};
main().catch((err) => {
console.error("prerender-meta failed:", err);
process.exit(1);
});