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.
@@ -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__ */ jsx("div", { className: "flex flex-wrap items-center gap-3 text-muted-foreground", children: (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)) }) }),
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 )\n}\n"],"names":[],"mappings":";;;AAGO,SAAS,cAAa;AAC3B,QAAM,cAAc,eAAA;AAEpB,6BACG,OAAA,EACE,WAAA,2CAAa,UACZ,qBAAC,UAAA,EAAO,WAAU,2CAChB,UAAA;AAAA,IAAA,oBAAC,MAAA,EAAG,WAAU,0EACX,UAAA,2CAAa,OAChB;AAAA,wBAGC,OAAA,EAAI,WAAU,2DACZ,WAAA,2CAAa,SACZ,oBAAC,QAAA,EAAK,WAAU,kDACb,qBAAW,IAAI,KAAK,YAAY,IAAc,CAAC,GAClD,GAEJ;AAAA,KAEC,2CAAa,gBACZ,oBAAC,SAAI,WAAU,mEACZ,qDAAa,YAAA,CAChB;AAAA,EAAA,EAAA,CAEJ,EAAA,CAEJ;AAEJ;"}
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
- posts = posts.sort((a, b) => a.name.localeCompare(b.name));
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 posts = posts.sort((a, b) => a.name.localeCompare(b.name));\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":[],"mappings":";;;;;;AAaA,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,UAAQ,MAAM,KAAK,CAAC,GAAG,MAAM,EAAE,KAAK,cAAc,EAAE,IAAI,CAAC;AAEzD,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;"}
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__ */ jsx("h1", { className: "text-2xl md:text-3xl font-semibold tracking-tight text-foreground", children: config.name }),
17
- config.description && /* @__PURE__ */ jsx("p", { className: "mt-2 text-muted-foreground", children: config.description })
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 {config.name}\n </h1>\n {config.description && (\n <p className=\"mt-2 text-muted-foreground\">\n {config.description}\n </p>\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,mBACb,UAAA;AAAA,UAAA,oBAAC,MAAA,EAAG,WAAU,qEACX,UAAA,OAAO,MACV;AAAA,UACC,OAAO,eACN,oBAAC,OAAE,WAAU,8BACV,iBAAO,YAAA,CACV;AAAA,QAAA,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;"}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "veslx",
3
- "version": "0.1.28",
3
+ "version": "0.1.29",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
- // Generate llms.txt content dynamically
130
- function generateLlmsTxt(): string {
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
- entries.sort((a, b) => {
147
- if (a.date && b.date) return b.date.localeCompare(a.date)
148
- if (a.date) return -1
149
- if (b.date) return 1
150
- return a.path.localeCompare(b.path)
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(`Homepage: ${siteConfig.homepage}`)
173
+ lines.push(`- Homepage: ${siteConfig.homepage}`)
162
174
  }
163
175
  if (siteConfig.github) {
164
- lines.push(`GitHub: https://github.com/${siteConfig.github}`)
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('Content is served as raw MDX files at /raw/{path}.')
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 ? ` - ${entry.description}` : ''
176
- lines.push(`${type} ${title}: /raw/${entry.path}${desc}`)
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 for CLI tools and LLMs
344
- const llmsTxtPath = path.join(outDir, 'llms.txt')
345
- fs.writeFileSync(llmsTxtPath, generateLlmsTxt())
346
- console.log(`Generated llms.txt`)
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
  }
@@ -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
- export function FrontMatter(){
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
- // Alphanumeric sorting by name
83
- posts = posts.sort((a, b) => a.name.localeCompare(b.name));
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
@@ -209,6 +209,11 @@
209
209
  letter-spacing: 0;
210
210
  }
211
211
 
212
+ /* Code inside pre blocks should inherit background from pre */
213
+ pre code {
214
+ background: transparent;
215
+ }
216
+
212
217
  /* Scrollbar styling */
213
218
  ::-webkit-scrollbar {
214
219
  width: 8px;
@@ -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
- <h1 className="text-2xl md:text-3xl font-semibold tracking-tight text-foreground">
21
- {config.name}
22
- </h1>
23
- {config.description && (
24
- <p className="mt-2 text-muted-foreground">
25
- {config.description}
26
- </p>
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
  )}