veslx 0.1.28 → 0.1.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/lib/export.ts +214 -0
- package/dist/client/components/front-matter.js +46 -1
- package/dist/client/components/front-matter.js.map +1 -1
- package/dist/client/components/post-list.js +16 -1
- package/dist/client/components/post-list.js.map +1 -1
- package/dist/client/pages/home.js +13 -3
- package/dist/client/pages/home.js.map +1 -1
- package/package.json +1 -1
- package/plugin/src/plugin.ts +111 -24
- package/plugin/src/types.ts +17 -0
- package/src/components/front-matter.tsx +60 -3
- package/src/components/post-list.tsx +17 -2
- package/src/index.css +5 -0
- package/src/pages/home.tsx +18 -8
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { createServer, type ViteDevServer } from 'vite'
|
|
2
|
+
import { chromium, type Browser } from 'playwright'
|
|
3
|
+
import { execSync } from 'child_process'
|
|
4
|
+
import importConfig from "./import-config"
|
|
5
|
+
import veslxPlugin from '../../plugin/src/plugin'
|
|
6
|
+
import path from 'path'
|
|
7
|
+
import fs from 'fs'
|
|
8
|
+
import { log } from './log'
|
|
9
|
+
|
|
10
|
+
interface PackageJson {
|
|
11
|
+
name?: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ExportOptions {
|
|
16
|
+
timeout?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function readPackageJson(cwd: string): Promise<PackageJson | null> {
|
|
20
|
+
const file = Bun.file(path.join(cwd, 'package.json'));
|
|
21
|
+
if (!await file.exists()) return null;
|
|
22
|
+
try {
|
|
23
|
+
return await file.json();
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getGitHubRepo(cwd: string): string {
|
|
30
|
+
try {
|
|
31
|
+
const remote = execSync('git remote get-url origin', { cwd, encoding: 'utf-8' }).trim();
|
|
32
|
+
const match = remote.match(/github\.com[:/]([^/]+\/[^/.]+)/);
|
|
33
|
+
return match ? match[1] : '';
|
|
34
|
+
} catch {
|
|
35
|
+
return '';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function getDefaultConfig(cwd: string) {
|
|
40
|
+
const pkg = await readPackageJson(cwd);
|
|
41
|
+
const folderName = path.basename(cwd);
|
|
42
|
+
const name = pkg?.name || folderName;
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
dir: '.',
|
|
46
|
+
site: {
|
|
47
|
+
name,
|
|
48
|
+
description: pkg?.description || '',
|
|
49
|
+
github: getGitHubRepo(cwd),
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Detect if a path refers to slides based on file naming conventions
|
|
56
|
+
*/
|
|
57
|
+
function isSlides(filePath: string): boolean {
|
|
58
|
+
const filename = path.basename(filePath).toLowerCase()
|
|
59
|
+
return (
|
|
60
|
+
filePath.endsWith('.slides.mdx') ||
|
|
61
|
+
filePath.endsWith('.slides.md') ||
|
|
62
|
+
filename === 'slides.mdx' ||
|
|
63
|
+
filename === 'slides.md'
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Generate default output filename from input path
|
|
69
|
+
*/
|
|
70
|
+
function getDefaultOutputPath(inputPath: string): string {
|
|
71
|
+
const baseName = inputPath
|
|
72
|
+
.replace(/\.(mdx|md)$/, '')
|
|
73
|
+
.replace(/\//g, '-')
|
|
74
|
+
.replace(/^-/, '')
|
|
75
|
+
return `${baseName}.pdf`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export default async function exportToPdf(
|
|
79
|
+
inputPath: string,
|
|
80
|
+
outputPath?: string,
|
|
81
|
+
options: ExportOptions = {}
|
|
82
|
+
): Promise<void> {
|
|
83
|
+
const cwd = process.cwd()
|
|
84
|
+
|
|
85
|
+
// Validate input file exists
|
|
86
|
+
const fullInputPath = path.isAbsolute(inputPath)
|
|
87
|
+
? inputPath
|
|
88
|
+
: path.resolve(cwd, inputPath)
|
|
89
|
+
|
|
90
|
+
if (!fs.existsSync(fullInputPath)) {
|
|
91
|
+
log.error(`File not found: ${inputPath}`)
|
|
92
|
+
process.exit(1)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Validate it's an MDX or MD file
|
|
96
|
+
if (!inputPath.endsWith('.mdx') && !inputPath.endsWith('.md')) {
|
|
97
|
+
log.error('Only .mdx and .md files can be exported')
|
|
98
|
+
process.exit(1)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Determine output path
|
|
102
|
+
const finalOutputPath = outputPath || getDefaultOutputPath(inputPath)
|
|
103
|
+
const fullOutputPath = path.isAbsolute(finalOutputPath)
|
|
104
|
+
? finalOutputPath
|
|
105
|
+
: path.resolve(cwd, finalOutputPath)
|
|
106
|
+
|
|
107
|
+
// Determine content type
|
|
108
|
+
const contentIsSlides = isSlides(inputPath)
|
|
109
|
+
|
|
110
|
+
log.info(`Exporting ${contentIsSlides ? 'slides' : 'post'}: ${inputPath}`)
|
|
111
|
+
|
|
112
|
+
let server: ViteDevServer | null = null
|
|
113
|
+
let browser: Browser | null = null
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
// Load config (same pattern as serve.ts)
|
|
117
|
+
const defaults = await getDefaultConfig(cwd)
|
|
118
|
+
let fileConfig = await importConfig(cwd)
|
|
119
|
+
|
|
120
|
+
const config = {
|
|
121
|
+
dir: fileConfig?.dir || defaults.dir,
|
|
122
|
+
site: {
|
|
123
|
+
...defaults.site,
|
|
124
|
+
...fileConfig?.site,
|
|
125
|
+
},
|
|
126
|
+
slides: fileConfig?.slides,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const veslxRoot = new URL('../..', import.meta.url).pathname
|
|
130
|
+
const configFile = new URL('../../vite.config.ts', import.meta.url).pathname
|
|
131
|
+
|
|
132
|
+
// Resolve content directory
|
|
133
|
+
const contentDir = path.isAbsolute(config.dir)
|
|
134
|
+
? config.dir
|
|
135
|
+
: path.resolve(cwd, config.dir)
|
|
136
|
+
|
|
137
|
+
// Start dev server on random available port
|
|
138
|
+
server = await createServer({
|
|
139
|
+
root: veslxRoot,
|
|
140
|
+
configFile,
|
|
141
|
+
cacheDir: path.join(cwd, 'node_modules/.vite'),
|
|
142
|
+
plugins: [veslxPlugin(contentDir, config)],
|
|
143
|
+
server: {
|
|
144
|
+
port: 0, // Auto-select available port
|
|
145
|
+
},
|
|
146
|
+
logLevel: 'silent',
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
await server.listen()
|
|
150
|
+
|
|
151
|
+
const serverUrl = server.resolvedUrls?.local[0]
|
|
152
|
+
if (!serverUrl) {
|
|
153
|
+
throw new Error('Failed to get server URL')
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Build page URL from relative path
|
|
157
|
+
const relativePath = path.relative(contentDir, fullInputPath)
|
|
158
|
+
const pageUrl = `${serverUrl}${relativePath}`
|
|
159
|
+
|
|
160
|
+
// Launch browser and navigate
|
|
161
|
+
browser = await chromium.launch({ headless: true })
|
|
162
|
+
const page = await browser.newPage()
|
|
163
|
+
|
|
164
|
+
await page.goto(pageUrl, {
|
|
165
|
+
waitUntil: 'networkidle',
|
|
166
|
+
timeout: options.timeout || 30000
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
// Wait for content to render
|
|
170
|
+
await page.waitForTimeout(500)
|
|
171
|
+
try {
|
|
172
|
+
await page.waitForSelector('article, .slides-container', { timeout: 5000 })
|
|
173
|
+
} catch {
|
|
174
|
+
// Content selector not found, continue anyway
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Export to PDF with appropriate settings
|
|
178
|
+
const pdfOptions = contentIsSlides
|
|
179
|
+
? {
|
|
180
|
+
path: fullOutputPath,
|
|
181
|
+
landscape: true,
|
|
182
|
+
printBackground: true,
|
|
183
|
+
preferCSSPageSize: true,
|
|
184
|
+
margin: { top: '0', right: '0', bottom: '0', left: '0' },
|
|
185
|
+
}
|
|
186
|
+
: {
|
|
187
|
+
path: fullOutputPath,
|
|
188
|
+
landscape: false,
|
|
189
|
+
printBackground: true,
|
|
190
|
+
preferCSSPageSize: true,
|
|
191
|
+
margin: { top: '1in', right: '1in', bottom: '1in', left: '1in' },
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
await page.pdf(pdfOptions)
|
|
195
|
+
|
|
196
|
+
log.success(finalOutputPath)
|
|
197
|
+
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (error instanceof Error) {
|
|
200
|
+
log.error(error.message)
|
|
201
|
+
} else {
|
|
202
|
+
log.error('Export failed')
|
|
203
|
+
}
|
|
204
|
+
process.exit(1)
|
|
205
|
+
|
|
206
|
+
} finally {
|
|
207
|
+
if (browser) {
|
|
208
|
+
await browser.close()
|
|
209
|
+
}
|
|
210
|
+
if (server) {
|
|
211
|
+
await server.close()
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -1,11 +1,56 @@
|
|
|
1
1
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useLocation } from "react-router-dom";
|
|
2
3
|
import { useFrontmatter } from "../lib/frontmatter-context.js";
|
|
3
4
|
import { formatDate } from "../lib/format-date.js";
|
|
5
|
+
import veslxConfig from "virtual:veslx-config";
|
|
6
|
+
function convertToLlmsTxt(rawMdx, frontmatter) {
|
|
7
|
+
const contentWithoutFrontmatter = rawMdx.replace(/^---[\s\S]*?---\n*/, "");
|
|
8
|
+
const parts = [];
|
|
9
|
+
const title = (frontmatter == null ? void 0 : frontmatter.title) || "Untitled";
|
|
10
|
+
parts.push(`# ${title}`);
|
|
11
|
+
if (frontmatter == null ? void 0 : frontmatter.description) {
|
|
12
|
+
parts.push("");
|
|
13
|
+
parts.push(`> ${frontmatter.description}`);
|
|
14
|
+
}
|
|
15
|
+
if (contentWithoutFrontmatter.trim()) {
|
|
16
|
+
parts.push("");
|
|
17
|
+
parts.push(contentWithoutFrontmatter.trim());
|
|
18
|
+
}
|
|
19
|
+
return parts.join("\n");
|
|
20
|
+
}
|
|
4
21
|
function FrontMatter() {
|
|
5
22
|
const frontmatter = useFrontmatter();
|
|
23
|
+
const location = useLocation();
|
|
24
|
+
const config = veslxConfig.site;
|
|
25
|
+
const rawUrl = `/raw${location.pathname.replace(/^\//, "/")}`;
|
|
26
|
+
const handleLlmsTxt = async (e) => {
|
|
27
|
+
e.preventDefault();
|
|
28
|
+
try {
|
|
29
|
+
const res = await fetch(rawUrl);
|
|
30
|
+
if (!res.ok) throw new Error("Failed to fetch");
|
|
31
|
+
const rawMdx = await res.text();
|
|
32
|
+
const llmsTxt = convertToLlmsTxt(rawMdx, frontmatter);
|
|
33
|
+
const blob = new Blob([llmsTxt], { type: "text/plain" });
|
|
34
|
+
const url = URL.createObjectURL(blob);
|
|
35
|
+
window.location.href = url;
|
|
36
|
+
} catch {
|
|
37
|
+
console.error("Failed to load llms.txt");
|
|
38
|
+
}
|
|
39
|
+
};
|
|
6
40
|
return /* @__PURE__ */ jsx("div", { children: (frontmatter == null ? void 0 : frontmatter.title) && /* @__PURE__ */ jsxs("header", { className: "not-prose flex flex-col gap-2 mb-8 pt-4", children: [
|
|
7
41
|
/* @__PURE__ */ jsx("h1", { className: "text-2xl md:text-3xl font-semibold tracking-tight text-foreground mb-3", children: frontmatter == null ? void 0 : frontmatter.title }),
|
|
8
|
-
/* @__PURE__ */
|
|
42
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-3 text-muted-foreground", children: [
|
|
43
|
+
(frontmatter == null ? void 0 : frontmatter.date) && /* @__PURE__ */ jsx("time", { className: "font-mono text-xs bg-muted px-2 py-0.5 rounded", children: formatDate(new Date(frontmatter.date)) }),
|
|
44
|
+
config.llmsTxt && /* @__PURE__ */ jsx(
|
|
45
|
+
"a",
|
|
46
|
+
{
|
|
47
|
+
href: "#",
|
|
48
|
+
onClick: handleLlmsTxt,
|
|
49
|
+
className: "font-mono text-xs text-muted-foreground/70 hover:text-foreground underline underline-offset-2 transition-colors",
|
|
50
|
+
children: "llms.txt"
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
] }),
|
|
9
54
|
(frontmatter == null ? void 0 : frontmatter.description) && /* @__PURE__ */ jsx("div", { className: "flex flex-wrap text-sm items-center gap-3 text-muted-foreground", children: frontmatter == null ? void 0 : frontmatter.description })
|
|
10
55
|
] }) });
|
|
11
56
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"front-matter.js","sources":["../../../src/components/front-matter.tsx"],"sourcesContent":["import { useFrontmatter } from \"@/lib/frontmatter-context\";\nimport { formatDate } from \"@/lib/format-date\"\n\nexport function FrontMatter(){\n const frontmatter = useFrontmatter();\n\n return (\n <div>\n {frontmatter?.title && (\n <header className=\"not-prose flex flex-col gap-2 mb-8 pt-4\">\n <h1 className=\"text-2xl md:text-3xl font-semibold tracking-tight text-foreground mb-3\">\n {frontmatter?.title}\n </h1>\n\n {/* Meta line */}\n <div className=\"flex flex-wrap items-center gap-3 text-muted-foreground\">\n {frontmatter?.date && (\n <time className=\"font-mono text-xs bg-muted px-2 py-0.5 rounded\">\n {formatDate(new Date(frontmatter.date as string))}\n </time>\n )}\n </div>\n\n {frontmatter?.description && (\n <div className=\"flex flex-wrap text-sm items-center gap-3 text-muted-foreground\">\n {frontmatter?.description}\n </div>\n )}\n </header>\n )}\n </div>\n )
|
|
1
|
+
{"version":3,"file":"front-matter.js","sources":["../../../src/components/front-matter.tsx"],"sourcesContent":["import { useLocation } from \"react-router-dom\";\nimport { useFrontmatter } from \"@/lib/frontmatter-context\";\nimport { formatDate } from \"@/lib/format-date\";\nimport veslxConfig from \"virtual:veslx-config\";\n\n/**\n * Convert MDX content to llms.txt format.\n */\nfunction convertToLlmsTxt(\n rawMdx: string,\n frontmatter?: { title?: string; description?: string }\n): string {\n const contentWithoutFrontmatter = rawMdx.replace(/^---[\\s\\S]*?---\\n*/, '')\n\n const parts: string[] = []\n\n const title = frontmatter?.title || 'Untitled'\n parts.push(`# ${title}`)\n\n if (frontmatter?.description) {\n parts.push('')\n parts.push(`> ${frontmatter.description}`)\n }\n\n if (contentWithoutFrontmatter.trim()) {\n parts.push('')\n parts.push(contentWithoutFrontmatter.trim())\n }\n\n return parts.join('\\n')\n}\n\nexport function FrontMatter() {\n const frontmatter = useFrontmatter();\n const location = useLocation();\n const config = veslxConfig.site;\n\n const rawUrl = `/raw${location.pathname.replace(/^\\//, '/')}`;\n\n const handleLlmsTxt = async (e: React.MouseEvent) => {\n e.preventDefault();\n try {\n const res = await fetch(rawUrl);\n if (!res.ok) throw new Error('Failed to fetch');\n const rawMdx = await res.text();\n const llmsTxt = convertToLlmsTxt(rawMdx, frontmatter);\n const blob = new Blob([llmsTxt], { type: 'text/plain' });\n const url = URL.createObjectURL(blob);\n window.location.href = url;\n } catch {\n console.error('Failed to load llms.txt');\n }\n };\n\n return (\n <div>\n {frontmatter?.title && (\n <header className=\"not-prose flex flex-col gap-2 mb-8 pt-4\">\n <h1 className=\"text-2xl md:text-3xl font-semibold tracking-tight text-foreground mb-3\">\n {frontmatter?.title}\n </h1>\n\n {/* Meta line */}\n <div className=\"flex flex-wrap items-center gap-3 text-muted-foreground\">\n {frontmatter?.date && (\n <time className=\"font-mono text-xs bg-muted px-2 py-0.5 rounded\">\n {formatDate(new Date(frontmatter.date as string))}\n </time>\n )}\n {config.llmsTxt && (\n <a\n href=\"#\"\n onClick={handleLlmsTxt}\n className=\"font-mono text-xs text-muted-foreground/70 hover:text-foreground underline underline-offset-2 transition-colors\"\n >\n llms.txt\n </a>\n )}\n </div>\n\n {frontmatter?.description && (\n <div className=\"flex flex-wrap text-sm items-center gap-3 text-muted-foreground\">\n {frontmatter?.description}\n </div>\n )}\n </header>\n )}\n </div>\n );\n}\n"],"names":[],"mappings":";;;;;AAQA,SAAS,iBACP,QACA,aACQ;AACR,QAAM,4BAA4B,OAAO,QAAQ,sBAAsB,EAAE;AAEzE,QAAM,QAAkB,CAAA;AAExB,QAAM,SAAQ,2CAAa,UAAS;AACpC,QAAM,KAAK,KAAK,KAAK,EAAE;AAEvB,MAAI,2CAAa,aAAa;AAC5B,UAAM,KAAK,EAAE;AACb,UAAM,KAAK,KAAK,YAAY,WAAW,EAAE;AAAA,EAC3C;AAEA,MAAI,0BAA0B,QAAQ;AACpC,UAAM,KAAK,EAAE;AACb,UAAM,KAAK,0BAA0B,MAAM;AAAA,EAC7C;AAEA,SAAO,MAAM,KAAK,IAAI;AACxB;AAEO,SAAS,cAAc;AAC5B,QAAM,cAAc,eAAA;AACpB,QAAM,WAAW,YAAA;AACjB,QAAM,SAAS,YAAY;AAE3B,QAAM,SAAS,OAAO,SAAS,SAAS,QAAQ,OAAO,GAAG,CAAC;AAE3D,QAAM,gBAAgB,OAAO,MAAwB;AACnD,MAAE,eAAA;AACF,QAAI;AACF,YAAM,MAAM,MAAM,MAAM,MAAM;AAC9B,UAAI,CAAC,IAAI,GAAI,OAAM,IAAI,MAAM,iBAAiB;AAC9C,YAAM,SAAS,MAAM,IAAI,KAAA;AACzB,YAAM,UAAU,iBAAiB,QAAQ,WAAW;AACpD,YAAM,OAAO,IAAI,KAAK,CAAC,OAAO,GAAG,EAAE,MAAM,cAAc;AACvD,YAAM,MAAM,IAAI,gBAAgB,IAAI;AACpC,aAAO,SAAS,OAAO;AAAA,IACzB,QAAQ;AACN,cAAQ,MAAM,yBAAyB;AAAA,IACzC;AAAA,EACF;AAEA,6BACG,OAAA,EACE,WAAA,2CAAa,UACZ,qBAAC,UAAA,EAAO,WAAU,2CAChB,UAAA;AAAA,IAAA,oBAAC,MAAA,EAAG,WAAU,0EACX,UAAA,2CAAa,OAChB;AAAA,IAGA,qBAAC,OAAA,EAAI,WAAU,2DACZ,UAAA;AAAA,OAAA,2CAAa,SACZ,oBAAC,QAAA,EAAK,WAAU,kDACb,UAAA,WAAW,IAAI,KAAK,YAAY,IAAc,CAAC,EAAA,CAClD;AAAA,MAED,OAAO,WACN;AAAA,QAAC;AAAA,QAAA;AAAA,UACC,MAAK;AAAA,UACL,SAAS;AAAA,UACT,WAAU;AAAA,UACX,UAAA;AAAA,QAAA;AAAA,MAAA;AAAA,IAED,GAEJ;AAAA,KAEC,2CAAa,gBACZ,oBAAC,SAAI,WAAU,mEACZ,qDAAa,YAAA,CAChB;AAAA,EAAA,EAAA,CAEJ,EAAA,CAEJ;AAEJ;"}
|
|
@@ -4,6 +4,7 @@ import { directoryToPostEntries, filterVisiblePosts, getFrontmatter } from "../l
|
|
|
4
4
|
import { useDirectory } from "../plugin/src/client.js";
|
|
5
5
|
import { ErrorDisplay } from "./page-error.js";
|
|
6
6
|
import { PostListItem } from "./post-list-item.js";
|
|
7
|
+
import veslxConfig from "virtual:veslx-config";
|
|
7
8
|
function formatName(name) {
|
|
8
9
|
return name.replace(/^\d+-/, "").replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
9
10
|
}
|
|
@@ -19,6 +20,7 @@ function getLinkPath(post) {
|
|
|
19
20
|
}
|
|
20
21
|
}
|
|
21
22
|
function PostList() {
|
|
23
|
+
var _a;
|
|
22
24
|
const { "*": path = "." } = useParams();
|
|
23
25
|
const { directory, error } = useDirectory(path);
|
|
24
26
|
if (error) {
|
|
@@ -35,7 +37,20 @@ function PostList() {
|
|
|
35
37
|
if (posts.length === 0) {
|
|
36
38
|
return /* @__PURE__ */ jsx("div", { className: "py-24 text-center", children: /* @__PURE__ */ jsx("p", { className: "text-muted-foreground font-mono text-sm tracking-wide", children: "no entries" }) });
|
|
37
39
|
}
|
|
38
|
-
|
|
40
|
+
const sortMode = ((_a = veslxConfig.posts) == null ? void 0 : _a.sort) ?? "alpha";
|
|
41
|
+
if (sortMode === "date") {
|
|
42
|
+
posts = posts.sort((a, b) => {
|
|
43
|
+
var _a2, _b;
|
|
44
|
+
const dateA = (_a2 = getFrontmatter(a)) == null ? void 0 : _a2.date;
|
|
45
|
+
const dateB = (_b = getFrontmatter(b)) == null ? void 0 : _b.date;
|
|
46
|
+
if (!dateA && !dateB) return a.name.localeCompare(b.name);
|
|
47
|
+
if (!dateA) return 1;
|
|
48
|
+
if (!dateB) return -1;
|
|
49
|
+
return new Date(dateB).getTime() - new Date(dateA).getTime();
|
|
50
|
+
});
|
|
51
|
+
} else {
|
|
52
|
+
posts = posts.sort((a, b) => a.name.localeCompare(b.name));
|
|
53
|
+
}
|
|
39
54
|
return /* @__PURE__ */ jsx("div", { className: "space-y-1 not-prose", children: posts.map((post) => {
|
|
40
55
|
const frontmatter = getFrontmatter(post);
|
|
41
56
|
const title = (frontmatter == null ? void 0 : frontmatter.title) || formatName(post.name);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"post-list.js","sources":["../../../src/components/post-list.tsx"],"sourcesContent":["import { useParams } from \"react-router-dom\";\nimport {\n type PostEntry,\n directoryToPostEntries,\n filterVisiblePosts,\n getFrontmatter,\n} from \"@/lib/content-classification\";\nimport { useDirectory } from \"../../plugin/src/client\";\nimport { ErrorDisplay } from \"./page-error\";\nimport Loading from \"./loading\";\nimport { PostListItem } from \"./post-list-item\";\n\n// Helper to format name for display (e.g., \"01-getting-started\" → \"Getting Started\")\nfunction formatName(name: string): string {\n return name\n .replace(/^\\d+-/, '')\n .replace(/-/g, ' ')\n .replace(/\\b\\w/g, c => c.toUpperCase());\n}\n\n// Helper to get link path from post\nfunction getLinkPath(post: PostEntry): string {\n if (post.file) {\n // Standalone MDX file\n return `/${post.file.path}`;\n } else if (post.slides && !post.readme) {\n // Folder with only slides\n return `/${post.slides.path}`;\n } else if (post.readme) {\n // Folder with readme\n return `/${post.readme.path}`;\n } else {\n // Fallback to folder path\n return `/${post.path}`;\n }\n}\n\nexport function PostList() {\n const { \"*\": path = \".\" } = useParams();\n\n const { directory, loading, error } = useDirectory(path)\n\n if (error) {\n return <ErrorDisplay error={error} path={path} />;\n }\n\n if (loading) {\n return (\n <Loading />\n )\n }\n\n if (!directory) {\n return (\n <div className=\"py-24 text-center\">\n <p className=\"text-muted-foreground font-mono text-sm tracking-wide\">no entries</p>\n </div>\n );\n }\n\n let posts = directoryToPostEntries(directory);\n\n if (posts.length === 0) {\n return (\n <div className=\"py-24 text-center\">\n <p className=\"text-muted-foreground font-mono text-sm tracking-wide\">no entries</p>\n </div>\n );\n }\n\n // Filter out hidden and draft posts\n posts = filterVisiblePosts(posts);\n\n if (posts.length === 0) {\n return (\n <div className=\"py-24 text-center\">\n <p className=\"text-muted-foreground font-mono text-sm tracking-wide\">no entries</p>\n </div>\n );\n }\n\n // Alphanumeric sorting by name\n
|
|
1
|
+
{"version":3,"file":"post-list.js","sources":["../../../src/components/post-list.tsx"],"sourcesContent":["import { useParams } from \"react-router-dom\";\nimport {\n type PostEntry,\n directoryToPostEntries,\n filterVisiblePosts,\n getFrontmatter,\n} from \"@/lib/content-classification\";\nimport { useDirectory } from \"../../plugin/src/client\";\nimport { ErrorDisplay } from \"./page-error\";\nimport Loading from \"./loading\";\nimport { PostListItem } from \"./post-list-item\";\nimport veslxConfig from \"virtual:veslx-config\";\n\n// Helper to format name for display (e.g., \"01-getting-started\" → \"Getting Started\")\nfunction formatName(name: string): string {\n return name\n .replace(/^\\d+-/, '')\n .replace(/-/g, ' ')\n .replace(/\\b\\w/g, c => c.toUpperCase());\n}\n\n// Helper to get link path from post\nfunction getLinkPath(post: PostEntry): string {\n if (post.file) {\n // Standalone MDX file\n return `/${post.file.path}`;\n } else if (post.slides && !post.readme) {\n // Folder with only slides\n return `/${post.slides.path}`;\n } else if (post.readme) {\n // Folder with readme\n return `/${post.readme.path}`;\n } else {\n // Fallback to folder path\n return `/${post.path}`;\n }\n}\n\nexport function PostList() {\n const { \"*\": path = \".\" } = useParams();\n\n const { directory, loading, error } = useDirectory(path)\n\n if (error) {\n return <ErrorDisplay error={error} path={path} />;\n }\n\n if (loading) {\n return (\n <Loading />\n )\n }\n\n if (!directory) {\n return (\n <div className=\"py-24 text-center\">\n <p className=\"text-muted-foreground font-mono text-sm tracking-wide\">no entries</p>\n </div>\n );\n }\n\n let posts = directoryToPostEntries(directory);\n\n if (posts.length === 0) {\n return (\n <div className=\"py-24 text-center\">\n <p className=\"text-muted-foreground font-mono text-sm tracking-wide\">no entries</p>\n </div>\n );\n }\n\n // Filter out hidden and draft posts\n posts = filterVisiblePosts(posts);\n\n if (posts.length === 0) {\n return (\n <div className=\"py-24 text-center\">\n <p className=\"text-muted-foreground font-mono text-sm tracking-wide\">no entries</p>\n </div>\n );\n }\n\n // Sort based on config\n const sortMode = veslxConfig.posts?.sort ?? 'alpha';\n if (sortMode === 'date') {\n // Sort by date descending (newest first), posts without dates go to the end\n posts = posts.sort((a, b) => {\n const dateA = getFrontmatter(a)?.date;\n const dateB = getFrontmatter(b)?.date;\n if (!dateA && !dateB) return a.name.localeCompare(b.name);\n if (!dateA) return 1;\n if (!dateB) return -1;\n return new Date(dateB as string).getTime() - new Date(dateA as string).getTime();\n });\n } else {\n // Alphanumeric sorting by name\n posts = posts.sort((a, b) => a.name.localeCompare(b.name));\n }\n\n return (\n <div className=\"space-y-1 not-prose\">\n {posts.map((post) => {\n const frontmatter = getFrontmatter(post);\n const title = (frontmatter?.title as string) || formatName(post.name);\n const description = frontmatter?.description as string | undefined;\n const date = frontmatter?.date ? new Date(frontmatter.date as string) : undefined;\n const linkPath = getLinkPath(post);\n const isSlides = linkPath.endsWith('SLIDES.mdx') || linkPath.endsWith('.slides.mdx');\n\n return (\n <PostListItem\n key={post.path}\n title={title}\n description={description}\n date={date}\n linkPath={linkPath}\n isSlides={isSlides}\n />\n );\n })}\n </div>\n );\n}\n"],"names":["_a"],"mappings":";;;;;;;AAcA,SAAS,WAAW,MAAsB;AACxC,SAAO,KACJ,QAAQ,SAAS,EAAE,EACnB,QAAQ,MAAM,GAAG,EACjB,QAAQ,SAAS,CAAA,MAAK,EAAE,aAAa;AAC1C;AAGA,SAAS,YAAY,MAAyB;AAC5C,MAAI,KAAK,MAAM;AAEb,WAAO,IAAI,KAAK,KAAK,IAAI;AAAA,EAC3B,WAAW,KAAK,UAAU,CAAC,KAAK,QAAQ;AAEtC,WAAO,IAAI,KAAK,OAAO,IAAI;AAAA,EAC7B,WAAW,KAAK,QAAQ;AAEtB,WAAO,IAAI,KAAK,OAAO,IAAI;AAAA,EAC7B,OAAO;AAEL,WAAO,IAAI,KAAK,IAAI;AAAA,EACtB;AACF;AAEO,SAAS,WAAW;;AACzB,QAAM,EAAE,KAAK,OAAO,IAAA,IAAQ,UAAA;AAE5B,QAAM,EAAE,WAAoB,UAAU,aAAa,IAAI;AAEvD,MAAI,OAAO;AACT,WAAO,oBAAC,cAAA,EAAa,OAAc,KAAA,CAAY;AAAA,EACjD;AAQA,MAAI,CAAC,WAAW;AACd,WACE,oBAAC,SAAI,WAAU,qBACb,8BAAC,KAAA,EAAE,WAAU,yDAAwD,UAAA,aAAA,CAAU,EAAA,CACjF;AAAA,EAEJ;AAEA,MAAI,QAAQ,uBAAuB,SAAS;AAE5C,MAAI,MAAM,WAAW,GAAG;AACtB,WACE,oBAAC,SAAI,WAAU,qBACb,8BAAC,KAAA,EAAE,WAAU,yDAAwD,UAAA,aAAA,CAAU,EAAA,CACjF;AAAA,EAEJ;AAGA,UAAQ,mBAAmB,KAAK;AAEhC,MAAI,MAAM,WAAW,GAAG;AACtB,WACE,oBAAC,SAAI,WAAU,qBACb,8BAAC,KAAA,EAAE,WAAU,yDAAwD,UAAA,aAAA,CAAU,EAAA,CACjF;AAAA,EAEJ;AAGA,QAAM,aAAW,iBAAY,UAAZ,mBAAmB,SAAQ;AAC5C,MAAI,aAAa,QAAQ;AAEvB,YAAQ,MAAM,KAAK,CAAC,GAAG,MAAM;;AAC3B,YAAM,SAAQA,MAAA,eAAe,CAAC,MAAhB,gBAAAA,IAAmB;AACjC,YAAM,SAAQ,oBAAe,CAAC,MAAhB,mBAAmB;AACjC,UAAI,CAAC,SAAS,CAAC,cAAc,EAAE,KAAK,cAAc,EAAE,IAAI;AACxD,UAAI,CAAC,MAAO,QAAO;AACnB,UAAI,CAAC,MAAO,QAAO;AACnB,aAAO,IAAI,KAAK,KAAe,EAAE,QAAA,IAAY,IAAI,KAAK,KAAe,EAAE,QAAA;AAAA,IACzE,CAAC;AAAA,EACH,OAAO;AAEL,YAAQ,MAAM,KAAK,CAAC,GAAG,MAAM,EAAE,KAAK,cAAc,EAAE,IAAI,CAAC;AAAA,EAC3D;AAEA,6BACG,OAAA,EAAI,WAAU,uBACZ,UAAA,MAAM,IAAI,CAAC,SAAS;AACnB,UAAM,cAAc,eAAe,IAAI;AACvC,UAAM,SAAS,2CAAa,UAAoB,WAAW,KAAK,IAAI;AACpE,UAAM,cAAc,2CAAa;AACjC,UAAM,QAAO,2CAAa,QAAO,IAAI,KAAK,YAAY,IAAc,IAAI;AACxE,UAAM,WAAW,YAAY,IAAI;AACjC,UAAM,WAAW,SAAS,SAAS,YAAY,KAAK,SAAS,SAAS,aAAa;AAEnF,WACE;AAAA,MAAC;AAAA,MAAA;AAAA,QAEC;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,MAAA;AAAA,MALK,KAAK;AAAA,IAAA;AAAA,EAQhB,CAAC,EAAA,CACH;AAEJ;"}
|
|
@@ -12,9 +12,19 @@ function Home() {
|
|
|
12
12
|
/* @__PURE__ */ jsxs("main", { className: "flex-1 mx-auto w-full max-w-[var(--content-width)] px-[var(--page-padding)]", children: [
|
|
13
13
|
/* @__PURE__ */ jsx("title", { children: isRoot ? config.name : `${config.name} - ${path}` }),
|
|
14
14
|
/* @__PURE__ */ jsxs("main", { className: "flex flex-col gap-8 mb-32 mt-12", children: [
|
|
15
|
-
isRoot && /* @__PURE__ */ jsxs("div", { className: "animate-fade-in", children: [
|
|
16
|
-
/* @__PURE__ */
|
|
17
|
-
|
|
15
|
+
isRoot && /* @__PURE__ */ jsxs("div", { className: "animate-fade-in flex items-start justify-between gap-4", children: [
|
|
16
|
+
/* @__PURE__ */ jsxs("div", { children: [
|
|
17
|
+
/* @__PURE__ */ jsx("h1", { className: "text-2xl md:text-3xl font-semibold tracking-tight text-foreground", children: config.name }),
|
|
18
|
+
config.description && /* @__PURE__ */ jsx("p", { className: "mt-2 text-muted-foreground", children: config.description })
|
|
19
|
+
] }),
|
|
20
|
+
config.llmsTxt && /* @__PURE__ */ jsx(
|
|
21
|
+
"a",
|
|
22
|
+
{
|
|
23
|
+
href: "/llms-full.txt",
|
|
24
|
+
className: "font-mono text-xs text-muted-foreground/70 hover:text-foreground underline underline-offset-2 transition-colors shrink-0",
|
|
25
|
+
children: "llms.txt"
|
|
26
|
+
}
|
|
27
|
+
)
|
|
18
28
|
] }),
|
|
19
29
|
/* @__PURE__ */ jsx("div", { className: "flex flex-col gap-2", children: /* @__PURE__ */ jsx("div", { className: "animate-fade-in", children: /* @__PURE__ */ jsx(PostList, {}) }) })
|
|
20
30
|
] })
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"home.js","sources":["../../../src/pages/home.tsx"],"sourcesContent":["import { useParams } from \"react-router-dom\"\nimport { PostList } from \"@/components/post-list\";\nimport { Header } from \"@/components/header\";\nimport veslxConfig from \"virtual:veslx-config\";\n\nexport function Home() {\n const { \"*\": path = \".\" } = useParams();\n const config = veslxConfig.site;\n\n const isRoot = path === \".\" || path === \"\";\n\n return (\n <div className=\"flex min-h-screen flex-col bg-background noise-overlay\">\n <Header />\n <main className=\"flex-1 mx-auto w-full max-w-[var(--content-width)] px-[var(--page-padding)]\">\n <title>{isRoot ? config.name : `${config.name} - ${path}`}</title>\n <main className=\"flex flex-col gap-8 mb-32 mt-12\">\n {isRoot && (\n <div className=\"animate-fade-in\">\n <h1 className=\"text-2xl md:text-3xl font-semibold tracking-tight text-foreground\">\n
|
|
1
|
+
{"version":3,"file":"home.js","sources":["../../../src/pages/home.tsx"],"sourcesContent":["import { useParams } from \"react-router-dom\"\nimport { PostList } from \"@/components/post-list\";\nimport { Header } from \"@/components/header\";\nimport veslxConfig from \"virtual:veslx-config\";\n\nexport function Home() {\n const { \"*\": path = \".\" } = useParams();\n const config = veslxConfig.site;\n\n const isRoot = path === \".\" || path === \"\";\n\n return (\n <div className=\"flex min-h-screen flex-col bg-background noise-overlay\">\n <Header />\n <main className=\"flex-1 mx-auto w-full max-w-[var(--content-width)] px-[var(--page-padding)]\">\n <title>{isRoot ? config.name : `${config.name} - ${path}`}</title>\n <main className=\"flex flex-col gap-8 mb-32 mt-12\">\n {isRoot && (\n <div className=\"animate-fade-in flex items-start justify-between gap-4\">\n <div>\n <h1 className=\"text-2xl md:text-3xl font-semibold tracking-tight text-foreground\">\n {config.name}\n </h1>\n {config.description && (\n <p className=\"mt-2 text-muted-foreground\">\n {config.description}\n </p>\n )}\n </div>\n {config.llmsTxt && (\n <a\n href=\"/llms-full.txt\"\n className=\"font-mono text-xs text-muted-foreground/70 hover:text-foreground underline underline-offset-2 transition-colors shrink-0\"\n >\n llms.txt\n </a>\n )}\n </div>\n )}\n\n <div className=\"flex flex-col gap-2\">\n <div className=\"animate-fade-in\">\n <PostList />\n </div>\n </div>\n </main>\n </main>\n </div>\n )\n}\n"],"names":[],"mappings":";;;;;AAKO,SAAS,OAAO;AACrB,QAAM,EAAE,KAAK,OAAO,IAAA,IAAQ,UAAA;AAC5B,QAAM,SAAS,YAAY;AAE3B,QAAM,SAAS,SAAS,OAAO,SAAS;AAExC,SACE,qBAAC,OAAA,EAAI,WAAU,0DACb,UAAA;AAAA,IAAA,oBAAC,QAAA,EAAO;AAAA,IACR,qBAAC,QAAA,EAAK,WAAU,+EACd,UAAA;AAAA,MAAA,oBAAC,SAAA,EAAO,mBAAS,OAAO,OAAO,GAAG,OAAO,IAAI,MAAM,IAAI,GAAA,CAAG;AAAA,MAC1D,qBAAC,QAAA,EAAK,WAAU,mCACb,UAAA;AAAA,QAAA,UACC,qBAAC,OAAA,EAAI,WAAU,0DACb,UAAA;AAAA,UAAA,qBAAC,OAAA,EACC,UAAA;AAAA,YAAA,oBAAC,MAAA,EAAG,WAAU,qEACX,UAAA,OAAO,MACV;AAAA,YACC,OAAO,eACN,oBAAC,OAAE,WAAU,8BACV,iBAAO,YAAA,CACV;AAAA,UAAA,GAEJ;AAAA,UACC,OAAO,WACN;AAAA,YAAC;AAAA,YAAA;AAAA,cACC,MAAK;AAAA,cACL,WAAU;AAAA,cACX,UAAA;AAAA,YAAA;AAAA,UAAA;AAAA,QAED,GAEJ;AAAA,QAGF,oBAAC,OAAA,EAAI,WAAU,uBACb,UAAA,oBAAC,OAAA,EAAI,WAAU,mBACb,UAAA,oBAAC,UAAA,CAAA,CAAS,EAAA,CACZ,EAAA,CACF;AAAA,MAAA,EAAA,CACF;AAAA,IAAA,EAAA,CACF;AAAA,EAAA,GACF;AAEJ;"}
|
package/package.json
CHANGED
package/plugin/src/plugin.ts
CHANGED
|
@@ -3,7 +3,7 @@ import path from 'path'
|
|
|
3
3
|
import fs from 'fs'
|
|
4
4
|
import yaml from 'js-yaml'
|
|
5
5
|
import type { IncomingMessage, ServerResponse } from 'http'
|
|
6
|
-
import { type VeslxConfig, type ResolvedSiteConfig, type ResolvedSlidesConfig, type ResolvedConfig, DEFAULT_SITE_CONFIG, DEFAULT_SLIDES_CONFIG } from './types'
|
|
6
|
+
import { type VeslxConfig, type ResolvedSiteConfig, type ResolvedSlidesConfig, type ResolvedPostsConfig, type ResolvedConfig, DEFAULT_SITE_CONFIG, DEFAULT_SLIDES_CONFIG, DEFAULT_POSTS_CONFIG } from './types'
|
|
7
7
|
import matter from 'gray-matter'
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -100,6 +100,11 @@ export default function contentPlugin(contentDir: string, config?: VeslxConfig,
|
|
|
100
100
|
...config?.slides,
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
let postsConfig: ResolvedPostsConfig = {
|
|
104
|
+
...DEFAULT_POSTS_CONFIG,
|
|
105
|
+
...config?.posts,
|
|
106
|
+
}
|
|
107
|
+
|
|
103
108
|
// Helper to reload config from file
|
|
104
109
|
function reloadConfig(): boolean {
|
|
105
110
|
if (!configPath || !fs.existsSync(configPath)) return false
|
|
@@ -114,6 +119,10 @@ export default function contentPlugin(contentDir: string, config?: VeslxConfig,
|
|
|
114
119
|
...DEFAULT_SLIDES_CONFIG,
|
|
115
120
|
...parsed?.slides,
|
|
116
121
|
}
|
|
122
|
+
postsConfig = {
|
|
123
|
+
...DEFAULT_POSTS_CONFIG,
|
|
124
|
+
...parsed?.posts,
|
|
125
|
+
}
|
|
117
126
|
return true
|
|
118
127
|
} catch (e) {
|
|
119
128
|
console.error('[veslx] Failed to reload config:', e)
|
|
@@ -126,8 +135,8 @@ export default function contentPlugin(contentDir: string, config?: VeslxConfig,
|
|
|
126
135
|
|
|
127
136
|
urlToDir.set('/raw', dir)
|
|
128
137
|
|
|
129
|
-
//
|
|
130
|
-
function
|
|
138
|
+
// Get sorted entries for llms.txt generation
|
|
139
|
+
function getLlmsEntries() {
|
|
131
140
|
const frontmatters = extractFrontmatters(dir)
|
|
132
141
|
const entries: { path: string; title?: string; description?: string; date?: string; isSlides: boolean }[] = []
|
|
133
142
|
|
|
@@ -143,50 +152,100 @@ export default function contentPlugin(contentDir: string, config?: VeslxConfig,
|
|
|
143
152
|
})
|
|
144
153
|
}
|
|
145
154
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
155
|
+
// Sort alphanumerically by path (0-foo before 1-bar before 10-baz)
|
|
156
|
+
entries.sort((a, b) => a.path.localeCompare(b.path, undefined, { numeric: true }))
|
|
157
|
+
|
|
158
|
+
return entries
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Generate llms.txt index (links to articles)
|
|
162
|
+
function generateLlmsTxt(): string {
|
|
163
|
+
const entries = getLlmsEntries()
|
|
152
164
|
|
|
153
165
|
const lines: string[] = [`# ${siteConfig.name}`]
|
|
154
166
|
if (siteConfig.description) {
|
|
155
|
-
lines.push(siteConfig.description)
|
|
167
|
+
lines.push(`> ${siteConfig.description}`)
|
|
156
168
|
}
|
|
157
169
|
lines.push('')
|
|
158
170
|
|
|
159
171
|
// Links section
|
|
160
172
|
if (siteConfig.homepage) {
|
|
161
|
-
lines.push(
|
|
173
|
+
lines.push(`- Homepage: ${siteConfig.homepage}`)
|
|
162
174
|
}
|
|
163
175
|
if (siteConfig.github) {
|
|
164
|
-
lines.push(
|
|
176
|
+
lines.push(`- GitHub: https://github.com/${siteConfig.github}`)
|
|
165
177
|
}
|
|
166
|
-
lines.push('Install: bun install -g veslx')
|
|
167
178
|
lines.push('')
|
|
168
179
|
|
|
169
|
-
lines.push('
|
|
180
|
+
lines.push('## Documentation')
|
|
170
181
|
lines.push('')
|
|
171
182
|
|
|
172
183
|
for (const entry of entries) {
|
|
173
|
-
const type = entry.isSlides ? '[slides]' : entry.date ? '[post]' : '[doc]'
|
|
174
184
|
const title = entry.title || entry.path.replace(/\.mdx?$/, '').split('/').pop()
|
|
175
|
-
const desc = entry.description ?
|
|
176
|
-
lines.push(
|
|
185
|
+
const desc = entry.description ? `: ${entry.description}` : ''
|
|
186
|
+
lines.push(`- [${title}](/raw/${entry.path})${desc}`)
|
|
177
187
|
}
|
|
178
188
|
lines.push('')
|
|
179
189
|
return lines.join('\n')
|
|
180
190
|
}
|
|
181
191
|
|
|
192
|
+
// Generate llms-full.txt with all article content inline
|
|
193
|
+
function generateLlmsFullTxt(): string {
|
|
194
|
+
const entries = getLlmsEntries()
|
|
195
|
+
|
|
196
|
+
const lines: string[] = [`# ${siteConfig.name}`]
|
|
197
|
+
if (siteConfig.description) {
|
|
198
|
+
lines.push(`> ${siteConfig.description}`)
|
|
199
|
+
}
|
|
200
|
+
lines.push('')
|
|
201
|
+
|
|
202
|
+
for (const entry of entries) {
|
|
203
|
+
const filePath = path.join(dir, entry.path)
|
|
204
|
+
if (!fs.existsSync(filePath)) continue
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
const content = fs.readFileSync(filePath, 'utf-8')
|
|
208
|
+
// Remove frontmatter and JSX components
|
|
209
|
+
const contentWithoutFrontmatter = content
|
|
210
|
+
.replace(/^---[\s\S]*?---\n*/, '')
|
|
211
|
+
.replace(/<[A-Z][a-zA-Z]*\s*\/>/g, '') // Self-closing JSX like <FrontMatter />
|
|
212
|
+
.replace(/<[A-Z][a-zA-Z]*[^>]*>[\s\S]*?<\/[A-Z][a-zA-Z]*>/g, '') // JSX with children
|
|
213
|
+
.replace(/\n{3,}/g, '\n\n') // Collapse multiple newlines
|
|
214
|
+
|
|
215
|
+
const title = entry.title || entry.path.replace(/\.mdx?$/, '').split('/').pop()
|
|
216
|
+
|
|
217
|
+
lines.push('---')
|
|
218
|
+
lines.push('')
|
|
219
|
+
lines.push(`## ${title}`)
|
|
220
|
+
if (entry.description) {
|
|
221
|
+
lines.push(`> ${entry.description}`)
|
|
222
|
+
}
|
|
223
|
+
lines.push('')
|
|
224
|
+
lines.push(contentWithoutFrontmatter.trim())
|
|
225
|
+
lines.push('')
|
|
226
|
+
} catch {
|
|
227
|
+
// Skip files that can't be read
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return lines.join('\n')
|
|
232
|
+
}
|
|
233
|
+
|
|
182
234
|
const middleware: Connect.NextHandleFunction = (req: IncomingMessage, res: ServerResponse, next: Connect.NextFunction) => {
|
|
183
|
-
// Serve llms.txt dynamically
|
|
184
|
-
if (req.url === '/llms.txt') {
|
|
235
|
+
// Serve llms.txt dynamically (only if enabled)
|
|
236
|
+
if (req.url === '/llms.txt' && siteConfig.llmsTxt) {
|
|
185
237
|
res.setHeader('Content-Type', 'text/plain')
|
|
186
238
|
res.end(generateLlmsTxt())
|
|
187
239
|
return
|
|
188
240
|
}
|
|
189
241
|
|
|
242
|
+
// Serve llms-full.txt with all content inline (only if enabled)
|
|
243
|
+
if (req.url === '/llms-full.txt' && siteConfig.llmsTxt) {
|
|
244
|
+
res.setHeader('Content-Type', 'text/plain')
|
|
245
|
+
res.end(generateLlmsFullTxt())
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
|
|
190
249
|
// Check if URL matches any registered content directory
|
|
191
250
|
for (const [urlBase, contentDir] of urlToDir.entries()) {
|
|
192
251
|
if (req.url?.startsWith(urlBase + '/')) {
|
|
@@ -224,6 +283,28 @@ export default function contentPlugin(contentDir: string, config?: VeslxConfig,
|
|
|
224
283
|
|
|
225
284
|
return {
|
|
226
285
|
name: 'content',
|
|
286
|
+
enforce: 'pre',
|
|
287
|
+
|
|
288
|
+
// Inject @source directive for Tailwind to scan content directory for classes
|
|
289
|
+
transform(code, id) {
|
|
290
|
+
// Only process CSS files containing the tailwindcss import
|
|
291
|
+
if (!id.endsWith('.css')) return null
|
|
292
|
+
if (!code.includes('@import "tailwindcss"')) return null
|
|
293
|
+
|
|
294
|
+
// Calculate relative path from CSS file to content directory
|
|
295
|
+
const cssDir = path.dirname(id)
|
|
296
|
+
let relativeContentDir = path.relative(cssDir, dir)
|
|
297
|
+
relativeContentDir = relativeContentDir.replace(/\\/g, '/') // Windows compatibility
|
|
298
|
+
|
|
299
|
+
// Inject @source directive after the tailwindcss import
|
|
300
|
+
const sourceDirective = `@source "${relativeContentDir}";`
|
|
301
|
+
const modified = code.replace(
|
|
302
|
+
/(@import\s+["']tailwindcss["'];?)/,
|
|
303
|
+
`$1\n${sourceDirective}`
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
return { code: modified, map: null }
|
|
307
|
+
},
|
|
227
308
|
|
|
228
309
|
// Inject @content alias and fs.allow into Vite config
|
|
229
310
|
config() {
|
|
@@ -294,7 +375,7 @@ export const modules = import.meta.glob(['@content/**/*.mdx', '@content/**/*.md'
|
|
|
294
375
|
}
|
|
295
376
|
if (id === RESOLVED_VIRTUAL_CONFIG_ID) {
|
|
296
377
|
// Generate virtual module with full config
|
|
297
|
-
const fullConfig: ResolvedConfig = { site: siteConfig, slides: slidesConfig }
|
|
378
|
+
const fullConfig: ResolvedConfig = { site: siteConfig, slides: slidesConfig, posts: postsConfig }
|
|
298
379
|
return `export default ${JSON.stringify(fullConfig)};`
|
|
299
380
|
}
|
|
300
381
|
},
|
|
@@ -340,10 +421,16 @@ export const modules = import.meta.glob(['@content/**/*.mdx', '@content/**/*.md'
|
|
|
340
421
|
copyDirSync(dir, destDir)
|
|
341
422
|
console.log(`Content copied successfully`)
|
|
342
423
|
|
|
343
|
-
// Generate llms.txt
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
424
|
+
// Generate llms.txt files if enabled
|
|
425
|
+
if (siteConfig.llmsTxt) {
|
|
426
|
+
const llmsTxtPath = path.join(outDir, 'llms.txt')
|
|
427
|
+
fs.writeFileSync(llmsTxtPath, generateLlmsTxt())
|
|
428
|
+
console.log(`Generated llms.txt`)
|
|
429
|
+
|
|
430
|
+
const llmsFullTxtPath = path.join(outDir, 'llms-full.txt')
|
|
431
|
+
fs.writeFileSync(llmsFullTxtPath, generateLlmsFullTxt())
|
|
432
|
+
console.log(`Generated llms-full.txt`)
|
|
433
|
+
}
|
|
347
434
|
} else {
|
|
348
435
|
console.warn(`Content directory not found: ${dir}`)
|
|
349
436
|
}
|
package/plugin/src/types.ts
CHANGED
|
@@ -2,33 +2,45 @@ export interface SlidesConfig {
|
|
|
2
2
|
scrollSnap?: boolean;
|
|
3
3
|
}
|
|
4
4
|
|
|
5
|
+
export interface PostsConfig {
|
|
6
|
+
sort?: 'date' | 'alpha';
|
|
7
|
+
}
|
|
8
|
+
|
|
5
9
|
export interface SiteConfig {
|
|
6
10
|
name?: string;
|
|
7
11
|
description?: string;
|
|
8
12
|
github?: string;
|
|
9
13
|
homepage?: string;
|
|
14
|
+
llmsTxt?: boolean;
|
|
10
15
|
}
|
|
11
16
|
|
|
12
17
|
export interface VeslxConfig {
|
|
13
18
|
dir?: string;
|
|
14
19
|
site?: SiteConfig;
|
|
15
20
|
slides?: SlidesConfig;
|
|
21
|
+
posts?: PostsConfig;
|
|
16
22
|
}
|
|
17
23
|
|
|
18
24
|
export interface ResolvedSlidesConfig {
|
|
19
25
|
scrollSnap: boolean;
|
|
20
26
|
}
|
|
21
27
|
|
|
28
|
+
export interface ResolvedPostsConfig {
|
|
29
|
+
sort: 'date' | 'alpha';
|
|
30
|
+
}
|
|
31
|
+
|
|
22
32
|
export interface ResolvedSiteConfig {
|
|
23
33
|
name: string;
|
|
24
34
|
description: string;
|
|
25
35
|
github: string;
|
|
26
36
|
homepage: string;
|
|
37
|
+
llmsTxt: boolean;
|
|
27
38
|
}
|
|
28
39
|
|
|
29
40
|
export interface ResolvedConfig {
|
|
30
41
|
site: ResolvedSiteConfig;
|
|
31
42
|
slides: ResolvedSlidesConfig;
|
|
43
|
+
posts: ResolvedPostsConfig;
|
|
32
44
|
}
|
|
33
45
|
|
|
34
46
|
export const DEFAULT_SITE_CONFIG: ResolvedSiteConfig = {
|
|
@@ -36,8 +48,13 @@ export const DEFAULT_SITE_CONFIG: ResolvedSiteConfig = {
|
|
|
36
48
|
description: '',
|
|
37
49
|
github: '',
|
|
38
50
|
homepage: '',
|
|
51
|
+
llmsTxt: false,
|
|
39
52
|
};
|
|
40
53
|
|
|
41
54
|
export const DEFAULT_SLIDES_CONFIG: ResolvedSlidesConfig = {
|
|
42
55
|
scrollSnap: true,
|
|
43
56
|
};
|
|
57
|
+
|
|
58
|
+
export const DEFAULT_POSTS_CONFIG: ResolvedPostsConfig = {
|
|
59
|
+
sort: 'alpha',
|
|
60
|
+
};
|
|
@@ -1,8 +1,56 @@
|
|
|
1
|
+
import { useLocation } from "react-router-dom";
|
|
1
2
|
import { useFrontmatter } from "@/lib/frontmatter-context";
|
|
2
|
-
import { formatDate } from "@/lib/format-date"
|
|
3
|
+
import { formatDate } from "@/lib/format-date";
|
|
4
|
+
import veslxConfig from "virtual:veslx-config";
|
|
3
5
|
|
|
4
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Convert MDX content to llms.txt format.
|
|
8
|
+
*/
|
|
9
|
+
function convertToLlmsTxt(
|
|
10
|
+
rawMdx: string,
|
|
11
|
+
frontmatter?: { title?: string; description?: string }
|
|
12
|
+
): string {
|
|
13
|
+
const contentWithoutFrontmatter = rawMdx.replace(/^---[\s\S]*?---\n*/, '')
|
|
14
|
+
|
|
15
|
+
const parts: string[] = []
|
|
16
|
+
|
|
17
|
+
const title = frontmatter?.title || 'Untitled'
|
|
18
|
+
parts.push(`# ${title}`)
|
|
19
|
+
|
|
20
|
+
if (frontmatter?.description) {
|
|
21
|
+
parts.push('')
|
|
22
|
+
parts.push(`> ${frontmatter.description}`)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (contentWithoutFrontmatter.trim()) {
|
|
26
|
+
parts.push('')
|
|
27
|
+
parts.push(contentWithoutFrontmatter.trim())
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return parts.join('\n')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function FrontMatter() {
|
|
5
34
|
const frontmatter = useFrontmatter();
|
|
35
|
+
const location = useLocation();
|
|
36
|
+
const config = veslxConfig.site;
|
|
37
|
+
|
|
38
|
+
const rawUrl = `/raw${location.pathname.replace(/^\//, '/')}`;
|
|
39
|
+
|
|
40
|
+
const handleLlmsTxt = async (e: React.MouseEvent) => {
|
|
41
|
+
e.preventDefault();
|
|
42
|
+
try {
|
|
43
|
+
const res = await fetch(rawUrl);
|
|
44
|
+
if (!res.ok) throw new Error('Failed to fetch');
|
|
45
|
+
const rawMdx = await res.text();
|
|
46
|
+
const llmsTxt = convertToLlmsTxt(rawMdx, frontmatter);
|
|
47
|
+
const blob = new Blob([llmsTxt], { type: 'text/plain' });
|
|
48
|
+
const url = URL.createObjectURL(blob);
|
|
49
|
+
window.location.href = url;
|
|
50
|
+
} catch {
|
|
51
|
+
console.error('Failed to load llms.txt');
|
|
52
|
+
}
|
|
53
|
+
};
|
|
6
54
|
|
|
7
55
|
return (
|
|
8
56
|
<div>
|
|
@@ -19,6 +67,15 @@ export function FrontMatter(){
|
|
|
19
67
|
{formatDate(new Date(frontmatter.date as string))}
|
|
20
68
|
</time>
|
|
21
69
|
)}
|
|
70
|
+
{config.llmsTxt && (
|
|
71
|
+
<a
|
|
72
|
+
href="#"
|
|
73
|
+
onClick={handleLlmsTxt}
|
|
74
|
+
className="font-mono text-xs text-muted-foreground/70 hover:text-foreground underline underline-offset-2 transition-colors"
|
|
75
|
+
>
|
|
76
|
+
llms.txt
|
|
77
|
+
</a>
|
|
78
|
+
)}
|
|
22
79
|
</div>
|
|
23
80
|
|
|
24
81
|
{frontmatter?.description && (
|
|
@@ -29,5 +86,5 @@ export function FrontMatter(){
|
|
|
29
86
|
</header>
|
|
30
87
|
)}
|
|
31
88
|
</div>
|
|
32
|
-
)
|
|
89
|
+
);
|
|
33
90
|
}
|
|
@@ -9,6 +9,7 @@ import { useDirectory } from "../../plugin/src/client";
|
|
|
9
9
|
import { ErrorDisplay } from "./page-error";
|
|
10
10
|
import Loading from "./loading";
|
|
11
11
|
import { PostListItem } from "./post-list-item";
|
|
12
|
+
import veslxConfig from "virtual:veslx-config";
|
|
12
13
|
|
|
13
14
|
// Helper to format name for display (e.g., "01-getting-started" → "Getting Started")
|
|
14
15
|
function formatName(name: string): string {
|
|
@@ -79,8 +80,22 @@ export function PostList() {
|
|
|
79
80
|
);
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
//
|
|
83
|
-
|
|
83
|
+
// Sort based on config
|
|
84
|
+
const sortMode = veslxConfig.posts?.sort ?? 'alpha';
|
|
85
|
+
if (sortMode === 'date') {
|
|
86
|
+
// Sort by date descending (newest first), posts without dates go to the end
|
|
87
|
+
posts = posts.sort((a, b) => {
|
|
88
|
+
const dateA = getFrontmatter(a)?.date;
|
|
89
|
+
const dateB = getFrontmatter(b)?.date;
|
|
90
|
+
if (!dateA && !dateB) return a.name.localeCompare(b.name);
|
|
91
|
+
if (!dateA) return 1;
|
|
92
|
+
if (!dateB) return -1;
|
|
93
|
+
return new Date(dateB as string).getTime() - new Date(dateA as string).getTime();
|
|
94
|
+
});
|
|
95
|
+
} else {
|
|
96
|
+
// Alphanumeric sorting by name
|
|
97
|
+
posts = posts.sort((a, b) => a.name.localeCompare(b.name));
|
|
98
|
+
}
|
|
84
99
|
|
|
85
100
|
return (
|
|
86
101
|
<div className="space-y-1 not-prose">
|
package/src/index.css
CHANGED
package/src/pages/home.tsx
CHANGED
|
@@ -16,14 +16,24 @@ export function Home() {
|
|
|
16
16
|
<title>{isRoot ? config.name : `${config.name} - ${path}`}</title>
|
|
17
17
|
<main className="flex flex-col gap-8 mb-32 mt-12">
|
|
18
18
|
{isRoot && (
|
|
19
|
-
<div className="animate-fade-in">
|
|
20
|
-
<
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
19
|
+
<div className="animate-fade-in flex items-start justify-between gap-4">
|
|
20
|
+
<div>
|
|
21
|
+
<h1 className="text-2xl md:text-3xl font-semibold tracking-tight text-foreground">
|
|
22
|
+
{config.name}
|
|
23
|
+
</h1>
|
|
24
|
+
{config.description && (
|
|
25
|
+
<p className="mt-2 text-muted-foreground">
|
|
26
|
+
{config.description}
|
|
27
|
+
</p>
|
|
28
|
+
)}
|
|
29
|
+
</div>
|
|
30
|
+
{config.llmsTxt && (
|
|
31
|
+
<a
|
|
32
|
+
href="/llms-full.txt"
|
|
33
|
+
className="font-mono text-xs text-muted-foreground/70 hover:text-foreground underline underline-offset-2 transition-colors shrink-0"
|
|
34
|
+
>
|
|
35
|
+
llms.txt
|
|
36
|
+
</a>
|
|
27
37
|
)}
|
|
28
38
|
</div>
|
|
29
39
|
)}
|