methanol 0.0.1 → 0.0.3

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.3",
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,31 @@ 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.USER_PRE_BUNDLE_HOOKS = normalizeHooks(config.preBundle)
312
+ state.USER_POST_BUNDLE_HOOKS = normalizeHooks(config.postBundle)
313
+ state.THEME_PRE_BUILD_HOOKS = normalizeHooks(state.USER_THEME?.preBuild)
314
+ state.THEME_POST_BUILD_HOOKS = normalizeHooks(state.USER_THEME?.postBuild)
315
+ state.THEME_PRE_BUNDLE_HOOKS = normalizeHooks(state.USER_THEME?.preBundle)
316
+ state.THEME_POST_BUNDLE_HOOKS = normalizeHooks(state.USER_THEME?.postBundle)
317
+ const starryNight = resolveStarryNightConfig(config.starryNight)
318
+ const cliCodeHighlighting = cli.CLI_CODE_HIGHLIGHTING
319
+ if (cliCodeHighlighting != null) {
320
+ state.STARRY_NIGHT_ENABLED = cliCodeHighlighting === true
321
+ state.STARRY_NIGHT_OPTIONS = cliCodeHighlighting === true ? starryNight.options : null
322
+ } else {
323
+ state.STARRY_NIGHT_ENABLED = starryNight.enabled
324
+ state.STARRY_NIGHT_OPTIONS = starryNight.enabled ? starryNight.options : null
325
+ }
281
326
 
282
327
  if (cli.CLI_INTERMEDIATE_DIR) {
283
328
  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,28 @@ 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()
109
+ const buildContext = pagesContext
110
+ ? {
111
+ pagesContext,
112
+ pages: pagesContext.pages,
113
+ pagesTree: pagesContext.pagesTree,
114
+ pagesByRoute: pagesContext.pagesByRoute,
115
+ site: pagesContext.site
116
+ }
117
+ : null
118
+ await runHooks(state.USER_PRE_BUNDLE_HOOKS, buildContext)
119
+ await runHooks(state.THEME_PRE_BUNDLE_HOOKS, buildContext)
74
120
  await runViteBuild(entry, htmlCache)
121
+ await runHooks(state.THEME_POST_BUNDLE_HOOKS, buildContext)
122
+ await runHooks(state.USER_POST_BUNDLE_HOOKS, buildContext)
75
123
  if (state.PAGEFIND_ENABLED) {
76
124
  await runPagefind()
77
125
  }
126
+ await runHooks(state.THEME_POST_BUILD_HOOKS, buildContext)
127
+ await runHooks(state.USER_POST_BUILD_HOOKS, buildContext)
78
128
  return
79
129
  }
80
130
  cli.showHelp()