🌳
pt0/peatsite/renderSourcePageAI.mjs
1import fs from 'fs'
2import path from 'path'
3import { codeToTokens } from 'shiki'
4import { baseStyles } from './siteStylesF.mjs'
6import { ptDir } from '../serverF/ptDirF.mts'
8const escHtml = s => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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'}
50 })
53const findExportLine = (targetContent, exportName, isDefault, isStar) => {
54 if (isStar) return 1
55 const lines = targetContent.split('\n')
56 const needle = isDefault ? 'default' : exportName
57 for (let i = 0; i < lines.length; i++) {
58 const l = lines[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
61 }
62 return null
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$/)
80 if (!strMatch) break
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)})
85 break
86 }
87 }
88 })
89 return anchorsByLine
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+['"](\.\.?\/[^'"]+)['"]/)
101 if (!m) return
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}`}
113 })
114 importsByLine.set(i + 1, {importPath, resolved: pt(diskResolved), linkedNames})
115 })
116 return importsByLine
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)
123 }
125 const linesHtml = tokens.map((lineTokens, i) => {
126 const lineNo = i + 1
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>`
138 }
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>`
143 }
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>`)
151 }
152 }
153 return `<span ${style}>${linkedText}</span>`
154 }
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>`
161 }).join('')
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>`
164 })
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'
179 try {
180 const result = await codeToTokens(content, {lang, theme: 'github-dark'})
181 tokens = result.tokens
182 bgColor = result.bg || bgColor
183 } catch {
184 const result = await codeToTokens(content, {lang: 'text', theme: 'github-dark'})
185 tokens = result.tokens
186 bgColor = result.bg || bgColor
187 }
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>
194<html>
195<head>
196 <meta charset="UTF-8">
198 <title>${filePath}</title>
199 <style>${srcPageStyles}</style>
200</head>
201<body>
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>
206 </div>
207</body>
208</html>`