diff --git a/Dockerfile b/Dockerfile index b6b80a8..288340f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,29 @@ WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . ENV NEXT_TELEMETRY_DISABLED=1 + +# Branding and service URLs are resolved at build time — the remark plugin +# bakes them into the compiled pages, so these must arrive as build args, not +# runtime env. Unset means OwlStack. Changing one needs a rebuild. +ARG NEXT_PUBLIC_BRAND_NAME +ARG NEXT_PUBLIC_BRAND_SLUG +ARG NEXT_PUBLIC_BRAND_DOMAIN +ARG NEXT_PUBLIC_BRAND_LOGO +ARG NEXT_PUBLIC_BRAND_FAVICON +ARG NEXT_PUBLIC_WEBSITE_URL +ARG NEXT_PUBLIC_APP_URL +ARG NEXT_PUBLIC_API_URL +ARG NEXT_PUBLIC_DOCS_URL +ENV NEXT_PUBLIC_BRAND_NAME=$NEXT_PUBLIC_BRAND_NAME \ + NEXT_PUBLIC_BRAND_SLUG=$NEXT_PUBLIC_BRAND_SLUG \ + NEXT_PUBLIC_BRAND_DOMAIN=$NEXT_PUBLIC_BRAND_DOMAIN \ + NEXT_PUBLIC_BRAND_LOGO=$NEXT_PUBLIC_BRAND_LOGO \ + NEXT_PUBLIC_BRAND_FAVICON=$NEXT_PUBLIC_BRAND_FAVICON \ + NEXT_PUBLIC_WEBSITE_URL=$NEXT_PUBLIC_WEBSITE_URL \ + NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL \ + NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \ + NEXT_PUBLIC_DOCS_URL=$NEXT_PUBLIC_DOCS_URL + RUN npm run build # Stage 3: Production runner diff --git a/README.md b/README.md index 30af046..0ec8d62 100644 --- a/README.md +++ b/README.md @@ -80,3 +80,32 @@ i18n/ # Translation files (fa, zh, ja) ## License This documentation is part of the [OwlStack](https://github.com/owlstacks) project. + +## Branding + +The docs site is built once per brand. Prose under `content/**` keeps naming +the product "OwlStack"; `lib/remark-brand.mjs` applies the deployment's brand +during the build, and `lib/brand.ts` covers everything written in TypeScript. + +| Variable | Effect | +|---|---| +| `NEXT_PUBLIC_BRAND_NAME` | Product name in prose, page titles, and the sidebar | +| `NEXT_PUBLIC_BRAND_SLUG` | Lowercase identifier | +| `NEXT_PUBLIC_BRAND_DOMAIN` | Bare apex domain | +| `NEXT_PUBLIC_BRAND_LOGO` | Nav mark, e.g. `/brand/fopost/logo.png` | +| `NEXT_PUBLIC_BRAND_FAVICON` | Browser tab icon | +| `NEXT_PUBLIC_WEBSITE_URL` / `_APP_URL` / `_API_URL` / `_DOCS_URL` | Service hosts, rewritten in prose and in code samples | + +Unset means OwlStack: the build is byte-for-byte what it was before. + +Two things are deliberately **not** rewritten: + +- **Names inside code fences.** Most are SDK symbols (`OwlStack::Client`, + `from owlstack import OwlStack`) that name a real published package, so + rewriting them would hand the reader code that does not run. Hostnames in + code *are* rewritten, so a copied `curl` still hits the right API. +- **URL slugs** such as `/introduction/what-is-owlstack`. They are routes with + existing inbound links, not branding. + +Add a brand by dropping `logo.png` and `favicon.ico` into +`public/brand//` and pointing the variables at them. diff --git a/app/[[...slug]]/page.tsx b/app/[[...slug]]/page.tsx index fdbd9fb..9ac31ec 100644 --- a/app/[[...slug]]/page.tsx +++ b/app/[[...slug]]/page.tsx @@ -8,6 +8,7 @@ import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; import { createRelativeLink } from 'fumadocs-ui/mdx'; import { getMDXComponents } from '@/mdx-components'; +import { BRAND } from '@/lib/brand'; export default async function Page(props: PageProps<'/[[...slug]]'>) { const params = await props.params; @@ -50,7 +51,7 @@ export async function generateMetadata( description: page.data.description, // page.url is the public path under /docs; root ('/') must not end with a slash alternates: { - canonical: `https://owlstack.app/docs${page.url === '/' ? '' : page.url}`, + canonical: `${BRAND.docsUrl}${page.url === '/' ? '' : page.url}`, }, }; } diff --git a/app/layout.tsx b/app/layout.tsx index 37388c7..1eed5f5 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -6,14 +6,16 @@ import { source } from '@/lib/source'; import { GeistSans } from 'geist/font/sans'; import { GeistMono } from 'geist/font/mono'; import type { Metadata } from 'next'; +import { BRAND } from '@/lib/brand'; export const metadata: Metadata = { - metadataBase: new URL('https://owlstack.app/docs'), + metadataBase: new URL(BRAND.docsUrl), title: { - template: '%s - OwlStack Docs', - default: 'OwlStack Docs', + template: `%s - ${BRAND.name} Docs`, + default: `${BRAND.name} Docs`, }, description: 'Unified Social Media Publishing SDK - Documentation', + icons: { icon: BRAND.favicon }, }; export default function Layout({ children }: LayoutProps<'/'>) { diff --git a/lib/brand.ts b/lib/brand.ts new file mode 100644 index 0000000..25f98b0 --- /dev/null +++ b/lib/brand.ts @@ -0,0 +1,25 @@ +/** + * Brand identity for this docs deployment. + * + * The docs site is built once per brand, so the name, domain, and logo are + * build-time configuration. Prose in `content/**` keeps writing "OwlStack"; + * the remark plugin in `lib/remark-brand.mjs` rewrites it during the build. + * Anything in TypeScript reads from here instead. + * + * Each field is listed explicitly because Next.js inlines + * `process.env.NEXT_PUBLIC_*` by literal text substitution — a computed + * lookup would be undefined in the browser bundle. + */ +const pick = (value: string | undefined, fallback: string) => value?.trim() || fallback; + +export const BRAND = { + name: pick(process.env.NEXT_PUBLIC_BRAND_NAME, 'OwlStack'), + slug: pick(process.env.NEXT_PUBLIC_BRAND_SLUG, 'owlstack'), + domain: pick(process.env.NEXT_PUBLIC_BRAND_DOMAIN, 'owlstack.app'), + logo: pick(process.env.NEXT_PUBLIC_BRAND_LOGO, '/brand/owlstack/logo.png'), + favicon: pick(process.env.NEXT_PUBLIC_BRAND_FAVICON, '/brand/owlstack/favicon.ico'), + websiteUrl: pick(process.env.NEXT_PUBLIC_WEBSITE_URL, 'https://owlstack.app'), + appUrl: pick(process.env.NEXT_PUBLIC_APP_URL, 'https://app.owlstack.app'), + apiUrl: pick(process.env.NEXT_PUBLIC_API_URL, 'https://api.owlstack.app'), + docsUrl: pick(process.env.NEXT_PUBLIC_DOCS_URL, 'https://owlstack.app/docs'), +} as const; diff --git a/lib/layout.shared.tsx b/lib/layout.shared.tsx index d248163..4c4cb48 100644 --- a/lib/layout.shared.tsx +++ b/lib/layout.shared.tsx @@ -1,7 +1,7 @@ import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared'; import { Globe, LayoutDashboard } from 'lucide-react'; import Image from 'next/image'; -import logo from '@/public/logo.png'; +import { BRAND } from '@/lib/brand'; /** * Shared layout configurations @@ -15,13 +15,13 @@ export function baseOptions(): BaseLayoutProps { title: ( <> OwlStack Logo - OwlStack Docs + {BRAND.name} Docs ), }, @@ -31,7 +31,7 @@ export function baseOptions(): BaseLayoutProps { label: 'Website', text: 'Website', icon: , - url: 'https://owlstack.app', + url: BRAND.websiteUrl, external: true, }, { @@ -39,7 +39,7 @@ export function baseOptions(): BaseLayoutProps { label: 'Dashboard', text: 'Dashboard', icon: , - url: 'https://app.owlstack.app', + url: BRAND.appUrl, external: true, }, ], diff --git a/lib/remark-brand.mjs b/lib/remark-brand.mjs new file mode 100644 index 0000000..4f82c30 --- /dev/null +++ b/lib/remark-brand.mjs @@ -0,0 +1,100 @@ +/** + * Apply the deployment's brand to the docs at build time. + * + * The 119 files under content/ are prose. Threading a JSX expression through + * every sentence that names the product would make them unreadable and would + * break the moment someone writes a new page the normal way — so the content + * keeps saying "OwlStack" and this module applies the brand as the docs are + * compiled. + * + * Two rules, deliberately different: + * + * Prose — the product name is rewritten, and so are service hostnames. + * Longer identifiers are left alone (`@owlstackapp/sdk`, + * `owlstack-core`): the published packages carry that name + * whatever the site is called. + * Code — only the hostnames, so a copied curl command hits the API the + * reader actually has an account on. The name is left alone inside + * a fence on purpose: most occurrences there are SDK symbols + * (`OwlStack::Client`, `use OwlStack\\OwlStack`, + * `from owlstack import OwlStack`) that name a real published + * package. Rewriting them would hand the reader code that does not + * run. The cost is a handful of ASCII diagrams that keep the old + * name; the alternative is broken samples. + * + * A no-op when nothing is configured: the defaults are the OwlStack values. + * + * Frontmatter and meta.json titles never reach a remark plugin — fumadocs + * parses them separately — so source.config.ts runs `brandText` over them + * through the schemas. That is where the sidebar and page titles come from. + */ + +const DEFAULTS = { + name: 'OwlStack', + websiteHost: 'owlstack.app', + appHost: 'app.owlstack.app', + apiHost: 'api.owlstack.app', + docsHost: 'owlstack.app/docs', +}; + +const pick = (value, fallback) => (value && value.trim()) || fallback; +const hostOf = (url, fallback) => + pick(url, '').replace(/^https?:\/\//, '').replace(/\/$/, '') || fallback; + +/** Standalone brand words only — never part of a longer identifier. */ +const NAME_RE = /(? from !== to); + return { name, hosts, changed: name !== DEFAULTS.name || hosts.length > 0 }; +} + +/** Service hostnames only. Safe inside a code sample. */ +export function brandCode(value) { + const { hosts } = config(); + return hosts.reduce((acc, [from, to]) => acc.split(from).join(to), value); +} + +/** Hostnames and the product name. For prose, titles, and descriptions. */ +export function brandText(value) { + const { name } = config(); + return brandCode(value).replace(NAME_RE, name); +} + +export default function remarkBrand() { + const { changed } = config(); + return (tree) => { + if (!changed) return; + visit(tree, (node) => { + if (node.type === 'text' || node.type === 'html') { + node.value = brandText(node.value); + } else if (node.type === 'code' || node.type === 'inlineCode') { + node.value = brandCode(node.value); + } else if (node.type === 'link' || node.type === 'definition') { + node.url = brandCode(node.url); + } + // MDX component props — — are + // attributes, not children, so the walk above never sees them. + for (const attr of node.attributes ?? []) { + if (attr.type === 'mdxJsxAttribute' && typeof attr.value === 'string') { + attr.value = brandText(attr.value); + } + } + }); + }; +} + +/** Minimal depth-first walk — avoids a unist-util-visit dependency. */ +function visit(node, fn) { + fn(node); + for (const child of node.children ?? []) visit(child, fn); +} diff --git a/next.config.mjs b/next.config.mjs index affc090..8649331 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -2,6 +2,11 @@ import { createMDX } from 'fumadocs-mdx/next'; const withMDX = createMDX(); +// The marketing site these redirects point at is per-brand. The /what-is-* +// and /why-* rewrites above are legacy content slugs, not branding — they +// name a page that exists, so they stay as written. +const WEBSITE_URL = process.env.NEXT_PUBLIC_WEBSITE_URL?.trim() || 'https://owlstack.app'; + /** @type {import('next').NextConfig} */ const config = { reactStrictMode: true, @@ -29,8 +34,8 @@ const config = { { source: '/guide/plans', destination: '/guide/plans/overview', permanent: true }, { source: '/guide/ai', destination: '/guide', permanent: true }, { source: '/guide/pro', destination: '/guide', permanent: true }, - { source: '/support', destination: 'https://owlstack.app/contact', permanent: true }, - { source: '/changelog', destination: 'https://owlstack.app/roadmap', permanent: true }, + { source: '/support', destination: `${WEBSITE_URL}/contact`, permanent: true }, + { source: '/changelog', destination: `${WEBSITE_URL}/roadmap`, permanent: true }, ]; }, }; diff --git a/public/brand/fopost/favicon.ico b/public/brand/fopost/favicon.ico new file mode 100644 index 0000000..3d02374 Binary files /dev/null and b/public/brand/fopost/favicon.ico differ diff --git a/public/brand/fopost/logo.png b/public/brand/fopost/logo.png new file mode 100644 index 0000000..24d5a7d Binary files /dev/null and b/public/brand/fopost/logo.png differ diff --git a/public/brand/owlstack/favicon.ico b/public/brand/owlstack/favicon.ico new file mode 100644 index 0000000..9efb7db Binary files /dev/null and b/public/brand/owlstack/favicon.ico differ diff --git a/public/brand/owlstack/logo.png b/public/brand/owlstack/logo.png new file mode 100644 index 0000000..2c720d2 Binary files /dev/null and b/public/brand/owlstack/logo.png differ diff --git a/source.config.ts b/source.config.ts index af55e96..3cb7ab3 100644 --- a/source.config.ts +++ b/source.config.ts @@ -5,20 +5,33 @@ import { metaSchema, } from 'fumadocs-mdx/config'; import { z } from 'zod'; +import remarkBrand, { brandText } from './lib/remark-brand.mjs'; + +/* Frontmatter and meta.json never reach a remark plugin — fumadocs parses + them before MDX compiles — so the brand is applied here instead. These are + the page titles, the descriptions in , and the sidebar labels. */ +const branded = z.string().transform(brandText); export const docs = defineDocs({ docs: { schema: frontmatterSchema.extend({ order: z.number().optional(), + title: branded, + description: branded.optional(), }), }, meta: { - schema: metaSchema, + schema: metaSchema.extend({ + title: branded.optional(), + pages: z.array(z.string()).optional(), + }), }, }); export default defineConfig({ mdxOptions: { - // MDX options + // Applies the deployment's brand to prose and service URLs as the docs + // compile, so content/** can keep naming the product the normal way. + remarkPlugins: [remarkBrand], }, });