Skip to content

Commit 5066cea

Browse files
committed
feat: add CopyPageButton component and enhance layout with expanded sample support
1 parent 5168e3e commit 5066cea

5 files changed

Lines changed: 297 additions & 15 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,9 @@ docs/.vitepress/data/
379379
# at docs build time — never committed; gh-pages serves the regenerated copy).
380380
docs/public/_playground/
381381
docs/public/_content/
382+
383+
# Per-page expanded-samples copies emitted by expandExpressiveSamplesPlugin
384+
docs/public/**/*.llm.md
382385
.artifacts/
383386

384387
# Worktrees

docs/.vitepress/config.mts

Lines changed: 60 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {defineConfig, type DefaultTheme, type HeadConfig} from 'vitepress'
22
import llmstxt from 'vitepress-plugin-llms'
33
import {expressiveSamplePlugin} from './plugins/expressive-sample'
4-
import {readFileSync, existsSync} from 'fs'
4+
import {readFileSync, writeFileSync, existsSync, mkdirSync} from 'fs'
55
import {resolve, dirname} from 'path'
66
import {fileURLToPath} from 'url'
77
import {createHash} from 'crypto'
@@ -134,31 +134,63 @@ const mimeTypes: Record<string, string> = {
134134
// highlighting) which our markdown-it plugin picks up and wraps as tabs
135135
// The fenced blocks are the single source of truth the Vue component reads
136136
// from via the `data-expressive-sample` marker injected on the first block.
137+
// Writes the clean `.llm.md` variant of a page into docs/public/ so VitePress
138+
// copies it into dist/ as a sibling of the rendered HTML. The CopyPageButton
139+
// fetches these URLs ("/<page>.llm.md") when the reader asks for the form
140+
// with rendered SQL inline.
141+
function writeLlmMd(relPath: string, content: string) {
142+
const outPath = resolve(__dirname, '../public', relPath.replace(/\.md$/, '.llm.md'))
143+
mkdirSync(dirname(outPath), { recursive: true })
144+
writeFileSync(outPath, content)
145+
}
146+
137147
function expandExpressiveSamplesPlugin() {
138148
return {
139149
name: 'expand-expressive-samples',
140150
enforce: 'pre' as const,
141151
transform(code: string, id: string) {
142152
if (!id.endsWith('.md')) return null
143-
if (!code.includes('::: expressive-sample')) return null
144153

145154
const relPath = id.includes('/docs/')
146155
? id.substring(id.indexOf('/docs/') + 6).replace(/\?.*$/, '')
147156
: id
157+
158+
// Skip dist/cache artifacts and public/ copies of .md that shouldn't be
159+
// transformed (e.g. the .llm.md files we generate below).
160+
if (relPath.startsWith('.vitepress/') || relPath.startsWith('public/')) return null
161+
162+
if (!code.includes('::: expressive-sample')) {
163+
// No samples — write a plain .llm.md mirroring the source so the
164+
// CopyPageButton can always point at a stable URL.
165+
writeLlmMd(relPath, code)
166+
return null
167+
}
168+
148169
const jsonPath = resolve(__dirname, 'data/samples', relPath.replace(/\.md$/, '.json'))
149-
if (!existsSync(jsonPath)) return null
170+
if (!existsSync(jsonPath)) {
171+
writeLlmMd(relPath, code)
172+
return null
173+
}
150174

151175
type Target = { label: string; language: string; output: string }
152176
type Sample = { key: string; snippet: string; setup?: string | null; targets: Record<string, Target> }
153177
let samples: Sample[]
154-
try { samples = JSON.parse(readFileSync(jsonPath, 'utf-8')) } catch { return null }
178+
try { samples = JSON.parse(readFileSync(jsonPath, 'utf-8')) } catch {
179+
writeLlmMd(relPath, code)
180+
return null
181+
}
155182

156183
const lines = code.split('\n')
157184
const result: string[] = []
185+
// Parallel stream for the .llm.md file: same prose, but expanded samples
186+
// are visible fenced blocks rather than a hidden div.
187+
const llm: string[] = []
158188
let i = 0
159189
while (i < lines.length) {
160190
if (!lines[i].trimStart().startsWith('::: expressive-sample')) {
161-
result.push(lines[i]); i++; continue
191+
result.push(lines[i])
192+
llm.push(lines[i])
193+
i++; continue
162194
}
163195
i++
164196
const bodyLines: string[] = []
@@ -181,6 +213,9 @@ function expandExpressiveSamplesPlugin() {
181213
result.push('::: expressive-sample')
182214
result.push(...bodyLines)
183215
result.push(':::')
216+
llm.push('::: expressive-sample')
217+
llm.push(...bodyLines)
218+
llm.push(':::')
184219
continue
185220
}
186221

@@ -190,19 +225,13 @@ function expandExpressiveSamplesPlugin() {
190225
result.push(...bodyLines)
191226
result.push(':::')
192227

193-
// Also emit fenced code blocks inside a hidden div. These are invisible
194-
// on the rendered page (Vue component handles the UI) but are included
195-
// in the raw .md that llms.txt sees, so crawlers/LLMs get the full SQL
196-
// and pipeline output for each render target.
228+
let csharpContent = sample.snippet
229+
if (sample.setup) csharpContent += '\n\n// Setup\n' + sample.setup
230+
231+
// Hidden-div form for llms.txt crawlers reading the full .md stream.
197232
result.push('')
198233
result.push('<div class="expressive-sample-llms" style="display:none">')
199234
result.push('')
200-
// For LLMs: include C# input and ONE representative SQL output (SQLite).
201-
// The other providers are mostly SQL-dialect noise that doesn't teach
202-
// anything about ExpressiveSharp; the generator output is boilerplate
203-
// that shouldn't influence LLM suggestions toward [InterceptsLocation].
204-
let csharpContent = sample.snippet
205-
if (sample.setup) csharpContent += '\n\n// Setup\n' + sample.setup
206235
result.push('```csharp')
207236
result.push(csharpContent)
208237
result.push('```')
@@ -218,7 +247,23 @@ function expandExpressiveSamplesPlugin() {
218247
result.push('')
219248
result.push('</div>')
220249
result.push('')
250+
251+
// Visible form for the .llm.md file readers copy into LLMs.
252+
llm.push('```csharp')
253+
llm.push(csharpContent)
254+
llm.push('```')
255+
if (sqlite) {
256+
llm.push('')
257+
llm.push(`**Generated SQL (SQLite):**`)
258+
llm.push('')
259+
llm.push('```' + sqlite.language)
260+
llm.push(sqlite.output)
261+
llm.push('```')
262+
}
263+
llm.push('')
221264
}
265+
266+
writeLlmMd(relPath, llm.join('\n'))
222267
return { code: result.join('\n'), map: null }
223268
}
224269
}

docs/.vitepress/theme/Layout.vue

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<script setup lang="ts">
2+
import DefaultTheme from 'vitepress/theme'
3+
import { useData } from 'vitepress'
4+
import CopyPageButton from './components/CopyPageButton.vue'
5+
6+
const { frontmatter } = useData()
7+
const { Layout } = DefaultTheme
8+
</script>
9+
10+
<template>
11+
<Layout>
12+
<template
13+
v-if="frontmatter.layout !== 'home' && frontmatter.copyPage !== false"
14+
#doc-before
15+
>
16+
<CopyPageButton />
17+
</template>
18+
</Layout>
19+
</template>
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
<script setup lang="ts">
2+
import { ref, computed, onMounted, onUnmounted } from 'vue'
3+
import { useData, withBase } from 'vitepress'
4+
5+
const { page } = useData()
6+
7+
const open = ref(false)
8+
const copied = ref<string | null>(null)
9+
const root = ref<HTMLElement | null>(null)
10+
11+
// withBase prepends site base (e.g. "/ExpressiveSharp/") and ensures a leading slash.
12+
const mdUrl = computed(() => withBase('/' + page.value.relativePath))
13+
const llmUrl = computed(() => withBase('/' + page.value.relativePath.replace(/\.md$/, '.llm.md')))
14+
15+
const absoluteUrl = computed(() => {
16+
if (typeof window === 'undefined') return ''
17+
return window.location.origin + window.location.pathname
18+
})
19+
20+
const llmPrompt = computed(() =>
21+
encodeURIComponent(
22+
`Read ${absoluteUrl.value} and help me with ExpressiveSharp.`
23+
)
24+
)
25+
26+
async function copyFromUrl(url: string, label: string) {
27+
try {
28+
console.log('[CopyPage] fetching', url)
29+
const res = await fetch(url)
30+
console.log('[CopyPage] response', res.status, res.headers.get('content-type'))
31+
if (!res.ok) throw new Error(`${res.status}`)
32+
const text = await res.text()
33+
console.log('[CopyPage] body starts with:', text.slice(0, 80))
34+
await navigator.clipboard.writeText(text)
35+
copied.value = label
36+
setTimeout(() => { if (copied.value === label) copied.value = null }, 1500)
37+
} catch (e) {
38+
console.error('[CopyPage] failed', e)
39+
copied.value = 'Copy failed'
40+
setTimeout(() => { if (copied.value === 'Copy failed') copied.value = null }, 1500)
41+
}
42+
open.value = false
43+
}
44+
45+
function copyMarkdown() { copyFromUrl(mdUrl.value, 'Copied!') }
46+
function copyMarkdownExpanded() { copyFromUrl(llmUrl.value, 'Copied!') }
47+
48+
function viewMarkdown() {
49+
window.open(mdUrl.value, '_blank', 'noopener')
50+
open.value = false
51+
}
52+
53+
function openInChatGpt() {
54+
window.open(`https://chat.openai.com/?q=${llmPrompt.value}`, '_blank', 'noopener')
55+
open.value = false
56+
}
57+
58+
function openInClaude() {
59+
window.open(`https://claude.ai/new?q=${llmPrompt.value}`, '_blank', 'noopener')
60+
open.value = false
61+
}
62+
63+
function onDocClick(e: MouseEvent) {
64+
if (!root.value) return
65+
if (!root.value.contains(e.target as Node)) open.value = false
66+
}
67+
68+
onMounted(() => document.addEventListener('click', onDocClick))
69+
onUnmounted(() => document.removeEventListener('click', onDocClick))
70+
</script>
71+
72+
<template>
73+
<div class="copy-page" ref="root">
74+
<div class="copy-page-group">
75+
<button class="copy-page-main" @click="copyMarkdown" :title="'Copy page as markdown'">
76+
<svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
77+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
78+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
79+
</svg>
80+
<span>{{ copied ?? 'Copy page' }}</span>
81+
</button>
82+
<button class="copy-page-chevron" @click="open = !open" :aria-expanded="open" :title="'More actions'">
83+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
84+
<polyline points="6 9 12 15 18 9"></polyline>
85+
</svg>
86+
</button>
87+
</div>
88+
<div v-if="open" class="copy-page-menu" role="menu">
89+
<button role="menuitem" @click="copyMarkdown">
90+
<span class="menu-title">Copy as Markdown</span>
91+
<span class="menu-hint">Raw page source</span>
92+
</button>
93+
<button role="menuitem" @click="copyMarkdownExpanded">
94+
<span class="menu-title">Copy with Rendered SQL</span>
95+
<span class="menu-hint">Live samples expanded inline</span>
96+
</button>
97+
<button role="menuitem" @click="viewMarkdown">
98+
<span class="menu-title">View as Markdown ↗</span>
99+
<span class="menu-hint">Open raw .md in new tab</span>
100+
</button>
101+
<div class="menu-divider"></div>
102+
<button role="menuitem" @click="openInChatGpt">
103+
<span class="menu-title">Open in ChatGPT ↗</span>
104+
<span class="menu-hint">Ask about this page</span>
105+
</button>
106+
<button role="menuitem" @click="openInClaude">
107+
<span class="menu-title">Open in Claude ↗</span>
108+
<span class="menu-hint">Ask about this page</span>
109+
</button>
110+
</div>
111+
</div>
112+
</template>
113+
114+
<style scoped>
115+
.copy-page {
116+
position: relative;
117+
display: flex;
118+
justify-content: flex-end;
119+
align-items: stretch;
120+
margin: 0 0 12px;
121+
font-size: 12px;
122+
font-weight: 500;
123+
z-index: 20;
124+
}
125+
126+
.copy-page-group {
127+
display: inline-flex;
128+
align-items: stretch;
129+
}
130+
131+
.copy-page-main,
132+
.copy-page-chevron {
133+
display: inline-flex;
134+
align-items: center;
135+
gap: 6px;
136+
padding: 4px 10px;
137+
background: var(--vp-c-bg-soft);
138+
color: var(--vp-c-text-2);
139+
border: 1px solid var(--vp-c-divider);
140+
cursor: pointer;
141+
transition: color 0.15s, background 0.15s, border-color 0.15s;
142+
}
143+
144+
.copy-page-main {
145+
border-radius: 6px 0 0 6px;
146+
border-right-width: 0;
147+
}
148+
149+
.copy-page-chevron {
150+
border-radius: 0 6px 6px 0;
151+
padding: 4px 6px;
152+
}
153+
154+
.copy-page-main:hover,
155+
.copy-page-chevron:hover,
156+
.copy-page-chevron[aria-expanded="true"] {
157+
color: var(--vp-c-text-1);
158+
background: var(--vp-c-bg);
159+
border-color: var(--vp-c-brand-1);
160+
}
161+
162+
.copy-page-main .icon {
163+
opacity: 0.75;
164+
}
165+
166+
.copy-page-menu {
167+
position: absolute;
168+
top: calc(100% + 6px);
169+
right: 0;
170+
min-width: 240px;
171+
padding: 6px;
172+
background: var(--vp-c-bg-elv, var(--vp-c-bg));
173+
border: 1px solid var(--vp-c-divider);
174+
border-radius: 8px;
175+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
176+
z-index: 50;
177+
}
178+
179+
.copy-page-menu button {
180+
display: flex;
181+
flex-direction: column;
182+
align-items: flex-start;
183+
width: 100%;
184+
padding: 6px 10px;
185+
background: transparent;
186+
border: none;
187+
border-radius: 4px;
188+
cursor: pointer;
189+
text-align: left;
190+
color: var(--vp-c-text-1);
191+
}
192+
193+
.copy-page-menu button:hover {
194+
background: var(--vp-c-bg-soft);
195+
}
196+
197+
.copy-page-menu .menu-title {
198+
font-size: 13px;
199+
font-weight: 500;
200+
}
201+
202+
.copy-page-menu .menu-hint {
203+
font-size: 11px;
204+
color: var(--vp-c-text-3);
205+
margin-top: 1px;
206+
}
207+
208+
.copy-page-menu .menu-divider {
209+
height: 1px;
210+
margin: 4px 0;
211+
background: var(--vp-c-divider);
212+
}
213+
</style>

docs/.vitepress/theme/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import DefaultTheme from 'vitepress/theme'
2+
import Layout from './Layout.vue'
23
import ExpressiveSample from './components/ExpressiveSample.vue'
34
import './custom.css'
45

56
export default {
67
...DefaultTheme,
8+
Layout,
79
enhanceApp({ app }) {
810
app.component('ExpressiveSample', ExpressiveSample)
911
}

0 commit comments

Comments
 (0)