veslx 0.1.28 → 0.1.30
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 +128 -27
- 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 + '/')) {
|
|
@@ -222,11 +281,15 @@ export default function contentPlugin(contentDir: string, config?: VeslxConfig,
|
|
|
222
281
|
next()
|
|
223
282
|
}
|
|
224
283
|
|
|
284
|
+
// Virtual module ID for the modified CSS
|
|
285
|
+
const VIRTUAL_CSS_MODULE = '\0veslx:index.css'
|
|
286
|
+
|
|
225
287
|
return {
|
|
226
288
|
name: 'content',
|
|
289
|
+
enforce: 'pre',
|
|
227
290
|
|
|
228
291
|
// Inject @content alias and fs.allow into Vite config
|
|
229
|
-
config() {
|
|
292
|
+
config(config, { command }) {
|
|
230
293
|
return {
|
|
231
294
|
resolve: {
|
|
232
295
|
alias: {
|
|
@@ -244,8 +307,18 @@ export default function contentPlugin(contentDir: string, config?: VeslxConfig,
|
|
|
244
307
|
}
|
|
245
308
|
},
|
|
246
309
|
|
|
247
|
-
//
|
|
248
|
-
resolveId(id) {
|
|
310
|
+
// Intercept CSS and virtual module imports
|
|
311
|
+
resolveId(id, importer) {
|
|
312
|
+
// Intercept index.css imported from main.tsx and redirect to our virtual module
|
|
313
|
+
// This allows us to inject @source directive for Tailwind to scan user content
|
|
314
|
+
if (id === './index.css' && importer?.endsWith('/src/main.tsx')) {
|
|
315
|
+
return VIRTUAL_CSS_MODULE
|
|
316
|
+
}
|
|
317
|
+
// Also catch the resolved path
|
|
318
|
+
if (id.endsWith('/src/index.css') && !id.startsWith('\0')) {
|
|
319
|
+
return VIRTUAL_CSS_MODULE
|
|
320
|
+
}
|
|
321
|
+
// Virtual modules for content
|
|
249
322
|
if (id === VIRTUAL_MODULE_ID) {
|
|
250
323
|
return RESOLVED_VIRTUAL_MODULE_ID
|
|
251
324
|
}
|
|
@@ -255,6 +328,28 @@ export default function contentPlugin(contentDir: string, config?: VeslxConfig,
|
|
|
255
328
|
},
|
|
256
329
|
|
|
257
330
|
load(id) {
|
|
331
|
+
// Serve the modified CSS content with @source directive
|
|
332
|
+
// This enables Tailwind v4 to scan the user's content directory for classes
|
|
333
|
+
if (id === VIRTUAL_CSS_MODULE) {
|
|
334
|
+
// Read the original CSS
|
|
335
|
+
const veslxRoot = path.dirname(path.dirname(__dirname))
|
|
336
|
+
const cssPath = path.join(veslxRoot, 'src/index.css')
|
|
337
|
+
const cssContent = fs.readFileSync(cssPath, 'utf-8')
|
|
338
|
+
|
|
339
|
+
// Use absolute path for @source directive
|
|
340
|
+
const absoluteContentDir = dir.replace(/\\/g, '/')
|
|
341
|
+
|
|
342
|
+
// Inject @source directive after the tailwindcss import
|
|
343
|
+
const sourceDirective = `@source "${absoluteContentDir}";`
|
|
344
|
+
const modified = cssContent.replace(
|
|
345
|
+
/(@import\s+["']tailwindcss["'];?)/,
|
|
346
|
+
`$1\n${sourceDirective}`
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
return modified
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Virtual module for content
|
|
258
353
|
if (id === RESOLVED_VIRTUAL_MODULE_ID) {
|
|
259
354
|
// Extract frontmatter from MDX files at build time (avoids MDX hook issues)
|
|
260
355
|
const frontmatterData = extractFrontmatters(dir);
|
|
@@ -294,7 +389,7 @@ export const modules = import.meta.glob(['@content/**/*.mdx', '@content/**/*.md'
|
|
|
294
389
|
}
|
|
295
390
|
if (id === RESOLVED_VIRTUAL_CONFIG_ID) {
|
|
296
391
|
// Generate virtual module with full config
|
|
297
|
-
const fullConfig: ResolvedConfig = { site: siteConfig, slides: slidesConfig }
|
|
392
|
+
const fullConfig: ResolvedConfig = { site: siteConfig, slides: slidesConfig, posts: postsConfig }
|
|
298
393
|
return `export default ${JSON.stringify(fullConfig)};`
|
|
299
394
|
}
|
|
300
395
|
},
|
|
@@ -340,10 +435,16 @@ export const modules = import.meta.glob(['@content/**/*.mdx', '@content/**/*.md'
|
|
|
340
435
|
copyDirSync(dir, destDir)
|
|
341
436
|
console.log(`Content copied successfully`)
|
|
342
437
|
|
|
343
|
-
// Generate llms.txt
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
438
|
+
// Generate llms.txt files if enabled
|
|
439
|
+
if (siteConfig.llmsTxt) {
|
|
440
|
+
const llmsTxtPath = path.join(outDir, 'llms.txt')
|
|
441
|
+
fs.writeFileSync(llmsTxtPath, generateLlmsTxt())
|
|
442
|
+
console.log(`Generated llms.txt`)
|
|
443
|
+
|
|
444
|
+
const llmsFullTxtPath = path.join(outDir, 'llms-full.txt')
|
|
445
|
+
fs.writeFileSync(llmsFullTxtPath, generateLlmsFullTxt())
|
|
446
|
+
console.log(`Generated llms-full.txt`)
|
|
447
|
+
}
|
|
347
448
|
} else {
|
|
348
449
|
console.warn(`Content directory not found: ${dir}`)
|
|
349
450
|
}
|
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
|
)}
|