diff --git a/docusaurus.config.ts b/docusaurus.config.ts index b3d9d5a..b383d67 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -3,6 +3,7 @@ import type { Config } from "@docusaurus/types"; import { themes as prismThemes } from "prism-react-renderer"; import rehypeKatex from "rehype-katex"; import remarkMath from "remark-math"; +import dvbTagsData from "./src/plugins/dvb-tags-data"; // This runs in Node.js - Don't use client-side code here (browser APIs, JSX...) @@ -103,6 +104,9 @@ const config: Config = { }, }; }, + // Sérialise la liste des tags et les tags de chaque doc pour les pages + // de tags (voir src/plugins/dvb-tags-data.ts). + dvbTagsData, ], themes: [ diff --git a/src/css/custom.css b/src/css/custom.css index ea8ac99..0d2674f 100644 --- a/src/css/custom.css +++ b/src/css/custom.css @@ -1,3 +1,5 @@ +@import "./tags.css"; + /* Polices */ @font-face { font-family: "Space Grotesk"; @@ -583,6 +585,8 @@ hr { .theme-doc-footer-tags-row a[rel="tag"] { font-family: var(--dvb-font-mono); + padding: 1px 6px; + font-size: 0.8em; } /* Admonitions — style « bloc » : encadré ink, carré de couleur avec l'icône, titre à côté */ @@ -773,3 +777,58 @@ hr { font-size: 12px; color: var(--dvb-muted); } + +@media (max-width: 996px) { + .footer { + padding: 2rem 1.5rem; + } + + .footer .container { + grid-template-columns: 1fr; + grid-template-areas: + "logo" + "links" + "copyright"; + row-gap: 2rem; + } + + .footer__links { + flex-wrap: wrap; + justify-content: flex-start; + gap: 2rem; + } + + .footer__copyright { + margin-top: 0; + } +} + +@media (max-width: 600px) { + .footer { + padding: 1.75rem 1.25rem; + } + + .footer .container { + row-gap: 1.5rem; + } + + .footer__links { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1.5rem 1rem; + } + + .footer__col { + width: 100%; + min-width: 0; + } + + .footer__bottom > .margin-bottom--sm a::after { + font-size: 1rem; + } + + .footer__link-item { + display: inline-block; + padding: 0.15rem 0; + } +} diff --git a/src/css/tags.css b/src/css/tags.css new file mode 100644 index 0000000..da1f2d3 --- /dev/null +++ b/src/css/tags.css @@ -0,0 +1,681 @@ +.dvbTagPage { + display: grid; + grid-template-columns: minmax(0, 1fr) 256px; + align-items: start; +} + +.dvbTagMain { + min-width: 0; + padding-right: 44px; +} + +.dvbTagCrumbs { + --ifm-breadcrumb-size-multiplier: 0.8; + margin-bottom: 0.8rem; +} + +.dvbTagHead { + margin-top: 28px; + margin-bottom: 32px; +} + +.dvbTagTitle { + margin: 0; + font-family: var(--dvb-font-sans); + font-size: 60px; + font-weight: 700; + letter-spacing: -0.05em; + line-height: 0.92; + color: var(--ifm-heading-color); +} + +.dvbTagLead { + margin: 14px 0 0; + max-width: 760px; + font-size: 17px; + line-height: 1.5; + color: var(--dvb-muted); +} + +.dvbTagLeadCount { + font-family: var(--dvb-font-mono); + font-size: 14px; + color: var(--ifm-heading-color); +} + +.dvbTagLeadSep { + margin: 0 12px; + color: var(--dvb-line); +} + +.dvbTagRule { + margin: 0; + border: 0; + border-top: 1px solid var(--dvb-edge); + background: none; + height: 0; +} + +.dvbTagList { + margin: 0; + padding: 0; + list-style: none; +} + +.dvbTagItem { + position: relative; + display: flex; + gap: 0; + padding: 20px 0 22px; + border-bottom: 1px solid var(--dvb-line); + cursor: pointer; +} + +.dvbTagIndex { + flex: none; + width: 34px; + padding-top: 4px; + font-family: var(--dvb-font-mono); + font-size: 12px; + color: var(--dvb-muted); +} + +.dvbTagItemBody { + min-width: 0; + flex: 1; +} + +.dvbTagItemTitle { + margin: 0; + font-family: var(--dvb-font-sans); + font-size: 23px; + font-weight: 700; + letter-spacing: -0.028em; + line-height: 1.2; +} + +.dvbTagItemLink { + color: var(--ifm-heading-color); + text-decoration: none; +} + +.dvbTagItemLink::after { + content: ""; + position: absolute; + inset: 0; +} + +.dvbTagItemLink:hover { + color: var(--ifm-color-primary); + text-decoration: none; +} + +.dvbTagItemDesc { + margin: 8px 0 0; + max-width: 660px; + font-size: 14.5px; + line-height: 1.55; + color: var(--dvb-muted); +} + +.dvbTagChips { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 12px; +} + +.dvbTagChip { + position: relative; + z-index: 1; + display: inline-flex; + align-items: center; + padding: 4px 8px; + line-height: 1; + border: 1px solid var(--dvb-line); + font-family: var(--dvb-font-mono); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--dvb-muted); + text-decoration: none; +} + +.dvbTagChip:hover { + border-color: var(--ifm-color-primary); + color: var(--ifm-color-primary); + text-decoration: none; +} + +.dvbTagChipActive { + border-color: var(--ifm-color-primary); + color: var(--ifm-color-primary); +} + +.dvbTagRail { + position: sticky; + top: calc(var(--ifm-navbar-height) + 1rem); + max-height: calc(100vh - (var(--ifm-navbar-height) + 2rem)); + overflow-y: auto; + padding-left: 24px; + padding-right: 14px; + border-left: 1px solid var(--dvb-line); +} + +.dvbTagRailHead { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + padding-bottom: 10px; + border-bottom: 1px solid var(--dvb-line); +} + +.dvbTagRailTitle { + margin: 0; + font-family: var(--dvb-font-sans); + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--ifm-heading-color); +} + +.dvbTagRailList { + margin: 12px 0 0; + padding: 0; + list-style: none; +} + +.dvbTagRailItem { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + padding: 5px 8px 5px 9px; + border-left: 3px solid transparent; + background: transparent; + color: var(--ifm-font-color-base); + text-decoration: none; +} + +.dvbTagRailItem:hover, +.dvbTagRailItem:focus { + background: var(--ifm-color-emphasis-200); + color: var(--ifm-font-color-base); + text-decoration: none; +} + +.dvbTagRailItemActive, +.dvbTagRailItemActive:hover, +.dvbTagRailItemActive:focus { + border-left-color: var(--ifm-color-primary); + background: var(--dvb-accent-wash); + color: var(--ifm-font-color-base); +} + +.dvbTagRailLabel { + font-size: 14px; + min-width: 0; +} + +.dvbTagRailCount { + flex: none; + font-family: var(--dvb-font-mono); + font-size: 11px; + color: var(--dvb-muted); +} + +.dvbTagRailAll { + flex: none; + color: var(--ifm-color-primary); + font-size: 13px; + text-decoration: none; +} + +.dvbTagRailAll:hover { + color: var(--ifm-color-primary); + text-decoration: none; +} + +.tagsIndexPage .main-wrapper { + display: flex; + flex-direction: column; +} + +.tagsIndexRoot { + display: flex; + flex: 1 0 auto; + align-items: stretch; + min-height: calc(100vh - var(--ifm-navbar-height)); + min-width: 0; +} + +.tagsIntro { + position: sticky; + top: var(--ifm-navbar-height); + display: flex; + flex: 0 0 372px; + flex-direction: column; + align-self: flex-start; + height: calc(100vh - var(--ifm-navbar-height)); + overflow-y: auto; + padding: 34px 30px 26px; + border-right: 1px solid var(--dvb-edge); +} + +.tagsCrumbs { + display: flex; + align-items: center; + gap: 7px; + font-family: var(--dvb-font-mono); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.14em; +} + +.tagsCrumb { + color: var(--dvb-muted); + text-decoration: none; +} + +.tagsCrumb:hover { + color: var(--ifm-color-primary); + text-decoration: none; +} + +.tagsCrumbSep { + color: var(--dvb-line); +} + +.tagsCrumbCurrent { + color: var(--ifm-color-primary); +} + +.tagsTitle { + margin: 26px 0 0; + font-family: var(--dvb-font-sans); + font-size: 68px; + font-weight: 700; + letter-spacing: -0.05em; + line-height: 0.88; + color: var(--ifm-heading-color); +} + +.tagsLead { + margin: 18px 0 0; + font-size: 16px; + line-height: 1.5; + color: var(--dvb-muted); +} + +.tagsSpacer { + flex: 1; + min-height: 32px; +} + +.tagsSortBox { + padding-top: 16px; + border-top: 1px solid var(--dvb-line); +} + +.tagsSortLabel { + display: block; + font-family: var(--dvb-font-mono); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--dvb-muted); +} + +.tagsSortChips { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 10px; +} + +.tagsSortChip { + padding: 6px 10px; + border: 1px solid var(--dvb-line); + background: transparent; + font-family: var(--dvb-font-mono); + font-size: 10.5px; + line-height: 1; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--dvb-muted); + cursor: pointer; +} + +.tagsSortChip:hover { + border-color: var(--dvb-edge); + color: var(--ifm-heading-color); +} + +.tagsSortChipActive, +.tagsSortChipActive:hover { + border-color: var(--ifm-color-primary); + background: var(--ifm-color-primary); + color: var(--dvb-paper); +} + +.tagsTable { + display: grid; + flex: 1; + grid-template-columns: max-content 1fr 118px; + align-content: start; + min-width: 0; +} + +.tagsHeadCell { + display: flex; + align-items: center; + height: 34px; + border-bottom: 1px solid var(--dvb-edge); + font-family: var(--dvb-font-mono); + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--dvb-muted); +} + +.tagsHeadCell:first-child { + padding-left: 30px; +} + +.tagsHeadCount { + justify-content: flex-end; + padding-right: 30px; +} + +.tagsCell { + border-bottom: 1px solid var(--dvb-line); + color: var(--ifm-font-color-base); + text-decoration: none; +} + +.tagsCellActive { + background: var(--dvb-accent-wash); +} + +.cellTag { + display: flex; + flex-direction: column; + justify-content: center; + padding: 13px 22px 14px 30px; +} + +.cellTag:hover { + text-decoration: none; +} + +.cellTagName { + font-family: var(--dvb-font-sans); + font-size: 19px; + font-weight: 700; + letter-spacing: -0.025em; + line-height: 1.15; + white-space: nowrap; + color: var(--ifm-heading-color); +} + +.tagsCellActive .cellTagName { + color: var(--ifm-color-primary); +} + +.cellTagDesc { + overflow: hidden; + max-width: 260px; + margin-top: 2px; + font-size: 12.5px; + line-height: 1.3; + white-space: nowrap; + text-overflow: ellipsis; + color: var(--dvb-muted); +} + +.cellPreview { + position: relative; + display: flex; + flex-wrap: nowrap; + align-items: center; + overflow: hidden; + gap: 8px; + padding-right: 16px; + cursor: pointer; +} + +.previewItem { + flex: none; + border-bottom: 1px solid var(--dvb-line); + font-size: 12.5px; + line-height: 1.4; + white-space: nowrap; + color: var(--ifm-font-color-base); + text-decoration: none; +} + +.previewItem:hover { + border-bottom-color: var(--ifm-color-primary); + color: var(--ifm-color-primary); + text-decoration: none; +} + +.previewMore { + flex: none; + font-family: var(--dvb-font-mono); + font-size: 11px; + white-space: nowrap; + color: var(--dvb-muted); +} + +.cellCount { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 9px; + padding-right: 30px; +} + +.cellCount:hover { + text-decoration: none; +} + +.countBar { + flex: 1; + height: 5px; + background: var(--dvb-line); +} + +.countBarFill { + display: block; + height: 100%; + background: var(--dvb-edge); +} + +.tagsCellActive .countBarFill { + background: var(--ifm-color-primary); +} + +.countValue { + flex: none; + font-family: var(--dvb-font-mono); + font-size: 13.5px; + font-weight: 700; + color: var(--ifm-heading-color); +} + +@media (max-width: 996px) { + .tagsIndexRoot { + flex-direction: column; + min-height: 0; + } + + .tagsIntro { + position: static; + flex: none; + align-self: auto; + height: auto; + overflow: visible; + padding: 18px 18px 20px; + border-right: none; + border-bottom: 1px solid var(--dvb-edge); + } + + .tagsTitle { + margin-top: 18px; + font-size: 44px; + } + + .tagsLead { + margin-top: 12px; + font-size: 14.5px; + } + + .tagsSpacer { + display: none; + } + + .tagsSortBox { + margin-top: 20px; + } + + .tagsSortChip { + padding: 7px 10px; + } + + .tagsHeadCell { + display: none; + } + + .tagsTable { + grid-template-columns: minmax(0, 1fr) auto; + grid-auto-flow: dense; + } + + .cellTag { + grid-column: 1; + min-width: 0; + padding: 13px 0 0 18px; + border-bottom: none; + } + + .cellTagName { + overflow: hidden; + font-size: 18px; + text-overflow: ellipsis; + } + + .cellTagDesc { + max-width: none; + font-size: 12px; + } + + .cellCount { + grid-column: 2; + align-items: flex-start; + padding: 15px 18px 0 0; + border-bottom: none; + } + + .countBar { + flex: none; + width: 74px; + margin-top: 5px; + } + + .countValue { + font-size: 13px; + } + + .cellPreview { + grid-column: 1 / -1; + min-width: 0; + margin-top: 8px; + padding: 0 18px 14px; + } +} + +@media (max-width: 996px) { + .dvbTagPage { + grid-template-columns: 1fr; + } + + .dvbTagMain { + padding-right: 0; + } + + .dvbTagRail { + position: static; + max-height: none; + overflow-y: visible; + margin-top: 4px; + padding: 20px 0 0; + border-left: none; + border-top: none; + } + + .dvbTagRailList { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 10px; + } + + .dvbTagRailItem { + justify-content: flex-start; + gap: 7px; + padding: 7px 10px; + border: 1px solid var(--dvb-line); + border-left-width: 1px; + background: transparent; + } + + .dvbTagRailLabel { + font-size: 13px; + font-weight: 500; + letter-spacing: -0.01em; + color: var(--ifm-heading-color); + } + + .dvbTagRailCount { + font-size: 10px; + } + + .dvbTagRailItem:not(.dvbTagRailItemActive):hover, + .dvbTagRailItem:not(.dvbTagRailItemActive):focus { + border-color: var(--dvb-edge); + background: transparent; + } + + .dvbTagRailItemActive, + .dvbTagRailItemActive:hover, + .dvbTagRailItemActive:focus { + border-color: var(--ifm-color-primary); + background: var(--dvb-accent-wash); + } + + .dvbTagRailItemActive .dvbTagRailLabel { + font-weight: 600; + color: var(--ifm-color-primary); + } + + .dvbTagRailItemActive .dvbTagRailCount { + color: var(--ifm-color-primary); + } +} + +@media (max-width: 600px) { + .dvbTagTitle { + font-size: 40px; + } + + .dvbTagIndex { + width: 26px; + } + + .dvbTagLead { + font-size: 16px; + } +} diff --git a/src/plugins/dvb-tags-data.ts b/src/plugins/dvb-tags-data.ts new file mode 100644 index 0000000..aae8155 --- /dev/null +++ b/src/plugins/dvb-tags-data.ts @@ -0,0 +1,127 @@ +import type { LoadedContent } from "@docusaurus/plugin-content-docs"; +import type { Plugin } from "@docusaurus/types"; + +/** + * Les pages de tags de Docusaurus ne reçoivent pas assez de données pour la + * mise en page DaVinciBot : + * + * - `DocTagDocListPage` ne connaît que SON tag, pas la liste complète des tags + * (nécessaire pour le rail « Tous les tags ») ; + * - les documents listés sont réduits à `id | title | description | permalink`, + * donc leurs propres tags sont perdus (nécessaires pour les chips) ; + * - `DocTagsListPage` ne reçoit que `label | permalink | description | count`, + * sans aucun document, alors que l'index affiche un aperçu du contenu de + * chaque tag (`tagDocs`). + * + * Ce plugin relit le contenu déjà chargé par les instances de + * `plugin-content-docs` (aucun markdown n'est reparsé) et le sérialise dans les + * données globales, scopé par `version.tagsPath` — qui vaut exactement le + * `tag.allTagsPath` reçu côté composant. Chaque instance de docs et chaque + * version a donc son propre jeu de tags, sans fuite de liens entre versions. + */ + +export const PLUGIN_NAME = "dvb-tags-data"; + +/** Nombre de documents retenus par tag pour l'aperçu de l'index. */ +const PREVIEW_LIMIT = 6; + +export type DvbTagRef = { + label: string; + permalink: string; +}; + +export type DvbTag = DvbTagRef & { + count: number; +}; + +export type DvbDocRef = { + title: string; + permalink: string; +}; + +export type DvbTagsScope = { + /** Tous les tags de cette instance/version, triés par label. */ + tags: DvbTag[]; + /** Tags de chaque document, indexés par permalien de document. */ + docTags: { [docPermalink: string]: DvbTagRef[] }; + /** + * Les `PREVIEW_LIMIT` premiers documents de chaque tag (triés par titre), + * indexés par permalien de tag. Alimente la colonne « Contenu » de l'index. + */ + tagDocs: { [tagPermalink: string]: DvbDocRef[] }; +}; + +/** Indexé par `version.tagsPath`, c.-à-d. le `tag.allTagsPath` des composants. */ +export type DvbTagsData = { [tagsPath: string]: DvbTagsScope }; + +export const EMPTY_TAGS_SCOPE: DvbTagsScope = { + tags: [], + docTags: {}, + tagDocs: {}, +}; + +export default function dvbTagsData(): Plugin { + return { + name: PLUGIN_NAME, + + allContentLoaded({ allContent, actions }) { + const docsInstances = (allContent["docusaurus-plugin-content-docs"] ?? + {}) as { [instanceId: string]: LoadedContent | undefined }; + + const data: DvbTagsData = {}; + + Object.values(docsInstances).forEach((instance) => { + instance?.loadedVersions.forEach((version) => { + const counts = new Map(); + const docTags: DvbTagsScope["docTags"] = {}; + const tagDocs: DvbTagsScope["tagDocs"] = {}; + + version.docs.forEach((doc) => { + docTags[doc.permalink] = doc.tags.map(({ label, permalink }) => ({ + label, + permalink, + })); + + // Les docs `unlisted` sont retirés des compteurs pour rester + // alignés sur le décompte affiché en production. + if (doc.unlisted) { + return; + } + + doc.tags.forEach(({ label, permalink }) => { + const known = counts.get(permalink); + if (known) { + known.count += 1; + } else { + counts.set(permalink, { label, permalink, count: 1 }); + } + + (tagDocs[permalink] ??= []).push({ + title: doc.title, + permalink: doc.permalink, + }); + }); + }); + + // Même ordre que `tag.items` sur la page d'un tag, puis troncature : + // seuls les premiers titres servent à l'aperçu. + Object.keys(tagDocs).forEach((permalink) => { + tagDocs[permalink] = tagDocs[permalink]! + .sort((a, b) => a.title.localeCompare(b.title, "fr")) + .slice(0, PREVIEW_LIMIT); + }); + + data[version.tagsPath] = { + tags: [...counts.values()].sort((a, b) => + a.label.localeCompare(b.label, "fr"), + ), + docTags, + tagDocs, + }; + }); + }); + + actions.setGlobalData(data); + }, + }; +} diff --git a/src/theme/DocTagDocListPage/index.tsx b/src/theme/DocTagDocListPage/index.tsx new file mode 100644 index 0000000..65f45b9 --- /dev/null +++ b/src/theme/DocTagDocListPage/index.tsx @@ -0,0 +1,270 @@ +import React from "react"; +import clsx from "clsx"; +import Link from "@docusaurus/Link"; +import { + PageMetadata, + HtmlClassNameProvider, + ThemeClassNames, + usePluralForm, +} from "@docusaurus/theme-common"; +import { translate } from "@docusaurus/Translate"; +import { usePluginData } from "@docusaurus/useGlobalData"; +import SearchMetadata from "@theme/SearchMetadata"; +import Unlisted from "@theme/ContentVisibility/Unlisted"; +import Heading from "@theme/Heading"; +import type { Props } from "@theme/DocTagDocListPage"; +import type { + DvbTagRef, + DvbTagsData, + DvbTagsScope, +} from "@site/src/plugins/dvb-tags-data"; +import { EMPTY_TAGS_SCOPE, PLUGIN_NAME } from "@site/src/plugins/dvb-tags-data"; + +// Très simple pluralisation : suffisant pour le titre du document. +function useNDocsTaggedPlural() { + const { selectMessage } = usePluralForm(); + return (count: number) => + selectMessage( + count, + translate( + { + id: "theme.docs.tagDocListPageTitle.nDocsTagged", + description: + 'Pluralized label for "{count} docs tagged". Use as much plural forms (separated by "|") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)', + message: "One doc tagged|{count} docs tagged", + }, + { count }, + ), + ); +} + +// Conservé tel quel : alimente le et l'index de recherche, même si +// le titre visible de la page est désormais le seul label du tag. +function usePageTitle(props: Props): string { + const nDocsTaggedPlural = useNDocsTaggedPlural(); + return translate( + { + id: "theme.docs.tagDocListPageTitle", + description: "The title of the page for a docs tag", + message: '{nDocsTagged} with "{tagName}"', + }, + { nDocsTagged: nDocsTaggedPlural(props.tag.count), tagName: props.tag.label }, + ); +} + +function useTagsScope(allTagsPath: string): DvbTagsScope { + const data = usePluginData(PLUGIN_NAME) as DvbTagsData | undefined; + return data?.[allTagsPath] ?? EMPTY_TAGS_SCOPE; +} + +function padCount(count: number): string { + return String(count).padStart(2, "0"); +} + +// Reprend le markup de `@theme/DocBreadcrumbs` pour hériter du style déjà +// appliqué aux pages de docs (« ACCUEIL » gris injecté en ::before, liens en +// encre, dernier segment en bleu) — cf. custom.css, section « Breadcrumb ». +function Breadcrumbs({ tag }: { tag: Props["tag"] }) { + return ( + <nav + className="theme-doc-breadcrumbs dvbTagCrumbs" + aria-label={translate({ + id: "theme.docs.breadcrumbs.navAriaLabel", + message: "Fil d'Ariane", + description: "The ARIA label for the breadcrumbs", + })} + > + <ul className="breadcrumbs"> + <li className="breadcrumbs__item"> + <Link + aria-label={translate({ + id: "theme.docs.breadcrumbs.home", + message: "Page d'accueil", + description: "The ARIA label for the home page in the breadcrumbs", + })} + className="breadcrumbs__link" + to="/" + > + <svg viewBox="0 0 24 24"> + <path + d="M10 19v-5h4v5c0 .55.45 1 1 1h3c.55 0 1-.45 1-1v-7h1.7c.46 0 .68-.57.33-.87L12.67 3.6c-.38-.34-.96-.34-1.34 0l-8.36 7.53c-.34.3-.13.87.33.87H5v7c0 .55.45 1 1 1h3c.55 0 1-.45 1-1z" + fill="currentColor" + /> + </svg> + </Link> + </li> + <li className="breadcrumbs__item"> + <Link className="breadcrumbs__link" to={tag.allTagsPath}> + Tags + </Link> + </li> + <li className="breadcrumbs__item breadcrumbs__item--active"> + <span className="breadcrumbs__link">{tag.label}</span> + </li> + </ul> + </nav> + ); +} + +function TagHeader({ tag }: { tag: Props["tag"] }) { + return ( + <header className="dvbTagHead"> + <Heading as="h1" className="dvbTagTitle"> + {tag.label} + </Heading> + <p className="dvbTagLead"> + <span className="dvbTagLeadCount"> + {tag.count} documentation{tag.count > 1 ? "s" : ""} + </span> + {tag.description && ( + <> + <span className="dvbTagLeadSep" aria-hidden="true"> + • + </span> + {tag.description} + </> + )} + </p> + </header> + ); +} + +function DocChips({ + tags, + currentPermalink, +}: { + tags: DvbTagRef[]; + currentPermalink: string; +}) { + if (tags.length === 0) { + return null; + } + return ( + <div className="dvbTagChips"> + {tags.map((docTag) => ( + <Link + key={docTag.permalink} + to={docTag.permalink} + className={clsx( + "dvbTagChip", + docTag.permalink === currentPermalink && "dvbTagChipActive", + )} + > + {docTag.label} + </Link> + ))} + </div> + ); +} + +function DocList({ + tag, + scope, +}: { + tag: Props["tag"]; + scope: DvbTagsScope; +}) { + return ( + <ol className="dvbTagList"> + {tag.items.map((doc, index) => ( + <li key={doc.id} className="dvbTagItem"> + <span className="dvbTagIndex" aria-hidden="true"> + {padCount(index + 1)} + </span> + <div className="dvbTagItemBody"> + <Heading as="h2" className="dvbTagItemTitle"> + <Link to={doc.permalink} className="dvbTagItemLink"> + {doc.title} + </Link> + </Heading> + {doc.description && ( + <p className="dvbTagItemDesc">{doc.description}</p> + )} + <DocChips + tags={scope.docTags[doc.permalink] ?? []} + currentPermalink={tag.permalink} + /> + </div> + </li> + ))} + </ol> + ); +} + +function TagRail({ tag, scope }: { tag: Props["tag"]; scope: DvbTagsScope }) { + return ( + <aside className={clsx("dvbTagRail", "thin-scrollbar")}> + <div className="dvbTagRailHead"> + <Heading as="h2" className="dvbTagRailTitle"> + Tous les tags + </Heading> + <Link to={tag.allTagsPath} className="dvbTagRailAll"> + Voir tous → + </Link> + </div> + {scope.tags.length > 0 && ( + <ul className="dvbTagRailList"> + {scope.tags.map((railTag) => { + const isActive = railTag.permalink === tag.permalink; + return ( + <li key={railTag.permalink}> + <Link + to={railTag.permalink} + className={clsx( + "dvbTagRailItem", + isActive && "dvbTagRailItemActive", + )} + aria-current={isActive ? "page" : undefined} + > + <span className="dvbTagRailLabel">{railTag.label}</span> + <span className="dvbTagRailCount">{railTag.count}</span> + </Link> + </li> + ); + })} + </ul> + )} + </aside> + ); +} + +function DocTagDocListPageMetadata({ title, tag }: Props & { title: string }) { + return ( + <> + <PageMetadata title={title} description={tag.description} /> + <SearchMetadata tag="doc_tag_doc_list" /> + </> + ); +} + +function DocTagDocListPageContent({ tag }: Props): React.ReactElement { + const scope = useTagsScope(tag.allTagsPath); + return ( + <HtmlClassNameProvider + className={clsx(ThemeClassNames.page.docsTagDocListPage)} + > + <div className="container margin-vert--lg"> + {tag.unlisted && <Unlisted />} + <div className="dvbTagPage"> + <div className="dvbTagMain"> + <Breadcrumbs tag={tag} /> + <TagHeader tag={tag} /> + <hr className="dvbTagRule" /> + <DocList tag={tag} scope={scope} /> + </div> + <TagRail tag={tag} scope={scope} /> + </div> + </div> + </HtmlClassNameProvider> + ); +} + +export default function DocTagDocListPage(props: Props): React.ReactElement { + const title = usePageTitle(props); + return ( + <> + <DocTagDocListPageMetadata {...props} title={title} /> + <DocTagDocListPageContent {...props} /> + </> + ); +} diff --git a/src/theme/DocTagsListPage/index.tsx b/src/theme/DocTagsListPage/index.tsx new file mode 100644 index 0000000..28a1513 --- /dev/null +++ b/src/theme/DocTagsListPage/index.tsx @@ -0,0 +1,375 @@ +import React, { + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import clsx from "clsx"; +import Link from "@docusaurus/Link"; +import { useHistory } from "@docusaurus/router"; +import ExecutionEnvironment from "@docusaurus/ExecutionEnvironment"; +import { + PageMetadata, + HtmlClassNameProvider, + ThemeClassNames, + translateTagsPageTitle, +} from "@docusaurus/theme-common"; +import { usePluginData } from "@docusaurus/useGlobalData"; +import SearchMetadata from "@theme/SearchMetadata"; +import Heading from "@theme/Heading"; +import type { Props } from "@theme/DocTagsListPage"; +import type { DvbDocRef, DvbTagsData } from "@site/src/plugins/dvb-tags-data"; +import { PLUGIN_NAME } from "@site/src/plugins/dvb-tags-data"; + +type TagsListItem = Props["tags"][number]; + +type SortKey = "volume" | "az" | "za"; + +type PointerHandlers = { + onPointerEnter: () => void; + onPointerLeave: () => void; + onFocus: () => void; + onBlur: () => void; +}; + +const SORT_OPTIONS: { key: SortKey; label: string }[] = [ + { key: "volume", label: "Volume" }, + { key: "az", label: "A → Z" }, + { key: "za", label: "Z → A" }, +]; + +const ITEM_CLASS = "previewItem"; +const MORE_CLASS = "previewMore"; + +/** Largeur supposée du `+N` tant qu'il n'est pas monté pour être mesuré. */ +const MORE_FALLBACK_WIDTH = 34; + +/** Référence stable : évite de recréer un tableau vide à chaque rendu. */ +const NO_DOCS: DvbDocRef[] = []; + +const useIsomorphicLayoutEffect = ExecutionEnvironment.canUseDOM + ? useLayoutEffect + : useEffect; + +/** + * Les permaliens de tags sont globalement uniques — ils sont préfixés par le + * `tagsPath` de leur version (`/tags/cdr` vs `/cdr/paris/2026/tags/cdr`). On + * peut donc aplatir tous les scopes en une seule table sans avoir à résoudre + * l'instance de docs courante. + */ +function useDocsByTag(): { [tagPermalink: string]: DvbDocRef[] } { + const data = usePluginData(PLUGIN_NAME) as DvbTagsData | undefined; + return useMemo( + () => Object.assign({}, ...Object.values(data ?? {}).map((s) => s.tagDocs)), + [data], + ); +} + +function useSortedTags(tags: TagsListItem[], sort: SortKey): TagsListItem[] { + return useMemo(() => { + const byLabel = (a: TagsListItem, b: TagsListItem) => + a.label.localeCompare(b.label, "fr"); + if (sort === "volume") { + return [...tags].sort((a, b) => b.count - a.count || byLabel(a, b)); + } + return [...tags].sort((a, b) => (sort === "az" ? 1 : -1) * byLabel(a, b)); + }, [tags, sort]); +} + +function padCount(count: number): string { + return String(count).padStart(2, "0"); +} + +function Intro({ + title, + sort, + onSort, +}: { + title: string; + sort: SortKey; + onSort: (key: SortKey) => void; +}) { + return ( + <aside className={clsx("tagsIntro", "thin-scrollbar")}> + <nav className="tagsCrumbs" aria-label="Fil d'Ariane"> + <Link to="/" className="tagsCrumb"> + Accueil + </Link> + <span className="tagsCrumbSep" aria-hidden="true"> + / + </span> + <span className="tagsCrumbCurrent" aria-current="page"> + {title} + </span> + </nav> + + <Heading as="h1" className="tagsTitle"> + {title} + </Heading> + + <p className="tagsLead"> + Points d'entrée transversaux dans la documentation : un tag + regroupe les pages qui traitent d'un même sujet, quel que soit le + parcours. + </p> + + <div className="tagsSpacer" /> + + <div className="tagsSortBox"> + <span className="tagsSortLabel" id="tagsSortLabel"> + Tri + </span> + <div + className="tagsSortChips" + role="group" + aria-labelledby="tagsSortLabel" + > + {SORT_OPTIONS.map((option) => ( + <button + key={option.key} + type="button" + className={clsx( + "tagsSortChip", + option.key === sort && "tagsSortChipActive", + )} + aria-pressed={option.key === sort} + onClick={() => onSort(option.key)} + > + {option.label} + </button> + ))} + </div> + </div> + </aside> + ); +} + +/** + * L'aperçu tient sur une seule ligne, quelle que soit la largeur disponible. + * Le CSS le garantit (`nowrap` + `overflow: hidden`) ; ce composant décide + * combien de titres afficher avant que le `+N` ne prenne le relais. + * + * La largeur d'un titre n'est connue qu'une fois rendu : on mesure donc une + * fois tous les titres montés, on met leurs étendues en cache (elles ne + * dépendent pas de la largeur du conteneur, les items étant en `flex: none`), + * puis on recalcule le seuil à chaque redimensionnement à partir du cache. + */ +function PreviewCell({ + tag, + docs, + className, + hover, + onNavigate, +}: { + tag: TagsListItem; + docs: DvbDocRef[]; + className: string; + hover: PointerHandlers; + onNavigate: () => void; +}) { + const cellRef = useRef<HTMLDivElement>(null); + const extents = useRef<number[] | null>(null); + const [fit, setFit] = useState(docs.length); + + useIsomorphicLayoutEffect(() => { + const cell = cellRef.current; + if (!cell || docs.length === 0) { + return undefined; + } + + const compute = () => { + const items = Array.from( + cell.querySelectorAll<HTMLElement>(`.${ITEM_CLASS}`), + ); + if (items.length === docs.length) { + extents.current = items.map((el) => el.offsetLeft + el.offsetWidth); + } + const ends = extents.current; + if (!ends || ends.length === 0) { + return; + } + + const style = getComputedStyle(cell); + const room = cell.clientWidth - (parseFloat(style.paddingRight) || 0); + const overflows = + ends[ends.length - 1]! > room || tag.count > ends.length; + + let visible = ends.length; + if (overflows) { + const more = cell.querySelector<HTMLElement>(`.${MORE_CLASS}`); + const reserve = + (more?.offsetWidth ?? MORE_FALLBACK_WIDTH) + + (parseFloat(style.columnGap) || 0); + visible = 0; + while (visible < ends.length && ends[visible]! <= room - reserve) { + visible += 1; + } + } + + setFit(Math.max(1, visible)); + }; + + compute(); + const observer = new ResizeObserver(compute); + observer.observe(cell); + return () => observer.disconnect(); + }, [docs, fit, tag.count]); + + useEffect(() => { + // Le swap de police élargit les titres sans toucher au conteneur : aucun + // ResizeObserver ne se déclenche, il faut réinvalider le cache à la main. + let cancelled = false; + document.fonts?.ready.then(() => { + if (!cancelled) { + extents.current = null; + setFit(docs.length); + } + }); + return () => { + cancelled = true; + }; + }, [docs]); + + const rest = tag.count - fit; + + return ( + <div ref={cellRef} className={className} {...hover} onClick={onNavigate}> + {docs.slice(0, fit).map((doc) => ( + <Link + key={doc.permalink} + to={doc.permalink} + className={ITEM_CLASS} + onClick={(e) => e.stopPropagation()} + > + {doc.title} + </Link> + ))} + {rest > 0 && <span className={MORE_CLASS}>+{rest}</span>} + </div> + ); +} + +function TagRow({ + tag, + docs, + countMax, + active, + onActive, +}: { + tag: TagsListItem; + docs: DvbDocRef[]; + countMax: number; + active: boolean; + onActive: (permalink: string | null) => void; +}) { + const history = useHistory(); + const goToTag = () => history.push(tag.permalink); + const cellClass = clsx("tagsCell", active && "tagsCellActive"); + + // `onMouseEnter` est inutilisable ici : `@docusaurus/Link` l'écrase par son + // propre gestionnaire de préchargement (`{...props}` étalé avant, cf. Link.js). + // Les événements pointer, eux, traversent intacts. + const hover: PointerHandlers = { + onPointerEnter: () => onActive(tag.permalink), + onPointerLeave: () => onActive(null), + onFocus: () => onActive(tag.permalink), + onBlur: () => onActive(null), + }; + + return ( + <> + <Link to={tag.permalink} className={clsx(cellClass, "cellTag")} {...hover}> + <span className="cellTagName">{tag.label}</span> + {tag.description && ( + <span className="cellTagDesc">{tag.description}</span> + )} + </Link> + + <PreviewCell + tag={tag} + docs={docs} + className={clsx(cellClass, "cellPreview")} + hover={hover} + onNavigate={goToTag} + /> + + <Link + to={tag.permalink} + tabIndex={-1} + aria-hidden="true" + className={clsx(cellClass, "cellCount")} + {...hover} + > + <span className="countBar" aria-hidden="true"> + <span + className="countBarFill" + style={{ width: `${(tag.count / countMax) * 100}%` }} + /> + </span> + <span className="countValue">{padCount(tag.count)}</span> + </Link> + </> + ); +} + +function DocTagsListPageContent({ + tags, + title, +}: Props & { title: string }): React.ReactElement { + const [sort, setSort] = useState<SortKey>("volume"); + const [active, setActive] = useState<string | null>(null); + const docsByTag = useDocsByTag(); + const sortedTags = useSortedTags(tags, sort); + const countMax = useMemo( + () => Math.max(1, ...tags.map((tag) => tag.count)), + [tags], + ); + + return ( + <HtmlClassNameProvider + className={clsx(ThemeClassNames.page.docsTagsListPage, "tagsIndexPage")} + > + <div className="tagsIndexRoot"> + <Intro title={title} sort={sort} onSort={setSort} /> + + <div className="tagsTable"> + <div className="tagsHeadCell">Tag</div> + <div className="tagsHeadCell">Contenu</div> + <div className="tagsHeadCell tagsHeadCount">Références</div> + + {sortedTags.map((tag) => ( + <React.Fragment key={tag.permalink}> + <TagRow + tag={tag} + docs={docsByTag[tag.permalink] ?? NO_DOCS} + countMax={countMax} + active={active === tag.permalink} + onActive={setActive} + /> + </React.Fragment> + ))} + </div> + </div> + </HtmlClassNameProvider> + ); +} + +function DocTagsListPageMetadata({ title }: { title: string }) { + return ( + <> + <PageMetadata title={title} /> + <SearchMetadata tag="doc_tags_list" /> + </> + ); +} + +export default function DocTagsListPage(props: Props): React.ReactElement { + const title = translateTagsPageTitle(); + return ( + <> + <DocTagsListPageMetadata title={title} /> + <DocTagsListPageContent {...props} title={title} /> + </> + ); +}