Skip to content
Merged
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
129 changes: 114 additions & 15 deletions website/client/.vitepress/config/configShard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,19 @@ const ogImageUrl = `${siteUrl}/images/og-image-large.png`;
const githubUrl = 'https://github.com/yamadashy/repomix';
const npmUrl = 'https://www.npmjs.com/package/repomix';

// Stable @id for the global WebSite node so per-page schemas (e.g. TechArticle.isPartOf)
// can reference it via `@id` instead of inlining a fresh node, which Google treats
// as a separate entity.
const websiteId = `${siteUrl}#website`;

// Shared author block used by both the global SoftwareApplication JSON-LD and the
// per-page TechArticle JSON-LD.
const siteAuthor = {
'@type': 'Person' as const,
name: 'Kazuki Yamada',
url: 'https://github.com/yamadashy',
};

const googleAnalyticsTag = 'G-7PTT4PLC69';

type PageHeadContext = {
Expand All @@ -35,40 +48,130 @@ type PageHeadContext = {
};
};

const buildPageUrl = (page: string) => {
const pathWithoutExtension = page.replace(/\.md$/, '');
const cleanPath = pathWithoutExtension.replace(/(^|\/)index$/, '$1');
// Order matters here: `en/...` is rewritten to the site root, so English
// content emits canonical URLs without a locale prefix. Every other locale
// keeps its folder as the URL prefix. Each entry carries its BCP-47 form
// (used in `hreflang` and Schema.org `inLanguage`) and OpenGraph form
// (underscore-separated, e.g. `en_US`). Keeping all three together prevents
// drift when a new locale is added.
export const localeConfig = {
en: { bcp47: 'en', og: 'en_US' },
'zh-cn': { bcp47: 'zh-CN', og: 'zh_CN' },
'zh-tw': { bcp47: 'zh-TW', og: 'zh_TW' },
ja: { bcp47: 'ja', og: 'ja_JP' },
es: { bcp47: 'es', og: 'es_ES' },
'pt-br': { bcp47: 'pt-BR', og: 'pt_BR' },
ko: { bcp47: 'ko', og: 'ko_KR' },
de: { bcp47: 'de', og: 'de_DE' },
fr: { bcp47: 'fr', og: 'fr_FR' },
it: { bcp47: 'it', og: 'it_IT' },
hi: { bcp47: 'hi', og: 'hi_IN' },
id: { bcp47: 'id', og: 'id_ID' },
vi: { bcp47: 'vi', og: 'vi_VN' },
ru: { bcp47: 'ru', og: 'ru_RU' },
tr: { bcp47: 'tr', og: 'tr_TR' },
} as const;

export type Locale = keyof typeof localeConfig;

if (!cleanPath || cleanPath === '/') {
return siteUrl;
const supportedLocales = Object.keys(localeConfig) as Locale[];

const stripPageSuffix = (rest: string) =>
rest
.replace(/\.md$/, '')
.replace(/(^|\/)index$/, '$1')
.replace(/^\/+/, '')
.replace(/\/+$/, '');

// Resolve the source page (e.g. `ja/guide/installation.md`) into its locale
// and the locale-relative remainder (`guide/installation`). English lives at
// `en/...` on disk but is served from the site root thanks to the `rewrites`
// rule, so the canonical URL omits the locale prefix.
const resolvePageLocale = (page: string): { locale: Locale; rest: string } => {
for (const locale of supportedLocales) {
if (page === `${locale}.md` || page === `${locale}/index.md` || page.startsWith(`${locale}/`)) {
const remainder = page === `${locale}.md` || page === `${locale}/index.md` ? '' : page.slice(locale.length + 1);
return { locale, rest: stripPageSuffix(remainder) };
}
}
return { locale: 'en', rest: stripPageSuffix(page) };
};

return `${siteUrl}/${cleanPath.replace(/^\/+/, '').replace(/\/$/, '')}`;
const buildLocaleUrl = (locale: Locale, rest: string): string => {
const prefix = locale === 'en' ? '' : `/${locale}`;
if (!rest) {
return `${siteUrl}${prefix}`;
}
return `${siteUrl}${prefix}/${rest}`;
};

const createPageHead = ({ page, title, description, pageData }: PageHeadContext): HeadConfig[] => {
if (pageData.isNotFound) {
return [];
}

const url = buildPageUrl(page);
const { locale, rest } = resolvePageLocale(page);
const url = buildLocaleUrl(locale, rest);
const isHome = rest === '';

return [
const tags: HeadConfig[] = [
['link', { rel: 'canonical', href: url }],
['meta', { property: 'og:type', content: isHome ? 'website' : 'article' }],
['meta', { property: 'og:title', content: title }],
['meta', { property: 'og:url', content: url }],
['meta', { property: 'og:description', content: description }],
['meta', { property: 'og:locale', content: localeConfig[locale].og }],
['meta', { name: 'twitter:title', content: title }],
['meta', { name: 'twitter:url', content: url }],
['meta', { name: 'twitter:description', content: description }],
];

// hreflang alternates so search engines can surface the right localized
// page to each user. `x-default` falls back to English. We also emit an
// `og:locale:alternate` for each non-current locale for social previews
// that honor it.
for (const alt of supportedLocales) {
tags.push([
'link',
{
rel: 'alternate',
hreflang: localeConfig[alt].bcp47,
href: buildLocaleUrl(alt, rest),
},
]);
if (alt !== locale) {
tags.push(['meta', { property: 'og:locale:alternate', content: localeConfig[alt].og }]);
}
}
tags.push(['link', { rel: 'alternate', hreflang: 'x-default', href: buildLocaleUrl('en', rest) }]);

// For documentation pages, emit a TechArticle JSON-LD that points back to
// the global WebSite node by `@id` so AI/search surfaces see a single
// linked entity across pages instead of a fresh inline WebSite per page.
if (!isHome) {
const articleJsonLd = {
'@context': 'https://schema.org',
'@type': 'TechArticle',
headline: title,
description,
inLanguage: localeConfig[locale].bcp47,
isPartOf: { '@id': websiteId },
mainEntityOfPage: { '@type': 'WebPage', '@id': url },
image: ogImageUrl,
author: siteAuthor,
};
tags.push(['script', { type: 'application/ld+json' }, JSON.stringify(articleJsonLd)]);
}

return tags;
};

// JSON-LD Structured Data
const jsonLd = {
'@context': 'https://schema.org',
'@graph': [
{
'@id': websiteId,
'@type': 'WebSite',
name: siteName,
url: siteUrl,
Expand All @@ -94,11 +197,7 @@ const jsonLd = {
softwareRequirements: 'Node.js 22.0.0 or higher',
image: `${siteUrl}/images/repomix-logo.svg`,
screenshot: ogImageUrl,
author: {
'@type': 'Person',
name: 'Kazuki Yamada',
url: 'https://github.com/yamadashy',
},
author: siteAuthor,
sameAs: [githubUrl, npmUrl],
featureList: [
'AI-optimized output formats (XML, Markdown, JSON, Plain Text)',
Expand Down Expand Up @@ -221,8 +320,8 @@ export const configShard = defineConfig({
['link', { rel: 'preconnect', href: 'https://challenges.cloudflare.com', crossorigin: '' }],
['link', { rel: 'dns-prefetch', href: 'https://challenges.cloudflare.com' }],

// OGP
['meta', { property: 'og:type', content: 'website' }],
// OGP. `og:type` is emitted per-page from `createPageHead` (article for
// docs, website for the home page) so we do not duplicate it here.
['meta', { property: 'og:site_name', content: siteName }],
['meta', { property: 'og:image', content: ogImageUrl }],
['meta', { name: 'twitter:card', content: 'summary_large_image' }],
Expand Down
Loading