3import { codeToTokens } from 'shiki' 8const escHtml = s => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') 10/** @typedef {{contentTransform?: ((s: string) => string) | null, pathTransform?: (p: string) => string, pathReverse?: (p: string) => string}} SrcPageOpts */ 12const langFromExt = (filePath) => { 13 const ext = filePath.split('.').pop() 14 const langMap = {mjs: 'javascript', mts: 'typescript', jsx: 'tsx', tsx: 'tsx', ts: 'typescript', js: 'javascript', json: 'json', md: 'markdown', css: 'css', html: 'html', sh: 'bash', yml: 'yaml', yaml: 'yaml', sql: 'sql', conf: 'nginx', toml: 'toml', Dockerfile: 'dockerfile'} 15 return langMap[ext] || 'text' 18const srcPageStyles = ` 20body { padding: 20px 40px; } 21.src-container { position: relative; } 22.src-container pre.shiki { margin: 0; padding: 16px 0; border-radius: 6px; font-size: 13px; line-height: 1.5; } 23.src-container pre.shiki code { display: block; } 24.line-row { display: flex; } 25.line-row:hover { background: rgba(255,255,255,0.04); } 26.line-row:target { background: rgba(255,210,0,0.08); } 27.line-num { flex-shrink: 0; width: 50px; text-align: right; padding-right: 16px; color: #555; user-select: none; font-size: 12px; } 28.line-num a { color: inherit; text-decoration: none; } 29.line-num a:hover { color: #9ECBFF; } 30.src-container .line { flex: 1; min-width: 0; white-space: pre-wrap; word-wrap: break-word; } 31.filename { font-size: 1.1em; color: #ccc; margin-bottom: 16px; font-family: monospace; } 32.home-link { position: absolute; top: 6px; left: 6px; text-decoration: none; font-size: 38px; opacity: 0.7; transition: opacity 0.2s; } 33.home-link:hover { opacity: 1; color: #fff; } 34.import-link { color: inherit; text-decoration: none; border-bottom: 1px dotted #555; } 35.import-link:hover { border-bottom-color: #9ECBFF; } 36.ident-link { color: inherit; text-decoration: none; border-bottom: 1px dotted rgba(255,255,255,0.2); } 37.ident-link:hover { border-bottom-color: #E1E4E8; } 40const parseImportedNames = (line) => { 41 const defaultM = line.match(/^import\s+(\w+)\s+from\s/) 42 if (defaultM) return [{name: defaultM[1], isDefault: true}] 43 const starM = line.match(/^import\s+\*\s+as\s+(\w+)\s+from\s/) 44 if (starM) return [{name: starM[1], isStar: true}] 45 const namedM = line.match(/\{\s*([^}]+)\}\s*from\s/) 46 if (!namedM) return [] 47 return namedM[1].split(',').map(s => s.trim()).filter(Boolean).map(s => { 48 const [orig, alias] = s.split(/\s+as\s+/) 49 return {name: alias || orig, exportName: orig, isDefault: orig === 'default'} 53const findExportLine = (targetContent, exportName, isDefault, isStar) => { 55 const lines = targetContent.split('\n') 56 const needle = isDefault ? 'default' : exportName 57 for (let i = 0; i < lines.length; i++) { 59 if (isDefault && /^export\s+default\b/.test(l)) return i + 1 60 if (!isDefault && new RegExp(`^export\\s.*\\b${needle}\\b`).test(l)) return i + 1 65/** @param {SrcPageOpts} [opts] */ 66const scanAnchorIncludes = (tokens, displayFilePath, opts = {}) => { 67 const {pathTransform, pathReverse} = opts 68 const pt = pathTransform || (p => p) 69 const pr = pathReverse || (p => p) 70 const fileDir = path.dirname(displayFilePath) 71 const anchorsByLine = new Map() 72 tokens.forEach((lineTokens, lineIdx) => { 73 for (let i = 0; i < lineTokens.length - 2; i++) { 74 const t = lineTokens[i] 75 if (t.content !== 'ptAnchorInclude') continue 76 for (let j = i + 1; j < Math.min(i + 4, lineTokens.length); j++) { 77 const next = lineTokens[j] 78 if (next.content.trim() === '' || next.content === '(') continue 79 const strMatch = next.content.match(/^(['"])(.+)\1$/) 81 const relPath = strMatch[2] 82 const displayResolved = path.normalize(path.join(fileDir, relPath)) 83 const diskResolved = pr(displayResolved) 84 anchorsByLine.set(lineIdx + 1, {relPath, quoted: next.content, resolved: pt(diskResolved)}) 92/** @param {SrcPageOpts} [opts] */ 93const scanImports = (source, displayFilePath, opts = {}) => { 94 const {contentTransform, pathTransform, pathReverse} = opts 95 const pt = pathTransform || (p => p) 96 const pr = pathReverse || (p => p) 97 const fileDir = path.dirname(displayFilePath) 98 const importsByLine = new Map() 99 source.split('\n').forEach((line, i) => { 100 const m = line.match(/from\s+['"](\.\.?\/[^'"]+)['"]/) || line.match(/import\s+['"](\.\.?\/[^'"]+)['"]/) 102 const importPath = m[1] 103 const displayResolved = path.normalize(path.join(fileDir, importPath)) 104 const diskResolved = pr(displayResolved) 105 const names = parseImportedNames(line) 106 const targetAbs = path.join(ptDir, diskResolved) 107 let targetContent = fs.existsSync(targetAbs) ? fs.readFileSync(targetAbs, 'utf8') : null 108 if (targetContent && contentTransform) targetContent = contentTransform(targetContent) 109 const linkedNames = names.map(n => { 110 const exportLine = targetContent ? findExportLine(targetContent, n.exportName || n.name, n.isDefault, n.isStar) : null 111 const hash = exportLine ? `#L${exportLine}` : '' 112 return {...n, href: `/src/${pt(diskResolved)}${hash}`} 114 importsByLine.set(i + 1, {importPath, resolved: pt(diskResolved), linkedNames}) 119const buildLinesHtml = (tokens, importsByLine, anchorsByLine, bgColor) => { 120 const identMap = new Map() 121 for (const info of importsByLine.values()) { 122 for (const n of info.linkedNames) identMap.set(n.name, n.href) 125 const linesHtml = tokens.map((lineTokens, i) => { 127 const importInfo = importsByLine.get(lineNo) 128 const anchorInfo = anchorsByLine.get(lineNo) 130 const tokensHtml = lineTokens.map(tok => { 131 const text = escHtml(tok.content) 132 const style = tok.color ? `style="color:${tok.color}"` : '' 134 // Check if this token is an import path string 135 if (importInfo && tok.content.includes(importInfo.importPath)) { 136 const linked = text.replace(escHtml(importInfo.importPath), `<a href="/src/${importInfo.resolved}" class="import-link">${escHtml(importInfo.importPath)}</a>`) 137 return `<span ${style}>${linked}</span>` 140 // Check if this token is a ptAnchorInclude path string 141 if (anchorInfo && tok.content === anchorInfo.quoted) { 142 return `<span ${style}><a href="/src/${anchorInfo.resolved}" class="import-link">${text}</a></span>` 145 // Check if this token contains imported identifiers (on import line) 146 if (importInfo && importInfo.linkedNames.some(n => tok.content.includes(n.name))) { 147 let linkedText = text 148 for (const n of importInfo.linkedNames) { 149 if (tok.content.includes(n.name)) { 150 linkedText = linkedText.replace(new RegExp(`\\b${escHtml(n.name)}\\b`, 'g'), `<a href="${n.href}" class="ident-link">${escHtml(n.name)}</a>`) 153 return `<span ${style}>${linkedText}</span>` 156 // Check if this token matches any imported identifier (usage elsewhere) 157 const identHref = identMap.get(tok.content) 158 if (identHref) return `<span ${style}><a href="${identHref}" class="ident-link">${text}</a></span>` 160 return `<span ${style}>${text}</span>` 163 return `<div class="line-row" id="L${lineNo}"><span class="line-num"><a href="#L${lineNo}">${lineNo}</a></span><span class="line">${tokensHtml}</span></div>` 166 return `<pre class="shiki github-dark" style="background-color:${bgColor};color:#e1e4e8" tabindex="0"><code>${linesHtml.join('')}</code></pre>` 169const jsShebangA = ['#!/usr/bin/env ptnode', '#!/usr/bin/env node'] 171/** @param {SrcPageOpts} [opts] */ 172export const renderSourcePage = async (filePath, content, opts = {}) => { 173 const {contentTransform, pathTransform, pathReverse} = opts 174 const firstLine = content.split('\n', 1)[0] 175 const lang = filePath.endsWith('Dockerfile') ? 'dockerfile' 176 : jsShebangA.some(s => firstLine.startsWith(s)) ? 'javascript' 177 : langFromExt(filePath) 178 let tokens = [], bgColor = '#24292e' 180 const result = await codeToTokens(content, {lang, theme: 'github-dark'}) 181 tokens = result.tokens 182 bgColor = result.bg || bgColor 184 const result = await codeToTokens(content, {lang: 'text', theme: 'github-dark'}) 185 tokens = result.tokens 186 bgColor = result.bg || bgColor 189 const importsByLine = scanImports(content, filePath, {contentTransform, pathTransform, pathReverse}) 190 const anchorsByLine = scanAnchorIncludes(tokens, filePath, {pathTransform, pathReverse}) 191 const linesHtml = buildLinesHtml(tokens, importsByLine, anchorsByLine, bgColor) 193 return `<!DOCTYPE html> 196 <meta charset="UTF-8"> 198 <title>${filePath}</title> 199 <style>${srcPageStyles}</style> 202 <a href="/" class="home-link">🌳</a> 203 <div style="padding-top: 40px;"> 204 <div class="filename">${filePath}</div> 205 <div class="src-container">${linesHtml}</div>