Skip to content

Instantly share code, notes, and snippets.

@markgarrigan
Last active June 20, 2025 20:17
Show Gist options
  • Select an option

  • Save markgarrigan/f939e252a49dd899efe91f93f3a9969b to your computer and use it in GitHub Desktop.

Select an option

Save markgarrigan/f939e252a49dd899efe91f93f3a9969b to your computer and use it in GitHub Desktop.
Markdown docs site in Angular App
/**
* @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>)
*/
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"
}
<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>
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));
}
}
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;
<!-- 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>
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();
}
}
<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>
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