methanol 0.0.1 → 0.0.2

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 ADDED
@@ -0,0 +1,58 @@
1
+ # Methanol
2
+
3
+ Opinionated MDX-first static site generator powered by rEFui + Vite.
4
+
5
+ For full documentation and examples, see the `methanol-docs` project.
6
+
7
+ ## Quick start
8
+
9
+ ```bash
10
+ # build
11
+ npx methanol build
12
+
13
+ # dev server
14
+ npx methanol dev
15
+
16
+ # preview the production build
17
+ npx methanol serve
18
+ ```
19
+
20
+ From this repo, use `node bin/methanol.js [dev|build|serve]`.
21
+
22
+ ## Project layout
23
+
24
+ Methanol expects a project like this:
25
+
26
+ ```
27
+ pages/ # .mdx pages (file-based routing)
28
+ components/ # JSX/TSX components used by MDX
29
+ public/ # static assets copied/served as-is
30
+ dist/ # build output
31
+ ```
32
+
33
+ ## Configuration
34
+
35
+ Create `methanol.config.{js,mjs,cjs,ts,jsx,tsx,mts,cts}` and export a function:
36
+
37
+ ```js
38
+ export default () => ({
39
+ // optional: search (Pagefind)
40
+ pagefind: {
41
+ enabled: true
42
+ },
43
+
44
+ // optional: code highlighting (Starry Night)
45
+ starryNight: false,
46
+
47
+ // optional: theme sources
48
+ theme: {
49
+ sources: {
50
+ '/.my-theme': './sources'
51
+ }
52
+ }
53
+ })
54
+ ```
55
+
56
+ ## CLI notes
57
+
58
+ - `methanol preview` is an alias for `methanol serve`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "methanol",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Static site generator powered by rEFui and MDX",
5
5
  "main": "./index.js",
6
6
  "type": "module",
@@ -16,6 +16,15 @@
16
16
  "bin": {
17
17
  "methanol": "./bin/methanol.js"
18
18
  },
19
+ "files": [
20
+ "bin/",
21
+ "src/",
22
+ "themes/",
23
+ "index.js",
24
+ "LICENSE",
25
+ "README.md",
26
+ "banner.txt"
27
+ ],
19
28
  "dependencies": {
20
29
  "@mdx-js/mdx": "^3.1.1",
21
30
  "@sindresorhus/fnv1a": "^3.1.0",
package/src/config.js CHANGED
@@ -34,8 +34,10 @@ const CONFIG_FILENAMES = [
34
34
  'methanol.config.mjs',
35
35
  'methanol.config.cjs',
36
36
  'methanol.config.ts',
37
+ 'methanol.config.jsx',
37
38
  'methanol.config.mts',
38
- 'methanol.config.cts'
39
+ 'methanol.config.cts',
40
+ 'methanol.config.tsx'
39
41
  ]
40
42
 
41
43
  const resolveRootPath = (value) => {
@@ -78,7 +80,7 @@ const resolveThemePublicDir = (root, value) => {
78
80
  }
79
81
 
80
82
  const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj || {}, key)
81
- const normalizeResources = (value, root) => {
83
+ const normalizeSources = (value, root) => {
82
84
  if (!value) return []
83
85
  const entries = []
84
86
  const addEntry = (find, replacement) => {
@@ -120,7 +122,7 @@ const resolvePagefindEnabled = (config) => {
120
122
  const resolvePagefindOptions = (config) => {
121
123
  const value = config?.pagefind
122
124
  if (!value || typeof value !== 'object') return null
123
- const { enabled, options, ...rest } = value
125
+ const { enabled, options, build, buildOptions, ...rest } = value
124
126
  if (options && typeof options === 'object') {
125
127
  return { ...options }
126
128
  }
@@ -130,16 +132,42 @@ const resolvePagefindOptions = (config) => {
130
132
  return null
131
133
  }
132
134
 
133
- const resolvePagefindBuildOptions = (config) => {
135
+ const resolvePagefindBuild = (config) => {
134
136
  const value = config?.pagefind
135
137
  if (!value || typeof value !== 'object') return null
136
- const buildOptions = value.buildOptions || value.build
137
- if (buildOptions && typeof buildOptions === 'object') {
138
- return { ...buildOptions }
138
+ const build = value.build
139
+ if (build && typeof build === 'object') {
140
+ return { ...build }
139
141
  }
140
142
  return null
141
143
  }
142
144
 
145
+ const resolveStarryNightConfig = (value) => {
146
+ if (value == null) return { enabled: false, options: null }
147
+ if (typeof value === 'boolean') {
148
+ return { enabled: value, options: null }
149
+ }
150
+ if (typeof value !== 'object') {
151
+ return { enabled: false, options: null }
152
+ }
153
+ const { enabled, options, ...rest } = value
154
+ if (enabled === false) return { enabled: false, options: null }
155
+ if (options && typeof options === 'object') {
156
+ return { enabled: true, options: { ...options } }
157
+ }
158
+ if (Object.keys(rest).length) {
159
+ return { enabled: true, options: { ...rest } }
160
+ }
161
+ return { enabled: true, options: null }
162
+ }
163
+
164
+ const normalizeHooks = (value) => {
165
+ if (!value) return []
166
+ if (typeof value === 'function') return [value]
167
+ if (Array.isArray(value)) return value.filter((entry) => typeof entry === 'function')
168
+ return []
169
+ }
170
+
143
171
  const loadConfigModule = async (filePath) => {
144
172
  return import(`${pathToFileURL(filePath).href}?t=${Date.now()}`)
145
173
  }
@@ -191,12 +219,12 @@ export const applyConfig = async (config, mode) => {
191
219
  if (mode) {
192
220
  state.CURRENT_MODE = mode
193
221
  }
194
- const paths = config.paths || config.dirs || {}
222
+ // config.paths / config.dirs are intentionally ignored (deprecated)
195
223
 
196
- const pagesDirValue = cli.CLI_PAGES_DIR || config.pagesDir || paths.pages
197
- const componentsDirValue = cli.CLI_COMPONENTS_DIR || config.componentsDir || paths.components
198
- const distDirValue = cli.CLI_OUTPUT_DIR || config.distDir || paths.dist
199
- const publicDirValue = cli.CLI_ASSETS_DIR ?? config.publicDir ?? paths.public
224
+ const pagesDirValue = cli.CLI_PAGES_DIR || config.pagesDir
225
+ const componentsDirValue = cli.CLI_COMPONENTS_DIR || config.componentsDir
226
+ const distDirValue = cli.CLI_OUTPUT_DIR || config.distDir
227
+ const publicDirValue = cli.CLI_ASSETS_DIR ?? config.publicDir
200
228
 
201
229
  const resolvePagesFallback = () => {
202
230
  const pagesPath = resolveFromRoot(root, 'pages', 'pages')
@@ -210,18 +238,18 @@ export const applyConfig = async (config, mode) => {
210
238
  : resolvePagesFallback()
211
239
  state.COMPONENTS_DIR = resolveFromRoot(root, componentsDirValue, 'components')
212
240
  state.STATIC_DIR = resolveOptionalPath(root, publicDirValue, 'public')
213
- state.BUILD_DIR = resolveFromRoot(root, config.buildDir || paths.build, 'build')
241
+ state.BUILD_DIR = resolveFromRoot(root, config.buildDir, 'build')
214
242
  state.DIST_DIR = resolveFromRoot(root, distDirValue, 'dist')
215
243
 
216
- const userSpecifiedPagesDir = cli.CLI_PAGES_DIR != null || hasOwn(config, 'pagesDir') || hasOwn(paths, 'pages')
244
+ const userSpecifiedPagesDir = cli.CLI_PAGES_DIR != null || hasOwn(config, 'pagesDir')
217
245
  if (userSpecifiedPagesDir && !existsSync(state.PAGES_DIR)) {
218
246
  throw new Error(`Pages directory not found: ${state.PAGES_DIR}`)
219
247
  }
220
- const userSpecifiedComponentsDir = cli.CLI_COMPONENTS_DIR != null || hasOwn(config, 'componentsDir') || hasOwn(paths, 'components')
248
+ const userSpecifiedComponentsDir = cli.CLI_COMPONENTS_DIR != null || hasOwn(config, 'componentsDir')
221
249
  if (userSpecifiedComponentsDir && !existsSync(state.COMPONENTS_DIR)) {
222
250
  throw new Error(`Components directory not found: ${state.COMPONENTS_DIR}`)
223
251
  }
224
- const userSpecifiedPublicDir = cli.CLI_ASSETS_DIR != null || hasOwn(config, 'publicDir') || hasOwn(paths, 'public')
252
+ const userSpecifiedPublicDir = cli.CLI_ASSETS_DIR != null || hasOwn(config, 'publicDir')
225
253
  if (userSpecifiedPublicDir && state.STATIC_DIR !== false && !existsSync(state.STATIC_DIR)) {
226
254
  state.STATIC_DIR = resolveFromRoot(root, publicDirValue, 'public')
227
255
  }
@@ -270,14 +298,27 @@ export const applyConfig = async (config, mode) => {
270
298
  ) {
271
299
  state.STATIC_DIR = state.THEME_PUBLIC_DIR
272
300
  }
273
- state.RESOURCES = normalizeResources(state.USER_THEME.resources, themeRoot)
301
+ state.SOURCES = normalizeSources(state.USER_THEME.sources, themeRoot)
274
302
  state.USER_VITE_CONFIG = config.vite || null
275
303
  state.USER_MDX_CONFIG = config.mdx || null
276
304
  state.RESOLVED_MDX_CONFIG = undefined
277
305
  state.RESOLVED_VITE_CONFIG = undefined
278
306
  state.PAGEFIND_ENABLED = resolvePagefindEnabled(config)
279
307
  state.PAGEFIND_OPTIONS = resolvePagefindOptions(config)
280
- state.PAGEFIND_BUILD_OPTIONS = resolvePagefindBuildOptions(config)
308
+ state.PAGEFIND_BUILD = resolvePagefindBuild(config)
309
+ state.USER_PRE_BUILD_HOOKS = normalizeHooks(config.preBuild)
310
+ state.USER_POST_BUILD_HOOKS = normalizeHooks(config.postBuild)
311
+ state.THEME_PRE_BUILD_HOOKS = normalizeHooks(state.USER_THEME?.preBuild)
312
+ state.THEME_POST_BUILD_HOOKS = normalizeHooks(state.USER_THEME?.postBuild)
313
+ const starryNight = resolveStarryNightConfig(config.starryNight)
314
+ const cliCodeHighlighting = cli.CLI_CODE_HIGHLIGHTING
315
+ if (cliCodeHighlighting != null) {
316
+ state.STARRY_NIGHT_ENABLED = cliCodeHighlighting === true
317
+ state.STARRY_NIGHT_OPTIONS = cliCodeHighlighting === true ? starryNight.options : null
318
+ } else {
319
+ state.STARRY_NIGHT_ENABLED = starryNight.enabled
320
+ state.STARRY_NIGHT_OPTIONS = starryNight.enabled ? starryNight.options : null
321
+ }
281
322
 
282
323
  if (cli.CLI_INTERMEDIATE_DIR) {
283
324
  state.INTERMEDIATE_DIR = resolveFromRoot(root, cli.CLI_INTERMEDIATE_DIR, 'build')
package/src/dev-server.js CHANGED
@@ -19,7 +19,8 @@
19
19
  */
20
20
 
21
21
  import { existsSync } from 'fs'
22
- import { resolve, dirname, extname, join, basename } from 'path'
22
+ import { readFile } from 'fs/promises'
23
+ import { resolve, dirname, extname, join, basename, relative } from 'path'
23
24
  import { fileURLToPath } from 'url'
24
25
  import chokidar from 'chokidar'
25
26
  import { createServer, mergeConfig } from 'vite'
@@ -151,6 +152,48 @@ export const runViteDev = async () => {
151
152
  return mdxPath
152
153
  }
153
154
 
155
+ const resolveHtmlCandidates = (pathname) => {
156
+ const candidates = []
157
+ if (pathname === '/' || pathname === '') {
158
+ candidates.push('/index.html')
159
+ } else if (pathname.endsWith('.html')) {
160
+ candidates.push(pathname)
161
+ } else {
162
+ candidates.push(`${pathname}.html`)
163
+ candidates.push(`${pathname}/index.html`)
164
+ }
165
+ return candidates.map((candidate) =>
166
+ resolve(state.PAGES_DIR, candidate.replace(/^\//, ''))
167
+ )
168
+ }
169
+
170
+ const shouldServeHtml = (relativePath, requestedPath, hasMdx) => {
171
+ if (hasMdx) return false
172
+ const baseName = basename(relativePath, '.html')
173
+ if (baseName.startsWith('_') || baseName.startsWith('.')) return false
174
+ const excludedDirs = pagesContext?.excludedDirs
175
+ if (excludedDirs?.size) {
176
+ const dir = relativePath.split('/').slice(0, -1).join('/')
177
+ for (const excludedDir of excludedDirs) {
178
+ if (!excludedDir) return false
179
+ if (dir === excludedDir || dir.startsWith(`${excludedDir}/`)) {
180
+ return false
181
+ }
182
+ }
183
+ }
184
+ const excludedRoutes = pagesContext?.excludedRoutes
185
+ if (excludedRoutes?.has(requestedPath)) return false
186
+ const excludedDirPaths = pagesContext?.excludedDirPaths
187
+ if (excludedDirPaths?.size) {
188
+ for (const dirPath of excludedDirPaths) {
189
+ if (requestedPath === dirPath || requestedPath.startsWith(`${dirPath}/`)) {
190
+ return false
191
+ }
192
+ }
193
+ }
194
+ return true
195
+ }
196
+
154
197
  const htmlMiddleware = async (req, res, next) => {
155
198
  if (!req.url || req.method !== 'GET') {
156
199
  return next()
@@ -206,9 +249,38 @@ export const runViteDev = async () => {
206
249
  ? (pagesContext?.pagesByRouteIndex?.get(requestedPath) ?? pagesContext?.pagesByRoute?.get(requestedPath) ?? null)
207
250
  : (pagesContext?.pagesByRoute?.get(requestedPath) ?? null)
208
251
  let filePath = pageMeta?.filePath || resolvePageFile(requestedPath)
252
+ const hasMdx = Boolean(pageMeta) || existsSync(filePath)
209
253
  let status = 200
210
254
  let renderRoutePath = requestedPath
211
255
 
256
+ if (!hasMdx) {
257
+ const candidates = resolveHtmlCandidates(pathname)
258
+ for (const candidate of candidates) {
259
+ if (!existsSync(candidate)) continue
260
+ const relativePath = relative(state.PAGES_DIR, candidate).replace(/\\/g, '/')
261
+ if (relativePath.startsWith('..')) {
262
+ continue
263
+ }
264
+ if (!shouldServeHtml(relativePath, requestedPath, hasMdx)) {
265
+ continue
266
+ }
267
+ try {
268
+ const html = await readFile(candidate, 'utf-8')
269
+ const candidateUrl = `/${relativePath}`
270
+ const transformed = await server.transformIndexHtml(candidateUrl, html)
271
+ res.statusCode = 200
272
+ res.setHeader('Content-Type', 'text/html')
273
+ res.end(transformed)
274
+ return
275
+ } catch (err) {
276
+ console.error(err)
277
+ res.statusCode = 500
278
+ res.end('Internal Server Error')
279
+ return
280
+ }
281
+ }
282
+ }
283
+
212
284
  if (isExcludedPath()) {
213
285
  if (notFoundPage) {
214
286
  filePath = notFoundPage.filePath
@@ -414,12 +486,13 @@ export const runViteDev = async () => {
414
486
  prevEntry.toc = null
415
487
  pagesContext.refreshPagesTree?.()
416
488
  pagesContext.refreshLanguages?.()
417
- if (prevEntry.frontmatter?.title == null) {
418
- if (prevEntry.content && prevEntry.content.trim().length) {
419
- await compilePageMdx(prevEntry, pagesContext)
420
- // Avoid caching a potentially stale render; recompile on request.
421
- prevEntry.mdxComponent = null
422
- }
489
+ if (prevEntry.content && prevEntry.content.trim().length) {
490
+ await compilePageMdx(prevEntry, pagesContext, {
491
+ lazyPagesTree: true,
492
+ refreshPagesTree: false
493
+ })
494
+ // Avoid caching a potentially stale render; recompile on request.
495
+ prevEntry.mdxComponent = null
423
496
  }
424
497
  return true
425
498
  }
package/src/main.js CHANGED
@@ -24,6 +24,7 @@ import { buildHtmlEntries, runViteBuild } from './build-system.js'
24
24
  import { runPagefind } from './pagefind.js'
25
25
  import { runVitePreview } from './preview-server.js'
26
26
  import { cli, state } from './state.js'
27
+ import { HTMLRenderer } from './renderer.js'
27
28
  import { readFile } from 'fs/promises'
28
29
 
29
30
  const printBanner = async () => {
@@ -61,7 +62,39 @@ const main = async () => {
61
62
  const mode = isDev ? 'development' : 'production'
62
63
  const config = await loadUserConfig(mode, cli.CLI_CONFIG_PATH)
63
64
  await applyConfig(config, mode)
65
+ const hookContext = {
66
+ mode,
67
+ root: state.ROOT_DIR,
68
+ command: normalizedCommand,
69
+ isDev,
70
+ isBuild,
71
+ isPreview,
72
+ HTMLRenderer,
73
+ site: {
74
+ name: state.SITE_NAME,
75
+ root: state.ROOT_DIR,
76
+ pagesDir: state.PAGES_DIR,
77
+ componentsDir: state.COMPONENTS_DIR,
78
+ publicDir: state.STATIC_DIR,
79
+ distDir: state.DIST_DIR,
80
+ mode: state.CURRENT_MODE,
81
+ pagefind: {
82
+ enabled: state.PAGEFIND_ENABLED,
83
+ options: state.PAGEFIND_OPTIONS || null,
84
+ build: state.PAGEFIND_BUILD || null
85
+ }
86
+ },
87
+ data: {}
88
+ }
89
+ const runHooks = async (hooks = [], extra = null) => {
90
+ const context = extra ? { ...hookContext, ...extra } : hookContext
91
+ for (const hook of hooks) {
92
+ await hook(context)
93
+ }
94
+ }
64
95
  if (isDev) {
96
+ await runHooks(state.USER_PRE_BUILD_HOOKS)
97
+ await runHooks(state.THEME_PRE_BUILD_HOOKS)
65
98
  await runViteDev()
66
99
  return
67
100
  }
@@ -70,11 +103,24 @@ const main = async () => {
70
103
  return
71
104
  }
72
105
  if (isBuild) {
73
- const { entry, htmlCache } = await buildHtmlEntries()
106
+ await runHooks(state.USER_PRE_BUILD_HOOKS)
107
+ await runHooks(state.THEME_PRE_BUILD_HOOKS)
108
+ const { entry, htmlCache, pagesContext } = await buildHtmlEntries()
74
109
  await runViteBuild(entry, htmlCache)
75
110
  if (state.PAGEFIND_ENABLED) {
76
111
  await runPagefind()
77
112
  }
113
+ const postBuildContext = pagesContext
114
+ ? {
115
+ pagesContext,
116
+ pages: pagesContext.pages,
117
+ pagesTree: pagesContext.pagesTree,
118
+ pagesByRoute: pagesContext.pagesByRoute,
119
+ site: pagesContext.site
120
+ }
121
+ : null
122
+ await runHooks(state.THEME_POST_BUILD_HOOKS, postBuildContext)
123
+ await runHooks(state.USER_POST_BUILD_HOOKS, postBuildContext)
78
124
  return
79
125
  }
80
126
  cli.showHelp()
package/src/mdx.js CHANGED
@@ -24,12 +24,13 @@ import * as JSXDevFactory from 'refui/jsx-dev-runtime'
24
24
  import rehypeSlug from 'rehype-slug'
25
25
  import extractToc from '@stefanprobst/rehype-extract-toc'
26
26
  import withTocExport from '@stefanprobst/rehype-extract-toc/mdx'
27
+ import rehypeStarryNight from 'rehype-starry-night'
27
28
  import { HTMLRenderer } from './renderer.js'
28
29
  import { signal, computed, read, Suspense, nextTick } from 'refui'
29
30
  import { createPortal } from 'refui/extras'
30
31
  import { pathToFileURL } from 'url'
31
32
  import { existsSync } from 'fs'
32
- import { resolve } from 'path'
33
+ import { resolve, dirname, basename, relative } from 'path'
33
34
  import { state } from './state.js'
34
35
  import { resolveUserMdxConfig } from './config.js'
35
36
  import { methanolCtx } from './rehype-plugins/methanol-ctx.js'
@@ -71,21 +72,113 @@ const resolveUserHeadAssets = () => {
71
72
  return assets
72
73
  }
73
74
 
74
- export const buildPageContext = ({ routePath, filePath, pageMeta, pagesContext }) => {
75
+ const resolvePageAssetUrl = (page, filePath) => {
76
+ const root = page?.source === 'theme' && state.THEME_PAGES_DIR
77
+ ? state.THEME_PAGES_DIR
78
+ : state.PAGES_DIR
79
+ if (!root) return null
80
+ const relPath = relative(root, filePath).replace(/\\/g, '/')
81
+ if (!relPath || relPath.startsWith('..')) return null
82
+ return `/${relPath}`
83
+ }
84
+
85
+ const resolvePageHeadAssets = (page) => {
86
+ if (!page?.filePath) return []
87
+ const baseDir = dirname(page.filePath)
88
+ const baseName = basename(page.filePath).replace(/\.(mdx|md)$/, '')
89
+ const pagesRoot = state.PAGES_DIR ? resolve(state.PAGES_DIR) : null
90
+ const isRootIndex =
91
+ pagesRoot && baseName === 'index' && resolve(baseDir) === pagesRoot && page.source !== 'theme'
92
+ const isRootStylePage =
93
+ pagesRoot && baseName === 'style' && resolve(baseDir) === pagesRoot && page.source !== 'theme'
94
+ const assets = []
95
+ const cssPath = resolve(baseDir, `${baseName}.css`)
96
+ if (existsSync(cssPath)) {
97
+ if (isRootStylePage) {
98
+ const rootStyle = resolve(pagesRoot, 'style.css')
99
+ if (cssPath === rootStyle) {
100
+ return assets
101
+ }
102
+ }
103
+ const href = resolvePageAssetUrl(page, cssPath)
104
+ if (href) {
105
+ assets.push(HTMLRenderer.c('link', { rel: 'stylesheet', href }))
106
+ }
107
+ }
108
+ const scriptExtensions = ['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts']
109
+ let scriptPath = null
110
+ for (const ext of scriptExtensions) {
111
+ const candidate = resolve(baseDir, `${baseName}${ext}`)
112
+ if (existsSync(candidate)) {
113
+ scriptPath = candidate
114
+ break
115
+ }
116
+ }
117
+ if (scriptPath) {
118
+ if (isRootIndex) {
119
+ const rootIndexJs = resolve(pagesRoot, 'index.js')
120
+ const rootIndexTs = resolve(pagesRoot, 'index.ts')
121
+ if (scriptPath === rootIndexJs || scriptPath === rootIndexTs) {
122
+ return assets
123
+ }
124
+ }
125
+ const src = resolvePageAssetUrl(page, scriptPath)
126
+ if (src) {
127
+ assets.push(HTMLRenderer.c('script', { type: 'module', src }))
128
+ }
129
+ }
130
+ return assets
131
+ }
132
+
133
+ export const buildPageContext = ({
134
+ routePath,
135
+ filePath,
136
+ pageMeta,
137
+ pagesContext,
138
+ lazyPagesTree = false
139
+ }) => {
75
140
  const page = pageMeta
76
- const pagesTree = pagesContext?.getPagesTree ? pagesContext.getPagesTree(routePath) : pagesContext?.pagesTree || []
77
141
  const language = pagesContext?.getLanguageForRoute ? pagesContext.getLanguageForRoute(routePath) : null
78
- return {
142
+ const getSiblings = pagesContext?.getSiblings
143
+ ? () => pagesContext.getSiblings(routePath, page?.filePath || filePath)
144
+ : null
145
+ if (page && getSiblings && page.getSiblings !== getSiblings) {
146
+ page.getSiblings = getSiblings
147
+ }
148
+ const ctx = {
79
149
  routePath,
80
150
  filePath,
81
151
  page,
82
152
  pages: pagesContext?.pages || [],
83
- pagesTree,
84
153
  pagesByRoute: pagesContext?.pagesByRoute || new Map(),
85
154
  languages: pagesContext?.languages || [],
86
155
  language,
87
- site: pagesContext?.site || null
156
+ site: pagesContext?.site || null,
157
+ getSiblings
158
+ }
159
+ const resolvePagesTree = () =>
160
+ pagesContext?.getPagesTree ? pagesContext.getPagesTree(routePath) : pagesContext?.pagesTree || []
161
+ if (lazyPagesTree) {
162
+ let cachedTree = null
163
+ let hasTree = false
164
+ Object.defineProperty(ctx, 'pagesTree', {
165
+ enumerable: true,
166
+ get() {
167
+ if (!hasTree) {
168
+ cachedTree = resolvePagesTree()
169
+ hasTree = true
170
+ }
171
+ return cachedTree
172
+ },
173
+ set(value) {
174
+ cachedTree = value
175
+ hasTree = true
176
+ }
177
+ })
178
+ } else {
179
+ ctx.pagesTree = resolvePagesTree()
88
180
  }
181
+ return ctx
89
182
  }
90
183
 
91
184
  const findTitleFromToc = (toc = []) => {
@@ -121,7 +214,39 @@ const findTitleFromToc = (toc = []) => {
121
214
 
122
215
  let cachedMdxConfig = null
123
216
 
124
- const resolveMdxConfig = async () => {
217
+ const normalizeStarryNightConfig = (value) => {
218
+ if (value == null) return null
219
+ if (typeof value === 'boolean') {
220
+ return { enabled: value, options: null }
221
+ }
222
+ if (typeof value !== 'object') return null
223
+ const { enabled, options, ...rest } = value
224
+ if (enabled === false) return { enabled: false, options: null }
225
+ if (options && typeof options === 'object') {
226
+ return { enabled: true, options: { ...options } }
227
+ }
228
+ if (Object.keys(rest).length) {
229
+ return { enabled: true, options: { ...rest } }
230
+ }
231
+ return { enabled: true, options: null }
232
+ }
233
+
234
+ const resolveStarryNightForPage = (frontmatter) => {
235
+ const base = {
236
+ enabled: state.STARRY_NIGHT_ENABLED === true,
237
+ options: state.STARRY_NIGHT_OPTIONS || null
238
+ }
239
+ if (!frontmatter || !Object.prototype.hasOwnProperty.call(frontmatter, 'starryNight')) {
240
+ return base
241
+ }
242
+ const override = normalizeStarryNightConfig(frontmatter.starryNight)
243
+ if (!override) return base
244
+ if (override.enabled === false) return { enabled: false, options: null }
245
+ const options = override.options != null ? override.options : base.options
246
+ return { enabled: true, options }
247
+ }
248
+
249
+ const resolveBaseMdxConfig = async () => {
125
250
  const userMdxConfig = await resolveUserMdxConfig()
126
251
  if (cachedMdxConfig) {
127
252
  return cachedMdxConfig
@@ -135,16 +260,33 @@ const resolveMdxConfig = async () => {
135
260
  rehypePlugins: [rehypeSlug, extractToc, [withTocExport, { name: 'toc' }]]
136
261
  }
137
262
  const mdxConfig = { ...baseMdxConfig, ...userMdxConfig }
138
- if (userMdxConfig.rehypePlugins.length) {
139
- mdxConfig.rehypePlugins = [...baseMdxConfig.rehypePlugins, ...userMdxConfig.rehypePlugins]
140
- }
263
+ const userRehypePlugins = Array.isArray(userMdxConfig.rehypePlugins) ? userMdxConfig.rehypePlugins : []
264
+ mdxConfig.rehypePlugins = [...baseMdxConfig.rehypePlugins, ...userRehypePlugins]
141
265
  mdxConfig.rehypePlugins.push(linkResolve)
142
266
  mdxConfig.rehypePlugins.push(methanolCtx)
143
267
  return (cachedMdxConfig = mdxConfig)
144
268
  }
145
269
 
270
+ const resolveMdxConfigForPage = async (frontmatter) => {
271
+ const baseConfig = await resolveBaseMdxConfig()
272
+ const mdxConfig = {
273
+ ...baseConfig,
274
+ rehypePlugins: [...baseConfig.rehypePlugins]
275
+ }
276
+ const starryNightConfig = resolveStarryNightForPage(frontmatter)
277
+ if (!starryNightConfig.enabled) return mdxConfig
278
+ const plugin = starryNightConfig.options ? [rehypeStarryNight, starryNightConfig.options] : [rehypeStarryNight]
279
+ const insertIndex = mdxConfig.rehypePlugins.indexOf(linkResolve)
280
+ if (insertIndex >= 0) {
281
+ mdxConfig.rehypePlugins.splice(insertIndex, 0, plugin)
282
+ } else {
283
+ mdxConfig.rehypePlugins.push(plugin)
284
+ }
285
+ return mdxConfig
286
+ }
287
+
146
288
  export const compileMdx = async ({ content, filePath, ctx }) => {
147
- const mdxConfig = await resolveMdxConfig()
289
+ const mdxConfig = await resolveMdxConfigForPage(ctx?.page?.frontmatter)
148
290
  const runtimeFactory = mdxConfig.development ? JSXDevFactory : JSXFactory
149
291
  const compiled = await compile({ value: content, path: filePath }, mdxConfig)
150
292
 
@@ -156,17 +298,22 @@ export const compileMdx = async ({ content, filePath, ctx }) => {
156
298
  })
157
299
  }
158
300
 
159
- export const compilePageMdx = async (page, pagesContext) => {
301
+ export const compilePageMdx = async (page, pagesContext, options = {}) => {
160
302
  if (!page || page.content == null || page.mdxComponent) return
161
- const mdxModule = await compileMdx({
162
- content: page.content,
163
- filePath: page.filePath,
164
- ctx: buildPageContext({
303
+ const { ctx = null, lazyPagesTree = false, refreshPagesTree = true } = options || {}
304
+ const activeCtx =
305
+ ctx ||
306
+ buildPageContext({
165
307
  routePath: page.routePath,
166
308
  filePath: page.filePath,
167
309
  pageMeta: page,
168
- pagesContext
310
+ pagesContext,
311
+ lazyPagesTree
169
312
  })
313
+ const mdxModule = await compileMdx({
314
+ content: page.content,
315
+ filePath: page.filePath,
316
+ ctx: activeCtx
170
317
  })
171
318
  page.mdxComponent = mdxModule.default
172
319
  page.toc = mdxModule.toc
@@ -181,11 +328,10 @@ export const compilePageMdx = async (page, pagesContext) => {
181
328
  }
182
329
  }
183
330
  if (typeof pagesContext?.setDerivedTitle === 'function') {
184
- pagesContext.setDerivedTitle(
185
- page.filePath,
186
- shouldUseTocTitle ? page.title : null,
187
- page.toc
188
- )
331
+ pagesContext.setDerivedTitle(page.filePath, shouldUseTocTitle ? page.title : null, page.toc)
332
+ }
333
+ if (ctx && refreshPagesTree && pagesContext?.getPagesTree) {
334
+ ctx.pagesTree = pagesContext.getPagesTree(activeCtx.routePath)
189
335
  }
190
336
  }
191
337
 
@@ -208,13 +354,19 @@ export const renderHtml = async ({
208
354
  pageMeta,
209
355
  pagesContext
210
356
  })
357
+ await compilePageMdx(pageMeta, pagesContext, { ctx })
211
358
 
212
359
  const [Head, Outlet] = createPortal()
213
360
  const ExtraHead = () => {
214
- return [RWND_INJECT, ...resolveUserHeadAssets(), Outlet(), RWND_FALLBACK]
361
+ return [
362
+ RWND_INJECT,
363
+ ...resolveUserHeadAssets(),
364
+ ...resolvePageHeadAssets(pageMeta),
365
+ Outlet(),
366
+ RWND_FALLBACK
367
+ ]
215
368
  }
216
369
 
217
- await compilePageMdx(pageMeta, pagesContext)
218
370
  const mdxComponent = pageMeta?.mdxComponent
219
371
 
220
372
  const Page = ({ components: extraComponents, ...props }, ...children) =>