@inglorious/ssx 1.3.6 → 1.4.1

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/README.md CHANGED
@@ -32,6 +32,7 @@ SSX takes your entity-based web apps and generates optimized static HTML with fu
32
32
  - **lit-html hydration** - Interactive UI without the bloat
33
33
  - **TypeScript Ready** - Write your pages and entities in TypeScript.
34
34
  - **Image Optimization** - Automatic compression for static assets.
35
+ - **Markdown Support** - Built-in support for `.md` pages with code highlighting and math.
35
36
 
36
37
  ### 🚀 Production Ready
37
38
 
@@ -397,6 +398,35 @@ SSX includes built-in image optimization using `vite-plugin-image-optimizer`.
397
398
  - **Automatic compression** - PNG, JPEG, GIF, SVG, WebP, and AVIF are compressed at build time.
398
399
  - **Lossless & Lossy** - Configurable settings via `vite` config in `site.config.js`.
399
400
 
401
+ ### 📝 Markdown Support
402
+
403
+ SSX treats `.md` files as first-class pages. You can create `src/pages/post.md` and it will be rendered automatically.
404
+
405
+ - **Frontmatter** - Metadata is exported as `metadata`.
406
+ - **Code Highlighting** - Built-in syntax highlighting with `highlight.js`.
407
+ - **Math Support** - LaTeX support via `katex` (use `$E=mc^2$` or `$$...$$`).
408
+ - **Mermaid Diagrams** - Use `mermaid` code blocks (requires client-side mermaid.js).
409
+
410
+ Configure the syntax highlighting theme in `site.config.js`:
411
+
412
+ ```javascript
413
+ export default {
414
+ markdown: {
415
+ theme: "monokai", // default: "github-dark"
416
+ },
417
+ }
418
+ ```
419
+
420
+ ```markdown
421
+ ---
422
+ title: My Post
423
+ ---
424
+
425
+ # Hello World
426
+
427
+ This is a markdown page.
428
+ ```
429
+
400
430
  ---
401
431
 
402
432
  ## CLI
@@ -464,16 +494,15 @@ my-site/
464
494
 
465
495
  ## Comparison to Other Tools
466
496
 
467
- | Feature | SSX | Next.js (SSG) | Astro | Eleventy |
468
- | ----------------------- | ----------- | ------------- | ------ | -------- |
469
- | Pre-rendered HTML | ✅ | ✅ | ✅ | ✅ |
470
- | Client hydration | ✅ lit-html | ✅ React | ✅ Any | ❌ |
471
- | Client routing | ✅ | ✅ | ✅ | ❌ |
472
- | Lazy loading | ✅ | ✅ | ✅ | ❌ |
473
- | Entity-based state | ✅ | ❌ | ❌ | ❌ |
474
- | No compilation required | ✅ | ❌ | ❌ | |
475
- | Zero config | | ❌ | | |
476
- | Framework agnostic | ❌ | ❌ | ✅ | ✅ |
497
+ | Feature | SSX | Next.js (SSG) | Astro | Eleventy |
498
+ | ------------------ | ----------- | ------------- | ------ | -------- |
499
+ | Pre-rendered HTML | ✅ | ✅ | ✅ | ✅ |
500
+ | Client hydration | ✅ lit-html | ✅ React | ✅ Any | ❌ |
501
+ | Client routing | ✅ | ✅ | ✅ | ❌ |
502
+ | Lazy loading | ✅ | ✅ | ✅ | ❌ |
503
+ | Entity-based state | ✅ | ❌ | ❌ | ❌ |
504
+ | Zero config | ✅ | ❌ | ❌ | |
505
+ | Framework agnostic | | ❌ | | |
477
506
 
478
507
  SSX is perfect if you:
479
508
 
@@ -643,7 +672,7 @@ Check out these example projects:
643
672
  - [x] TypeScript support
644
673
  - [x] Image optimization
645
674
  - [ ] API routes (serverless functions)
646
- - [ ] MDX support
675
+ - [x] Markdown support
647
676
  - [ ] i18n helpers
648
677
 
649
678
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/ssx",
3
- "version": "1.3.6",
3
+ "version": "1.4.1",
4
4
  "description": "Server-Side-X. Xecution? Xperience? Who knows.",
5
5
  "author": "IceOnFire <antony.mistretta@gmail.com> (https://ingloriouscoderz.it)",
6
6
  "license": "MIT",
@@ -26,7 +26,8 @@
26
26
  "ssx": "./bin/ssx.js"
27
27
  },
28
28
  "exports": {
29
- ".": "./types/index.d.ts"
29
+ ".": "./types/index.d.ts",
30
+ "./markdown": "./src/utils/markdown.js"
30
31
  },
31
32
  "files": [
32
33
  "bin",
@@ -44,12 +45,18 @@
44
45
  "connect": "^3.7.0",
45
46
  "fast-xml-parser": "^5.3.3",
46
47
  "glob": "^13.0.0",
48
+ "gray-matter": "^4.0.3",
49
+ "highlight.js": "^11.11.1",
50
+ "katex": "^0.16.27",
51
+ "markdown-it": "^14.1.0",
52
+ "markdown-it-texmath": "^1.0.0",
53
+ "mermaid": "^11.12.2",
47
54
  "rollup-plugin-minify-template-literals": "^1.1.7",
48
55
  "sharp": "^0.34.5",
49
56
  "svgo": "^4.0.0",
50
57
  "vite": "^7.1.3",
51
58
  "vite-plugin-image-optimizer": "^2.0.3",
52
- "@inglorious/web": "4.0.2"
59
+ "@inglorious/web": "4.0.3"
53
60
  },
54
61
  "devDependencies": {
55
62
  "prettier": "^3.6.2",
@@ -103,10 +103,12 @@ export async function build(options = {}) {
103
103
  loader,
104
104
  )
105
105
  // For skipped pages, load their metadata from disk if needed for sitemap/RSS
106
- const skippedPages = await generatePages(store, pagesToSkip, {
107
- ...mergedOptions,
108
- shouldGenerateHtml: false,
109
- })
106
+ const skippedPages = await generatePages(
107
+ store,
108
+ pagesToSkip,
109
+ { ...mergedOptions, shouldGenerateHtml: false },
110
+ loader,
111
+ )
110
112
 
111
113
  // Combine rendered and skipped pages for sitemap/RSS
112
114
  const allGeneratedPages = [...changedPages, ...skippedPages]
@@ -24,9 +24,16 @@ export function extractPageMetadata(store, page, entity, options = {}) {
24
24
 
25
25
  // sitemap metadata
26
26
  const loc = `${hostname}${path}`
27
- const lastmod = updatedAt
28
- ? new Date(updatedAt).toISOString().split("T")[0]
29
- : new Date().toISOString().split("T")[0]
27
+
28
+ let lastmod
29
+ try {
30
+ lastmod = updatedAt
31
+ ? new Date(updatedAt).toISOString().split("T")[0]
32
+ : new Date().toISOString().split("T")[0]
33
+ } catch {
34
+ console.warn(`⚠️ Invalid updatedAt date for page ${path}: ${updatedAt}`)
35
+ lastmod = new Date().toISOString().split("T")[0]
36
+ }
30
37
 
31
38
  // rss metadata
32
39
  const title = getPageOption("title", DEFAULT_OPTIONS)
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it, vi } from "vitest"
2
+
3
+ import { extractPageMetadata } from "./metadata.js"
4
+
5
+ describe("extractPageMetadata", () => {
6
+ it("should handle invalid updatedAt gracefully", () => {
7
+ const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
8
+ const store = { _api: {} }
9
+ const page = {
10
+ path: "/test",
11
+ module: { metadata: { updatedAt: "invalid-date" } },
12
+ }
13
+ const entity = {}
14
+
15
+ const metadata = extractPageMetadata(store, page, entity)
16
+ expect(metadata.lastmod).toBeDefined()
17
+ expect(consoleSpy).toHaveBeenCalled()
18
+ })
19
+ })
@@ -3,6 +3,8 @@ import path from "node:path"
3
3
  import { mergeConfig } from "vite"
4
4
  import { ViteImageOptimizer } from "vite-plugin-image-optimizer"
5
5
 
6
+ import { markdownPlugin } from "../utils/markdown.js"
7
+
6
8
  // import { minifyTemplateLiterals } from "rollup-plugin-minify-template-literals"
7
9
 
8
10
  /**
@@ -14,6 +16,7 @@ export function createViteConfig(options = {}) {
14
16
  outDir = "dist",
15
17
  publicDir = "public",
16
18
  vite = {},
19
+ markdown = {},
17
20
  } = options
18
21
 
19
22
  return mergeConfig(
@@ -25,6 +28,7 @@ export function createViteConfig(options = {}) {
25
28
  ViteImageOptimizer({
26
29
  // Options can be overridden by the user in site.config.js via the `vite` property
27
30
  }),
31
+ markdownPlugin(markdown),
28
32
  ],
29
33
  build: {
30
34
  outDir,
@@ -2,6 +2,8 @@ import path from "node:path"
2
2
 
3
3
  import { mergeConfig } from "vite"
4
4
 
5
+ import { markdownPlugin } from "../utils/markdown.js"
6
+
5
7
  /**
6
8
  * Creates a Vite configuration object for the SSX dev server.
7
9
  * It sets up the root directory, public directory, aliases, and the virtual file plugin.
@@ -13,7 +15,12 @@ import { mergeConfig } from "vite"
13
15
  * @returns {Object} The merged Vite configuration.
14
16
  */
15
17
  export function createViteConfig(options = {}) {
16
- const { rootDir = "src", publicDir = "public", vite = {} } = options
18
+ const {
19
+ rootDir = "src",
20
+ publicDir = "public",
21
+ vite = {},
22
+ markdown = {},
23
+ } = options
17
24
  const { port = 3000 } = vite.dev ?? {}
18
25
 
19
26
  return mergeConfig(
@@ -22,7 +29,7 @@ export function createViteConfig(options = {}) {
22
29
  publicDir: path.resolve(process.cwd(), rootDir, publicDir),
23
30
  server: { port, middlewareMode: true },
24
31
  appType: "custom",
25
- plugins: [virtualPlugin()],
32
+ plugins: [virtualPlugin(), markdownPlugin(markdown)],
26
33
  resolve: {
27
34
  alias: {
28
35
  "@": path.resolve(process.cwd(), rootDir),
@@ -124,7 +124,7 @@ export async function resolvePage(url, pagesDir = "pages") {
124
124
  */
125
125
  export async function getRoutes(pagesDir = "pages") {
126
126
  // Find all .js and .ts files in pages directory
127
- const files = await glob("**/*.{js,ts,jsx,tsx}", {
127
+ const files = await glob("**/*.{js,ts,jsx,tsx,md}", {
128
128
  cwd: pagesDir,
129
129
  ignore: ["**/*.test.{js,ts}", "**/*.spec.{js,ts}"],
130
130
  posix: true,
@@ -192,7 +192,7 @@ export function matchRoute(pattern, url) {
192
192
  function filePathToPattern(file) {
193
193
  let pattern = file
194
194
  .replace(/\\/g, "/")
195
- .replace(/\.(js|ts|jsx|tsx)$/, "") // Remove extension
195
+ .replace(/\.(js|ts|jsx|tsx|md)$/, "") // Remove extension
196
196
  .replace(/\/index$/, "") // index becomes root of directory
197
197
  .replace(/^index$/, "") // Handle root index
198
198
  .replace(/__(\w+)/g, "*") // __path becomes *
@@ -39,7 +39,7 @@ describe("router", () => {
39
39
  // Root usually comes after specific paths but before catch-all if it was a catch-all root,
40
40
  // but here / is static.
41
41
  // Let's just check that we found them.
42
- expect(routes).toHaveLength(5)
42
+ expect(routes).toHaveLength(6)
43
43
  })
44
44
  })
45
45
 
@@ -100,11 +100,11 @@ describe("router", () => {
100
100
  expect(pages).toMatchSnapshot()
101
101
 
102
102
  // Dynamic route without staticPaths should be skipped (and warn)
103
- const blogPage = pages.find((p) => p.path.includes("/blog/"))
103
+ const blogPage = pages.find((p) => p.path.includes("/api/"))
104
104
  expect(blogPage).toBeUndefined()
105
105
 
106
106
  expect(consoleSpy).toHaveBeenCalled()
107
- expect(consoleSpy.mock.calls[1][0]).toContain("has no staticPaths")
107
+ expect(consoleSpy.mock.calls[2][0]).toContain("has no staticPaths")
108
108
  })
109
109
  })
110
110
 
@@ -0,0 +1,90 @@
1
+ import hljs from "highlight.js"
2
+ import katex from "katex"
3
+ import MarkdownIt from "markdown-it"
4
+ import texmath from "markdown-it-texmath"
5
+
6
+ export function createMarkdownRenderer() {
7
+ const md = new MarkdownIt({
8
+ html: true,
9
+ linkify: true,
10
+ typographer: true,
11
+ highlight: (str, lang) => {
12
+ if (lang === "mermaid") {
13
+ return `<div class="mermaid">${str}</div>`
14
+ }
15
+ if (lang && hljs.getLanguage(lang)) {
16
+ try {
17
+ return hljs.highlight(str, { language: lang }).value
18
+ } catch (err) {
19
+ console.error(err)
20
+ }
21
+ }
22
+ return "" // use external default escaping
23
+ },
24
+ })
25
+
26
+ // Add LaTeX support
27
+ md.use(texmath, {
28
+ engine: katex,
29
+ delimiters: "dollars",
30
+ katexOptions: { macros: { "\\RR": "\\mathbb{R}" } },
31
+ })
32
+
33
+ return md
34
+ }
35
+
36
+ export function renderMarkdown(markdown) {
37
+ const md = createMarkdownRenderer()
38
+ // Simple frontmatter stripping for runtime rendering (avoids Buffer/gray-matter on client)
39
+ const content = markdown.replace(/^---[\s\S]*?---\n/, "")
40
+ return md.render(content)
41
+ }
42
+
43
+ export function markdownPlugin(options = {}) {
44
+ const { theme = "github-dark" } = options
45
+ const md = createMarkdownRenderer()
46
+
47
+ return {
48
+ name: "ssx-markdown",
49
+ enforce: "pre",
50
+ async transform(code, id) {
51
+ if (!id.endsWith(".md")) return
52
+
53
+ const matter = (await import("gray-matter")).default
54
+ const { content, data } = matter(code)
55
+ const htmlContent = md.render(content)
56
+ const hasMermaid =
57
+ content.includes('class="mermaid"') || content.includes("```mermaid")
58
+
59
+ let mermaidCode = ""
60
+ if (hasMermaid) {
61
+ mermaidCode = `
62
+ import mermaid from "mermaid"
63
+ if (typeof window !== "undefined") {
64
+ mermaid.initialize({ startOnLoad: false })
65
+ }
66
+ `
67
+ }
68
+
69
+ return `
70
+ import { html, unsafeHTML } from "@inglorious/web"
71
+ import "katex/dist/katex.min.css"
72
+ import "highlight.js/styles/${theme}.css"
73
+ ${mermaidCode}
74
+
75
+ export const metadata = ${JSON.stringify(data)}
76
+
77
+ export default {
78
+ render() {
79
+ if (typeof window !== "undefined" && ${hasMermaid}) {
80
+ setTimeout(() => {
81
+ mermaid.run({ querySelector: ".mermaid" })
82
+ }, 0)
83
+ }
84
+ return html\`<div class="markdown-body">\${unsafeHTML(${JSON.stringify(htmlContent)})}</div>\`
85
+ }
86
+ }
87
+ `
88
+ },
89
+ }
90
+ }
package/types/index.d.ts CHANGED
@@ -1,280 +1,71 @@
1
- /**
2
- * Represents a page being built or processed.
3
- */
4
- export interface Page {
5
- /**
6
- * The final URL path for the page (e.g., "/about").
7
- */
8
- path: string
9
- /**
10
- * The route pattern that matched this page (e.g., "/posts/:id").
11
- */
12
- pattern: string
13
- /**
14
- * The absolute file path to the source component.
15
- */
16
- filePath: string
17
- [key: string]: any
18
- }
1
+ import type { UserConfig } from "vite"
19
2
 
20
- /**
21
- * Options passed to the layout function.
22
- */
23
- export interface LayoutOptions {
24
- /**
25
- * The language attribute for the <html> tag.
26
- */
3
+ export interface SiteConfig {
4
+ /** Site title */
5
+ title?: string
6
+ /** HTML lang attribute */
27
7
  lang?: string
28
- /**
29
- * The character encoding.
30
- */
8
+ /** HTML charset */
31
9
  charset?: string
32
- /**
33
- * The page title.
34
- */
35
- title?: string
36
- /**
37
- * Meta tags to include in <head>.
38
- */
10
+ /** Meta tags */
39
11
  meta?: Record<string, string>
40
- /**
41
- * Stylesheets to include.
42
- */
12
+
13
+ /** Global styles to inject */
43
14
  styles?: string[]
44
- /**
45
- * Additional HTML to inject into <head>.
46
- */
47
- head?: string
48
- /**
49
- * Scripts to include.
50
- */
15
+ /** Global scripts to inject */
51
16
  scripts?: string[]
52
- /**
53
- * Whether the build is running in development mode.
54
- */
55
- isDev?: boolean
56
- [key: string]: any
57
- }
58
17
 
59
- /**
60
- * Configuration for the sitemap generation.
61
- */
62
- export interface SitemapConfig {
63
- /**
64
- * The base hostname for the sitemap URLs (e.g., "https://example.com").
65
- */
66
- hostname: string
67
- /**
68
- * A function to filter which pages are included in the sitemap.
69
- */
70
- filter?: (page: Page) => boolean
71
- /**
72
- * Default values for sitemap entries.
73
- */
74
- defaults?: {
75
- /**
76
- * How frequently the page is likely to change.
77
- */
78
- changefreq?: string
79
- /**
80
- * The priority of this URL relative to other URLs on your site.
81
- */
82
- priority?: number
83
- /**
84
- * The date of last modification.
85
- */
86
- lastmod?: string | Date
87
- }
88
- }
18
+ /** Source root directory (default: "src") */
19
+ rootDir?: string
20
+ /** Output directory (default: "dist") */
21
+ outDir?: string
22
+ /** Public directory (default: "public") */
23
+ publicDir?: string
24
+ /** Base path for the site */
25
+ basePath?: string
26
+ /** Favicon path */
27
+ favicon?: string
89
28
 
90
- /**
91
- * Configuration for the RSS feed generation.
92
- */
93
- export interface RssConfig {
94
- /**
95
- * The title of the RSS feed.
96
- */
97
- title: string
98
- /**
99
- * The description of the RSS feed.
100
- */
101
- description: string
102
- /**
103
- * The link to the site associated with the feed.
104
- */
105
- link: string
106
- /**
107
- * The output path for the RSS feed file.
108
- * @default "/feed.xml"
109
- */
110
- feedPath?: string
111
- /**
112
- * The language of the feed.
113
- */
114
- language?: string
115
- /**
116
- * Copyright notice for content in the feed.
117
- */
118
- copyright?: string
119
- /**
120
- * Maximum number of items to include in the feed.
121
- */
122
- maxItems?: number
123
- /**
124
- * A function to filter which pages are included in the feed.
125
- */
126
- filter?: (page: Page) => boolean
127
- }
29
+ /** Router configuration */
30
+ router?: {
31
+ trailingSlash?: boolean
32
+ scrollBehavior?: "auto" | "smooth"
33
+ }
128
34
 
129
- /**
130
- * Configuration for a URL redirect.
131
- */
132
- export interface RedirectConfig {
133
- /**
134
- * The source path or pattern to redirect from.
135
- */
136
- from: string
137
- /**
138
- * The destination path to redirect to.
139
- */
140
- to: string
141
- /**
142
- * The HTTP status code for the redirect.
143
- * @default 301
144
- */
145
- status?: number
146
- }
35
+ /** Vite configuration */
36
+ vite?: UserConfig
147
37
 
148
- /**
149
- * Configuration for the client-side router.
150
- */
151
- export interface RouterConfig {
152
- /**
153
- * Whether to enforce trailing slashes on URLs.
154
- */
155
- trailingSlash?: boolean
156
- /**
157
- * The scroll behavior when navigating between pages.
158
- */
159
- scrollBehavior?: "auto" | "smooth"
160
- }
38
+ /** Markdown configuration */
39
+ markdown?: {
40
+ /** Highlight.js theme (default: "github-dark") */
41
+ theme?: string
42
+ }
161
43
 
162
- /**
163
- * Result object passed to the afterBuild hook.
164
- */
165
- export interface BuildResult {
166
- /**
167
- * The total number of pages generated.
168
- */
169
- pages: number
170
- [key: string]: any
171
- }
44
+ /** Sitemap configuration */
45
+ sitemap?: {
46
+ hostname: string
47
+ filter?: (page: any) => boolean
48
+ defaults?: {
49
+ changefreq?: string
50
+ priority?: number
51
+ }
52
+ }
172
53
 
173
- /**
174
- * Lifecycle hooks for the build process.
175
- */
176
- export interface SSXHooks {
177
- /**
178
- * Called before the build process starts.
179
- */
180
- beforeBuild?: (config: SiteConfig) => Promise<void> | void
181
- /**
182
- * Called after the build process completes.
183
- */
184
- afterBuild?: (result: BuildResult) => Promise<void> | void
185
- /**
186
- * Called after an individual page is built.
187
- */
188
- onPageBuild?: (page: Page) => Promise<void> | void
189
- }
54
+ /** RSS configuration */
55
+ rss?: {
56
+ title: string
57
+ description: string
58
+ link: string
59
+ feedPath?: string
60
+ language?: string
61
+ copyright?: string
62
+ maxItems?: number
63
+ filter?: (page: any) => boolean
64
+ }
190
65
 
191
- /**
192
- * Main configuration object for SSX.
193
- */
194
- export interface SiteConfig {
195
- /**
196
- * The language attribute for the <html> tag.
197
- * @default "en"
198
- */
199
- lang?: string
200
- /**
201
- * The character encoding for the site.
202
- * @default "UTF-8"
203
- */
204
- charset?: string
205
- /**
206
- * The default title for pages.
207
- */
208
- title?: string
209
- /**
210
- * Default meta tags to be applied to all pages.
211
- * Keys are meta names/properties, values are content.
212
- */
213
- meta?: Record<string, string>
214
- /**
215
- * List of CSS file paths or URLs to include globally.
216
- */
217
- styles?: string[]
218
- /**
219
- * List of JavaScript file paths or URLs to include globally.
220
- */
221
- scripts?: string[]
222
- /**
223
- * A function that renders the full HTML document structure.
224
- * Receives the page body and options.
225
- */
226
- layout?: (body: string, options: LayoutOptions) => string
227
- /**
228
- * A function to wrap the page content before layout.
229
- * Useful for adding common UI elements like headers/footers around the content.
230
- */
231
- wrapper?: (body: any) => any
232
- /**
233
- * The base URL path for the application.
234
- * @default "/"
235
- */
236
- basePath?: string
237
- /**
238
- * The directory containing source files.
239
- * @default "src"
240
- */
241
- rootDir?: string
242
- /**
243
- * The directory where build artifacts will be output.
244
- * @default "dist"
245
- */
246
- outDir?: string
247
- /**
248
- * The directory containing static assets to be copied to the output.
249
- * @default "public"
250
- */
251
- publicDir?: string
252
- /**
253
- * Path to the favicon file.
254
- */
255
- favicon?: string
256
- /**
257
- * Configuration for generating a sitemap.xml.
258
- */
259
- sitemap?: SitemapConfig
260
- /**
261
- * Configuration for generating an RSS feed.
262
- */
263
- rss?: RssConfig
264
- /**
265
- * List of redirect rules.
266
- */
267
- redirects?: RedirectConfig[]
268
- /**
269
- * Configuration for the client-side router.
270
- */
271
- router?: RouterConfig
272
- /**
273
- * Configuration options passed directly to Vite.
274
- */
275
- vite?: Record<string, any>
276
- /**
277
- * Lifecycle hooks for the build process.
278
- */
279
- hooks?: SSXHooks
66
+ /** Build hooks */
67
+ hooks?: {
68
+ beforeBuild?: (config: SiteConfig) => Promise<void> | void
69
+ afterBuild?: (result: any) => Promise<void> | void
70
+ }
280
71
  }