GistFileExplorer

2 variants. Open one in isolation: use the link on each card.

NestedPaths

Open alone →
src/index.tsCopied!4 lines · 53 B
export function main(): void {
console.log('ok')
}
src/util/helpers.tsCopied!2 lines · 31 B
export const VERSION = '1.0.0'
config.yamlCopied!3 lines · 22 B
service:
port: 4321

src/ui/2_organisms/GistFileExplorer.astro
---
import { Code } from 'astro-expressive-code/components'
import type { GistFileFigure } from '../../lib/gist-files'
import { buildGistFileTreeRootFromDisplayPaths } from '../../lib/gist-file-tree-model'
import GistExplorerTreeList from './GistExplorerTreeList.astro'
import '../../styles/gist-explorer.css'
/**
* `codeSlotExpressiveHtml` is the full trusted `<Code>` HTML (Storybook static prerender only).
* Production gist pages omit it and use `<Code>`.
*/
type GistFileExplorerPanel = GistFileFigure & {
codeSlotExpressiveHtml?: string
}
interface Props {
panels: GistFileExplorerPanel[]
}
const { panels } = Astro.props
const gistFileTreeRoot =
panels.length > 0 ? buildGistFileTreeRootFromDisplayPaths(panels.map(p => p.displayPath)) : null
function gistPanelLineCount(code: string): number {
if (code.length === 0) return 0
return code.split('\n').length
}
function gistPanelByteLength(code: string): number {
return new TextEncoder().encode(code).length
}
function formatGistFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
return `${(bytes / 1024).toFixed(1)} KB`
}
function gistCaptionFileStats(code: string): string {
const lineCount = gistPanelLineCount(code)
const lineLabel = lineCount === 1 ? 'line' : 'lines'
return `${lineCount} ${lineLabel} · ${formatGistFileSize(gistPanelByteLength(code))}`
}
---
{
panels.length > 0 && gistFileTreeRoot ? (
<div class="not-prose gist-files-block">
<div class="gist-source-files">
<div class="gist-editor-shell gist-editor-shell--explorer">
<div class="gist-explorer-chrome">
<div class="gist-explorer-toolbar meta">
<span class="gist-explorer-toolbar-spacer" aria-hidden="true" />
<button
type="button"
class="gist-line-wrap-toggle"
id="gist-line-wrap-toggle"
aria-pressed="false"
aria-controls="gist-source-code-panels"
>
Line wrap
</button>
</div>
</div>
<div class="gist-explorer-body">
<div class="gist-file-tree-wrap">
<div class="gist-file-tree-scroll">
<nav id="gist-source-file-tree" class="gist-file-tree" aria-label="Gist source files">
<div class="gist-file-tree-header">
<div class="gist-file-tree-header-top">
<span class="gist-file-tree-heading">Files</span>
<button
type="button"
class="gist-file-tree-sidebar-toggle"
aria-expanded="true"
aria-controls="gist-file-tree-panel"
aria-label="Collapse file list"
>
<span class="gist-file-tree-sidebar-toggle-icon" aria-hidden="true" />
</button>
</div>
<div class="gist-file-tree-search-wrap">
<input
type="search"
class="gist-file-tree-filter"
placeholder="Go to file"
autocomplete="off"
aria-label="Filter source files"
/>
</div>
</div>
<GistExplorerTreeList entries={gistFileTreeRoot.children} pathPrefix="" isRoot />
</nav>
</div>
</div>
<div class="gist-editor-code" id="gist-source-code-panels">
{panels.map(panel => (
<figure
class="code-file not-prose"
data-gist-file={panel.displayPath}
data-gist-lines={String(gistPanelLineCount(panel.code))}
data-gist-bytes={String(gistPanelByteLength(panel.code))}
>
<figcaption class="code-filename meta">
<span class="gist-filename-row">
<button
type="button"
class="gist-file-tree-expand-btn"
aria-label="Show file list"
aria-controls="gist-source-file-tree"
>
<span class="gist-file-tree-expand-btn-icon" aria-hidden="true" />
</button>
<span class="gist-code-caption-path">{panel.displayPath}</span>
<span class="gist-caption-path-actions">
<button
type="button"
class="gist-copy-path-btn"
data-gist-copy-path={panel.displayPath}
aria-label="Copy file path to clipboard"
title="Copy path"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</svg>
</button>
<span class="gist-copy-path-feedback" aria-live="polite">Copied!</span>
</span>
</span>
<span class="gist-caption-file-stats meta">{gistCaptionFileStats(panel.code)}</span>
</figcaption>
{panel.codeSlotExpressiveHtml != null && panel.codeSlotExpressiveHtml.trim().length > 0 ? (
<Fragment set:html={panel.codeSlotExpressiveHtml} />
) : (
<Code code={panel.code} lang={panel.lang} meta='frame="none"' showLineNumbers={true} wrap={false} />
)}
</figure>
))}
</div>
</div>
</div>
</div>
</div>
) : null
}
<script>
document.addEventListener('DOMContentLoaded', () => {
const filesBlock = document.querySelector<HTMLElement>('.gist-files-block')
if (!filesBlock) return
const codePane = filesBlock.querySelector('.gist-editor-code')
if (!(codePane instanceof HTMLElement)) return
const GIST_LINE_WRAP_STORAGE_KEY = 'blog:gist-line-wrap'
const GIST_FILE_TREE_PANEL_ID = 'gist-file-tree-panel'
function readStoredLineWrap(): boolean {
try {
return window.localStorage.getItem(GIST_LINE_WRAP_STORAGE_KEY) === '1'
} catch {
return false
}
}
function persistLineWrap(enabled: boolean) {
try {
window.localStorage.setItem(GIST_LINE_WRAP_STORAGE_KEY, enabled ? '1' : '0')
} catch {
/* ignore quota / private mode */
}
}
function gistCodePres(): HTMLElement[] {
return Array.from(codePane.querySelectorAll<HTMLElement>('.expressive-code pre'))
}
function applyGistLineWrap(enabled: boolean) {
for (const pre of gistCodePres()) {
if (enabled) pre.classList.add('wrap')
else pre.classList.remove('wrap')
}
}
const initialWrap = readStoredLineWrap()
applyGistLineWrap(initialWrap)
const figures = Array.from(codePane.querySelectorAll<HTMLElement>('figure.code-file'))
if (figures.length === 0) return
const multi = figures.length > 1
const fileTreeNav = filesBlock.querySelector<HTMLElement>('#gist-source-file-tree')
const explorerBody = filesBlock.querySelector<HTMLElement>('.gist-explorer-body')
const sidebarToggle = fileTreeNav?.querySelector<HTMLButtonElement>('.gist-file-tree-sidebar-toggle')
const filterInput = fileTreeNav?.querySelector<HTMLInputElement>('.gist-file-tree-filter')
function readHashPath(): string | null {
const raw = window.location.hash.slice(1)
if (!raw) return null
try {
return decodeURIComponent(raw)
} catch {
return raw
}
}
function setHashToFile(displayPath: string) {
const u = new URL(window.location.href)
u.hash = encodeURIComponent(displayPath)
history.replaceState(null, '', u.href)
}
function expandAncestorFolders(treeNav: HTMLElement, displayPath: string) {
const segments = displayPath.split('/').filter(Boolean)
for (let depth = 1; depth < segments.length; depth++) {
const dirPath = segments.slice(0, depth).join('/')
const folder = treeNav.querySelector<HTMLButtonElement>(
`.gist-tree-folder[data-gist-dir="${CSS.escape(dirPath)}"]`,
)
folder?.setAttribute('aria-expanded', 'true')
}
}
function setGistFileTreeCollapsed(collapsed: boolean) {
if (!explorerBody || !sidebarToggle) return
explorerBody.classList.toggle('gist-explorer-body--tree-collapsed', collapsed)
sidebarToggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true')
sidebarToggle.setAttribute('aria-label', collapsed ? 'Expand file list' : 'Collapse file list')
const panel = fileTreeNav?.querySelector<HTMLElement>(`#${CSS.escape(GIST_FILE_TREE_PANEL_ID)}`)
if (collapsed) {
panel?.setAttribute('aria-hidden', 'true')
} else {
panel?.removeAttribute('aria-hidden')
}
}
function selectIndex(index: number) {
const i = Math.max(0, Math.min(index, figures.length - 1))
figures.forEach((f, j) => {
f.hidden = multi && j !== i
})
fileTreeNav?.querySelectorAll<HTMLButtonElement>('.gist-tree-file').forEach(b => {
const j = Number(b.dataset.figureIndex)
if (j === i) {
b.setAttribute('aria-current', 'true')
} else {
b.removeAttribute('aria-current')
}
})
const path = figures[i]?.dataset.gistFile?.trim()
const explorerBodyEl = fileTreeNav?.closest('.gist-explorer-body')
const treeCollapsed = explorerBodyEl?.classList.contains('gist-explorer-body--tree-collapsed')
if (path && fileTreeNav && !treeCollapsed) {
expandAncestorFolders(fileTreeNav, path)
fileTreeNav
.querySelector<HTMLButtonElement>(
`.gist-tree-file[data-figure-index="${CSS.escape(String(i))}"]`,
)
?.scrollIntoView({ block: 'nearest' })
}
}
function applyFromHash(options?: { scroll?: boolean }) {
const wanted = readHashPath()
const idx = wanted ? figures.findIndex(f => (f.dataset.gistFile ?? '').trim() === wanted) : -1
const i = idx >= 0 ? idx : 0
selectIndex(i)
if (options?.scroll && idx >= 0) {
figures[i]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
}
sidebarToggle?.addEventListener('click', () => {
if (!explorerBody) return
const collapsed = !explorerBody.classList.contains('gist-explorer-body--tree-collapsed')
setGistFileTreeCollapsed(collapsed)
})
filterInput?.addEventListener('input', () => {
const raw = filterInput.value.trim().toLowerCase()
fileTreeNav?.querySelectorAll<HTMLButtonElement>('.gist-tree-file').forEach(btn => {
const li = btn.closest('li')
if (!li) return
const path = (btn.dataset.gistFilterPath ?? '').toLowerCase()
li.style.display = !raw || path.includes(raw) ? '' : 'none'
})
})
fileTreeNav?.querySelectorAll<HTMLButtonElement>('.gist-tree-folder').forEach(folderButton => {
folderButton.addEventListener('click', () => {
const open = folderButton.getAttribute('aria-expanded') === 'true'
folderButton.setAttribute('aria-expanded', open ? 'false' : 'true')
})
})
fileTreeNav?.querySelectorAll<HTMLButtonElement>('.gist-tree-file').forEach(fileButton => {
fileButton.addEventListener('click', () => {
const idx = Number(fileButton.dataset.figureIndex)
if (Number.isNaN(idx)) return
selectIndex(idx)
const path = fileButton.dataset.gistFilterPath?.trim()
if (path) setHashToFile(path)
})
})
figures.forEach((figure, i) => {
figure.setAttribute('role', 'region')
figure.setAttribute('aria-label', `Source: ${figure.dataset.gistFile?.trim() ?? `file ${i + 1}`}`)
if (multi && i !== 0) figure.hidden = true
})
function wireGistCopyPathButtons() {
codePane.querySelectorAll<HTMLButtonElement>('.gist-copy-path-btn').forEach(btn => {
const text = btn.dataset.gistCopyPath?.trim()
const feedback = btn.nextElementSibling
let copyPathTimer: ReturnType<typeof setTimeout> | null = null
btn.addEventListener('click', async () => {
if (!text) return
try {
await navigator.clipboard.writeText(text)
if (
feedback instanceof HTMLElement &&
feedback.classList.contains('gist-copy-path-feedback')
) {
if (copyPathTimer) clearTimeout(copyPathTimer)
feedback.style.opacity = '1'
copyPathTimer = setTimeout(() => {
feedback.style.opacity = '0'
}, 1500)
}
} catch {
/* clipboard unavailable */
}
})
})
}
wireGistCopyPathButtons()
codePane.querySelectorAll<HTMLButtonElement>('.gist-file-tree-expand-btn').forEach(btn => {
btn.addEventListener('click', () => {
setGistFileTreeCollapsed(false)
})
})
function wireGistLineWrapToggle() {
const toggle = document.getElementById('gist-line-wrap-toggle')
if (!toggle) return
toggle.setAttribute('aria-pressed', initialWrap ? 'true' : 'false')
toggle.addEventListener('click', () => {
const pressed = toggle.getAttribute('aria-pressed') === 'true'
const next = !pressed
toggle.setAttribute('aria-pressed', next ? 'true' : 'false')
persistLineWrap(next)
applyGistLineWrap(next)
})
}
wireGistLineWrapToggle()
const hadMatchingHash = (() => {
const wanted = readHashPath()
if (!wanted) return false
return figures.some(f => (f.dataset.gistFile ?? '').trim() === wanted)
})()
applyFromHash({ scroll: hadMatchingHash })
window.addEventListener('hashchange', () => {
applyFromHash({ scroll: true })
})
})
</script>