methanol 0.0.10 → 0.0.12

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/src/pages.js CHANGED
@@ -22,9 +22,11 @@ import matter from 'gray-matter'
22
22
  import { readdir, readFile, stat } from 'fs/promises'
23
23
  import { existsSync } from 'fs'
24
24
  import { resolve, join, relative } from 'path'
25
+ import { cpus } from 'os'
26
+ import { Worker } from 'worker_threads'
25
27
  import { state, cli } from './state.js'
26
28
  import { withBase } from './config.js'
27
- import { compilePageMdx } from './mdx.js'
29
+ import { compileMdxSource, compilePageMdx } from './mdx.js'
28
30
  import { createStageLogger } from './stage-logger.js'
29
31
 
30
32
  const isPageFile = (name) => name.endsWith('.mdx') || name.endsWith('.md')
@@ -32,6 +34,137 @@ const isIgnoredEntry = (name) => name.startsWith('.') || name.startsWith('_')
32
34
 
33
35
  const pageMetadataCache = new Map()
34
36
  const pageDerivedCache = new Map()
37
+ const MDX_WORKER_URL = new URL('./workers/mdx-compile-worker.js', import.meta.url)
38
+ const cliOverrides = {
39
+ CLI_INTERMEDIATE_DIR: cli.CLI_INTERMEDIATE_DIR,
40
+ CLI_EMIT_INTERMEDIATE: cli.CLI_EMIT_INTERMEDIATE,
41
+ CLI_HOST: cli.CLI_HOST,
42
+ CLI_PORT: cli.CLI_PORT,
43
+ CLI_PAGES_DIR: cli.CLI_PAGES_DIR,
44
+ CLI_COMPONENTS_DIR: cli.CLI_COMPONENTS_DIR,
45
+ CLI_ASSETS_DIR: cli.CLI_ASSETS_DIR,
46
+ CLI_OUTPUT_DIR: cli.CLI_OUTPUT_DIR,
47
+ CLI_CONFIG_PATH: cli.CLI_CONFIG_PATH,
48
+ CLI_SITE_NAME: cli.CLI_SITE_NAME,
49
+ CLI_CODE_HIGHLIGHTING: cli.CLI_CODE_HIGHLIGHTING,
50
+ CLI_JOBS: cli.CLI_JOBS,
51
+ CLI_VERBOSE: cli.CLI_VERBOSE,
52
+ CLI_BASE: cli.CLI_BASE,
53
+ CLI_SEARCH: cli.CLI_SEARCH,
54
+ CLI_PWA: cli.CLI_PWA
55
+ }
56
+
57
+ const resolveWorkerCount = (total) => {
58
+ const cpuCount = Math.max(1, cpus()?.length || 1)
59
+ const requested = state.WORKER_JOBS
60
+ if (requested == null || requested <= 0) {
61
+ const items = Math.max(1, Number.isFinite(total) ? total : 1)
62
+ const autoCount = Math.round(Math.log(items))
63
+ return Math.max(1, Math.min(cpuCount, autoCount))
64
+ }
65
+ return Math.max(1, Math.min(cpuCount, Math.floor(requested)))
66
+ }
67
+
68
+ const compileMdxSources = async (pages, options = {}) => {
69
+ const targets = pages.filter((page) => page && page.content != null && !page.mdxComponent)
70
+ const results = new Map()
71
+ if (!targets.length) return results
72
+ const { onProgress } = options || {}
73
+ const reportProgress = (page) => {
74
+ if (typeof onProgress === 'function') {
75
+ onProgress(page)
76
+ }
77
+ }
78
+ const workerCount = Math.min(resolveWorkerCount(targets.length), targets.length)
79
+ if (workerCount <= 1) {
80
+ for (const page of targets) {
81
+ const result = await compileMdxSource({
82
+ content: page.content,
83
+ path: page.path,
84
+ frontmatter: page.frontmatter
85
+ })
86
+ results.set(page, result)
87
+ reportProgress(page)
88
+ }
89
+ return results
90
+ }
91
+
92
+ return await new Promise((resolve, reject) => {
93
+ const workers = []
94
+ const pending = new Map()
95
+ let cursor = 0
96
+ let nextId = 0
97
+ let finished = false
98
+
99
+ const finalize = async (error) => {
100
+ if (finished) return
101
+ finished = true
102
+ await Promise.all(workers.map((worker) => worker.terminate().catch(() => null)))
103
+ if (error) {
104
+ reject(error)
105
+ return
106
+ }
107
+ resolve(results)
108
+ }
109
+
110
+ const assign = (worker) => {
111
+ if (cursor >= targets.length) return false
112
+ const page = targets[cursor++]
113
+ const id = nextId++
114
+ pending.set(id, page)
115
+ worker.postMessage({
116
+ id,
117
+ path: page.path,
118
+ content: page.content,
119
+ frontmatter: page.frontmatter
120
+ })
121
+ return true
122
+ }
123
+
124
+ const handleMessage = (worker, message) => {
125
+ if (finished) return
126
+ const { id, result, error } = message || {}
127
+ const page = pending.get(id)
128
+ pending.delete(id)
129
+ if (!page) return
130
+ if (error) {
131
+ void finalize(new Error(error))
132
+ return
133
+ }
134
+ results.set(page, result)
135
+ reportProgress(page)
136
+ assign(worker)
137
+ if (results.size === targets.length && pending.size === 0) {
138
+ void finalize()
139
+ }
140
+ }
141
+
142
+ const handleError = (error) => {
143
+ if (finished) return
144
+ void finalize(error instanceof Error ? error : new Error(String(error)))
145
+ }
146
+
147
+ for (let i = 0; i < workerCount; i += 1) {
148
+ const worker = new Worker(MDX_WORKER_URL, {
149
+ type: 'module',
150
+ workerData: {
151
+ mode: state.CURRENT_MODE,
152
+ configPath: cli.CLI_CONFIG_PATH,
153
+ cli: cliOverrides
154
+ }
155
+ })
156
+ workers.push(worker)
157
+ worker.on('message', (message) => handleMessage(worker, message))
158
+ worker.on('error', handleError)
159
+ worker.on('exit', (code) => {
160
+ if (code !== 0) {
161
+ handleError(new Error(`MDX worker exited with code ${code}`))
162
+ }
163
+ })
164
+ assign(worker)
165
+ }
166
+ })
167
+ }
35
168
 
36
169
  const collectLanguagesFromPages = (pages = []) => {
37
170
  const languages = new Map()
@@ -202,6 +335,10 @@ const buildPagesTree = (pages, options = {}) => {
202
335
  if (!rootPrefix) return normalizeRoutePath(localPath)
203
336
  return normalizeRoutePath(`${rootPrefix}${localPath}`)
204
337
  }
338
+ const resolveDirFsPath = (dir) => {
339
+ if (!rootDir) return resolve(state.PAGES_DIR, dir)
340
+ return resolve(state.PAGES_DIR, join(rootDir, dir))
341
+ }
205
342
  const currentRouteWithinRoot = resolveRouteWithinRoot(currentRoutePath)
206
343
  const isUnderRoot = (page) => {
207
344
  if (!rootDir) return true
@@ -270,7 +407,7 @@ const buildPagesTree = (pages, options = {}) => {
270
407
  const dir = {
271
408
  type: 'directory',
272
409
  name,
273
- path: resolve(state.PAGES_DIR, path),
410
+ path: resolveDirFsPath(path),
274
411
  children: [],
275
412
  depth,
276
413
  routePath: buildDirRoutePath(path),
@@ -612,47 +749,12 @@ const buildNavSequence = (nodes, pagesByRoute) => {
612
749
  return result
613
750
  }
614
751
 
615
- export const buildPagesContext = async ({ compileAll = true } = {}) => {
616
- const logEnabled = state.CURRENT_MODE === 'production' && cli.command === 'build'
617
- const stageLogger = createStageLogger(logEnabled)
618
- const collectToken = stageLogger.start('Collecting pages')
619
- const collected = await collectPages()
620
- stageLogger.end(collectToken)
621
- let pages = collected.pages
622
- const excludedRoutes = collected.excludedRoutes
623
- const excludedDirs = collected.excludedDirs
624
- const hasIndex = pages.some((page) => page.routePath === '/')
625
- if (!hasIndex) {
626
- const content = buildIndexFallback(pages, state.SITE_NAME)
627
- pages.unshift({
628
- routePath: '/',
629
- routeHref: withBase('/'),
630
- path: resolve(state.PAGES_DIR, 'index.md'),
631
- relativePath: 'index.md',
632
- name: 'index',
633
- dir: '',
634
- segments: [],
635
- depth: 0,
636
- isIndex: true,
637
- title: state.SITE_NAME || 'Methanol Site',
638
- weight: null,
639
- date: null,
640
- isRoot: false,
641
- hidden: false,
642
- content,
643
- frontmatter: {},
644
- matter: null,
645
- stats: { size: content.length, createdAt: null, updatedAt: null },
646
- createdAt: null,
647
- updatedAt: null
648
- })
649
- if (excludedRoutes?.has('/')) {
650
- excludedRoutes.delete('/')
651
- }
652
- }
653
-
752
+ export const createPagesContextFromPages = ({ pages, excludedRoutes, excludedDirs } = {}) => {
753
+ const pageList = Array.isArray(pages) ? pages : []
754
+ const routeExcludes = excludedRoutes || new Set()
755
+ const dirExcludes = excludedDirs || new Set()
654
756
  const pagesByRoute = new Map()
655
- for (const page of pages) {
757
+ for (const page of pageList) {
656
758
  if (!pagesByRoute.has(page.routePath)) {
657
759
  pagesByRoute.set(page.routePath, page)
658
760
  }
@@ -699,7 +801,7 @@ export const buildPagesContext = async ({ compileAll = true } = {}) => {
699
801
  }
700
802
  let pagesTree = getPagesTree('/')
701
803
  const notFound = pagesByRoute.get('/404') || null
702
- const languages = collectLanguagesFromPages(pages)
804
+ const languages = collectLanguagesFromPages(pageList)
703
805
  const userSite = state.USER_SITE || {}
704
806
  const siteBase = state.VITE_BASE ?? userSite.base ?? null
705
807
  const site = {
@@ -719,9 +821,9 @@ export const buildPagesContext = async ({ compileAll = true } = {}) => {
719
821
  },
720
822
  generatedAt: new Date().toISOString()
721
823
  }
722
- const excludedDirPaths = new Set(Array.from(excludedDirs).map((dir) => `/${dir}`))
824
+ const excludedDirPaths = new Set(Array.from(dirExcludes).map((dir) => `/${dir}`))
723
825
  const pagesContext = {
724
- pages,
826
+ pages: pageList,
725
827
  pagesByRoute,
726
828
  getPageByRoute,
727
829
  pagesTree,
@@ -771,31 +873,96 @@ export const buildPagesContext = async ({ compileAll = true } = {}) => {
771
873
  pagesContext.getLanguageForRoute = (routePath) =>
772
874
  resolveLanguageForRoute(pagesContext.languages, routePath)
773
875
  },
774
- excludedRoutes,
775
- excludedDirs,
876
+ excludedRoutes: routeExcludes,
877
+ excludedDirs: dirExcludes,
776
878
  excludedDirPaths,
777
879
  notFound,
778
880
  languages,
779
881
  getLanguageForRoute: (routePath) => resolveLanguageForRoute(languages, routePath),
780
882
  site
781
883
  }
884
+ return pagesContext
885
+ }
886
+
887
+ export const buildPagesContext = async ({ compileAll = true } = {}) => {
888
+ const logEnabled = state.CURRENT_MODE === 'production' && cli.command === 'build'
889
+ const stageLogger = createStageLogger(logEnabled)
890
+ const collectToken = stageLogger.start('Collecting pages')
891
+ const collected = await collectPages()
892
+ stageLogger.end(collectToken)
893
+ let pages = collected.pages
894
+ const excludedRoutes = collected.excludedRoutes
895
+ const excludedDirs = collected.excludedDirs
896
+ const hasIndex = pages.some((page) => page.routePath === '/')
897
+ if (!hasIndex) {
898
+ const content = buildIndexFallback(pages, state.SITE_NAME)
899
+ pages.unshift({
900
+ routePath: '/',
901
+ routeHref: withBase('/'),
902
+ path: resolve(state.PAGES_DIR, 'index.md'),
903
+ relativePath: 'index.md',
904
+ name: 'index',
905
+ dir: '',
906
+ segments: [],
907
+ depth: 0,
908
+ isIndex: true,
909
+ title: state.SITE_NAME || 'Methanol Site',
910
+ weight: null,
911
+ date: null,
912
+ isRoot: false,
913
+ hidden: false,
914
+ content,
915
+ frontmatter: {},
916
+ matter: null,
917
+ stats: { size: content.length, createdAt: null, updatedAt: null },
918
+ createdAt: null,
919
+ updatedAt: null
920
+ })
921
+ if (excludedRoutes?.has('/')) {
922
+ excludedRoutes.delete('/')
923
+ }
924
+ }
925
+
926
+ const pagesContext = createPagesContextFromPages({
927
+ pages,
928
+ excludedRoutes,
929
+ excludedDirs
930
+ })
782
931
  if (compileAll) {
783
932
  const compileToken = stageLogger.start('Compiling MDX')
784
- const totalPages = pages.length
785
- for (let i = 0; i < pages.length; i++) {
786
- const page = pages[i]
787
- if (logEnabled) {
788
- stageLogger.update(compileToken, `Compiling MDX [${i + 1}/${totalPages}] ${page.path}`)
933
+ const compileTargets = pages.filter((page) => page && page.content != null && !page.mdxComponent)
934
+ const totalPages = compileTargets.length
935
+ let completed = 0
936
+ const compiledSources = await compileMdxSources(compileTargets, {
937
+ onProgress: (page) => {
938
+ if (!logEnabled) return
939
+ completed += 1
940
+ stageLogger.update(
941
+ compileToken,
942
+ `Compiling MDX [${completed}/${totalPages}] ${page.routePath || page.path}`
943
+ )
789
944
  }
945
+ })
946
+ stageLogger.end(compileToken)
947
+ const executeToken = stageLogger.start('Running MDX')
948
+ completed = 0
949
+ for (const page of compileTargets) {
950
+ const compiled = compiledSources.get(page) || null
790
951
  await compilePageMdx(page, pagesContext, {
791
952
  lazyPagesTree: true,
792
- refreshPagesTree: false
953
+ refreshPagesTree: false,
954
+ compiled
793
955
  })
956
+ if (logEnabled) {
957
+ completed += 1
958
+ stageLogger.update(
959
+ executeToken,
960
+ `Running MDX [${completed}/${totalPages}] ${page.routePath || page.path}`
961
+ )
962
+ }
794
963
  }
795
- stageLogger.end(compileToken)
796
- pagesTreeCache.clear()
797
- pagesTree = getPagesTree('/')
798
- pagesContext.pagesTree = pagesTree
964
+ stageLogger.end(executeToken)
965
+ pagesContext.refreshPagesTree?.()
799
966
  }
800
967
  return pagesContext
801
968
  }
package/src/state.js CHANGED
@@ -104,14 +104,14 @@ const withCommonOptions = (y) =>
104
104
  .option('highlight', {
105
105
  describe: 'Enable or disable code highlighting',
106
106
  type: 'boolean',
107
- coerce: (value) => {
108
- if (value == null) return null
109
- if (typeof value === 'boolean') return value
110
- const normalized = String(value).trim().toLowerCase()
111
- if (normalized === 'true') return true
112
- if (normalized === 'false') return false
113
- return null
114
- }
107
+ default: undefined
108
+ })
109
+ .option('jobs', {
110
+ alias: 'j',
111
+ describe: 'Worker thread count (0 for auto)',
112
+ type: 'number',
113
+ requiresArg: true,
114
+ nargs: 1
115
115
  })
116
116
  .option('verbose', {
117
117
  alias: 'v',
@@ -126,12 +126,14 @@ const withCommonOptions = (y) =>
126
126
  nargs: 1
127
127
  })
128
128
  .option('search', {
129
- describe: 'Enable search indexing (pagefind)',
130
- type: 'boolean'
129
+ describe: 'Enable or disable search indexing (pagefind)',
130
+ type: 'boolean',
131
+ default: undefined
131
132
  })
132
133
  .option('pwa', {
133
- describe: 'Enable PWA support',
134
- type: 'boolean'
134
+ describe: 'Enable or disable PWA support',
135
+ type: 'boolean',
136
+ default: undefined
135
137
  })
136
138
 
137
139
  const parser = yargs(hideBin(process.argv))
@@ -161,10 +163,11 @@ export const cli = {
161
163
  CLI_CONFIG_PATH: argv.config || null,
162
164
  CLI_SITE_NAME: argv['site-name'] || null,
163
165
  CLI_CODE_HIGHLIGHTING: typeof argv.highlight === 'boolean' ? argv.highlight : null,
166
+ CLI_JOBS: typeof argv.jobs === 'number' && Number.isFinite(argv.jobs) ? argv.jobs : null,
164
167
  CLI_VERBOSE: Boolean(argv.verbose),
165
168
  CLI_BASE: argv.base || null,
166
- CLI_SEARCH: argv.search,
167
- CLI_PWA: argv.pwa
169
+ CLI_SEARCH: typeof argv.search === 'boolean' ? argv.search : undefined,
170
+ CLI_PWA: typeof argv.pwa === 'boolean' ? argv.pwa : undefined
168
171
  }
169
172
 
170
173
  export const state = {
@@ -204,6 +207,7 @@ export const state = {
204
207
  THEME_POST_BUNDLE_HOOKS: [],
205
208
  STARRY_NIGHT_ENABLED: false,
206
209
  STARRY_NIGHT_OPTIONS: null,
210
+ WORKER_JOBS: 0,
207
211
  GFM_ENABLED: true,
208
212
  PWA_ENABLED: false,
209
213
  PWA_OPTIONS: null,
@@ -0,0 +1,156 @@
1
+ /* Copyright Yukino Song, SudoMaker Ltd.
2
+ *
3
+ * Licensed to the Apache Software Foundation (ASF) under one
4
+ * or more contributor license agreements. See the NOTICE file
5
+ * distributed with this work for additional information
6
+ * regarding copyright ownership. The ASF licenses this file
7
+ * to you under the Apache License, Version 2.0 (the
8
+ * "License"); you may not use this file except in compliance
9
+ * with the License. You may obtain a copy of the License at
10
+ *
11
+ * http://www.apache.org/licenses/LICENSE-2.0
12
+ *
13
+ * Unless required by applicable law or agreed to in writing,
14
+ * software distributed under the License is distributed on an
15
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16
+ * KIND, either express or implied. See the License for the
17
+ * specific language governing permissions and limitations
18
+ * under the License.
19
+ */
20
+
21
+ import { cpus } from 'os'
22
+ import { Worker } from 'worker_threads'
23
+ import { state, cli } from '../state.js'
24
+
25
+ const BUILD_WORKER_URL = new URL('./build-worker.js', import.meta.url)
26
+ const cliOverrides = {
27
+ CLI_INTERMEDIATE_DIR: cli.CLI_INTERMEDIATE_DIR,
28
+ CLI_EMIT_INTERMEDIATE: cli.CLI_EMIT_INTERMEDIATE,
29
+ CLI_HOST: cli.CLI_HOST,
30
+ CLI_PORT: cli.CLI_PORT,
31
+ CLI_PAGES_DIR: cli.CLI_PAGES_DIR,
32
+ CLI_COMPONENTS_DIR: cli.CLI_COMPONENTS_DIR,
33
+ CLI_ASSETS_DIR: cli.CLI_ASSETS_DIR,
34
+ CLI_OUTPUT_DIR: cli.CLI_OUTPUT_DIR,
35
+ CLI_CONFIG_PATH: cli.CLI_CONFIG_PATH,
36
+ CLI_SITE_NAME: cli.CLI_SITE_NAME,
37
+ CLI_CODE_HIGHLIGHTING: cli.CLI_CODE_HIGHLIGHTING,
38
+ CLI_JOBS: cli.CLI_JOBS,
39
+ CLI_VERBOSE: cli.CLI_VERBOSE,
40
+ CLI_BASE: cli.CLI_BASE,
41
+ CLI_SEARCH: cli.CLI_SEARCH,
42
+ CLI_PWA: cli.CLI_PWA
43
+ }
44
+
45
+ const resolveWorkerCount = (total) => {
46
+ const cpuCount = Math.max(1, cpus()?.length || 1)
47
+ const requested = state.WORKER_JOBS
48
+ if (requested == null || requested <= 0) {
49
+ const items = Math.max(1, Number.isFinite(total) ? total : 1)
50
+ const autoCount = Math.round(Math.log(items))
51
+ return Math.max(1, Math.min(cpuCount, autoCount))
52
+ }
53
+ return Math.max(1, Math.min(cpuCount, Math.floor(requested)))
54
+ }
55
+
56
+ export const createBuildWorkers = (pageCount, options = {}) => {
57
+ const { command = 'build' } = options || {}
58
+ const workerCount = Math.min(resolveWorkerCount(pageCount), pageCount || 1) || 1
59
+ const workers = []
60
+ for (let i = 0; i < workerCount; i += 1) {
61
+ workers.push(
62
+ new Worker(BUILD_WORKER_URL, {
63
+ type: 'module',
64
+ workerData: {
65
+ mode: state.CURRENT_MODE,
66
+ configPath: cli.CLI_CONFIG_PATH,
67
+ command,
68
+ cli: cliOverrides
69
+ }
70
+ })
71
+ )
72
+ }
73
+ const assignments = Array.from({ length: workers.length }, () => [])
74
+ for (let i = 0; i < pageCount; i += 1) {
75
+ assignments[i % workers.length].push(i)
76
+ }
77
+ return { workers, assignments }
78
+ }
79
+
80
+ export const terminateWorkers = async (workers = []) => {
81
+ await Promise.all(workers.map((worker) => worker.terminate().catch(() => null)))
82
+ }
83
+
84
+ export const runWorkerStage = async ({ workers, stage, messages, onProgress, collect }) => {
85
+ return await new Promise((resolve, reject) => {
86
+ let completed = 0
87
+ let doneCount = 0
88
+ const results = []
89
+ const handleFailure = (error) => {
90
+ for (const w of workers) {
91
+ const handler = handlers.get(w)
92
+ if (handler) {
93
+ w.off('message', handler)
94
+ w.off('error', errorHandlers.get(w))
95
+ w.off('exit', exitHandlers.get(w))
96
+ }
97
+ }
98
+ reject(error instanceof Error ? error : new Error(String(error)))
99
+ }
100
+ const handleMessage = (worker, message) => {
101
+ if (!message || message.stage !== stage) return
102
+ if (message.type === 'progress') {
103
+ completed += 1
104
+ if (onProgress) {
105
+ onProgress(completed)
106
+ }
107
+ return
108
+ }
109
+ if (message.type === 'done') {
110
+ if (collect) {
111
+ const data = collect(message)
112
+ if (Array.isArray(data) && data.length) {
113
+ results.push(...data)
114
+ }
115
+ }
116
+ doneCount += 1
117
+ if (doneCount >= workers.length) {
118
+ for (const w of workers) {
119
+ const handler = handlers.get(w)
120
+ if (handler) {
121
+ w.off('message', handler)
122
+ w.off('error', errorHandlers.get(w))
123
+ w.off('exit', exitHandlers.get(w))
124
+ }
125
+ }
126
+ resolve(results)
127
+ }
128
+ return
129
+ }
130
+ if (message.type === 'error') {
131
+ handleFailure(new Error(message.error || 'Worker error'))
132
+ }
133
+ }
134
+ const handlers = new Map()
135
+ const errorHandlers = new Map()
136
+ const exitHandlers = new Map()
137
+ for (const worker of workers) {
138
+ const handler = (message) => handleMessage(worker, message)
139
+ handlers.set(worker, handler)
140
+ worker.on('message', handler)
141
+ const errorHandler = (error) => handleFailure(error)
142
+ const exitHandler = (code) => {
143
+ if (code !== 0) {
144
+ handleFailure(new Error(`Build worker exited with code ${code}`))
145
+ }
146
+ }
147
+ errorHandlers.set(worker, errorHandler)
148
+ exitHandlers.set(worker, exitHandler)
149
+ worker.on('error', errorHandler)
150
+ worker.on('exit', exitHandler)
151
+ }
152
+ for (const entry of messages) {
153
+ entry.worker.postMessage(entry.message)
154
+ }
155
+ })
156
+ }