diff --git a/packages/docs/.env.example b/packages/docs/.env.example new file mode 100644 index 0000000000..02d2d08332 --- /dev/null +++ b/packages/docs/.env.example @@ -0,0 +1,3 @@ +# Show draft documentation pages in the sidebar and allow direct URL access. +# Pages marked `draft: true` are hidden in production builds. +# VITE_SHOW_DRAFTS=true diff --git a/packages/docs/app/components/DocDraftBanner.tsx b/packages/docs/app/components/DocDraftBanner.tsx new file mode 100644 index 0000000000..74bc552a66 --- /dev/null +++ b/packages/docs/app/components/DocDraftBanner.tsx @@ -0,0 +1,16 @@ +import { useT } from "@agent-native/core/client/i18n"; + +export default function DocDraftBanner() { + const t = useT(); + return ( +
+ {t("docs.draftLabel")} — {t("docs.draftDescription")} +
+ ); +} diff --git a/packages/docs/app/components/DocsSidebar.tsx b/packages/docs/app/components/DocsSidebar.tsx index 89615af632..cbe3eec7e3 100644 --- a/packages/docs/app/components/DocsSidebar.tsx +++ b/packages/docs/app/components/DocsSidebar.tsx @@ -193,6 +193,11 @@ export default function DocsSidebar() { current === item.id ? null : item.id, ) } + style={ + item.draft + ? { color: "var(--approaches-warn)" } + : undefined + } > {item.label} {item.label} @@ -236,6 +246,11 @@ export default function DocsSidebar() { tabIndex={ childrenTabbable ? undefined : -1 } + style={ + child.draft + ? { color: "var(--approaches-warn)" } + : undefined + } > {child.label} diff --git a/packages/docs/app/components/docs-content.ts b/packages/docs/app/components/docs-content.ts index 5421b0c688..e5e10ee390 100644 --- a/packages/docs/app/components/docs-content.ts +++ b/packages/docs/app/components/docs-content.ts @@ -50,6 +50,7 @@ export interface DocEntry { title: string; description: string; search: string; + draft?: boolean; body: string; // markdown body (without frontmatter) headings: { id: string; label: string; level: number }[]; } @@ -150,6 +151,7 @@ function docEntryFromPath(path: string, raw: string): DocEntry { title: data.title || slug, description: data.description || "", search: data.search || "", + draft: data.draft === "true" || undefined, body, headings, }; @@ -243,6 +245,34 @@ export async function loadDoc( return promise; } +/** + * Loads a doc and applies draft visibility, checking the canonical + * (default-locale) entry's draft status even when serving a localized + * translation. A translation's frontmatter can drift from the canonical + * page it was translated from, so gating on the localized doc alone lets a + * draft leak through any locale whose translator forgot `draft: true`. + */ +export async function loadDocRespectingDraftVisibility( + slug: string, + locale: unknown = DEFAULT_DOCS_LOCALE, +): Promise { + const doc = await loadDoc(slug, locale); + if (!doc) return undefined; + + const docsLocale = normalizeDocsLocale(locale); + const canonical = + docsLocale === DEFAULT_DOCS_LOCALE + ? doc + : await loadDoc(slug, DEFAULT_DOCS_LOCALE); + const isDraft = Boolean(doc.draft || canonical?.draft); + + if (isDraft && import.meta.env.VITE_SHOW_DRAFTS !== "true") return undefined; + // Normalize `draft` to the resolved status so callers that render a draft + // banner off this flag stay correct for translations whose frontmatter + // omits `draft: true` even though the canonical page is a draft. + return isDraft === Boolean(doc.draft) ? doc : { ...doc, draft: isDraft }; +} + export function hasLocalizedDoc(locale: unknown, slug: string): boolean { const docsLocale = normalizeDocsLocale(locale); if (docsLocale === DEFAULT_DOCS_LOCALE) { diff --git a/packages/docs/app/components/docsNavItems.ts b/packages/docs/app/components/docsNavItems.ts index bd31f7138a..f4559bb4ec 100644 --- a/packages/docs/app/components/docsNavItems.ts +++ b/packages/docs/app/components/docsNavItems.ts @@ -9,6 +9,7 @@ export type NavItem = { id: string; label: string; to?: string; + draft?: boolean; children?: NavItem[]; }; export type NavSection = { id: string; title: string; items: NavItem[] }; @@ -19,6 +20,7 @@ type NavItemConfig = { id: string; labelKey: keyof typeof enUS.nav; slug?: string; + draft?: boolean; children?: NavItemConfig[]; }; @@ -892,17 +894,24 @@ function navLabel(t: Translate, key: keyof typeof enUS.nav): string { return t(`nav.${key}`) || enMessage(`nav.${key}`); } +const SHOW_DRAFTS = import.meta.env.VITE_SHOW_DRAFTS === "true"; + function toNavItem( config: NavItemConfig, locale: DocsLocale, t: Translate, -): NavItem { +): NavItem | null { + if (config.draft && !SHOW_DRAFTS) return null; const slug = config.slug; + const children = config.children + ?.map((child) => toNavItem(child, locale, t)) + .filter((item): item is NavItem => item !== null); return { id: config.id, label: navLabel(t, config.labelKey), to: slug ? docsPathForSlug(slug, locale) : undefined, - children: config.children?.map((child) => toNavItem(child, locale, t)), + draft: config.draft || undefined, + children, }; } @@ -913,8 +922,10 @@ export function getDocsNavSections( return NAV_SECTION_CONFIG.map((section) => ({ id: section.id, title: navLabel(t, section.titleKey), - items: section.items.map((item) => toNavItem(item, locale, t)), - })); + items: section.items + .map((item) => toNavItem(item, locale, t)) + .filter((item): item is NavItem => item !== null), + })).filter((section) => section.items.length > 0); } // Flat list for prev/next navigation and current-item lookups. Nested diff --git a/packages/docs/app/i18n/ar-SA.ts b/packages/docs/app/i18n/ar-SA.ts index 38fa519a40..6c6e9334c4 100644 --- a/packages/docs/app/i18n/ar-SA.ts +++ b/packages/docs/app/i18n/ar-SA.ts @@ -53,6 +53,9 @@ const arSA = { copyMarkdownError: "تعذر نسخ Markdown", previous: "السابق", next: "التالي", + draftLabel: "مسودة", + draftDescription: + "هذه الصفحة قيد الإنشاء. قد يكون المحتوى غير مكتمل أو عرضة للتغيير قبل النشر.", }, search: { dialogLabel: "البحث في الوثائق", diff --git a/packages/docs/app/i18n/de-DE.ts b/packages/docs/app/i18n/de-DE.ts index 1b87c971db..0f4b497609 100644 --- a/packages/docs/app/i18n/de-DE.ts +++ b/packages/docs/app/i18n/de-DE.ts @@ -53,6 +53,9 @@ const deDE = { copyMarkdownError: "Markdown konnte nicht kopiert werden", previous: "Zurück", next: "Weiter", + draftLabel: "Entwurf", + draftDescription: + "Diese Seite befindet sich in Bearbeitung. Der Inhalt kann unvollständig sein oder sich vor der Veröffentlichung ändern.", }, search: { dialogLabel: "Dokumentation durchsuchen", diff --git a/packages/docs/app/i18n/en-US.ts b/packages/docs/app/i18n/en-US.ts index f86dd2ad5e..f7b6ab4d3c 100644 --- a/packages/docs/app/i18n/en-US.ts +++ b/packages/docs/app/i18n/en-US.ts @@ -51,6 +51,9 @@ const enUS = { copyMarkdownError: "Couldn't copy Markdown", previous: "Previous", next: "Next", + draftLabel: "Draft", + draftDescription: + "This page is a work in progress. Content may be incomplete or subject to change before publication.", }, common: { copied: "Copied", diff --git a/packages/docs/app/i18n/es-ES.ts b/packages/docs/app/i18n/es-ES.ts index 717b1d7210..ad5c7f7f60 100644 --- a/packages/docs/app/i18n/es-ES.ts +++ b/packages/docs/app/i18n/es-ES.ts @@ -53,6 +53,9 @@ const esES = { copyMarkdownError: "No se pudo copiar Markdown", previous: "Anterior", next: "Siguiente", + draftLabel: "Borrador", + draftDescription: + "Esta página está en construcción. El contenido puede estar incompleto o sujeto a cambios antes de su publicación.", }, search: { dialogLabel: "Buscar documentación", diff --git a/packages/docs/app/i18n/fr-FR.ts b/packages/docs/app/i18n/fr-FR.ts index ffc188f270..43384e91fd 100644 --- a/packages/docs/app/i18n/fr-FR.ts +++ b/packages/docs/app/i18n/fr-FR.ts @@ -53,6 +53,9 @@ const frFR = { copyMarkdownError: "Impossible de copier le Markdown", previous: "Précédent", next: "Suivant", + draftLabel: "Brouillon", + draftDescription: + "Cette page est en cours de rédaction. Le contenu peut être incomplet ou sujet à modification avant publication.", }, search: { dialogLabel: "Rechercher la documentation", diff --git a/packages/docs/app/i18n/hi-IN.ts b/packages/docs/app/i18n/hi-IN.ts index 293be8ff1e..814d97248f 100644 --- a/packages/docs/app/i18n/hi-IN.ts +++ b/packages/docs/app/i18n/hi-IN.ts @@ -53,6 +53,9 @@ const hiIN = { copyMarkdownError: "Markdown कॉपी नहीं हो सका", previous: "पिछला", next: "अगला", + draftLabel: "ड्राफ़्ट", + draftDescription: + "यह पेज अभी तैयार किया जा रहा है। प्रकाशन से पहले सामग्री अधूरी हो सकती है या इसमें बदलाव हो सकता है।", }, search: { dialogLabel: "दस्तावेज़ खोजें", diff --git a/packages/docs/app/i18n/ja-JP.ts b/packages/docs/app/i18n/ja-JP.ts index 021b431a08..2d9c668d1c 100644 --- a/packages/docs/app/i18n/ja-JP.ts +++ b/packages/docs/app/i18n/ja-JP.ts @@ -53,6 +53,9 @@ const jaJP = { copyMarkdownError: "Markdownをコピーできませんでした", previous: "前へ", next: "次へ", + draftLabel: "ドラフト", + draftDescription: + "このページは作成中です。内容は公開前に不完全であったり変更される場合があります。", }, search: { dialogLabel: "ドキュメントを検索", diff --git a/packages/docs/app/i18n/ko-KR.ts b/packages/docs/app/i18n/ko-KR.ts index 779cb1c213..225d020cc1 100644 --- a/packages/docs/app/i18n/ko-KR.ts +++ b/packages/docs/app/i18n/ko-KR.ts @@ -53,6 +53,9 @@ const koKR = { copyMarkdownError: "Markdown을 복사할 수 없음", previous: "이전", next: "다음", + draftLabel: "초안", + draftDescription: + "이 페이지는 작업 중입니다. 게시 전까지 콘텐츠가 불완전하거나 변경될 수 있습니다.", }, search: { dialogLabel: "문서 검색", diff --git a/packages/docs/app/i18n/pt-BR.ts b/packages/docs/app/i18n/pt-BR.ts index 2f5dd807cd..7ec12b23fa 100644 --- a/packages/docs/app/i18n/pt-BR.ts +++ b/packages/docs/app/i18n/pt-BR.ts @@ -53,6 +53,9 @@ const ptBR = { copyMarkdownError: "Não foi possível copiar Markdown", previous: "Anterior", next: "Próximo", + draftLabel: "Rascunho", + draftDescription: + "Esta página está em andamento. O conteúdo pode estar incompleto ou sujeito a alterações antes da publicação.", }, search: { dialogLabel: "Pesquisar documentação", diff --git a/packages/docs/app/i18n/zh-CN.ts b/packages/docs/app/i18n/zh-CN.ts index 27da19bf14..027555d727 100644 --- a/packages/docs/app/i18n/zh-CN.ts +++ b/packages/docs/app/i18n/zh-CN.ts @@ -53,6 +53,8 @@ const zhCN = { copyMarkdownError: "无法复制 Markdown", previous: "上一页", next: "下一页", + draftLabel: "草稿", + draftDescription: "此页面仍在编写中。内容在发布前可能不完整或有所变动。", }, search: { dialogLabel: "搜索文档", diff --git a/packages/docs/app/i18n/zh-TW.ts b/packages/docs/app/i18n/zh-TW.ts index 42bcf57854..c7ba8cef47 100644 --- a/packages/docs/app/i18n/zh-TW.ts +++ b/packages/docs/app/i18n/zh-TW.ts @@ -51,6 +51,8 @@ const messages = { copyMarkdownError: "無法複製 Markdown", previous: "上一頁面", next: "下一頁面", + draftLabel: "草稿", + draftDescription: "此頁面仍在製作中。內容在發布前可能不完整或有所變動。", }, search: { dialogLabel: "搜尋檔案", diff --git a/packages/docs/app/routes/docs.$locale.$slug.tsx b/packages/docs/app/routes/docs.$locale.$slug.tsx index aa98352e18..32cb173263 100644 --- a/packages/docs/app/routes/docs.$locale.$slug.tsx +++ b/packages/docs/app/routes/docs.$locale.$slug.tsx @@ -6,7 +6,11 @@ import { } from "react-router"; import DocContent from "../components/DocContent"; -import { loadDoc, type DocEntry } from "../components/docs-content"; +import DocDraftBanner from "../components/DocDraftBanner"; +import { + loadDocRespectingDraftVisibility, + type DocEntry, +} from "../components/docs-content"; import { DEFAULT_DOCS_LOCALE, docsPathForSlug, @@ -57,7 +61,7 @@ export async function loader({ params, request, url }: LoaderFunctionArgs) { throw redirect(docsPathForSlug(slug, locale), 301); } - const doc = await loadDoc(slug, locale); + const doc = await loadDocRespectingDraftVisibility(slug, locale); if (!doc) { throw new Response("Not Found", { status: 404 }); } @@ -106,6 +110,7 @@ export default function LocalizedDocPage() { toc={toc} markdownUrl={docsMarkdownPathForDoc(doc.slug, locale) ?? undefined} > + {doc.draft && } ); diff --git a/packages/docs/app/routes/docs.$slug.tsx b/packages/docs/app/routes/docs.$slug.tsx index 45a59079ae..cd6a57aeb7 100644 --- a/packages/docs/app/routes/docs.$slug.tsx +++ b/packages/docs/app/routes/docs.$slug.tsx @@ -1,7 +1,11 @@ import { redirect, useLoaderData, type LoaderFunctionArgs } from "react-router"; import DocContent from "../components/DocContent"; -import { loadDoc, type DocEntry } from "../components/docs-content"; +import DocDraftBanner from "../components/DocDraftBanner"; +import { + loadDocRespectingDraftVisibility, + type DocEntry, +} from "../components/docs-content"; import { DEFAULT_DOCS_LOCALE, docsPathForSlug, @@ -39,7 +43,7 @@ export async function loader({ params }: LoaderFunctionArgs) { if (target) { throw redirect(docsPathForSlug(target, DEFAULT_DOCS_LOCALE), 301); } - const doc = await loadDoc(slug); + const doc = await loadDocRespectingDraftVisibility(slug); if (!doc) { throw new Response("Not Found", { status: 404 }); } @@ -84,6 +88,7 @@ export default function DocPage() { docsMarkdownPathForDoc(doc.slug, DEFAULT_DOCS_LOCALE) ?? undefined } > + {doc.draft && } ); diff --git a/packages/docs/app/routes/docs._index.tsx b/packages/docs/app/routes/docs._index.tsx index 5bc2371d0c..0cdca010b7 100644 --- a/packages/docs/app/routes/docs._index.tsx +++ b/packages/docs/app/routes/docs._index.tsx @@ -5,7 +5,11 @@ import { } from "react-router"; import DocContent from "../components/DocContent"; -import { loadDoc, type DocEntry } from "../components/docs-content"; +import DocDraftBanner from "../components/DocDraftBanner"; +import { + loadDocRespectingDraftVisibility, + type DocEntry, +} from "../components/docs-content"; import { DEFAULT_DOCS_LOCALE, isDocsLocale } from "../components/docs-locale"; import { docsMarkdownPathForDoc } from "../components/docs-seo"; import DocsLayout from "../components/DocsLayout"; @@ -20,7 +24,10 @@ function routeLocale(params: LoaderFunctionArgs["params"]) { export async function loader({ params, }: LoaderFunctionArgs): Promise { - const doc = await loadDoc(GETTING_STARTED_SLUG, routeLocale(params)); + const doc = await loadDocRespectingDraftVisibility( + GETTING_STARTED_SLUG, + routeLocale(params), + ); if (!doc) throw new Response("Not Found", { status: 404 }); return doc; } @@ -61,6 +68,7 @@ export default function DocsIndex() { toc={toc} markdownUrl={docsMarkdownPathForDoc(currentDoc.slug, locale) ?? undefined} > + {currentDoc.draft && } );