diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index f72b471..4ffde4e 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -151,41 +151,37 @@ Macros let you insert dynamic content blocks directly inside post/page Markdown Use macros when you need reusable rich blocks (for example embedded videos, media galleries, archive grids, or computed tag clouds) without writing raw HTML. -### Supported macros +### YouTube macro -- `[[youtube id="VIDEO_ID" title="Optional title"]]` - - Embeds a YouTube video. - - `id` is required. - - `title` is optional (used for accessibility label). +Use `[[youtube id="VIDEO_ID" title="Optional title"]]` when you want to embed a YouTube clip directly in a post or page. This macro is best for video references, walkthroughs, and embedded talks that should stay in the editorial flow instead of linking out to another tab. -- `[[vimeo id="VIDEO_ID" title="Optional title"]]` - - Embeds a Vimeo video. - - `id` is required. - - `title` is optional. +The `id` parameter is required and should contain the YouTube video ID. The `title` parameter is optional, but recommended for accessibility because it becomes the reader-facing label for the embedded frame. -- `[[gallery columns="3" caption="Optional caption"]]` - - Renders a lightbox-enabled gallery using media linked to the current post. - - `columns` is optional (`1` to `6`, default `3`). - - `caption` is optional. +### Vimeo macro -- `[[photo_archive year="2025" month="2"]]` - - Renders a photo archive grid from media dates. - - `year` is optional (when omitted, recent months are shown). - - `month` is optional (used with `year` for a single month view). - - Legacy alias `[[photo_album ...]]` is also supported. +Use `[[vimeo id="VIDEO_ID" title="Optional title"]]` for Vimeo-hosted video content. It behaves similarly to the YouTube macro, but targets Vimeo as the video source. -- `[[tag_cloud orientation="mixed_diagonal" width="900" height="420"]]` - - Builds a word cloud from published tag usage counts. - - Word size scales by usage quantity. - - Word color is theme-aware and uses Pico CSS semantic colors. - - Colors are distributed by quantity quantiles with easing, so dense datasets still show visible variation. - - Visual order remains least-to-most: blue → green → yellow → orange → red. - - Clicking a word opens that tag archive route. - - `orientation` is optional and supports: - - `horizontal` (all words horizontal) - - `mixed_hv` (mix of horizontal and vertical) - - `mixed_diagonal` (mix of horizontal/vertical/diagonal angles) - - `width` and `height` are optional (defaults `900` and `420`). +As with YouTube, `id` is required and `title` is optional. Use `title` whenever possible so screen-reader and assistive-technology users receive useful context. + +### Gallery macro + +Use `[[gallery columns="3" caption="Optional caption"]]` to render a lightbox-enabled media gallery from assets linked to the current post. This macro is appropriate when several related images belong together and should be browsed as one visual group. + +The `columns` parameter controls layout density and accepts values from `1` to `6` (default is `3`). The optional `caption` parameter adds context above or below the gallery depending on theme presentation. + +### Photo archive macro + +Use `[[photo_archive year="2025" month="2"]]` when you want an archive-style grid based on media dates. This is useful for timeline-oriented projects where readers should navigate image collections by month or year. + +Both `year` and `month` are optional. If `year` is omitted, bDS shows recent months. If `year` is provided without `month`, bDS presents the year scope. The legacy alias `[[photo_album ...]]` is still supported for compatibility. + +### Tag cloud macro + +Use `[[tag_cloud orientation="mixed_diagonal" width="900" height="420"]]` to visualize published tag usage as a weighted cloud. This macro is best for discovery pages, thematic overviews, and archive entry points where content density matters. + +Word size scales with usage counts. Colors are theme-aware and distributed by quantity quantiles using eased interpolation so high-volume datasets stay readable. The color progression remains least-to-most (blue → green → yellow → orange → red), and clicking a word opens that tag archive route. + +The optional `orientation` parameter supports `horizontal`, `mixed_hv`, and `mixed_diagonal`. The optional `width` and `height` parameters control canvas size and default to `900` and `420`. ### Key takeaways diff --git a/src/renderer/components/DocumentationView/DocumentationView.css b/src/renderer/components/DocumentationView/DocumentationView.css index 21df37b..50700cc 100644 --- a/src/renderer/components/DocumentationView/DocumentationView.css +++ b/src/renderer/components/DocumentationView/DocumentationView.css @@ -1,3 +1,6 @@ +@import '@highlightjs/cdn-assets/styles/github.min.css' screen and (prefers-color-scheme: light); +@import '@highlightjs/cdn-assets/styles/github-dark.min.css' screen and (prefers-color-scheme: dark); + .documentation-view { --doc-bg: var(--pico-background-color); --doc-surface: var(--pico-card-background-color); @@ -94,6 +97,35 @@ overflow: auto; } +.documentation-code-block { + position: relative; +} + +.documentation-code-copy-button { + position: absolute; + top: 8px; + right: 8px; + border: 1px solid var(--doc-border); + border-radius: 6px; + background: var(--doc-surface); + color: var(--doc-text); + width: 30px; + height: 30px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 1; +} + +.documentation-code-copy-button:hover { + background: var(--doc-code-bg); +} + +.documentation-code-block pre { + margin-top: 0; +} + .documentation-content.markdown-body blockquote { margin: 10px 0; padding: 0 0 0 12px; diff --git a/src/renderer/components/DocumentationView/DocumentationView.tsx b/src/renderer/components/DocumentationView/DocumentationView.tsx index d20049b..221056b 100644 --- a/src/renderer/components/DocumentationView/DocumentationView.tsx +++ b/src/renderer/components/DocumentationView/DocumentationView.tsx @@ -1,15 +1,248 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useRef } from 'react'; import Markdown from 'marked-react'; +import hljs from '@highlightjs/cdn-assets/es/highlight.min.js'; +import type { ReactNode } from 'react'; import documentationContent from '../../../../DOCUMENTATION.md?raw'; import { useAppStore } from '../../store'; import { useI18n } from '../../i18n'; import { ensureRendererPicoThemeStylesheet, getRendererPicoTheme } from '../../utils/picoTheme'; import './DocumentationView.css'; +const HEADING_LEVELS = new Set([1, 2, 3, 4, 5, 6]); + +function extractText(content: ReactNode): string { + if (typeof content === 'string' || typeof content === 'number') { + return String(content); + } + + if (Array.isArray(content)) { + return content.map(extractText).join(''); + } + + if (React.isValidElement(content)) { + return extractText((content.props as { children?: ReactNode }).children); + } + + return ''; +} + +function slugifyHeading(value: string): string { + return value + .normalize('NFKD') + .toLowerCase() + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9\s-]/g, '') + .trim() + .replace(/\s+/g, '-') + .replace(/-+/g, '-'); +} + +function isScrollable(element: HTMLElement): boolean { + const style = window.getComputedStyle(element); + const overflowY = style.overflowY; + const canScroll = overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay'; + return canScroll && element.scrollHeight > element.clientHeight; +} + +function resolveScrollContainer(target: HTMLElement, preferred: HTMLElement | null): HTMLElement | null { + if (preferred && preferred.contains(target)) { + return preferred; + } + + let current: HTMLElement | null = target.parentElement; + while (current) { + if (isScrollable(current)) { + return current; + } + current = current.parentElement; + } + + if (preferred) { + return preferred; + } + + const scrollingElement = document.scrollingElement; + return scrollingElement instanceof HTMLElement ? scrollingElement : null; +} + +function resolveTargetHeadingInArticle(articleElement: HTMLElement, targetId: string): HTMLElement | null { + for (const candidate of articleElement.querySelectorAll('[id]')) { + if (candidate.id === targetId) { + return candidate; + } + } + + const headingCandidates = articleElement.querySelectorAll('h1, h2, h3, h4, h5, h6'); + for (const heading of headingCandidates) { + const headingSlug = slugifyHeading(heading.textContent ?? ''); + if (headingSlug === targetId) { + heading.id = targetId; + return heading; + } + } + + return null; +} + export const DocumentationView: React.FC = () => { const { t: tr } = useI18n(); const { picoTheme } = useAppStore(); const resolvedTheme = getRendererPicoTheme(picoTheme); + const scrollContainerRef = useRef(null); + const articleRef = useRef(null); + const headingSlugCounts = new Map(); + let rendererKeyIndex = 0; + + const jumpToDocumentationHash = (href: string): boolean => { + if (!href.startsWith('#') || href.length < 2) { + return false; + } + + const targetId = decodeURIComponent(href.slice(1)); + const articleElement = articleRef.current; + const preferredScrollContainer = scrollContainerRef.current; + + if (!articleElement) { + console.info('[DocumentationView] hash jump skipped', { href, targetId, reason: 'article-not-available' }); + return false; + } + + const targetHeading = resolveTargetHeadingInArticle(articleElement, targetId); + + if (!targetHeading) { + console.info('[DocumentationView] hash jump skipped', { href, targetId, reason: 'target-not-found-or-outside-article' }); + return false; + } + + const scrollContainer = resolveScrollContainer(targetHeading, preferredScrollContainer); + if (!scrollContainer) { + console.info('[DocumentationView] hash jump skipped', { href, targetId, reason: 'no-scroll-container' }); + return false; + } + + const beforeTop = scrollContainer.scrollTop; + + const containerRect = scrollContainer.getBoundingClientRect(); + const headingRect = targetHeading.getBoundingClientRect(); + const targetTop = Math.max(0, scrollContainer.scrollTop + (headingRect.top - containerRect.top) - 12); + + scrollContainer.scrollTop = 0; + scrollContainer.scrollTop = targetTop; + window.location.hash = href; + + articleElement.dataset.lastJumpTarget = targetId; + articleElement.dataset.lastJumpTop = String(targetTop); + articleElement.dataset.lastJumpContainer = scrollContainer.className || scrollContainer.tagName; + + console.info('[DocumentationView] hash jump applied', { + href, + targetId, + beforeTop, + targetTop, + afterTop: scrollContainer.scrollTop, + container: scrollContainer.className || scrollContainer.tagName, + }); + + return true; + }; + + const getRendererKey = (prefix: string): string => { + rendererKeyIndex += 1; + return `${prefix}-${rendererKeyIndex}`; + }; + + const markdownRenderer = { + heading(children: ReactNode, level: number) { + const levelNumber = HEADING_LEVELS.has(level as 1 | 2 | 3 | 4 | 5 | 6) ? level : 2; + const headingText = extractText(children); + const baseId = slugifyHeading(headingText); + const existingCount = headingSlugCounts.get(baseId) ?? 0; + const nextCount = existingCount + 1; + headingSlugCounts.set(baseId, nextCount); + const headingId = existingCount === 0 ? baseId : `${baseId}-${nextCount}`; + + return React.createElement(`h${levelNumber}` as keyof JSX.IntrinsicElements, { id: headingId, key: getRendererKey('heading') }, children); + }, + link(href: string, text: ReactNode) { + if (!href.startsWith('#')) { + return {text}; + } + + return ( + { + if (jumpToDocumentationHash(href)) { + event.preventDefault(); + } + }} + > + {text} + + ); + }, + code(code: ReactNode, lang: string | undefined) { + const normalizedLanguage = typeof lang === 'string' ? lang.trim().toLowerCase() : ''; + const sourceCode = extractText(code); + const codeBlockKey = getRendererKey('code-block'); + + let highlightedHtml = ''; + let languageClass = ''; + + if (normalizedLanguage.length > 0 && hljs.getLanguage(normalizedLanguage)) { + highlightedHtml = hljs.highlight(sourceCode, { language: normalizedLanguage }).value; + languageClass = `language-${normalizedLanguage}`; + } else { + const autoDetected = hljs.highlightAuto(sourceCode); + highlightedHtml = autoDetected.value; + languageClass = autoDetected.language ? `language-${autoDetected.language}` : 'language-plaintext'; + } + + return ( +
+ +
+            
+          
+
+ ); + }, + }; useEffect(() => { ensureRendererPicoThemeStylesheet(resolvedTheme).catch((error) => { @@ -23,10 +256,13 @@ export const DocumentationView: React.FC = () => {

{tr('docs.title')}

{tr('docs.subtitle')}

-
+
-
- {documentationContent} +
+ {documentationContent}
diff --git a/src/renderer/i18n/locales/de.json b/src/renderer/i18n/locales/de.json index e7901ac..b067b84 100644 --- a/src/renderer/i18n/locales/de.json +++ b/src/renderer/i18n/locales/de.json @@ -244,6 +244,7 @@ "postLinks.openTitle": "Öffnen: {title}", "docs.title": "Dokumentation", "docs.subtitle": "Benutzerhandbuch für diese installierte bDS-Version.", + "docs.copyCode": "Code kopieren", "gitDiff.header": "Unterschied: {target}", "gitDiff.noProject": "Kein aktives Projekt ausgewählt.", "gitDiff.noProjectPath": "Projektpfad konnte nicht ermittelt werden.", diff --git a/src/renderer/i18n/locales/en.json b/src/renderer/i18n/locales/en.json index 3090d18..04bf787 100644 --- a/src/renderer/i18n/locales/en.json +++ b/src/renderer/i18n/locales/en.json @@ -244,6 +244,7 @@ "postLinks.openTitle": "Open: {title}", "docs.title": "Documentation", "docs.subtitle": "User guide for this installed bDS version.", + "docs.copyCode": "Copy code", "gitDiff.header": "Diff: {target}", "gitDiff.noProject": "No active project selected.", "gitDiff.noProjectPath": "Unable to resolve project path.", diff --git a/src/renderer/i18n/locales/es.json b/src/renderer/i18n/locales/es.json index 1617338..462db6c 100644 --- a/src/renderer/i18n/locales/es.json +++ b/src/renderer/i18n/locales/es.json @@ -244,6 +244,7 @@ "postLinks.openTitle": "Abrir: {title}", "docs.title": "Documentación", "docs.subtitle": "Guía de usuario para esta versión instalada de bDS.", + "docs.copyCode": "Copiar código", "gitDiff.header": "Diferencia: {target}", "gitDiff.noProject": "No hay un proyecto activo seleccionado.", "gitDiff.noProjectPath": "No se pudo resolver la ruta del proyecto.", diff --git a/src/renderer/i18n/locales/fr.json b/src/renderer/i18n/locales/fr.json index ea8ebef..04db7a4 100644 --- a/src/renderer/i18n/locales/fr.json +++ b/src/renderer/i18n/locales/fr.json @@ -244,6 +244,7 @@ "postLinks.openTitle": "Ouvrir: {title}", "docs.title": "Guide utilisateur", "docs.subtitle": "Guide utilisateur pour cette version installée de bDS.", + "docs.copyCode": "Copier le code", "gitDiff.header": "Diff : {target}", "gitDiff.noProject": "Aucun projet actif sélectionné.", "gitDiff.noProjectPath": "Impossible de résoudre le chemin du projet.", diff --git a/src/renderer/i18n/locales/it.json b/src/renderer/i18n/locales/it.json index fe09b96..50da265 100644 --- a/src/renderer/i18n/locales/it.json +++ b/src/renderer/i18n/locales/it.json @@ -244,6 +244,7 @@ "postLinks.openTitle": "Apri: {title}", "docs.title": "Documentazione", "docs.subtitle": "Guida utente per questa versione installata di bDS.", + "docs.copyCode": "Copia codice", "gitDiff.header": "Differenza: {target}", "gitDiff.noProject": "Nessun progetto attivo selezionato.", "gitDiff.noProjectPath": "Impossibile risolvere il percorso del progetto.", diff --git a/tests/renderer/components/DocumentationView.test.tsx b/tests/renderer/components/DocumentationView.test.tsx new file mode 100644 index 0000000..3e32279 --- /dev/null +++ b/tests/renderer/components/DocumentationView.test.tsx @@ -0,0 +1,246 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { DocumentationView } from '../../../src/renderer/components/DocumentationView/DocumentationView'; +import { I18nProvider } from '../../../src/renderer/i18n'; +import { useAppStore } from '../../../src/renderer/store'; + +const openSpy = vi.fn(); + +Object.defineProperty(window, 'open', { + value: openSpy, + writable: true, +}); + +describe('DocumentationView', () => { + beforeEach(() => { + vi.clearAllMocks(); + Object.defineProperty(window, 'location', { + value: { + ...window.location, + hash: '', + }, + writable: true, + }); + useAppStore.setState({ + picoTheme: 'slate', + } as never); + + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: vi.fn().mockResolvedValue(undefined), + }, + writable: true, + }); + }); + + it('renders fenced code blocks with language-specific classes for syntax highlighting', async () => { + const { container } = render( + + + + ); + + await screen.findByText('bDS User Guide'); + const pythonBlock = container.querySelector('pre > code.language-python'); + expect(pythonBlock).not.toBeNull(); + }); + + it('renders heading anchors and keeps in-document toc links in-page', async () => { + const { container } = render( + + + + ); + + const targetHeading = await screen.findByRole('heading', { level: 2, name: 'Who this guide is for' }); + expect(targetHeading).toHaveAttribute('id', 'who-this-guide-is-for'); + + const tocLink = container.querySelector('a[href="#who-this-guide-is-for"]'); + expect(tocLink).not.toBeNull(); + + const scrollContainer = container.querySelector('.documentation-scroll'); + expect(scrollContainer).not.toBeNull(); + + Object.defineProperty(scrollContainer as HTMLElement, 'scrollTop', { + value: 10, + writable: true, + }); + + Object.defineProperty(scrollContainer as HTMLElement, 'getBoundingClientRect', { + value: () => ({ top: 100, left: 0, width: 800, height: 600, right: 800, bottom: 700, x: 0, y: 100, toJSON: () => ({}) }), + writable: true, + }); + + Object.defineProperty(targetHeading, 'getBoundingClientRect', { + value: () => ({ top: 340, left: 0, width: 100, height: 20, right: 100, bottom: 360, x: 0, y: 340, toJSON: () => ({}) }), + writable: true, + }); + + fireEvent.click(tocLink as HTMLAnchorElement); + + expect((scrollContainer as HTMLElement).scrollTop).toBe(238); + const article = container.querySelector('.documentation-article') as HTMLElement; + expect(article.dataset.lastJumpTarget).toBe('who-this-guide-is-for'); + expect(article.dataset.lastJumpTop).toBe('238'); + expect(window.location.hash).toBe('#who-this-guide-is-for'); + expect(window.open).not.toHaveBeenCalled(); + }); + + it('keeps scroll position unchanged for unresolved in-document links', async () => { + const { container } = render( + + + + ); + + await screen.findByRole('heading', { level: 2, name: 'Who this guide is for' }); + const tocLink = container.querySelector('a[href="#does-not-exist"]'); + if (!tocLink) { + const article = container.querySelector('.documentation-article') as HTMLElement; + const missingAnchor = document.createElement('a'); + missingAnchor.setAttribute('href', '#does-not-exist'); + missingAnchor.textContent = 'Missing'; + article.appendChild(missingAnchor); + } + + const unresolvedLink = container.querySelector('a[href="#does-not-exist"]'); + expect(unresolvedLink).not.toBeNull(); + + const scrollContainer = container.querySelector('.documentation-scroll') as HTMLElement; + expect(scrollContainer).not.toBeNull(); + + Object.defineProperty(scrollContainer, 'scrollTop', { + value: 222, + writable: true, + }); + + expect(() => fireEvent.click(unresolvedLink as HTMLAnchorElement)).not.toThrow(); + expect(scrollContainer.scrollTop).toBe(222); + }); + + it('handles toc clicks when clicking nested content inside the anchor', async () => { + const { container } = render( + + + + ); + + const targetHeading = await screen.findByRole('heading', { level: 2, name: 'Who this guide is for' }); + const tocLink = container.querySelector('a[href="#who-this-guide-is-for"]') as HTMLAnchorElement; + expect(tocLink).not.toBeNull(); + + const nested = document.createElement('span'); + nested.textContent = 'Who this guide is for'; + tocLink.appendChild(nested); + + const scrollContainer = container.querySelector('.documentation-scroll') as HTMLElement; + Object.defineProperty(scrollContainer, 'scrollTop', { + value: 5, + writable: true, + }); + + Object.defineProperty(scrollContainer, 'getBoundingClientRect', { + value: () => ({ top: 100, left: 0, width: 800, height: 600, right: 800, bottom: 700, x: 0, y: 100, toJSON: () => ({}) }), + writable: true, + }); + + Object.defineProperty(targetHeading, 'getBoundingClientRect', { + value: () => ({ top: 300, left: 0, width: 100, height: 20, right: 100, bottom: 320, x: 0, y: 300, toJSON: () => ({}) }), + writable: true, + }); + + fireEvent.click(nested); + expect(scrollContainer.scrollTop).toBe(193); + }); + + it('resolves hash target within the clicked documentation instance', async () => { + const { container } = render( + + + + + ); + + await screen.findAllByRole('heading', { level: 2, name: 'Using macros' }); + + const wrappers = container.querySelectorAll('.documentation-view'); + expect(wrappers.length).toBe(2); + + const firstScroll = wrappers[0].querySelector('.documentation-scroll') as HTMLElement; + const secondScroll = wrappers[1].querySelector('.documentation-scroll') as HTMLElement; + const secondLink = wrappers[1].querySelector('a[href="#using-macros"]') as HTMLAnchorElement; + const secondArticle = wrappers[1].querySelector('.documentation-article') as HTMLElement; + const secondHeading = Array.from(secondArticle.querySelectorAll('[id]')).find((node) => node.id === 'using-macros') as HTMLElement; + + Object.defineProperty(firstScroll, 'scrollTop', { value: 7, writable: true }); + Object.defineProperty(secondScroll, 'scrollTop', { value: 11, writable: true }); + + Object.defineProperty(secondScroll, 'getBoundingClientRect', { + value: () => ({ top: 120, left: 0, width: 700, height: 500, right: 700, bottom: 620, x: 0, y: 120, toJSON: () => ({}) }), + writable: true, + }); + + Object.defineProperty(secondHeading, 'getBoundingClientRect', { + value: () => ({ top: 420, left: 0, width: 300, height: 30, right: 300, bottom: 450, x: 0, y: 420, toJSON: () => ({}) }), + writable: true, + }); + + fireEvent.click(secondLink); + + expect(firstScroll.scrollTop).toBe(7); + expect(secondScroll.scrollTop).toBe(299); + expect(secondArticle.dataset.lastJumpTarget).toBe('using-macros'); + }); + + it('falls back to heading text slug when heading id does not match hash', async () => { + const { container } = render( + + + + ); + + const targetHeading = await screen.findByRole('heading', { level: 2, name: 'Using scripting (early access)' }); + targetHeading.id = 'unexpected-id'; + + const tocLink = container.querySelector('a[href="#using-scripting-early-access"]') as HTMLAnchorElement; + expect(tocLink).not.toBeNull(); + + const scrollContainer = container.querySelector('.documentation-scroll') as HTMLElement; + Object.defineProperty(scrollContainer, 'scrollTop', { + value: 50, + writable: true, + }); + + Object.defineProperty(scrollContainer, 'getBoundingClientRect', { + value: () => ({ top: 100, left: 0, width: 800, height: 600, right: 800, bottom: 700, x: 0, y: 100, toJSON: () => ({}) }), + writable: true, + }); + + Object.defineProperty(targetHeading, 'getBoundingClientRect', { + value: () => ({ top: 450, left: 0, width: 200, height: 24, right: 200, bottom: 474, x: 0, y: 450, toJSON: () => ({}) }), + writable: true, + }); + + fireEvent.click(tocLink); + + expect(scrollContainer.scrollTop).toBe(388); + expect(targetHeading.id).toBe('using-scripting-early-access'); + }); + + it('shows a copy button for fenced code blocks and copies the block content', async () => { + render( + + + + ); + + const copyButtons = await screen.findAllByRole('button', { name: /copy code/i }); + expect(copyButtons.length).toBeGreaterThan(0); + + fireEvent.click(copyButtons[0]); + + expect(navigator.clipboard.writeText).toHaveBeenCalled(); + expect(window.open).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/renderer/documentationStructure.test.ts b/tests/renderer/documentationStructure.test.ts index b2686a1..a47fdc7 100644 --- a/tests/renderer/documentationStructure.test.ts +++ b/tests/renderer/documentationStructure.test.ts @@ -21,6 +21,11 @@ describe('documentation structure and presentation', () => { const markdown = readFileSync(docPath, 'utf8'); expect(markdown).toContain('## Using macros'); + expect(markdown).toContain('### YouTube macro'); + expect(markdown).toContain('### Vimeo macro'); + expect(markdown).toContain('### Gallery macro'); + expect(markdown).toContain('### Photo archive macro'); + expect(markdown).toContain('### Tag cloud macro'); expect(markdown).toContain('[[youtube'); expect(markdown).toContain('[[vimeo'); expect(markdown).toContain('[[gallery');