methanol 0.0.5 → 0.0.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "methanol",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
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
@@ -273,7 +273,10 @@ const buildPagesTree = (pages, options = {}) => {
273
273
  const shouldExposeHidden =
274
274
  !isHidden404 &&
275
275
  page.hiddenByFrontmatter === true &&
276
- (page.routePath === currentRoutePath || isUnderExposedHiddenDir(page.dir))
276
+ (
277
+ page.routePath === currentRoutePath ||
278
+ (page.isIndex && page.dir && isUnderExposedHiddenDir(page.dir))
279
+ )
277
280
  if (!shouldExposeHidden) {
278
281
  continue
279
282
  }
@@ -679,7 +682,9 @@ export const buildPagesContext = async ({ compileAll = true } = {}) => {
679
682
  let pagesTree = getPagesTree('/')
680
683
  const notFound = pagesByRoute.get('/404') || null
681
684
  const languages = collectLanguagesFromPages(pages)
685
+ const userSite = state.USER_SITE || {}
682
686
  const site = {
687
+ ...userSite,
683
688
  name: state.SITE_NAME,
684
689
  root: state.ROOT_DIR,
685
690
  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)
110
+ }
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
+ }
67
143
  }
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
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,
@@ -0,0 +1,38 @@
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
+ export default function ButtonGroup({
22
+ children,
23
+ className = '',
24
+ align = 'left', // left, center, right
25
+ ...props
26
+ }) {
27
+ const classes = [
28
+ 'button-group',
29
+ `button-group--${align}`,
30
+ className
31
+ ].filter(Boolean).join(' ')
32
+
33
+ return (
34
+ <div class={classes} {...props}>
35
+ {children}
36
+ </div>
37
+ )
38
+ }
@@ -0,0 +1,37 @@
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
+ export default function LinkButton({
22
+ children,
23
+ variant = 'primary', // primary, secondary, outline, ghost
24
+ size = 'md', // sm, md, lg
25
+ class: className = '',
26
+ ...props
27
+ }) {
28
+ const classes = ['link-button', `link-button--${variant}`, `link-button--${size}`, className]
29
+ .filter(Boolean)
30
+ .join(' ')
31
+
32
+ return (
33
+ <a class={classes} {...props}>
34
+ {children}
35
+ </a>
36
+ )
37
+ }
@@ -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
@@ -87,8 +89,9 @@ const PAGE_TEMPLATE = ({ Page, ExtraHead, components, ctx }) => {
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
91
  const excerpt = pageFrontmatter.excerpt ?? `${title} | ${siteName} - Powered by Methanol`
90
- const ogTitle = pageFrontmatter.ogTitle ?? null
91
- const ogDescription = pageFrontmatter.ogDescription ?? null
92
+ const _ogTitle = pageFrontmatter.ogTitle ?? title ?? null
93
+ const ogTitle = _ogTitle ? `${_ogTitle} | ${siteName}` : null
94
+ const ogDescription = pageFrontmatter.ogDescription ?? excerpt ?? null
92
95
  const ogImage = pageFrontmatter.ogImage ?? null
93
96
  const ogUrl = pageFrontmatter.ogUrl ?? null
94
97
  const twitterTitle = pageFrontmatter.twitterTitle ?? ogTitle
@@ -142,7 +145,7 @@ const PAGE_TEMPLATE = ({ Page, ExtraHead, components, ctx }) => {
142
145
  ) : null
143
146
  return (
144
147
  <>
145
- {R.rawHTML`<!DOCTYPE html>`}
148
+ {DOCHTML}
146
149
  <html lang={htmlLang}>
147
150
  <head>
148
151
  <meta charset="UTF-8" />
@@ -151,7 +154,7 @@ const PAGE_TEMPLATE = ({ Page, ExtraHead, components, ctx }) => {
151
154
  {title} | {siteName}
152
155
  </title>
153
156
  {baseHref ? <base href={baseHref} /> : null}
154
- <link rel="icon" href={favicon} />
157
+ {favicon ? <link rel="icon" href={favicon} /> : null}
155
158
  <meta name="description" content={excerpt} />
156
159
  {ogTitle ? <meta property="og:title" content={ogTitle} /> : null}
157
160
  {ogDescription ? <meta property="og:description" content={ogDescription} /> : null}
@@ -163,23 +166,7 @@ const PAGE_TEMPLATE = ({ Page, ExtraHead, components, ctx }) => {
163
166
  {twitterImage ? <meta name="twitter:image" content={twitterImage} /> : null}
164
167
  <ExtraHead />
165
168
  <link rel="preload stylesheet" as="style" href="/.methanol_theme_default/style.css" />
166
- {R.rawHTML`
167
- <script>
168
- (function() {
169
- const savedTheme = localStorage.getItem('methanol-theme');
170
- const systemTheme = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
171
- const theme = savedTheme || systemTheme;
172
- document.documentElement.classList.toggle('light', theme === 'light');
173
- document.documentElement.classList.toggle('dark', theme === 'dark');
174
-
175
- const savedAccent = localStorage.getItem('methanol-accent');
176
- if (savedAccent && savedAccent !== 'default') {
177
- document.documentElement.classList.add('accent-' + savedAccent);
178
- }
179
- })();
180
- </script>
181
- `}
182
- <script type="module" src="/.methanol_theme_default/prefetch.js" defer></script>
169
+ <script src="/theme-prepare.js"></script>
183
170
  </head>
184
171
  <body>
185
172
  <input type="checkbox" id="nav-toggle" class="nav-toggle" />
@@ -244,7 +231,7 @@ const PAGE_TEMPLATE = ({ Page, ExtraHead, components, ctx }) => {
244
231
  <aside class="sidebar">
245
232
  <div class="sidebar-header">
246
233
  <div class="logo">
247
- <img src={logo} alt="logo" fetchpriority="high"/>
234
+ {logo ? <img src={logo} alt="logo" fetchpriority="high"/> : null}
248
235
  <span>{siteName}</span>
249
236
  </div>
250
237
  {pagefindEnabled ? <ThemeSearchBox options={pagefindOptions} /> : 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;
@@ -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);
@@ -1646,6 +1627,110 @@ a {
1646
1627
  }
1647
1628
  }
1648
1629
 
1630
+ /* --- Components --- */
1631
+
1632
+ .link-button {
1633
+ display: inline-flex;
1634
+ align-items: center;
1635
+ justify-content: center;
1636
+ font-weight: 600;
1637
+ border-radius: 999px;
1638
+ text-decoration: none !important;
1639
+ transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
1640
+ cursor: pointer;
1641
+ line-height: 1;
1642
+ gap: 0.5rem;
1643
+ white-space: nowrap;
1644
+ }
1645
+
1646
+ .link-button:active {
1647
+ transform: scale(0.96);
1648
+ }
1649
+
1650
+ /* Variants */
1651
+ .link-button--primary {
1652
+ background-color: var(--accent);
1653
+ color: var(--accent-foreground);
1654
+ border: 1px solid transparent;
1655
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
1656
+
1657
+ &:hover {
1658
+ opacity: 0.9;
1659
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
1660
+ transform: translateY(-1px);
1661
+ }
1662
+ }
1663
+
1664
+ .link-button--secondary {
1665
+ background-color: var(--surface-elevated);
1666
+ color: var(--text);
1667
+ border: 1px solid var(--border);
1668
+
1669
+ &:hover {
1670
+ background-color: var(--surface-muted);
1671
+ border-color: var(--muted);
1672
+ }
1673
+ }
1674
+
1675
+ .link-button--outline {
1676
+ background-color: transparent;
1677
+ color: var(--text);
1678
+ border: 1px solid var(--border);
1679
+
1680
+ &:hover {
1681
+ border-color: var(--accent);
1682
+ color: var(--accent);
1683
+ background-color: var(--accent-soft);
1684
+ }
1685
+ }
1686
+
1687
+ .link-button--ghost {
1688
+ background-color: transparent;
1689
+ color: var(--muted);
1690
+ border: 1px solid transparent;
1691
+
1692
+ &:hover {
1693
+ color: var(--accent);
1694
+ background-color: var(--accent-soft);
1695
+ }
1696
+ }
1697
+
1698
+ /* Sizes */
1699
+ .link-button--sm {
1700
+ padding: 0.375rem 0.75rem;
1701
+ font-size: 0.85rem;
1702
+ }
1703
+
1704
+ .link-button--md {
1705
+ padding: 0.625rem 1.25rem;
1706
+ font-size: 0.95rem;
1707
+ }
1708
+
1709
+ .link-button--lg {
1710
+ padding: 0.875rem 1.75rem;
1711
+ font-size: 1.1rem;
1712
+ }
1713
+
1714
+ .button-group {
1715
+ display: flex;
1716
+ flex-wrap: wrap;
1717
+ gap: 1rem;
1718
+ margin: 1.5rem 0;
1719
+ align-items: center;
1720
+ }
1721
+
1722
+ .button-group--left {
1723
+ justify-content: flex-start;
1724
+ }
1725
+
1726
+ .button-group--center {
1727
+ justify-content: center;
1728
+ }
1729
+
1730
+ .button-group--right {
1731
+ justify-content: flex-end;
1732
+ }
1733
+
1649
1734
  /* --- View Transitions --- */
1650
1735
 
1651
1736
  @view-transition {