mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
Website: seedance 2.5 page (#1674)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
|
||||
// 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);
|
||||
});
|
||||
Reference in New Issue
Block a user