---
// Docs layout for the @tabler/docs app.
// Standalone layout with its own
; does NOT use BaseLayout.astro.
// Development uses the unminified assets; production additionally emits the SEO metadata.
import { site } from '@shared/lib/site';
import { isExternal } from '@shared/lib/url';
import libs from '@tabler/core/libs.json';
import docs from '@data/docs.json';
import Icon from '@ui/Icon.astro';
import DocsNavbar from '@components/DocsNavbar.astro';
import DocsMenu from '@components/DocsMenu.astro';
import DocsSearch from '@components/DocsSearch.astro';
import DocsClassnames from '@components/DocsClassnames.astro';
import DocsToc from '@components/DocsToc.astro';
import type { TocItem } from '@components/DocsToc.astro';
import DocsPagination from '@components/DocsPagination.astro';
import DocsCard from '@components/DocsCard.astro';
import { getDocsPage } from '@lib/docs-pages';
import Prose from '@ui/Prose.astro';
import PageScripts from '@shared/components/PageScripts.astro';
interface Props {
/** front matter: the page's h1 heading */
title: string;
/** front matter: optional SEO title override */
seoTitle?: string | undefined;
/** front matter: lead below the title (p.text-secondary.fs-3.lh-3) */
summary?: string | undefined;
/** repo-relative path of the file implementing the component, linked next to the edit link */
sourcePath?: string | undefined;
/** class reference table, rendered above the page body */
classnames?: Record | undefined;
/** front matter: SEO description */
description?: string | undefined;
/** front matter: optional SEO description override */
seoDescription?: string | undefined;
/** front matter `added-in`: renders the "Added in X" badge next to the h1 */
addedIn?: string | undefined;
/** Table of contents (h2/h3 from the markdown content) — see DocsToc.astro */
toc?: TocItem[];
/**
* Page URL in the docs namespace, e.g. "/ui/components/alert".
* By default derived from Astro.url.pathname by stripping the /docs prefix.
* Drives the active menu entry, pagination, the "Edit this page" link, and
* the relative favicon path.
*/
url?: string | undefined;
/** Extra libraries from libs.json (css+js) */
docsLibs?: string[];
/** front matter `css-plugins`: extra tabler-.css sheets for this page's demos */
cssPlugins?: string[];
/** front matter `related`: docs URLs of related pages, rendered above the pagination */
related?: string[];
/** hides child and previous/next page navigation */
hidePagination?: boolean | undefined;
/** repo-relative source path for the "Edit this page" link, e.g. "docs/content/ui/components/alert.mdx" */
editPath?: string | undefined;
/** url of this page's markdown mirror (see pages/[...slug].md.ts); omitted for pages outside the docs collection */
markdownUrl?: string | undefined;
}
const {
title,
seoTitle,
summary,
classnames,
sourcePath,
description,
seoDescription,
addedIn,
toc = [],
docsLibs = [],
cssPlugins = [],
related = [],
hidePagination = false,
editPath,
markdownUrl,
} = Astro.props;
// Always-on plugin styles. Heavy demo-only sheets (flags, payments, socials)
// are opt-in per page via the `css-plugins` frontmatter key; marketing is not
// used by any docs page.
const basePlugins = ['themes', 'vendors'];
const pagePlugins = [...basePlugins, ...cssPlugins.filter((plugin) => !basePlugins.includes(plugin))];
const environment = process.env.NODE_ENV || 'production';
// Vercel preview/branch deployments must not be indexed (VERCEL_ENV is unset locally).
const isVercelPreview = !!process.env.VERCEL_ENV && process.env.VERCEL_ENV !== 'production';
// Strip /docs prefix (and optional .html) for the docs namespace URL.
// Canonical form has no trailing slash (matching vercel.json trailingSlash: false);
// the root stays "/".
const docsUrl =
Astro.props.url ??
(() => {
const p = Astro.url.pathname
.replace(/^\/docs(?=\/|$)/, '')
.replace(/\.html$/, '')
.replace(/\/+$/, '');
return p || '/';
})();
const inSection = (prefix: string) => docsUrl === prefix || docsUrl.startsWith(`${prefix}/`);
const pageSection = inSection('/ui')
? 'UI'
: inSection('/icons')
? 'Icons'
: inSection('/illustrations')
? 'Illustrations'
: inSection('/emails')
? 'Emails'
: '';
const metaTitle = seoTitle ?? title;
const metaDescription = seoDescription ?? description;
const siteName = pageSection ? `Tabler ${pageSection} Documentation` : 'Tabler Documentation';
const canonicalUrl = new URL(docsUrl, Astro.site ?? site.docsUrl).href;
const ogImageUrl = new URL('/static/og.png', Astro.site ?? site.docsUrl).href;
// Relative asset base: for /ui/components/alert/ -> "../../.."
const depth = docsUrl.split('/').filter(Boolean).length;
const relative = depth === 0 ? '.' : '../'.repeat(depth).slice(0, -1);
// Source path for the "Edit this page" link. Prefer the real path passed by the
// route (from the collection entry); fall back to the flat-page convention.
const editFilePath =
editPath ?? (docsUrl === '/' ? 'docs/content/index.mdx' : `docs/content${docsUrl.replace(/\/$/, '')}.mdx`);
// docs-libs + clipboard (always included). In dev, file names stay unchanged
// (e.g. dist/clipboard.min.js keeps .min).
type Lib = { npm?: string; js?: string[]; css?: string[] };
const libEntries = Object.entries(libs as Record);
const libUrl = (lib: Lib, file: string) =>
isExternal(file)
? file
: `/dist/libs/${lib.npm}/${file}`;
const docsLibCss = libEntries
.filter(([name]) => docsLibs.includes(name))
.flatMap(([, lib]) => (lib.css ?? []).map((file) => libUrl(lib, file)));
const docsLibJs = libEntries
.filter(([name]) => docsLibs.includes(name) || name === 'clipboard')
.flatMap(([, lib]) => (lib.js ?? []).map((file) => libUrl(lib, file)));
// Eventually belongs in src/lib/site.ts (out of scope for this task).
const opencollectiveUrl = 'https://opencollective.com/tabler';
const xUrl = 'https://x.com/tabler_io';
const linkedinUrl = 'https://www.linkedin.com/company/tabler-io';
const year = new Date().getFullYear();
// JSON-LD breadcrumbs. Segment names come from the docs.json menu tree;
// top-level sections are not in the menu, so they get explicit names.
const sectionNames: Record = {
'/ui': 'Tabler UI',
'/icons': 'Tabler Icons',
'/illustrations': 'Tabler Illustrations',
'/emails': 'Tabler Emails',
};
type MenuNode = { title?: string; url?: string; children?: MenuNode[] };
const menuTitleByUrl = new Map();
const collectTitles = (nodes: MenuNode[]) => {
for (const node of nodes) {
if (node.url?.startsWith('/') && node.title) menuTitleByUrl.set(node.url, node.title);
if (node.children) collectTitles(node.children);
}
};
collectTitles(docs.menu as MenuNode[]);
// WebSite schema on the homepage only — lets Google pick the right site name.
// No SearchAction: docs search is a modal, there is no /search?q= results URL.
const websiteJsonLd =
docsUrl === '/'
? JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebSite',
'name': 'Tabler Documentation',
'alternateName': 'Tabler Docs',
'url': `${site.docsUrl}/`,
})
: null;
// Breadcrumb trail — one source for the visible nav above the h1 and the
// JSON-LD, so the structured data mirrors what the page shows.
const breadcrumbs = (() => {
if (docsUrl === '/' || docsUrl === '/404') return null;
const segments = docsUrl.split('/').filter(Boolean);
const items = [{ name: 'Docs', path: '/' }];
let path = '';
segments.forEach((segment, index) => {
path += `/${segment}`;
const isLast = index === segments.length - 1;
const name = isLast ? title : (menuTitleByUrl.get(path) ?? sectionNames[path] ?? segment);
items.push({ name, path });
});
return items;
})();
const breadcrumbJsonLd = breadcrumbs
? JSON.stringify({
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: breadcrumbs.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.name,
item: item.path === '/' ? `${site.docsUrl}/` : `${site.docsUrl}${item.path}`,
})),
})
: null;
type DocsLink = { title: string; url: string; icon: string };
const docsLinks = docs.links as DocsLink[];
// Card data for the "Related" section (title/description from the target
// page's frontmatter; menu title as fallback).
const relatedPages = await Promise.all(
related.map(
async (url) =>
(await getDocsPage(url)) ?? { url, title: menuTitleByUrl.get(url) ?? url, description: '', order: 0 },
),
);
---
{metaTitle} | {siteName}
{isVercelPreview && }
{markdownUrl && }
{
environment === 'production' && (
{metaDescription && }
{metaDescription && }
{metaDescription && }
{websiteJsonLd && }
{breadcrumbJsonLd && }
)
}
{
environment === 'development' && (
)
}
{
docsLibCss.length > 0 && (
'} />
{docsLibCss.map((href) => )}
'} />
)
}
{pagePlugins.map((plugin) => )}
Skip to main content
{
markdownUrl && (
A Markdown version of this page is available at {`${site.docsUrl}${markdownUrl}`}.
)
}
{breadcrumbs && (
)}
{title}
{
addedIn && (
Added in {addedIn}
)
}
{summary}
{/* Page must supply headings with ids (MDX does this by default). */}
{classnames && }
{relatedPages.length > 0 && (
{relatedPages.map((page) => (
))}
)}
{!hidePagination && }
{!hidePagination &&
}
{
docsLibJs.length > 0 && (
'} />
{docsLibJs.map((src) => )}
'} />
)
}