methanol 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Opinionated MDX-first static site generator powered by rEFui + Vite.
4
4
 
5
- For full documentation and examples, see the `methanol-docs` project.
5
+ For full documentation and examples, visit [Methanol Docs](https://methanol.netlify.app/).
6
6
 
7
7
  ## Quick start
8
8
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "methanol",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "Static site generator powered by rEFui and MDX",
5
5
  "main": "./index.js",
6
6
  "type": "module",
@@ -28,7 +28,7 @@ import { renderHtml } from './mdx.js'
28
28
  import { buildComponentRegistry } from './components.js'
29
29
  import { methanolVirtualHtmlPlugin, methanolResolverPlugin } from './vite-plugins.js'
30
30
  import { createStageLogger } from './stage-logger.js'
31
- import { copyPublicDir } from './public-assets.js'
31
+ import { preparePublicAssets } from './public-assets.js'
32
32
 
33
33
  const ensureDir = async (dir) => {
34
34
  await mkdir(dir, { recursive: true })
@@ -163,11 +163,11 @@ export const buildHtmlEntries = async () => {
163
163
  }
164
164
 
165
165
  export const runViteBuild = async (entry, htmlCache) => {
166
- if (state.STATIC_DIR !== false) {
167
- await copyPublicDir({
168
- sourceDir: state.THEME_PUBLIC_DIR,
169
- targetDir: state.STATIC_DIR,
170
- label: 'theme public'
166
+ if (state.STATIC_DIR !== false && state.MERGED_ASSETS_DIR) {
167
+ await preparePublicAssets({
168
+ themeDir: state.THEME_ASSETS_DIR,
169
+ userDir: state.USER_ASSETS_DIR,
170
+ targetDir: state.MERGED_ASSETS_DIR
171
171
  })
172
172
  }
173
173
  const copyPublicDirEnabled = state.STATIC_DIR !== false
package/src/config.js CHANGED
@@ -216,6 +216,7 @@ export const applyConfig = async (config, mode) => {
216
216
  state.ROOT_DIR = root
217
217
  const configSiteName = cli.CLI_SITE_NAME ?? config.site?.name ?? null
218
218
  state.SITE_NAME = configSiteName || basename(root) || 'Methanol Site'
219
+ state.USER_SITE = config.site && typeof config.site === 'object' ? { ...config.site } : null
219
220
  if (mode) {
220
221
  state.CURRENT_MODE = mode
221
222
  }
@@ -289,15 +290,36 @@ export const applyConfig = async (config, mode) => {
289
290
  if (hasOwn(state.USER_THEME, 'publicDir') && state.THEME_PUBLIC_DIR && !existsSync(state.THEME_PUBLIC_DIR)) {
290
291
  throw new Error(`Theme public directory not found: ${state.THEME_PUBLIC_DIR}`)
291
292
  }
292
- if (
293
- state.STATIC_DIR !== false &&
294
- !userSpecifiedPublicDir &&
295
- !existsSync(state.STATIC_DIR) &&
296
- state.THEME_PUBLIC_DIR &&
297
- existsSync(state.THEME_PUBLIC_DIR)
298
- ) {
299
- state.STATIC_DIR = state.THEME_PUBLIC_DIR
293
+
294
+ // Asset Merging Logic
295
+ const userAssetsDir = userSpecifiedPublicDir
296
+ ? resolveFromRoot(root, publicDirValue, 'public')
297
+ : resolveFromRoot(root, 'public', 'public')
298
+
299
+ const hasUserAssets = existsSync(userAssetsDir)
300
+ state.USER_ASSETS_DIR = hasUserAssets ? userAssetsDir : null
301
+ state.THEME_ASSETS_DIR = state.THEME_PUBLIC_DIR && existsSync(state.THEME_PUBLIC_DIR) ? state.THEME_PUBLIC_DIR : null
302
+
303
+ if (state.STATIC_DIR !== false) {
304
+ if (!hasUserAssets) {
305
+ // Optimization: No user assets, just use theme assets directly
306
+ state.STATIC_DIR = state.THEME_ASSETS_DIR
307
+ state.MERGED_ASSETS_DIR = null
308
+ } else {
309
+ // We need to merge
310
+ const nodeModulesPath = resolve(root, 'node_modules')
311
+ if (existsSync(nodeModulesPath)) {
312
+ state.MERGED_ASSETS_DIR = resolve(nodeModulesPath, '.methanol/assets')
313
+ } else {
314
+ state.MERGED_ASSETS_DIR = resolve(state.PAGES_DIR || resolve(root, 'pages'), '.methanol/assets')
315
+ }
316
+ state.STATIC_DIR = state.MERGED_ASSETS_DIR
317
+ }
318
+ } else {
319
+ state.STATIC_DIR = false
320
+ state.MERGED_ASSETS_DIR = null
300
321
  }
322
+
301
323
  state.SOURCES = normalizeSources(state.USER_THEME.sources, themeRoot)
302
324
  state.USER_VITE_CONFIG = config.vite || null
303
325
  state.USER_MDX_CONFIG = config.mdx || null
package/src/dev-server.js CHANGED
@@ -39,10 +39,13 @@ import {
39
39
  import { buildPagesContext, buildPageEntry, routePathFromFile } from './pages.js'
40
40
  import { compilePageMdx, renderHtml } from './mdx.js'
41
41
  import { methanolResolverPlugin } from './vite-plugins.js'
42
- import { copyPublicDir } from './public-assets.js'
42
+ import { preparePublicAssets, updateAsset } from './public-assets.js'
43
43
 
44
44
  export const runViteDev = async () => {
45
45
  const baseFsAllow = [state.ROOT_DIR, state.USER_THEME.root].filter(Boolean)
46
+ if (state.MERGED_ASSETS_DIR) {
47
+ baseFsAllow.push(state.MERGED_ASSETS_DIR)
48
+ }
46
49
  const baseConfig = {
47
50
  configFile: false,
48
51
  root: state.PAGES_DIR,
@@ -63,11 +66,11 @@ export const runViteDev = async () => {
63
66
  }
64
67
  const userConfig = await resolveUserViteConfig('serve')
65
68
  const finalConfig = userConfig ? mergeConfig(baseConfig, userConfig) : baseConfig
66
- if (state.STATIC_DIR !== false) {
67
- await copyPublicDir({
68
- sourceDir: state.THEME_PUBLIC_DIR,
69
- targetDir: state.STATIC_DIR,
70
- label: 'theme public'
69
+ if (state.STATIC_DIR !== false && state.MERGED_ASSETS_DIR) {
70
+ await preparePublicAssets({
71
+ themeDir: state.THEME_ASSETS_DIR,
72
+ userDir: state.USER_ASSETS_DIR,
73
+ targetDir: state.MERGED_ASSETS_DIR
71
74
  })
72
75
  }
73
76
  if (cli.CLI_PORT != null) {
@@ -94,6 +97,28 @@ export const runViteDev = async () => {
94
97
  }
95
98
  const server = await createServer(finalConfig)
96
99
 
100
+ if (state.MERGED_ASSETS_DIR && state.USER_ASSETS_DIR) {
101
+ const assetWatcher = chokidar.watch(state.USER_ASSETS_DIR, {
102
+ ignoreInitial: true
103
+ })
104
+ const handleAssetUpdate = (type, filePath) => {
105
+ const relPath = relative(state.USER_ASSETS_DIR, filePath)
106
+ enqueue(async () => {
107
+ await updateAsset({
108
+ type,
109
+ filePath,
110
+ relPath,
111
+ themeDir: state.THEME_ASSETS_DIR,
112
+ userDir: state.USER_ASSETS_DIR,
113
+ targetDir: state.MERGED_ASSETS_DIR
114
+ })
115
+ })
116
+ }
117
+ assetWatcher.on('add', (filePath) => handleAssetUpdate('add', filePath))
118
+ assetWatcher.on('change', (filePath) => handleAssetUpdate('change', filePath))
119
+ assetWatcher.on('unlink', (filePath) => handleAssetUpdate('unlink', filePath))
120
+ }
121
+
97
122
  const themeComponentsDir = state.THEME_COMPONENTS_DIR
98
123
  const themeEnv = state.THEME_ENV
99
124
  const themeRegistry = themeComponentsDir
package/src/main.js CHANGED
@@ -62,6 +62,7 @@ const main = async () => {
62
62
  const mode = isDev ? 'development' : 'production'
63
63
  const config = await loadUserConfig(mode, cli.CLI_CONFIG_PATH)
64
64
  await applyConfig(config, mode)
65
+ const userSite = state.USER_SITE || {}
65
66
  const hookContext = {
66
67
  mode,
67
68
  root: state.ROOT_DIR,
@@ -71,6 +72,7 @@ const main = async () => {
71
72
  isPreview,
72
73
  HTMLRenderer,
73
74
  site: {
75
+ ...userSite,
74
76
  name: state.SITE_NAME,
75
77
  root: state.ROOT_DIR,
76
78
  pagesDir: state.PAGES_DIR,
package/src/pages.js CHANGED
@@ -679,7 +679,9 @@ export const buildPagesContext = async ({ compileAll = true } = {}) => {
679
679
  let pagesTree = getPagesTree('/')
680
680
  const notFound = pagesByRoute.get('/404') || null
681
681
  const languages = collectLanguagesFromPages(pages)
682
+ const userSite = state.USER_SITE || {}
682
683
  const site = {
684
+ ...userSite,
683
685
  name: state.SITE_NAME,
684
686
  root: state.ROOT_DIR,
685
687
  pagesDir: state.PAGES_DIR,
@@ -18,56 +18,127 @@
18
18
  * under the License.
19
19
  */
20
20
 
21
- import { readdir, stat, copyFile, mkdir } from 'fs/promises'
21
+ import { readdir, stat, lstat, copyFile, mkdir, symlink, unlink, rm, link } from 'fs/promises'
22
22
  import { existsSync } from 'fs'
23
- import { resolve, dirname, relative } from 'path'
23
+ import { resolve, dirname, relative, parse } from 'path'
24
24
 
25
25
  const ensureDir = async (dir) => {
26
26
  await mkdir(dir, { recursive: true })
27
27
  }
28
28
 
29
- const copyDir = async (sourceDir, targetDir, onFile) => {
30
- await ensureDir(targetDir)
29
+ const isWindows = process.platform === 'win32'
30
+
31
+ const linkOrCopyFile = async (src, dest) => {
32
+ try {
33
+ try {
34
+ await lstat(dest)
35
+ await unlink(dest)
36
+ } catch (e) {
37
+ if (e.code !== 'ENOENT') {
38
+ try {
39
+ await rm(dest, { recursive: true, force: true })
40
+ } catch (e2) {
41
+ console.error(`Methanol: Failed to clean destination ${dest}`, e2)
42
+ }
43
+ }
44
+ }
45
+ } catch (err) {
46
+ console.error(`Methanol: Failed to remove existing file at ${dest}`, err)
47
+ }
48
+
49
+ try {
50
+ await ensureDir(dirname(dest))
51
+
52
+ if (isWindows) {
53
+ // Windows: Check for different drives first
54
+ if (parse(src).root.toLowerCase() !== parse(dest).root.toLowerCase()) {
55
+ await copyFile(src, dest)
56
+ return 'copied'
57
+ }
58
+
59
+ // Try hard link (no admin required)
60
+ try {
61
+ await link(src, dest)
62
+ return 'hardlinked'
63
+ } catch (err) {
64
+ // Fallback to copy
65
+ // console.warn(`Methanol: Hardlink failed for ${src} -> ${dest}. Falling back to copy.`, err.message)
66
+ await copyFile(src, dest)
67
+ return 'copied (fallback)'
68
+ }
69
+ } else {
70
+ // macOS/Linux: Symlink
71
+ await symlink(src, dest)
72
+ return 'symlinked'
73
+ }
74
+ } catch (err) {
75
+ console.error(`Methanol: Failed to link ${src} to ${dest}`, err)
76
+ return 'failed'
77
+ }
78
+ }
79
+
80
+ const processDir = async (sourceDir, targetDir, accumulated = new Set()) => {
81
+ if (!existsSync(sourceDir)) return
31
82
  const entries = await readdir(sourceDir)
32
83
  for (const entry of entries) {
33
- if (entry.startsWith('.')) {
34
- continue
35
- }
84
+ if (entry.startsWith('.')) continue
36
85
  const sourcePath = resolve(sourceDir, entry)
37
86
  const targetPath = resolve(targetDir, entry)
38
87
  const stats = await stat(sourcePath)
88
+
39
89
  if (stats.isDirectory()) {
40
- await copyDir(sourcePath, targetPath, onFile)
90
+ await processDir(sourcePath, targetPath, accumulated)
41
91
  } else {
42
- if (existsSync(targetPath)) {
43
- if (onFile) {
44
- onFile(sourcePath, targetPath, { skipped: true })
45
- }
46
- continue
47
- }
48
- await ensureDir(dirname(targetPath))
49
- await copyFile(sourcePath, targetPath)
50
- if (onFile) {
51
- onFile(sourcePath, targetPath, { skipped: false })
52
- }
92
+ await linkOrCopyFile(sourcePath, targetPath)
93
+ accumulated.add(relative(targetDir, targetPath))
53
94
  }
54
95
  }
55
96
  }
56
97
 
57
- export const copyPublicDir = async ({ sourceDir, targetDir, label = 'public' }) => {
58
- if (!sourceDir || !targetDir) return
59
- if (!existsSync(sourceDir)) return
60
- const resolvedSource = resolve(sourceDir)
61
- const resolvedTarget = resolve(targetDir)
62
- if (resolvedSource === resolvedTarget) return
63
- const created = !existsSync(resolvedTarget)
64
- await ensureDir(resolvedTarget)
65
- if (created) {
66
- console.log(`Methanol: created ${label} directory`)
98
+ export const preparePublicAssets = async ({ themeDir, userDir, targetDir }) => {
99
+ if (existsSync(targetDir)) {
100
+ await rm(targetDir, { recursive: true, force: true })
101
+ }
102
+ await ensureDir(targetDir)
103
+
104
+ if (themeDir) {
105
+ await processDir(themeDir, targetDir)
106
+ }
107
+
108
+ if (userDir) {
109
+ await processDir(userDir, targetDir)
67
110
  }
68
- await copyDir(resolvedSource, resolvedTarget, (sourcePath, targetPath, info) => {
69
- const rel = relative(resolvedSource, sourcePath).replace(/\\/g, '/')
70
- if (info?.skipped) return
71
- console.log(`Methanol: copied ${label}/${rel}`)
72
- })
73
111
  }
112
+
113
+ export const updateAsset = async ({ type, filePath, themeDir, userDir, targetDir, relPath }) => {
114
+ const targetPath = resolve(targetDir, relPath)
115
+
116
+ if (type === 'unlink') {
117
+ try {
118
+ try {
119
+ await unlink(targetPath)
120
+ } catch (e) {
121
+ if (e.code !== 'ENOENT') {
122
+ await rm(targetPath, { recursive: true, force: true })
123
+ }
124
+ }
125
+
126
+ if (themeDir) {
127
+ const themePath = resolve(themeDir, relPath)
128
+ if (existsSync(themePath)) {
129
+ await linkOrCopyFile(themePath, targetPath)
130
+ return 'restored theme asset'
131
+ }
132
+ }
133
+ } catch (err) {
134
+ console.error(`Methanol: Error updating asset ${relPath}`, err)
135
+ }
136
+ return 'removed'
137
+ } else {
138
+ const sourcePath = userDir ? resolve(userDir, relPath) : null
139
+ if (sourcePath && existsSync(sourcePath)) {
140
+ await linkOrCopyFile(sourcePath, targetPath)
141
+ return 'updated'
142
+ }
143
+ }
144
+ }
package/src/state.js CHANGED
@@ -160,6 +160,7 @@ export const state = {
160
160
  THEME_PUBLIC_DIR: null,
161
161
  THEME_ENV: null,
162
162
  USER_THEME: null,
163
+ USER_SITE: null,
163
164
  USER_VITE_CONFIG: null,
164
165
  USER_MDX_CONFIG: null,
165
166
  USER_PUBLIC_OVERRIDE: false,
@@ -29,19 +29,21 @@ export default function (props, ...children) {
29
29
  const top = signal(0)
30
30
  const height = signal(0)
31
31
  const opacity = signal(0)
32
-
32
+
33
33
  const updateActive = () => {
34
34
  if (!el.value) return
35
-
35
+
36
36
  const links = Array.from(el.value.querySelectorAll('a'))
37
37
  if (!links.length) return
38
38
 
39
39
  // Map links to their corresponding content anchors
40
- const anchors = links.map(link => {
41
- const href = link.getAttribute('href')
42
- if (!href || !href.startsWith('#')) return null
43
- return document.getElementById(href.slice(1))
44
- }).filter(Boolean)
40
+ const anchors = links
41
+ .map((link) => {
42
+ const href = link.getAttribute('href')
43
+ if (!href || !href.startsWith('#')) return null
44
+ return document.getElementById(href.slice(1))
45
+ })
46
+ .filter(Boolean)
45
47
 
46
48
  if (!anchors.length) return
47
49
 
@@ -56,13 +58,13 @@ export default function (props, ...children) {
56
58
  for (let i = 0; i < anchors.length; i++) {
57
59
  const anchor = anchors[i]
58
60
  const nextAnchor = anchors[i + 1]
59
-
61
+
60
62
  const sectionStart = anchor.offsetTop - threshold
61
63
  const sectionEnd = nextAnchor ? nextAnchor.offsetTop - threshold : document.body.offsetHeight
62
64
 
63
65
  // A section is visible if its range overlaps with the viewport [scrollY, scrollY + windowHeight]
64
- const isVisible = sectionStart < (scrollY + windowHeight - threshold) && sectionEnd > scrollY
65
-
66
+ const isVisible = sectionStart < scrollY + windowHeight - threshold && sectionEnd > scrollY
67
+
66
68
  if (isVisible) {
67
69
  visibleAnchors.add(anchor)
68
70
  }
@@ -77,10 +79,10 @@ export default function (props, ...children) {
77
79
  let firstActiveLink = null
78
80
  let lastActiveLink = null
79
81
 
80
- links.forEach(l => {
82
+ links.forEach((l) => {
81
83
  const href = l.getAttribute('href')
82
84
  const anchorId = href ? href.slice(1) : null
83
- const anchor = anchors.find(a => a.id === anchorId)
85
+ const anchor = anchors.find((a) => a.id === anchorId)
84
86
  if (visibleAnchors.has(anchor)) {
85
87
  l.classList.add('active')
86
88
  if (!firstActiveLink) firstActiveLink = l
@@ -130,7 +132,7 @@ export default function (props, ...children) {
130
132
  ticking = true
131
133
  }
132
134
  }
133
-
135
+
134
136
  // Wait for mount/layout
135
137
  useEffect(() => {
136
138
  updateActive()
@@ -144,9 +146,14 @@ export default function (props, ...children) {
144
146
 
145
147
  return (
146
148
  <aside class="toc-panel" $ref={el}>
147
- <div class="toc-indicator" style:top={t`${top}px`} style:height={t`${height}px`} style:opacity={t`${opacity}`}></div>
149
+ <div
150
+ class="toc-indicator"
151
+ style:top={t`${top}px`}
152
+ style:height={t`${height}px`}
153
+ style:opacity={t`${opacity}`}
154
+ ></div>
148
155
  <div class="toc">
149
- <h4>On this page</h4>
156
+ <div class="toc-heading">On this page</div>
150
157
  <ul>{...children}</ul>
151
158
  </div>
152
159
  </aside>
@@ -53,7 +53,7 @@ export default function (props, ...children) {
53
53
  return (
54
54
  <aside class="toc-panel">
55
55
  <div class="toc">
56
- <h4>On this page</h4>
56
+ <div class="toc-heading">On this page</div>
57
57
  <ul>{...children}</ul>
58
58
  </div>
59
59
  </aside>
@@ -21,7 +21,7 @@
21
21
  export function heading(Tag) {
22
22
  return (props, ...children) => (
23
23
  <Tag {...props}>
24
- {props.id && <a class="heading-anchor" href={`#${props.id}`} aria-hidden />}
24
+ {props.id && <a class="heading-anchor" href={`#${props.id}`} aria-label={`Link to ${props.id}`}/>}
25
25
  {...children}
26
26
  </Tag>
27
27
  )
@@ -21,6 +21,8 @@
21
21
  import { HTMLRenderer as R } from 'methanol'
22
22
  import { renderToc } from './components/ThemeToCContainer.static.jsx'
23
23
 
24
+ const DOCHTML = R.rawHTML`<!DOCTYPE html>`
25
+
24
26
  const renderPageTree = (nodes = [], currentRoute, depth = 0) => {
25
27
  const items = []
26
28
  let hasActive = false
@@ -86,7 +88,7 @@ const PAGE_TEMPLATE = ({ Page, ExtraHead, components, ctx }) => {
86
88
  const themeFavIcon = '/favicon.png'
87
89
  const logo = pageFrontmatter.logo ?? rootFrontmatter.logo ?? ctx.site?.logo ?? themeLogo
88
90
  const favicon = pageFrontmatter.favicon ?? rootFrontmatter.favicon ?? ctx.site?.favicon ?? themeFavIcon
89
- const excerpt = pageFrontmatter.excerpt ?? null
91
+ const excerpt = pageFrontmatter.excerpt ?? `${title} | ${siteName} - Powered by Methanol`
90
92
  const ogTitle = pageFrontmatter.ogTitle ?? null
91
93
  const ogDescription = pageFrontmatter.ogDescription ?? null
92
94
  const ogImage = pageFrontmatter.ogImage ?? null
@@ -100,8 +102,7 @@ const PAGE_TEMPLATE = ({ Page, ExtraHead, components, ctx }) => {
100
102
  const nextPage = siblings?.next || null
101
103
  const languages = Array.isArray(ctx.languages) ? ctx.languages : []
102
104
  const currentLanguageHref = ctx.language?.href || ctx.language?.routePath || null
103
- const languageCode =
104
- pageFrontmatter.langCode ?? rootFrontmatter.langCode ?? ctx.language?.code ?? 'en'
105
+ const languageCode = pageFrontmatter.langCode ?? rootFrontmatter.langCode ?? ctx.language?.code ?? 'en'
105
106
  const htmlLang = typeof languageCode === 'string' && languageCode.trim() ? languageCode : 'en'
106
107
  const pagefindEnabled = ctx.site?.pagefind?.enabled !== false
107
108
  const pagefindOptions = ctx.site?.pagefind?.options || null
@@ -143,7 +144,7 @@ const PAGE_TEMPLATE = ({ Page, ExtraHead, components, ctx }) => {
143
144
  ) : null
144
145
  return (
145
146
  <>
146
- {R.rawHTML`<!DOCTYPE html>`}
147
+ {DOCHTML}
147
148
  <html lang={htmlLang}>
148
149
  <head>
149
150
  <meta charset="UTF-8" />
@@ -152,8 +153,8 @@ const PAGE_TEMPLATE = ({ Page, ExtraHead, components, ctx }) => {
152
153
  {title} | {siteName}
153
154
  </title>
154
155
  {baseHref ? <base href={baseHref} /> : null}
155
- <link rel="icon" href={favicon} />
156
- {excerpt ? <meta name="description" content={excerpt} /> : null}
156
+ {favicon ? <link rel="icon" href={favicon} /> : null}
157
+ <meta name="description" content={excerpt} />
157
158
  {ogTitle ? <meta property="og:title" content={ogTitle} /> : null}
158
159
  {ogDescription ? <meta property="og:description" content={ogDescription} /> : null}
159
160
  {ogImage ? <meta property="og:image" content={ogImage} /> : null}
@@ -164,23 +165,7 @@ const PAGE_TEMPLATE = ({ Page, ExtraHead, components, ctx }) => {
164
165
  {twitterImage ? <meta name="twitter:image" content={twitterImage} /> : null}
165
166
  <ExtraHead />
166
167
  <link rel="preload stylesheet" as="style" href="/.methanol_theme_default/style.css" />
167
- {R.rawHTML`
168
- <script>
169
- (function() {
170
- const savedTheme = localStorage.getItem('methanol-theme');
171
- const systemTheme = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
172
- const theme = savedTheme || systemTheme;
173
- document.documentElement.classList.toggle('light', theme === 'light');
174
- document.documentElement.classList.toggle('dark', theme === 'dark');
175
-
176
- const savedAccent = localStorage.getItem('methanol-accent');
177
- if (savedAccent && savedAccent !== 'default') {
178
- document.documentElement.classList.add('accent-' + savedAccent);
179
- }
180
- })();
181
- </script>
182
- `}
183
- <script type="module" src="/.methanol_theme_default/prefetch.js" defer></script>
168
+ <script src="/theme-prepare.js"></script>
184
169
  </head>
185
170
  <body>
186
171
  <input type="checkbox" id="nav-toggle" class="nav-toggle" />
@@ -201,11 +186,7 @@ const PAGE_TEMPLATE = ({ Page, ExtraHead, components, ctx }) => {
201
186
  </svg>
202
187
  </label>
203
188
  {pagefindEnabled ? (
204
- <button
205
- class="search-toggle-label"
206
- aria-label="Open search"
207
- onclick="window.__methanolSearchOpen()"
208
- >
189
+ <button class="search-toggle-label" aria-label="Open search" onclick="window.__methanolSearchOpen()">
209
190
  <svg
210
191
  width="24"
211
192
  height="24"
@@ -249,7 +230,7 @@ const PAGE_TEMPLATE = ({ Page, ExtraHead, components, ctx }) => {
249
230
  <aside class="sidebar">
250
231
  <div class="sidebar-header">
251
232
  <div class="logo">
252
- <img src={logo} />
233
+ {logo ? <img src={logo} alt="logo" fetchpriority="high"/> : null}
253
234
  <span>{siteName}</span>
254
235
  </div>
255
236
  {pagefindEnabled ? <ThemeSearchBox options={pagefindOptions} /> : null}
@@ -272,7 +253,9 @@ const PAGE_TEMPLATE = ({ Page, ExtraHead, components, ctx }) => {
272
253
  <span class="page-nav-label">Previous</span>
273
254
  <span class="page-nav-title">{prevPage.title || prevPage.routePath}</span>
274
255
  </a>
275
- ) : <div class="page-nav-spacer"></div>}
256
+ ) : (
257
+ <div class="page-nav-spacer"></div>
258
+ )}
276
259
  {nextPage ? (
277
260
  <a class="page-nav-card next" href={nextPage.routeHref || nextPage.routePath}>
278
261
  <span class="page-nav-label">Next</span>
@@ -283,11 +266,17 @@ const PAGE_TEMPLATE = ({ Page, ExtraHead, components, ctx }) => {
283
266
  ) : null}
284
267
  {page ? (
285
268
  <footer class="page-meta">
269
+ <div class="page-meta-item">Updated: {page.updatedAt || '-'}</div>
286
270
  <div class="page-meta-item">
287
- Updated: {page.updatedAt || '-'}
288
- </div>
289
- <div class="page-meta-item">
290
- Powered by <a href="https://github.com/SudoMaker/Methanol" target="_blank" rel="noopener noreferrer" class="methanol-link">Methanol</a>
271
+ Powered by{' '}
272
+ <a
273
+ href="https://github.com/SudoMaker/Methanol"
274
+ target="_blank"
275
+ rel="noopener noreferrer"
276
+ class="methanol-link"
277
+ >
278
+ Methanol
279
+ </a>
291
280
  </div>
292
281
  </footer>
293
282
  ) : null}
@@ -18,7 +18,19 @@
18
18
  * under the License.
19
19
  */
20
20
 
21
- (() => {
21
+ ;(function initThemeColor() {
22
+ const savedTheme = localStorage.getItem('methanol-theme')
23
+ const systemTheme = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'
24
+ const theme = savedTheme || systemTheme
25
+ document.documentElement.classList.toggle('light', theme === 'light')
26
+ document.documentElement.classList.toggle('dark', theme === 'dark')
27
+
28
+ const savedAccent = localStorage.getItem('methanol-accent')
29
+ if (savedAccent && savedAccent !== 'default') {
30
+ document.documentElement.classList.add('accent-' + savedAccent)
31
+ }
32
+ })()
33
+ ;(function initPrefetch() {
22
34
  const prefetched = new Set()
23
35
  const canPrefetch = (anchor) => {
24
36
  if (!anchor || !anchor.href) return false
@@ -270,7 +270,7 @@ a {
270
270
  .sidebar {
271
271
  position: sticky;
272
272
  top: 0;
273
- height: 100vh;
273
+ height: 100dvh;
274
274
  overflow: visible;
275
275
  padding: 2rem 0;
276
276
  display: flex;
@@ -1105,7 +1105,7 @@ a {
1105
1105
  }
1106
1106
 
1107
1107
  .toc {
1108
- h4 {
1108
+ .toc-heading {
1109
1109
  margin: 0 0 1rem 0.5rem;
1110
1110
  font-size: 0.8rem;
1111
1111
  font-weight: 600;
@@ -1420,6 +1420,7 @@ a {
1420
1420
  transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
1421
1421
  position: relative;
1422
1422
  overflow: hidden;
1423
+ -webkit-tap-highlight-color: transparent;
1423
1424
 
1424
1425
  &:hover {
1425
1426
  border-color: var(--accent);
@@ -1473,7 +1474,7 @@ a {
1473
1474
 
1474
1475
  .page-nav-card.next {
1475
1476
  text-align: right;
1476
- align-items: flex-end;
1477
+ align-items: stretch;
1477
1478
 
1478
1479
  .page-nav-label {
1479
1480
  justify-content: flex-end;
@@ -1493,26 +1494,6 @@ a {
1493
1494
  grid-template-columns: 1fr;
1494
1495
  gap: 1rem;
1495
1496
  }
1496
-
1497
- .page-nav-card.next {
1498
- text-align: left;
1499
- align-items: flex-start;
1500
-
1501
- .page-nav-label {
1502
- flex-direction: row;
1503
- }
1504
-
1505
- .page-nav-label::after {
1506
- display: none;
1507
- }
1508
-
1509
- .page-nav-label::before {
1510
- content: '→';
1511
- font-family: system-ui;
1512
- margin-right: 0.1rem;
1513
- transition: transform 0.2s ease;
1514
- }
1515
- }
1516
1497
  }
1517
1498
 
1518
1499
  /* --- Mobile / Toggles --- */
@@ -1566,7 +1547,7 @@ a {
1566
1547
  .toc-panel {
1567
1548
  position: fixed;
1568
1549
  top: 0;
1569
- bottom: 0;
1550
+ height: 100dvh;
1570
1551
  width: 280px;
1571
1552
  background: var(--surface);
1572
1553
  border: 1px solid var(--border);