Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>/` and pointing the variables at them.
3 changes: 2 additions & 1 deletion app/[[...slug]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}`,
},
};
}
8 changes: 5 additions & 3 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<'/'>) {
Expand Down
25 changes: 25 additions & 0 deletions lib/brand.ts
Original file line number Diff line number Diff line change
@@ -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;
12 changes: 6 additions & 6 deletions lib/layout.shared.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -15,13 +15,13 @@ export function baseOptions(): BaseLayoutProps {
title: (
<>
<Image
src={logo}
alt="OwlStack Logo"
src={BRAND.logo}
alt={`${BRAND.name} Logo`}
width={28}
height={28}
className="rounded-lg"
/>
OwlStack Docs
{BRAND.name} Docs
</>
),
},
Expand All @@ -31,15 +31,15 @@ export function baseOptions(): BaseLayoutProps {
label: 'Website',
text: 'Website',
icon: <Globe size={16} />,
url: 'https://owlstack.app',
url: BRAND.websiteUrl,
external: true,
},
{
type: 'icon',
label: 'Dashboard',
text: 'Dashboard',
icon: <LayoutDashboard size={16} />,
url: 'https://app.owlstack.app',
url: BRAND.appUrl,
external: true,
},
],
Expand Down
100 changes: 100 additions & 0 deletions lib/remark-brand.mjs
Original file line number Diff line number Diff line change
@@ -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 = /(?<![A-Za-z0-9_@/-])Owl[Ss]tack(?![A-Za-z0-9_-])/g;

function config() {
const name = pick(process.env.NEXT_PUBLIC_BRAND_NAME, DEFAULTS.name);
// Matched scheme-less so a bare "api.owlstack.app" in an ASCII diagram is
// rewritten too, and longest-first so app./api./docs paths are consumed
// before the apex they contain.
const hosts = [
[DEFAULTS.docsHost, hostOf(process.env.NEXT_PUBLIC_DOCS_URL, DEFAULTS.docsHost)],
[DEFAULTS.apiHost, hostOf(process.env.NEXT_PUBLIC_API_URL, DEFAULTS.apiHost)],
[DEFAULTS.appHost, hostOf(process.env.NEXT_PUBLIC_APP_URL, DEFAULTS.appHost)],
[DEFAULTS.websiteHost, hostOf(process.env.NEXT_PUBLIC_WEBSITE_URL, DEFAULTS.websiteHost)],
].filter(([from, to]) => 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 — <Card title="What is OwlStack?" /> — 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);
}
9 changes: 7 additions & 2 deletions next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 },
];
},
};
Expand Down
Binary file added public/brand/fopost/favicon.ico
Binary file not shown.
Binary file added public/brand/fopost/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/brand/owlstack/favicon.ico
Binary file not shown.
Binary file added public/brand/owlstack/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 15 additions & 2 deletions source.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <head>, 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],
},
});
Loading