Last active
June 20, 2025 20:17
-
-
Save markgarrigan/f939e252a49dd899efe91f93f3a9969b to your computer and use it in GitHub Desktop.
Markdown docs site in Angular App
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * @typedef TOCItem | |
| * @property {number} level.required - Heading level (1 for h1, 2 for h2, etc.) | |
| * @property {string} text.required - Text of the heading | |
| * @property {string} slug.required - Slugified ID used for anchor navigation | |
| */ | |
| /** | |
| * @typedef PatternIndexItem | |
| * @property {number} id.required - Unique identifier for the page | |
| * @property {string} title.required - Title of the page (from front matter or filename) | |
| * @property {string} path.required - Slugified path to the file (e.g. components/button) | |
| */ | |
| /** | |
| * @typedef SearchResult | |
| * @property {string} title.required - Title of the matching file | |
| * @property {string} path.required - Slugified path to the match | |
| * @property {string} snippet.required - Snippet of content showing the match (with <mark>) | |
| */ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| export type PatternNode = PatternDirectory | PatternFile; | |
| export interface PatternDirectory { | |
| type: 'directory'; | |
| name: string; | |
| slug: string; | |
| children: PatternNode[]; | |
| } | |
| export interface PatternFile { | |
| type: 'file'; | |
| name: string; // e.g. "intro.md" | |
| title: string; // from front matter or fallback | |
| slug: string; // slugified filename | |
| path: string; // e.g. "guides/intro.md" | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <div class="flex flex-col md:flex-row gap-8"> | |
| <!-- TOC Sidebar --> | |
| <aside *ngIf="toc.length" class="w-full md:w-64 shrink-0 border-l border-gray-200 pl-4 sticky top-20 max-h-screen overflow-y-auto text-sm"> | |
| <h2 class="text-xs font-semibold uppercase text-gray-500 mb-2">On this page</h2> | |
| <ul class="space-y-1"> | |
| <li *ngFor="let item of toc"> | |
| <a | |
| [href]="'#' + item.slug" | |
| [ngClass]="{ | |
| 'pl-0': item.level === 1, | |
| 'pl-2': item.level === 2, | |
| 'pl-4': item.level === 3, | |
| 'font-bold text-blue-600': activeSlug === item.slug, | |
| 'text-gray-600 hover:text-black': activeSlug !== item.slug | |
| }" | |
| class="block transition-colors" | |
| > | |
| {{ item.text }} | |
| </a> | |
| </li> | |
| </ul> | |
| <!-- Back to top link --> | |
| <div class="mt-4 pt-2 border-t border-gray-100"> | |
| <a href="#top" class="text-xs text-gray-500 hover:text-black">↑ Back to top</a> | |
| </div> | |
| </aside> | |
| <!-- Markdown Content --> | |
| <article class="prose max-w-none flex-1" id="top"> | |
| <h1 class="text-3xl font-bold mb-4">{{ title }}</h1> | |
| <div [innerHTML]="html"></div> | |
| </article> | |
| </div> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { | |
| Component, | |
| OnInit, | |
| AfterViewInit, | |
| ElementRef, | |
| ViewChild, | |
| } from '@angular/core'; | |
| import { ActivatedRoute } from '@angular/router'; | |
| import { HttpClient } from '@angular/common/http'; | |
| import { marked } from 'marked'; | |
| @Component({ | |
| selector: 'app-pattern-viewer', | |
| standalone: true, | |
| imports: [], | |
| templateUrl: './pattern-viewer.component.html', | |
| }) | |
| export class PatternViewerComponent implements OnInit, AfterViewInit { | |
| html = ''; | |
| title = ''; | |
| toc: { level: number; text: string; slug: string }[] = []; | |
| activeSlug = ''; | |
| @ViewChild('content', { static: false }) contentRef!: ElementRef; | |
| constructor( | |
| private route: ActivatedRoute, | |
| private http: HttpClient | |
| ) {} | |
| ngOnInit() { | |
| this.route.url.subscribe(() => { | |
| const slug = this.route.snapshot.url.map(s => s.path).join('/'); | |
| this.http.get<any>(`/api/patterns/${slug}`).subscribe(data => { | |
| this.title = data.attributes.title || slug; | |
| this.toc = data.toc; | |
| this.html = this.generateAnchoredHTML(data.body); | |
| setTimeout(() => this.observeHeadings(), 50); | |
| }); | |
| }); | |
| } | |
| ngAfterViewInit() { | |
| setTimeout(() => this.observeHeadings(), 100); | |
| } | |
| generateAnchoredHTML(markdown: string): string { | |
| marked.use({ | |
| renderer: { | |
| heading(text, level) { | |
| if (level > 3) return `<h${level}>${text}</h${level}>`; | |
| const slug = text | |
| .toLowerCase() | |
| .replace(/[^\w\s-]/g, '') | |
| .trim() | |
| .replace(/\s+/g, '-'); | |
| return `<h${level} id="${slug}" class="scroll-mt-20">${text}</h${level}>`; | |
| }, | |
| }, | |
| }); | |
| return marked(markdown); | |
| } | |
| observeHeadings() { | |
| if (!this.contentRef?.nativeElement) return; | |
| const headings = Array.from( | |
| this.contentRef.nativeElement.querySelectorAll('h1[id], h2[id], h3[id]') | |
| ) as HTMLElement[]; | |
| if (!headings.length) return; | |
| const observer = new IntersectionObserver( | |
| entries => { | |
| const visible = entries.find(entry => entry.isIntersecting); | |
| if (visible?.target?.id) { | |
| this.activeSlug = visible.target.id; | |
| } | |
| }, | |
| { | |
| rootMargin: '-40% 0px -50% 0px', | |
| threshold: [0], | |
| } | |
| ); | |
| headings.forEach(h => observer.observe(h)); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| const express = require('express'); | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const fm = require('front-matter'); | |
| const FlexSearch = require('flexsearch'); | |
| const router = express.Router(); | |
| const PATTERNS_DIR = path.join(__dirname, '../patterns'); | |
| let flatIndex = []; | |
| let searchIndex = new FlexSearch.Document({ | |
| document: { | |
| id: 'id', | |
| index: ['title', 'body'], | |
| store: ['title', 'path'], | |
| }, | |
| }); | |
| function slugify(str) { | |
| return str | |
| .toLowerCase() | |
| .replace(/\.md$/, '') | |
| .replace(/[^\w\s-]/g, '') | |
| .trim() | |
| .replace(/\s+/g, '-') | |
| .replace(/-+/g, '-'); | |
| } | |
| function generateTOC(markdown) { | |
| const lines = markdown.split('\n'); | |
| const toc = []; | |
| for (const line of lines) { | |
| const match = line.match(/^(#{1,6})\s+(.*)$/); | |
| if (match) { | |
| const level = match[1].length; | |
| if (level <= 3) { | |
| const text = match[2].trim(); | |
| const slug = slugify(text); | |
| toc.push({ level, text, slug }); | |
| } | |
| } | |
| } | |
| return toc; | |
| } | |
| function buildTree(dir, parentSlugs = []) { | |
| const entries = fs.readdirSync(dir, { withFileTypes: true }); | |
| return entries.map(entry => { | |
| const fullPath = path.join(dir, entry.name); | |
| const slug = slugify(entry.name); | |
| if (entry.isDirectory()) { | |
| return { | |
| type: 'directory', | |
| name: entry.name, | |
| slug, | |
| children: buildTree(fullPath, [...parentSlugs, slug]), | |
| }; | |
| } | |
| if (entry.isFile() && entry.name.endsWith('.md')) { | |
| const raw = fs.readFileSync(fullPath, 'utf8'); | |
| const parsed = fm(raw); | |
| const fileSlug = slugify(entry.name); | |
| const pathSlugs = [...parentSlugs, fileSlug]; | |
| return { | |
| type: 'file', | |
| name: entry.name, | |
| title: parsed.attributes.title || entry.name, | |
| slug: fileSlug, | |
| path: pathSlugs.join('/'), | |
| }; | |
| } | |
| return null; | |
| }).filter(Boolean); | |
| } | |
| function buildFlatIndex(dir, parentSlugs = []) { | |
| const entries = fs.readdirSync(dir, { withFileTypes: true }); | |
| for (const entry of entries) { | |
| const fullPath = path.join(dir, entry.name); | |
| const slug = slugify(entry.name); | |
| if (entry.isDirectory()) { | |
| buildFlatIndex(fullPath, [...parentSlugs, slug]); | |
| } else if (entry.isFile() && entry.name.endsWith('.md')) { | |
| const raw = fs.readFileSync(fullPath, 'utf8'); | |
| const parsed = fm(raw); | |
| const title = parsed.attributes.title || entry.name.replace('.md', ''); | |
| const pathSlug = [...parentSlugs, slug].join('/'); | |
| const id = flatIndex.length + 1; | |
| const item = { | |
| id, | |
| title, | |
| slug, | |
| path: pathSlug, | |
| body: parsed.body, | |
| }; | |
| flatIndex.push(item); | |
| searchIndex.add(item); | |
| } | |
| } | |
| } | |
| buildFlatIndex(PATTERNS_DIR); | |
| /** | |
| * GET /api/patterns | |
| * @summary Get nested tree of markdown files and folders | |
| * @tags patterns | |
| * @return {array<object>} 200 - Nested file tree - application/json | |
| */ | |
| router.get('/patterns', (req, res) => { | |
| try { | |
| const tree = buildTree(PATTERNS_DIR); | |
| res.json(tree); | |
| } catch (err) { | |
| res.status(500).json({ error: 'Failed to build pattern tree', details: err.message }); | |
| } | |
| }); | |
| /** | |
| * GET /api/patterns/index | |
| * @summary Get flat index of all markdown files | |
| * @tags patterns | |
| * @return {array<PatternIndexItem>} 200 - Flat index - application/json | |
| */ | |
| router.get('/patterns/index', (req, res) => { | |
| res.json(flatIndex.map(({ id, title, path }) => ({ id, title, path }))); | |
| }); | |
| /** | |
| * GET /api/patterns/search | |
| * @summary Search markdown content and return matched files with snippets | |
| * @tags patterns | |
| * @param {string} q.query.required - Search query | |
| * @return {array<SearchResult>} 200 - Matched content results - application/json | |
| */ | |
| router.get('/patterns/search', (req, res) => { | |
| const q = req.query.q; | |
| if (!q) return res.json([]); | |
| const query = q.trim(); | |
| const results = searchIndex.search(query, { enrich: true }); | |
| const flat = results.flatMap(r => r.result).map(id => { | |
| const file = flatIndex.find(f => f.id === id); | |
| if (!file) return null; | |
| const snippetRegex = new RegExp(`(.{0,40})(${query})(.{0,40})`, 'i'); | |
| const match = file.body.match(snippetRegex); | |
| const snippet = match | |
| ? `${match[1]}<mark>${match[2]}</mark>${match[3]}` | |
| : file.body.slice(0, 120) + '...'; | |
| return { | |
| title: file.title, | |
| path: file.path, | |
| snippet, | |
| }; | |
| }).filter(Boolean); | |
| res.json(flat); | |
| }); | |
| /** | |
| * GET /api/patterns/{slug*} | |
| * @summary Get a specific markdown file and its TOC | |
| * @tags patterns | |
| * @param {string} slug*.path.required - Slug path to markdown file | |
| * @return {object} 200 - Pattern markdown content - application/json | |
| * @return {object} 404 - Not found | |
| */ | |
| router.get('/patterns/:slug*', (req, res) => { | |
| try { | |
| const slugs = req.params.slug ?? []; | |
| const resolvedPath = path.join(PATTERNS_DIR, ...slugs) + '.md'; | |
| if (!resolvedPath.startsWith(PATTERNS_DIR)) { | |
| return res.status(400).json({ error: 'Invalid path' }); | |
| } | |
| if (!fs.existsSync(resolvedPath)) { | |
| return res.status(404).json({ error: 'Pattern not found' }); | |
| } | |
| const raw = fs.readFileSync(resolvedPath, 'utf8'); | |
| const parsed = fm(raw); | |
| const toc = generateTOC(parsed.body); | |
| res.json({ | |
| attributes: parsed.attributes, | |
| body: parsed.body, | |
| toc, | |
| }); | |
| } catch (err) { | |
| res.status(500).json({ error: 'Failed to load pattern', details: err.message }); | |
| } | |
| }); | |
| module.exports = router; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!-- Trigger Input --> | |
| <div class="relative"> | |
| <input | |
| type="search" | |
| placeholder="Search documentation..." | |
| class="w-full p-2 pl-4 pr-20 rounded border" | |
| (focus)="open()" | |
| /> | |
| <kbd | |
| class="absolute right-2 top-1/2 -translate-y-1/2 text-xs bg-gray-100 border border-gray-300 px-2 py-0.5 rounded text-gray-600" | |
| >/</kbd> | |
| </div> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { | |
| Component, | |
| HostListener, | |
| OnInit, | |
| ViewChild, | |
| ElementRef, | |
| } from '@angular/core'; | |
| import { CommonModule } from '@angular/common'; | |
| import { HttpClient } from '@angular/common/http'; | |
| import { Router } from '@angular/router'; | |
| import { debounceTime, distinctUntilChanged, Subject } from 'rxjs'; | |
| @Component({ | |
| selector: 'app-search-modal', | |
| standalone: true, | |
| imports: [CommonModule], | |
| templateUrl: './search-modal.component.html', | |
| }) | |
| export class SearchModalComponent implements OnInit { | |
| query = ''; | |
| results: any[] = []; | |
| activeIndex = 0; | |
| isOpen = false; | |
| private queryChanged = new Subject<string>(); | |
| @ViewChild('searchInput') searchInput!: ElementRef; | |
| constructor(private http: HttpClient, private router: Router) {} | |
| ngOnInit() { | |
| this.queryChanged | |
| .pipe(debounceTime(200), distinctUntilChanged()) | |
| .subscribe(query => { | |
| if (query.trim().length === 0) { | |
| this.results = []; | |
| return; | |
| } | |
| this.http | |
| .get<any[]>(`/api/patterns/search?q=${encodeURIComponent(query)}`) | |
| .subscribe(data => { | |
| this.results = data; | |
| this.activeIndex = 0; | |
| }); | |
| }); | |
| } | |
| open() { | |
| this.isOpen = true; | |
| setTimeout(() => this.searchInput?.nativeElement?.focus(), 50); | |
| } | |
| close() { | |
| this.isOpen = false; | |
| this.results = []; | |
| this.query = ''; | |
| } | |
| onInput() { | |
| this.queryChanged.next(this.query); | |
| } | |
| @HostListener('document:keydown.escape') | |
| onEscape() { | |
| if (this.isOpen) this.close(); | |
| } | |
| @HostListener('document:keydown.arrowdown') | |
| onArrowDown() { | |
| if (!this.isOpen) return; | |
| this.activeIndex = (this.activeIndex + 1) % this.results.length; | |
| } | |
| @HostListener('document:keydown.arrowup') | |
| onArrowUp() { | |
| if (!this.isOpen) return; | |
| this.activeIndex = (this.activeIndex - 1 + this.results.length) % this.results.length; | |
| } | |
| @HostListener('document:keydown.enter') | |
| onEnter() { | |
| if (!this.isOpen || !this.results[this.activeIndex]) return; | |
| const path = this.results[this.activeIndex].path; | |
| this.router.navigate(['/patterns', path.split('/')]); | |
| this.close(); | |
| } | |
| @HostListener('document:keydown', ['$event']) | |
| onGlobalKey(event: KeyboardEvent) { | |
| if ( | |
| event.key === '/' && | |
| !this.isOpen && | |
| document.activeElement?.tagName !== 'INPUT' && | |
| document.activeElement?.tagName !== 'TEXTAREA' | |
| ) { | |
| event.preventDefault(); | |
| this.open(); | |
| } | |
| } | |
| selectResult(index: number) { | |
| this.activeIndex = index; | |
| const path = this.results[index].path; | |
| this.router.navigate(['/patterns', path.split('/')]); | |
| this.close(); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <ul class="pl-4 space-y-1"> | |
| <li *ngFor="let node of nodes"> | |
| <div class="flex items-center gap-1"> | |
| <ng-container *ngIf="node.type === 'directory'"> | |
| <button (click)="toggle(node)" class="text-gray-500 hover:text-black"> | |
| <i [ngClass]="node.open ? 'ri-folder-5-line' : 'ri-folder-3-line'"></i> | |
| </button> | |
| <span class="font-semibold">{{ node.name }}</span> | |
| </ng-container> | |
| <ng-container *ngIf="node.type === 'file'"> | |
| <i class="ri-article-line text-gray-500"></i> | |
| <a | |
| [routerLink]="[basePath, ...node.path.split('/')]" | |
| class="text-blue-600 hover:underline" | |
| > | |
| {{ node.title }} | |
| </a> | |
| </ng-container> | |
| </div> | |
| <div *ngIf="node.children && node.open" class="pl-4"> | |
| <patterns-tree | |
| [nodes]="node.children" | |
| [basePath]="basePath" | |
| [expandedPaths]="expandedPaths" | |
| ></patterns-tree> | |
| </div> | |
| </li> | |
| </ul> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { Component, Input } from '@angular/core'; | |
| import { Router, RouterModule } from '@angular/router'; | |
| import { CommonModule } from '@angular/common'; | |
| @Component({ | |
| selector: 'patterns-tree', | |
| standalone: true, | |
| imports: [CommonModule, RouterModule], | |
| templateUrl: './pattern-tree.component.html', | |
| }) | |
| export class PatternsTreeComponent { | |
| @Input() nodes: any[] = []; | |
| @Input() basePath = '/patterns'; | |
| @Input() expandedPaths: string[] = []; | |
| toggle(node: any) { | |
| node.open = !node.open; | |
| } | |
| isExpanded(path: string): boolean { | |
| return this.expandedPaths.includes(path); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment