methanol 0.0.0 → 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.
Files changed (48) hide show
  1. package/LICENSE +203 -0
  2. package/README.md +58 -0
  3. package/banner.txt +6 -0
  4. package/bin/methanol.js +24 -0
  5. package/index.js +22 -0
  6. package/package.json +51 -9
  7. package/src/assets.js +30 -0
  8. package/src/build-system.js +200 -0
  9. package/src/components.js +145 -0
  10. package/src/config.js +396 -0
  11. package/src/dev-server.js +632 -0
  12. package/src/main.js +133 -0
  13. package/src/mdx.js +406 -0
  14. package/src/node-loader.js +88 -0
  15. package/src/pagefind.js +107 -0
  16. package/src/pages.js +771 -0
  17. package/src/preview-server.js +58 -0
  18. package/src/public-assets.js +73 -0
  19. package/src/register-loader.js +29 -0
  20. package/src/rehype-plugins/link-resolve.js +116 -0
  21. package/src/rehype-plugins/methanol-ctx.js +89 -0
  22. package/src/renderer.js +25 -0
  23. package/src/rewind.js +117 -0
  24. package/src/stage-logger.js +59 -0
  25. package/src/state.js +179 -0
  26. package/src/virtual-module/inject.js +30 -0
  27. package/src/virtual-module/loader.js +116 -0
  28. package/src/virtual-module/pagefind.js +108 -0
  29. package/src/vite-plugins.js +173 -0
  30. package/themes/default/components/ThemeAccentSwitch.client.jsx +95 -0
  31. package/themes/default/components/ThemeAccentSwitch.static.jsx +23 -0
  32. package/themes/default/components/ThemeColorSwitch.client.jsx +95 -0
  33. package/themes/default/components/ThemeColorSwitch.static.jsx +23 -0
  34. package/themes/default/components/ThemeSearchBox.client.jsx +324 -0
  35. package/themes/default/components/ThemeSearchBox.static.jsx +40 -0
  36. package/themes/default/components/ThemeToCContainer.client.jsx +154 -0
  37. package/themes/default/components/ThemeToCContainer.static.jsx +61 -0
  38. package/themes/default/components/pre.client.jsx +84 -0
  39. package/themes/default/components/pre.static.jsx +27 -0
  40. package/themes/default/heading.jsx +35 -0
  41. package/themes/default/index.js +41 -0
  42. package/themes/default/page.jsx +303 -0
  43. package/themes/default/pages/404.mdx +8 -0
  44. package/themes/default/pages/index.mdx +31 -0
  45. package/themes/default/public/favicon.png +0 -0
  46. package/themes/default/public/logo.png +0 -0
  47. package/themes/default/sources/prefetch.js +49 -0
  48. package/themes/default/sources/style.css +1660 -0
package/src/pages.js ADDED
@@ -0,0 +1,771 @@
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 matter from 'gray-matter'
22
+ import { readdir, readFile, stat } from 'fs/promises'
23
+ import { existsSync } from 'fs'
24
+ import { resolve, join, relative } from 'path'
25
+ import { state, cli } from './state.js'
26
+ import { compilePageMdx } from './mdx.js'
27
+ import { createStageLogger } from './stage-logger.js'
28
+
29
+ const isPageFile = (name) => name.endsWith('.mdx') || name.endsWith('.md')
30
+ const isInternalPage = (name) => name.startsWith('_') || name.startsWith('.')
31
+ const isIgnoredEntry = (name) => name.startsWith('.') || name.startsWith('_')
32
+
33
+ const pageMetadataCache = new Map()
34
+ const pageDerivedCache = new Map()
35
+
36
+ const collectLanguagesFromPages = (pages = []) => {
37
+ const languages = new Map()
38
+ for (const page of pages) {
39
+ if (!page?.isIndex) continue
40
+ const label = page?.frontmatter?.lang
41
+ if (label == null || label === '') continue
42
+ const routePath = page.routePath || '/'
43
+ const href = page.routeHref || routePath || '/'
44
+ const frontmatterCode = page?.frontmatter?.langCode
45
+ const code =
46
+ typeof frontmatterCode === 'string' && frontmatterCode.trim()
47
+ ? frontmatterCode.trim()
48
+ : routePath === '/'
49
+ ? null
50
+ : routePath.replace(/^\/+/, '')
51
+ languages.set(routePath, {
52
+ routePath,
53
+ href,
54
+ label: String(label),
55
+ code: code || null
56
+ })
57
+ }
58
+ return Array.from(languages.values()).sort((a, b) => a.href.localeCompare(b.href))
59
+ }
60
+
61
+ const normalizeRoutePath = (value) => {
62
+ if (!value || value === '/') return '/'
63
+ return value.replace(/\/+$/, '')
64
+ }
65
+
66
+ const resolveLanguageForRoute = (languages = [], routePath = '/') => {
67
+ if (!languages.length) return null
68
+ const normalizedRoute = normalizeRoutePath(routePath)
69
+ let best = null
70
+ let bestLength = -1
71
+ let rootLanguage = null
72
+ for (const lang of languages) {
73
+ const base = normalizeRoutePath(lang?.routePath || lang?.href)
74
+ if (!base) continue
75
+ if (base === '/') {
76
+ rootLanguage = lang
77
+ continue
78
+ }
79
+ if (normalizedRoute === base || normalizedRoute.startsWith(`${base}/`)) {
80
+ if (base.length > bestLength) {
81
+ best = lang
82
+ bestLength = base.length
83
+ }
84
+ }
85
+ }
86
+ return best || rootLanguage
87
+ }
88
+
89
+ export const routePathFromFile = (filePath, pagesDir = state.PAGES_DIR) => {
90
+ if (!filePath.endsWith('.mdx') && !filePath.endsWith('.md')) {
91
+ return null
92
+ }
93
+ const relPath = relative(pagesDir, filePath)
94
+ if (relPath.startsWith('..')) {
95
+ return null
96
+ }
97
+ const name = relPath.replace(/\.(mdx|md)$/, '')
98
+ const baseName = name.split(/[\\/]/).pop()
99
+ if (isInternalPage(baseName)) {
100
+ return null
101
+ }
102
+ const normalized = name.replace(/\\/g, '/')
103
+ if (normalized === 'index') {
104
+ return '/'
105
+ }
106
+ if (normalized.endsWith('/index')) {
107
+ return `/${normalized.slice(0, -'/index'.length)}`
108
+ }
109
+ return `/${normalized}`
110
+ }
111
+
112
+ const parseFrontmatter = (raw) => {
113
+ const parsed = matter(raw)
114
+ const data = parsed.data || {}
115
+ const content = parsed.content ?? ''
116
+ return {
117
+ data,
118
+ content,
119
+ matter: parsed.matter ?? null
120
+ }
121
+ }
122
+
123
+ const parsePageMetadata = async (filePath) => {
124
+ const raw = await readFile(filePath, 'utf-8')
125
+ const { data: frontmatter, content, matter } = parseFrontmatter(raw)
126
+ let title = frontmatter.title
127
+ return {
128
+ raw,
129
+ content,
130
+ frontmatter,
131
+ matter,
132
+ title
133
+ }
134
+ }
135
+
136
+ const parseWeight = (value) => {
137
+ if (value == null || value === '') return null
138
+ const parsed = Number(value)
139
+ return Number.isFinite(parsed) ? parsed : null
140
+ }
141
+
142
+ const parseDate = (value) => {
143
+ if (!value) return null
144
+ const date = new Date(value)
145
+ return Number.isNaN(date.valueOf()) ? null : date.toISOString()
146
+ }
147
+
148
+ const stripRootPrefix = (value, rootDir) => {
149
+ if (!rootDir || !value) return value
150
+ if (value === rootDir) return ''
151
+ if (value.startsWith(`${rootDir}/`)) {
152
+ return value.slice(rootDir.length + 1)
153
+ }
154
+ return value
155
+ }
156
+
157
+ const buildPagesTree = (pages, options = {}) => {
158
+ const rootPath = normalizeRoutePath(options.rootPath || '/')
159
+ const rootDir = rootPath === '/' ? '' : rootPath.replace(/^\/+/, '')
160
+ const includeHiddenRoot = Boolean(options.includeHiddenRoot)
161
+ const currentRoutePath = normalizeRoutePath(options.currentRoutePath || '/')
162
+ const rootSegments = rootDir ? rootDir.split('/') : []
163
+ const resolveRouteWithinRoot = (routePath) => {
164
+ if (!routePath) return '/'
165
+ if (!rootDir) return routePath
166
+ if (routePath === rootPath) return '/'
167
+ if (routePath.startsWith(`${rootPath}/`)) {
168
+ const stripped = routePath.slice(rootPath.length)
169
+ return stripped.startsWith('/') ? stripped : `/${stripped}`
170
+ }
171
+ return routePath
172
+ }
173
+ const currentRouteWithinRoot = resolveRouteWithinRoot(currentRoutePath)
174
+ const isUnderRoot = (page) => {
175
+ if (!rootDir) return true
176
+ return page.routePath === rootPath || page.routePath.startsWith(`${rootPath}/`)
177
+ }
178
+ const treePages = pages
179
+ .filter((page) => !page.isInternal)
180
+ .filter((page) => isUnderRoot(page))
181
+ .map((page) => {
182
+ if (!rootDir) return page
183
+ const relativePath = stripRootPrefix(page.relativePath, rootDir)
184
+ const dir = stripRootPrefix(page.dir, rootDir)
185
+ const segments = page.segments.slice(rootSegments.length)
186
+ const depth = segments.length
187
+ return {
188
+ ...page,
189
+ relativePath,
190
+ dir,
191
+ segments,
192
+ depth
193
+ }
194
+ })
195
+ const root = []
196
+ const dirs = new Map()
197
+ const hiddenDirs = new Set(
198
+ treePages
199
+ .filter((page) => page.isIndex && page.dir && page.hidden && !(includeHiddenRoot && page.routePath === rootPath))
200
+ .map((page) => page.dir)
201
+ )
202
+ const exposedHiddenDirs = new Set()
203
+ if (currentRoutePath && currentRoutePath !== '/' && hiddenDirs.size) {
204
+ for (const hiddenDir of hiddenDirs) {
205
+ const hiddenRoute = `/${hiddenDir}`
206
+ if (
207
+ currentRouteWithinRoot === hiddenRoute ||
208
+ currentRouteWithinRoot.startsWith(`${hiddenRoute}/`)
209
+ ) {
210
+ exposedHiddenDirs.add(hiddenDir)
211
+ }
212
+ }
213
+ }
214
+ if (includeHiddenRoot && rootDir) {
215
+ for (const hiddenDir of Array.from(hiddenDirs)) {
216
+ if (rootDir === hiddenDir || rootDir.startsWith(`${hiddenDir}/`)) {
217
+ hiddenDirs.delete(hiddenDir)
218
+ }
219
+ }
220
+ }
221
+ if (exposedHiddenDirs.size) {
222
+ for (const hiddenDir of exposedHiddenDirs) {
223
+ hiddenDirs.delete(hiddenDir)
224
+ }
225
+ }
226
+ const isUnderHiddenDir = (dir) => {
227
+ if (!dir) return false
228
+ const parts = dir.split('/')
229
+ for (let i = 1; i <= parts.length; i++) {
230
+ const candidate = parts.slice(0, i).join('/')
231
+ if (hiddenDirs.has(candidate)) {
232
+ return true
233
+ }
234
+ }
235
+ return false
236
+ }
237
+ const getDirNode = (path, name, depth) => {
238
+ if (dirs.has(path)) return dirs.get(path)
239
+ const dir = {
240
+ type: 'directory',
241
+ name,
242
+ path: `/${path}`,
243
+ children: [],
244
+ depth,
245
+ routePath: null,
246
+ title: null,
247
+ weight: null,
248
+ date: null,
249
+ routeHref: null,
250
+ isRoot: false
251
+ }
252
+ dirs.set(path, dir)
253
+ return dir
254
+ }
255
+ const isUnderExposedHiddenDir = (dir) => {
256
+ if (!dir || !exposedHiddenDirs.size) return false
257
+ for (const hiddenDir of exposedHiddenDirs) {
258
+ if (dir === hiddenDir || dir.startsWith(`${hiddenDir}/`)) {
259
+ return true
260
+ }
261
+ }
262
+ return false
263
+ }
264
+ for (const page of treePages) {
265
+ if (page.hidden && !(includeHiddenRoot && page.routePath === rootPath)) {
266
+ const isHidden404 = page.routePath === '/404'
267
+ const shouldExposeHidden =
268
+ !isHidden404 &&
269
+ page.hiddenByFrontmatter === true &&
270
+ (page.routePath === currentRoutePath || isUnderExposedHiddenDir(page.dir))
271
+ if (!shouldExposeHidden) {
272
+ continue
273
+ }
274
+ }
275
+ if (isUnderHiddenDir(page.dir)) {
276
+ continue
277
+ }
278
+ const parts = page.relativePath.split('/')
279
+ parts.pop()
280
+ let cursor = root
281
+ let currentPath = ''
282
+ for (const part of parts) {
283
+ currentPath = currentPath ? `${currentPath}/${part}` : part
284
+ if (hiddenDirs.has(currentPath)) {
285
+ cursor = null
286
+ break
287
+ }
288
+ const dir = getDirNode(currentPath, part, currentPath.split('/').length)
289
+ if (!cursor.includes(dir)) {
290
+ cursor.push(dir)
291
+ }
292
+ cursor = dir.children
293
+ }
294
+ if (!cursor) {
295
+ continue
296
+ }
297
+ if (page.isIndex && page.dir) {
298
+ const dir = getDirNode(page.dir, page.dir.split('/').pop(), page.depth)
299
+ dir.routePath = page.routePath
300
+ dir.routeHref = page.routeHref || page.routePath
301
+ dir.title = page.title
302
+ dir.weight = page.weight ?? null
303
+ dir.date = page.date ?? null
304
+ dir.isRoot = page.isRoot || false
305
+ dir.page = page
306
+ continue
307
+ }
308
+ cursor.push({
309
+ type: 'page',
310
+ ...page
311
+ })
312
+ }
313
+ const compareNodes = (a, b, level = 0) => {
314
+ if (a?.routePath === '/' && b?.routePath !== '/') return -1
315
+ if (b?.routePath === '/' && a?.routePath !== '/') return 1
316
+ if (a?.isIndex && !b?.isIndex) return -1
317
+ if (b?.isIndex && !a?.isIndex) return 1
318
+ if (level === 0 && a?.type !== b?.type) {
319
+ return a.type === 'page' ? -1 : 1
320
+ }
321
+ const weightA = Number.isFinite(a.weight) ? a.weight : null
322
+ const weightB = Number.isFinite(b.weight) ? b.weight : null
323
+ if (weightA != null || weightB != null) {
324
+ if (weightA == null) return 1
325
+ if (weightB == null) return -1
326
+ if (weightA !== weightB) return weightA - weightB
327
+ }
328
+ const dateA = a.date ? new Date(a.date).valueOf() : null
329
+ const dateB = b.date ? new Date(b.date).valueOf() : null
330
+ if (dateA != null || dateB != null) {
331
+ if (dateA == null) return 1
332
+ if (dateB == null) return -1
333
+ if (dateA !== dateB) return dateB - dateA
334
+ }
335
+ const labelA = (a.title || a.name || '').toLowerCase()
336
+ const labelB = (b.title || b.name || '').toLowerCase()
337
+ if (labelA < labelB) return -1
338
+ if (labelA > labelB) return 1
339
+ return 0
340
+ }
341
+ const sortTree = (nodes, level = 0) => {
342
+ nodes.sort((a, b) => compareNodes(a, b, level))
343
+ for (const node of nodes) {
344
+ if (node.type === 'directory') {
345
+ sortTree(node.children, level + 1)
346
+ }
347
+ }
348
+ }
349
+ sortTree(root)
350
+ return root
351
+ }
352
+
353
+ const walkPages = async function* (dir, basePath = '') {
354
+ const entries = await readdir(dir)
355
+ const files = []
356
+ const dirs = []
357
+
358
+ for (const entry of entries.sort()) {
359
+ if (isIgnoredEntry(entry)) {
360
+ continue
361
+ }
362
+ const fullPath = join(dir, entry)
363
+ const stats = await stat(fullPath)
364
+ if (stats.isDirectory()) {
365
+ dirs.push({ entry, fullPath })
366
+ } else if (isPageFile(entry)) {
367
+ files.push({ entry, fullPath })
368
+ }
369
+ }
370
+
371
+ for (const { entry, fullPath } of files) {
372
+ const name = entry.replace(/\.(mdx|md)$/, '')
373
+ const relativePath = join(basePath, name).replace(/\\/g, '/')
374
+ const isIndex = name === 'index'
375
+ const routePath = isIndex ? (basePath ? `/${basePath}` : '/') : `/${relativePath}`
376
+ const routeHref = isIndex && basePath ? `/${basePath}/` : routePath
377
+ yield { routePath, routeHref, filePath: fullPath, isIndex }
378
+ }
379
+
380
+ for (const { entry, fullPath } of dirs) {
381
+ yield* walkPages(fullPath, join(basePath, entry))
382
+ }
383
+ }
384
+
385
+ export const buildPageEntry = async ({ filePath, pagesDir, source }) => {
386
+ const routePath = routePathFromFile(filePath, pagesDir)
387
+ if (!routePath) return null
388
+ const relPath = relative(pagesDir, filePath).replace(/\\/g, '/')
389
+ const name = relPath.replace(/\.(mdx|md)$/, '')
390
+ const baseName = name.split('/').pop()
391
+ const dir = name.split('/').slice(0, -1).join('/')
392
+ const dirName = dir ? dir.split('/').pop() : ''
393
+ const isIndex = baseName === 'index'
394
+ const routeHref = isIndex && dir ? `/${dir}/` : routePath
395
+ const segments = routePath.split('/').filter(Boolean)
396
+ const stats = await stat(filePath)
397
+ const cached = pageMetadataCache.get(filePath)
398
+ let metadata = null
399
+ if (cached && cached.mtimeMs === stats.mtimeMs) {
400
+ metadata = cached.metadata
401
+ } else {
402
+ metadata = await parsePageMetadata(filePath)
403
+ pageMetadataCache.set(filePath, { mtimeMs: stats.mtimeMs, metadata })
404
+ }
405
+ const derived = pageDerivedCache.get(filePath)
406
+ const exclude = Boolean(metadata.frontmatter?.exclude)
407
+ const frontmatterHidden = metadata.frontmatter?.hidden
408
+ const hiddenByFrontmatter = frontmatterHidden === true
409
+ const isNotFoundPage = routePath === '/404'
410
+ const hidden = frontmatterHidden === false
411
+ ? false
412
+ : frontmatterHidden === true
413
+ ? true
414
+ : isNotFoundPage || Boolean(metadata.frontmatter?.isRoot)
415
+ return {
416
+ routePath,
417
+ routeHref,
418
+ filePath,
419
+ source,
420
+ relativePath: relPath,
421
+ name: baseName,
422
+ dir,
423
+ segments,
424
+ depth: segments.length,
425
+ isIndex,
426
+ isInternal: isInternalPage(baseName),
427
+ title: metadata.title || derived?.title || (baseName === 'index' ? (dirName || 'Home') : baseName),
428
+ weight: parseWeight(metadata.frontmatter?.weight),
429
+ date: parseDate(metadata.frontmatter?.date) || parseDate(stats.mtime),
430
+ isRoot: Boolean(metadata.frontmatter?.isRoot),
431
+ hidden,
432
+ hiddenByFrontmatter,
433
+ exclude,
434
+ content: metadata.content,
435
+ frontmatter: metadata.frontmatter,
436
+ toc: derived?.toc || null,
437
+ matter: metadata.matter ?? null,
438
+ stats: {
439
+ size: stats.size,
440
+ createdAt: stats.birthtime?.toISOString?.() || null,
441
+ updatedAt: stats.mtime?.toISOString?.() || null
442
+ },
443
+ createdAt: stats.birthtime?.toISOString?.() || null,
444
+ updatedAt: stats.mtime?.toISOString?.() || null
445
+ }
446
+ }
447
+
448
+ const collectPagesFromDir = async (pagesDir, source) => {
449
+ if (!pagesDir || !existsSync(pagesDir)) {
450
+ return []
451
+ }
452
+ const pages = []
453
+ for await (const page of walkPages(pagesDir)) {
454
+ const entry = await buildPageEntry({
455
+ filePath: page.filePath,
456
+ pagesDir,
457
+ source
458
+ })
459
+ if (entry) {
460
+ entry.routeHref = page.routeHref || entry.routeHref
461
+ entry.isIndex = page.isIndex || entry.isIndex
462
+ pages.push(entry)
463
+ }
464
+ }
465
+ return pages
466
+ }
467
+
468
+ const collectPages = async () => {
469
+ const userPages = await collectPagesFromDir(state.PAGES_DIR, 'user')
470
+ const themePages = state.THEME_PAGES_DIR
471
+ ? await collectPagesFromDir(state.THEME_PAGES_DIR, 'theme')
472
+ : []
473
+ const userRoutes = new Set(userPages.map((page) => page.routePath))
474
+ const pages = [...userPages, ...themePages.filter((page) => !userRoutes.has(page.routePath))]
475
+ const excludedDirs = new Set(pages.filter((page) => page.exclude && page.isIndex && page.dir).map((page) => page.dir))
476
+ const isUnderExcludedDir = (dir) => {
477
+ if (!dir) return false
478
+ const parts = dir.split('/')
479
+ for (let i = 1; i <= parts.length; i++) {
480
+ const candidate = parts.slice(0, i).join('/')
481
+ if (excludedDirs.has(candidate)) {
482
+ return true
483
+ }
484
+ }
485
+ return false
486
+ }
487
+ const excludedRoutes = new Set(pages.filter((page) => page.exclude).map((page) => page.routePath))
488
+ const filteredPages = pages.filter((page) => {
489
+ if (page.exclude) return false
490
+ if (isUnderExcludedDir(page.dir)) return false
491
+ return true
492
+ })
493
+ return { pages: filteredPages, excludedRoutes, excludedDirs }
494
+ }
495
+
496
+ const buildIndexFallback = (pages, siteName) => {
497
+ const visiblePages = pages
498
+ .filter((page) => !page.isInternal && page.routePath !== '/')
499
+ .sort((a, b) => a.routePath.localeCompare(b.routePath))
500
+
501
+ const lines = [
502
+ `# ${siteName || 'Methanol Site'}`,
503
+ '',
504
+ 'No `index.md` or `index.mdx` found in your pages directory.',
505
+ '',
506
+ '## Pages'
507
+ ]
508
+
509
+ if (!visiblePages.length) {
510
+ lines.push('', 'No pages found yet.')
511
+ return lines.join('\n')
512
+ }
513
+
514
+ lines.push('')
515
+ for (const page of visiblePages) {
516
+ const label = page.title || page.routePath
517
+ lines.push(`- [${label}](${encodeURI(page.routePath)})`)
518
+ }
519
+
520
+ return lines.join('\n')
521
+ }
522
+
523
+ const resolveRootPath = (routePath, pagesByRoute, pagesByRouteIndex = null) => {
524
+ const normalized = normalizeRoutePath(routePath || '/')
525
+ const segments = normalized.split('/').filter(Boolean)
526
+ const lookup = pagesByRouteIndex || pagesByRoute
527
+ for (let i = segments.length; i >= 1; i--) {
528
+ const candidate = `/${segments.slice(0, i).join('/')}`
529
+ const page = lookup.get(candidate)
530
+ if (page?.isIndex && page?.isRoot) {
531
+ return candidate
532
+ }
533
+ }
534
+ return '/'
535
+ }
536
+
537
+ const buildNavSequence = (nodes, pagesByRoute) => {
538
+ const result = []
539
+ const seen = new Set()
540
+ const addEntry = (entry) => {
541
+ if (!entry?.routePath) return
542
+ const key = entry.filePath || entry.routePath
543
+ if (seen.has(key)) return
544
+ seen.add(key)
545
+ result.push(entry)
546
+ }
547
+ const walk = (items = []) => {
548
+ for (const node of items) {
549
+ if (node.type === 'directory') {
550
+ if (node.routePath) {
551
+ const page = pagesByRoute.get(node.routePath) || node.page || null
552
+ if (page) addEntry(page)
553
+ }
554
+ if (node.children?.length) {
555
+ walk(node.children)
556
+ }
557
+ continue
558
+ }
559
+ if (node.type === 'page') {
560
+ const page = pagesByRoute.get(node.routePath) || node
561
+ addEntry(page)
562
+ }
563
+ }
564
+ }
565
+ walk(nodes)
566
+ return result
567
+ }
568
+
569
+ export const buildPagesContext = async ({ compileAll = true } = {}) => {
570
+ const logEnabled = state.CURRENT_MODE === 'production' && cli.command === 'build'
571
+ const stageLogger = createStageLogger(logEnabled)
572
+ const collectToken = stageLogger.start('Collecting pages')
573
+ const collected = await collectPages()
574
+ stageLogger.end(collectToken)
575
+ let pages = collected.pages
576
+ const excludedRoutes = collected.excludedRoutes
577
+ const excludedDirs = collected.excludedDirs
578
+ const hasIndex = pages.some((page) => page.routePath === '/')
579
+ if (!hasIndex) {
580
+ const content = buildIndexFallback(pages, state.SITE_NAME)
581
+ pages = [
582
+ {
583
+ routePath: '/',
584
+ routeHref: '/',
585
+ filePath: resolve(state.PAGES_DIR, 'index.md'),
586
+ relativePath: 'index.md',
587
+ name: 'index',
588
+ dir: '',
589
+ segments: [],
590
+ depth: 0,
591
+ isIndex: true,
592
+ isInternal: false,
593
+ title: state.SITE_NAME || 'Methanol Site',
594
+ weight: null,
595
+ date: null,
596
+ isRoot: false,
597
+ hidden: false,
598
+ content,
599
+ frontmatter: {},
600
+ matter: null,
601
+ stats: { size: content.length, createdAt: null, updatedAt: null },
602
+ createdAt: null,
603
+ updatedAt: null
604
+ },
605
+ ...pages
606
+ ]
607
+ if (excludedRoutes?.has('/')) {
608
+ excludedRoutes.delete('/')
609
+ }
610
+ }
611
+
612
+ const pagesByRoute = new Map()
613
+ const pagesByRouteIndex = new Map()
614
+ for (const page of pages) {
615
+ if (page.isIndex) {
616
+ pagesByRouteIndex.set(page.routePath, page)
617
+ if (!pagesByRoute.has(page.routePath)) {
618
+ pagesByRoute.set(page.routePath, page)
619
+ }
620
+ continue
621
+ }
622
+ const existing = pagesByRoute.get(page.routePath)
623
+ if (!existing || existing.isIndex) {
624
+ pagesByRoute.set(page.routePath, page)
625
+ }
626
+ }
627
+ const getPageByRoute = (routePath, options = {}) => {
628
+ const { filePath, preferIndex } = options || {}
629
+ if (filePath) {
630
+ for (const page of pages) {
631
+ if (page.routePath === routePath && page.filePath === filePath) {
632
+ return page
633
+ }
634
+ }
635
+ }
636
+ if (preferIndex === true) {
637
+ return pagesByRouteIndex.get(routePath) || pagesByRoute.get(routePath) || null
638
+ }
639
+ if (preferIndex === false) {
640
+ return pagesByRoute.get(routePath) || pagesByRouteIndex.get(routePath) || null
641
+ }
642
+ return pagesByRoute.get(routePath) || pagesByRouteIndex.get(routePath) || null
643
+ }
644
+ const pagesTreeCache = new Map()
645
+ const navSequenceCache = new Map()
646
+ const getPagesTree = (routePath) => {
647
+ const rootPath = resolveRootPath(routePath, pagesByRoute, pagesByRouteIndex)
648
+ const normalizedRoute = normalizeRoutePath(routePath || '/')
649
+ const cacheKey = `${rootPath}::${normalizedRoute}`
650
+ if (pagesTreeCache.has(cacheKey)) {
651
+ return pagesTreeCache.get(cacheKey)
652
+ }
653
+ const tree = buildPagesTree(pages, {
654
+ rootPath,
655
+ includeHiddenRoot: rootPath !== '/',
656
+ currentRoutePath: normalizedRoute
657
+ })
658
+ pagesTreeCache.set(cacheKey, tree)
659
+ return tree
660
+ }
661
+ const getNavSequence = (routePath) => {
662
+ const rootPath = resolveRootPath(routePath, pagesByRoute, pagesByRouteIndex)
663
+ const normalizedRoute = normalizeRoutePath(routePath || '/')
664
+ const cacheKey = `${rootPath}::${normalizedRoute}`
665
+ if (navSequenceCache.has(cacheKey)) {
666
+ return navSequenceCache.get(cacheKey)
667
+ }
668
+ const tree = getPagesTree(routePath)
669
+ const sequence = buildNavSequence(tree, pagesByRoute)
670
+ navSequenceCache.set(cacheKey, sequence)
671
+ return sequence
672
+ }
673
+ let pagesTree = getPagesTree('/')
674
+ const notFound = pagesByRoute.get('/404') || null
675
+ const languages = collectLanguagesFromPages(pages)
676
+ const site = {
677
+ name: state.SITE_NAME,
678
+ root: state.ROOT_DIR,
679
+ pagesDir: state.PAGES_DIR,
680
+ componentsDir: state.COMPONENTS_DIR,
681
+ publicDir: state.STATIC_DIR,
682
+ distDir: state.DIST_DIR,
683
+ mode: state.CURRENT_MODE,
684
+ pagefind: {
685
+ enabled: state.PAGEFIND_ENABLED,
686
+ options: state.PAGEFIND_OPTIONS || null,
687
+ build: state.PAGEFIND_BUILD || null
688
+ },
689
+ generatedAt: new Date().toISOString()
690
+ }
691
+ const excludedDirPaths = new Set(Array.from(excludedDirs).map((dir) => `/${dir}`))
692
+ const pagesContext = {
693
+ pages,
694
+ pagesByRoute,
695
+ pagesByRouteIndex,
696
+ getPageByRoute,
697
+ pagesTree,
698
+ getPagesTree,
699
+ derivedTitleCache: pageDerivedCache,
700
+ setDerivedTitle: (filePath, title, toc) => {
701
+ if (!filePath) return
702
+ pageDerivedCache.set(filePath, { title, toc })
703
+ },
704
+ clearDerivedTitle: (filePath) => {
705
+ if (!filePath) return
706
+ pageDerivedCache.delete(filePath)
707
+ },
708
+ refreshPagesTree: () => {
709
+ pagesTreeCache.clear()
710
+ navSequenceCache.clear()
711
+ pagesContext.pagesTree = getPagesTree('/')
712
+ },
713
+ getSiblings: (routePath, filePath = null) => {
714
+ if (!routePath) return { prev: null, next: null }
715
+ const sequence = getNavSequence(routePath)
716
+ if (!sequence.length) return { prev: null, next: null }
717
+ let index = -1
718
+ if (filePath) {
719
+ index = sequence.findIndex((entry) => entry.filePath === filePath)
720
+ }
721
+ if (index < 0) {
722
+ index = sequence.findIndex((entry) => entry.routePath === routePath)
723
+ }
724
+ if (index < 0) return { prev: null, next: null }
725
+ const toNavEntry = (entry) => {
726
+ if (!entry) return null
727
+ return {
728
+ routePath: entry.routePath,
729
+ routeHref: entry.routeHref || entry.routePath,
730
+ title: entry.title || entry.name || entry.routePath,
731
+ filePath: entry.filePath || null
732
+ }
733
+ }
734
+ return {
735
+ prev: toNavEntry(sequence[index - 1] || null),
736
+ next: toNavEntry(sequence[index + 1] || null)
737
+ }
738
+ },
739
+ refreshLanguages: () => {
740
+ pagesContext.languages = collectLanguagesFromPages(pages)
741
+ pagesContext.getLanguageForRoute = (routePath) =>
742
+ resolveLanguageForRoute(pagesContext.languages, routePath)
743
+ },
744
+ excludedRoutes,
745
+ excludedDirs,
746
+ excludedDirPaths,
747
+ notFound,
748
+ languages,
749
+ getLanguageForRoute: (routePath) => resolveLanguageForRoute(languages, routePath),
750
+ site
751
+ }
752
+ if (compileAll) {
753
+ const compileToken = stageLogger.start('Compiling MDX')
754
+ const totalPages = pages.length
755
+ for (let i = 0; i < pages.length; i++) {
756
+ const page = pages[i]
757
+ if (logEnabled) {
758
+ stageLogger.update(compileToken, `Compiling MDX [${i + 1}/${totalPages}] ${page.filePath}`)
759
+ }
760
+ await compilePageMdx(page, pagesContext, {
761
+ lazyPagesTree: true,
762
+ refreshPagesTree: false
763
+ })
764
+ }
765
+ stageLogger.end(compileToken)
766
+ pagesTreeCache.clear()
767
+ pagesTree = getPagesTree('/')
768
+ pagesContext.pagesTree = pagesTree
769
+ }
770
+ return pagesContext
771
+ }