methanol 0.0.14 → 0.0.16

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 (41) hide show
  1. package/index.js +1 -0
  2. package/package.json +1 -1
  3. package/src/build-system.js +1 -0
  4. package/src/client/virtual-module/assets.js +7 -6
  5. package/src/config.js +33 -3
  6. package/src/dev-server.js +68 -26
  7. package/src/error-page.jsx +49 -0
  8. package/src/mdx.js +235 -8
  9. package/src/pages-index.js +42 -0
  10. package/src/pages.js +1 -0
  11. package/src/reframe.js +1 -1
  12. package/src/state.js +8 -0
  13. package/src/text-utils.js +60 -0
  14. package/src/vite-plugins.js +12 -27
  15. package/src/workers/build-pool.js +1 -0
  16. package/themes/blog/README.md +26 -0
  17. package/themes/blog/components/CategoryView.client.jsx +164 -0
  18. package/themes/blog/components/CategoryView.static.jsx +35 -0
  19. package/themes/blog/components/CollectionView.client.jsx +151 -0
  20. package/themes/blog/components/CollectionView.static.jsx +37 -0
  21. package/themes/blog/components/PostList.client.jsx +92 -0
  22. package/themes/blog/components/PostList.static.jsx +36 -0
  23. package/themes/blog/components/ThemeSearchBox.client.jsx +427 -0
  24. package/themes/blog/components/ThemeSearchBox.static.jsx +40 -0
  25. package/themes/blog/index.js +40 -0
  26. package/themes/blog/pages/404.mdx +12 -0
  27. package/themes/blog/pages/about.mdx +14 -0
  28. package/themes/blog/pages/categories.mdx +6 -0
  29. package/themes/blog/pages/collections.mdx +6 -0
  30. package/themes/blog/pages/index.mdx +16 -0
  31. package/themes/blog/pages/offline.mdx +11 -0
  32. package/themes/blog/sources/style.css +579 -0
  33. package/themes/blog/src/date-utils.js +28 -0
  34. package/themes/blog/src/heading.jsx +37 -0
  35. package/themes/blog/src/layout-categories.jsx +65 -0
  36. package/themes/blog/src/layout-collections.jsx +64 -0
  37. package/themes/blog/src/layout-home.jsx +65 -0
  38. package/themes/blog/src/layout-post.jsx +40 -0
  39. package/themes/blog/src/page.jsx +152 -0
  40. package/themes/blog/src/post-utils.js +83 -0
  41. package/themes/default/src/page.jsx +2 -2
@@ -0,0 +1,42 @@
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 JSON5 from 'json5'
22
+ import { extractExcerpt } from './text-utils.js'
23
+
24
+ const OMIT_KEYS = new Set(['content', 'mdxComponent', 'mdxCtx', 'getSiblings'])
25
+
26
+ const sanitizePage = (page) => {
27
+ const result = {}
28
+ for (const [key, value] of Object.entries(page || {})) {
29
+ if (OMIT_KEYS.has(key)) continue
30
+ if (typeof value === 'function') continue
31
+ result[key] = value
32
+ }
33
+ result.excerpt = extractExcerpt(page)
34
+ return result
35
+ }
36
+
37
+ export const serializePagesIndex = (pages) => {
38
+ const list = Array.isArray(pages)
39
+ ? pages.filter((page) => !page?.hidden).map(sanitizePage)
40
+ : []
41
+ return JSON5.stringify(list)
42
+ }
package/src/pages.js CHANGED
@@ -51,6 +51,7 @@ const cliOverrides = {
51
51
  CLI_VERBOSE: cli.CLI_VERBOSE,
52
52
  CLI_BASE: cli.CLI_BASE,
53
53
  CLI_SEARCH: cli.CLI_SEARCH,
54
+ CLI_THEME: cli.CLI_THEME,
54
55
  CLI_PWA: cli.CLI_PWA
55
56
  }
56
57
 
package/src/reframe.js CHANGED
@@ -65,7 +65,7 @@ export function env(parentEnv) {
65
65
 
66
66
  const id = renderCount++
67
67
  const idStr = id.toString(16)
68
- const script = `$$rfrm(${JSON.stringify(key)},${id},${Object.keys(props).length ? JSON5.stringify(props) : '{}'})`
68
+ const script = `$$rfrm(${JSON.stringify(key)},${id},${Object.keys(props).length ? JSON5.stringify(props).replace(/<\/script/ig, '<\\/script') : '{}'})`
69
69
 
70
70
  return (R) => {
71
71
  return [
package/src/state.js CHANGED
@@ -130,6 +130,12 @@ const withCommonOptions = (y) =>
130
130
  type: 'boolean',
131
131
  default: undefined
132
132
  })
133
+ .option('theme', {
134
+ describe: 'Theme to use (name or path)',
135
+ type: 'string',
136
+ requiresArg: true,
137
+ nargs: 1
138
+ })
133
139
  .option('pwa', {
134
140
  describe: 'Enable or disable PWA support',
135
141
  type: 'boolean',
@@ -167,6 +173,7 @@ export const cli = {
167
173
  CLI_VERBOSE: Boolean(argv.verbose),
168
174
  CLI_BASE: argv.base || null,
169
175
  CLI_SEARCH: typeof argv.search === 'boolean' ? argv.search : undefined,
176
+ CLI_THEME: argv.theme || null,
170
177
  CLI_PWA: typeof argv.pwa === 'boolean' ? argv.pwa : undefined
171
178
  }
172
179
 
@@ -192,6 +199,7 @@ export const state = {
192
199
  USER_VITE_CONFIG: null,
193
200
  USER_MDX_CONFIG: null,
194
201
  USER_PUBLIC_OVERRIDE: false,
202
+ PAGES_CONTEXT: null,
195
203
  SOURCES: [],
196
204
  PAGEFIND_ENABLED: false,
197
205
  PAGEFIND_OPTIONS: null,
@@ -0,0 +1,60 @@
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
+ const DEFAULT_EXCERPT_LENGTH = 200
22
+
23
+ const collapseWhitespace = (value) => value.replace(/\s+/g, ' ').trim()
24
+
25
+ const stripFirstHeading = (value) => value.replace(/^\s{0,3}#{1,6}\s+.*$/m, ' ')
26
+
27
+ const stripMarkdown = (value) => {
28
+ let text = value
29
+ text = text.replace(/```[\s\S]*?```/g, ' ')
30
+ text = text.replace(/`[^`]*`/g, ' ')
31
+ text = stripFirstHeading(text)
32
+ text = text.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
33
+ text = text.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
34
+ text = text.replace(/<[^>]+>/g, ' ')
35
+ text = text.replace(/^\s{0,3}#{1,6}\s+/gm, '')
36
+ text = text.replace(/^\s{0,3}>\s?/gm, '')
37
+ text = text.replace(/^\s*[-*+]\s+/gm, '')
38
+ text = text.replace(/^\s*\d+\.\s+/gm, '')
39
+ return collapseWhitespace(text)
40
+ }
41
+
42
+ export const extractExcerpt = (page, options = {}) => {
43
+ if (!page) return ''
44
+ const length =
45
+ typeof options.length === 'number' && Number.isFinite(options.length)
46
+ ? options.length
47
+ : DEFAULT_EXCERPT_LENGTH
48
+ const raw =
49
+ page.excerpt ||
50
+ page.frontmatter?.excerpt ||
51
+ page.frontmatter?.description ||
52
+ page.content ||
53
+ ''
54
+ const sanitized = stripMarkdown(String(raw))
55
+ if (!sanitized) return ''
56
+ if (length > 0 && sanitized.length > length) {
57
+ return `${sanitized.slice(0, length).trim()}...`
58
+ }
59
+ return sanitized
60
+ }
@@ -26,7 +26,8 @@ import { normalizePath } from 'vite'
26
26
  import { state } from './state.js'
27
27
  import { resolveBasePrefix } from './config.js'
28
28
  import { genRegistryScript } from './components.js'
29
- import { INJECT_SCRIPT, LOADER_SCRIPT, PAGEFIND_LOADER_SCRIPT, PWA_INJECT_SCRIPT } from './client/virtual-module/assets.js'
29
+ import { serializePagesIndex } from './pages-index.js'
30
+ import { virtualModuleDir, INJECT_SCRIPT, LOADER_SCRIPT, PAGEFIND_LOADER_SCRIPT, PWA_INJECT_SCRIPT } from './client/virtual-module/assets.js'
30
31
  import { projectRequire } from './node-loader.js'
31
32
 
32
33
  const require = createRequire(import.meta.url)
@@ -126,13 +127,10 @@ const virtualModuleMap = {
126
127
  get registry() {
127
128
  return `export const registry = ${genRegistryScript()}`
128
129
  },
129
- get 'registry.js'() {
130
- return `export const registry = ${genRegistryScript()}`
131
- },
132
130
  get loader() {
133
131
  return LOADER_SCRIPT()
134
132
  },
135
- get 'inject.js'() {
133
+ get 'inject'() {
136
134
  return INJECT_SCRIPT()
137
135
  },
138
136
  get 'pagefind-loader'() {
@@ -144,6 +142,10 @@ const virtualModuleMap = {
144
142
  }
145
143
 
146
144
  return ''
145
+ },
146
+ get pages() {
147
+ const pages = state.PAGES_CONTEXT?.pages || []
148
+ return `export const pages = ${serializePagesIndex(pages)}\nexport default pages`
147
149
  }
148
150
  }
149
151
 
@@ -156,20 +158,6 @@ const getSchemeModuleKey = (id) => {
156
158
  return id.slice(virtualModuleScheme.length)
157
159
  }
158
160
 
159
- const resolveVirtualModuleId = (id) => {
160
- if (id.startsWith(virtualModulePrefix)) {
161
- return id
162
- }
163
- const basePrefix = resolveBasePrefix()
164
- if (basePrefix) {
165
- const prefixed = `${basePrefix}${virtualModulePrefix}`
166
- if (id.startsWith(prefixed)) {
167
- return id.slice(basePrefix.length)
168
- }
169
- }
170
- return null
171
- }
172
-
173
161
  export const methanolResolverPlugin = () => {
174
162
  return {
175
163
  name: 'methanol-resolver',
@@ -186,19 +174,16 @@ export const methanolResolverPlugin = () => {
186
174
  return require.resolve(id)
187
175
  }
188
176
 
177
+ // Very weird workaround for Vite
178
+ if (id.startsWith(virtualModulePrefix)) {
179
+ return resolve(virtualModuleDir, id.slice(virtualModulePrefix.length))
180
+ }
181
+
189
182
  const schemeKey = getSchemeModuleKey(id)
190
183
  if (schemeKey && Object.prototype.hasOwnProperty.call(virtualModuleMap, schemeKey)) {
191
184
  return '\0' + id
192
185
  }
193
186
 
194
- const virtualId = resolveVirtualModuleId(id)
195
- if (virtualId) {
196
- const _moduleId = getModuleIdSegment(virtualId, virtualModulePrefix.length)
197
- if (Object.prototype.hasOwnProperty.call(virtualModuleMap, _moduleId)) {
198
- return '\0' + virtualId
199
- }
200
- }
201
-
202
187
  if (state.SOURCES.length) {
203
188
  const { pathname, search } = new URL(id, 'http://methanol')
204
189
  for (const entry of state.SOURCES) {
@@ -39,6 +39,7 @@ const cliOverrides = {
39
39
  CLI_VERBOSE: cli.CLI_VERBOSE,
40
40
  CLI_BASE: cli.CLI_BASE,
41
41
  CLI_SEARCH: cli.CLI_SEARCH,
42
+ CLI_THEME: cli.CLI_THEME,
42
43
  CLI_PWA: cli.CLI_PWA
43
44
  }
44
45
 
@@ -0,0 +1,26 @@
1
+ # Blog Theme
2
+
3
+ A simple, clean blog theme for Methanol.
4
+
5
+ ## Features
6
+ - Clean typography
7
+ - Post list on homepage
8
+ - Responsive design
9
+ - Dark mode support (via system preference)
10
+
11
+ ## Usage
12
+
13
+ To use this theme, configure your Methanol project to point to this directory.
14
+
15
+ ```js
16
+ // methanol.config.js
17
+ export default {
18
+ theme: './themes/blog',
19
+ // ...
20
+ }
21
+ ```
22
+
23
+ ## Structure
24
+ - `src/page.jsx`: Main layout template.
25
+ - `sources/style.css`: Stylesheet.
26
+ - `pages/`: Default pages (Home, 404, Offline).
@@ -0,0 +1,164 @@
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 { signal, $, For, If, onCondition } from 'refui'
22
+ import pages from 'methanol:pages'
23
+ import { formatDate } from '../src/date-utils.js'
24
+
25
+ const rawPages = Array.isArray(pages) ? pages : []
26
+ const isPostPage = (p) => {
27
+ if (!p?.routeHref) return false
28
+ if (p.routeHref === '/' || p.routeHref === '/404' || p.routeHref === '/offline') return false
29
+ if (p.routeHref.startsWith('/.methanol')) return false
30
+ return true
31
+ }
32
+ const resolvePosts = () => {
33
+ const list = rawPages.filter(isPostPage)
34
+ list.sort((a, b) => {
35
+ const dateA = new Date(a.frontmatter?.date || a.stats?.createdAt || 0)
36
+ const dateB = new Date(b.frontmatter?.date || b.stats?.createdAt || 0)
37
+ return dateB - dateA
38
+ })
39
+ return list.map((p) => ({
40
+ title: p.title,
41
+ routeHref: p.routeHref,
42
+ frontmatter: p.frontmatter || {},
43
+ stats: p.stats || {},
44
+ excerpt: p.excerpt || p.frontmatter?.excerpt || ''
45
+ }))
46
+ }
47
+ const allPosts = resolvePosts()
48
+ const defaultCategories = Array.from(
49
+ new Set(
50
+ allPosts.flatMap((p) => {
51
+ const c = p.frontmatter?.categories
52
+ return Array.isArray(c) ? c : c ? [c] : []
53
+ })
54
+ )
55
+ ).sort()
56
+
57
+ export default function ({ categories = null } = {}) {
58
+ const currentCategory = signal('')
59
+ const activeCategory = onCondition(currentCategory)
60
+ const visibleCount = signal(10)
61
+ const categoryList = Array.isArray(categories) && categories.length ? categories : defaultCategories
62
+
63
+ if (typeof window !== 'undefined') {
64
+ // Initial setup on client side
65
+ const params = new URLSearchParams(window.location.search)
66
+ const cat = params.get('category')
67
+ if (cat) currentCategory.value = cat
68
+
69
+ window.addEventListener('popstate', () => {
70
+ const p = new URLSearchParams(window.location.search)
71
+ currentCategory.value = p.get('category') || ''
72
+ })
73
+ }
74
+
75
+ const filteredPosts = $(() => {
76
+ const cat = currentCategory.value
77
+ if (!cat) return allPosts
78
+ return allPosts.filter((p) => {
79
+ const pCats = p.frontmatter?.categories || []
80
+ return Array.isArray(pCats) ? pCats.includes(cat) : pCats === cat
81
+ })
82
+ })
83
+
84
+ const visiblePosts = $(() => filteredPosts.value.slice(0, visibleCount.value))
85
+ const hasMore = $(() => visibleCount.value < filteredPosts.value.length)
86
+ const showEmpty = $(() => filteredPosts.value.length === 0)
87
+
88
+ const loadMore = () => {
89
+ visibleCount.value += 10
90
+ }
91
+
92
+ const setCategory = (cat) => {
93
+ currentCategory.value = cat
94
+ visibleCount.value = 10
95
+ const url = new URL(window.location)
96
+ if (cat) {
97
+ url.searchParams.set('category', cat)
98
+ } else {
99
+ url.searchParams.delete('category')
100
+ }
101
+ window.history.pushState({}, '', url)
102
+ }
103
+
104
+ return (
105
+ <div class="category-view">
106
+ <div class="category-list">
107
+ <button class="category-tag" class:active={activeCategory('')} on:click={() => setCategory('')}>
108
+ All
109
+ </button>
110
+ <For entries={categoryList}>
111
+ {({ item: cat }) => (
112
+ <button
113
+ class="category-tag"
114
+ class:active={activeCategory(cat.value)}
115
+ on:click={() => setCategory(cat.value)}
116
+ >
117
+ {cat}
118
+ </button>
119
+ )}
120
+ </For>
121
+ </div>
122
+
123
+ <div class="post-list">
124
+ <For entries={visiblePosts}>
125
+ {({ item: p }) => {
126
+ const dateStr = formatDate(p.frontmatter?.date || p.stats?.createdAt)
127
+
128
+ return (
129
+ <article class="post-item">
130
+ <div class="post-meta">
131
+ {dateStr && <span class="post-date">{dateStr}</span>}
132
+ {p.frontmatter.categories && (
133
+ <span class="post-categories">
134
+ &middot;{' '}
135
+ {Array.isArray(p.frontmatter.categories)
136
+ ? p.frontmatter.categories.join(', ')
137
+ : p.frontmatter.categories}
138
+ </span>
139
+ )}
140
+ </div>
141
+ <h2 class="post-item-title">
142
+ <a href={p.routeHref}>{p.title || 'Untitled'}</a>
143
+ </h2>
144
+ <div class="post-excerpt">{p.frontmatter.excerpt || p.excerpt || 'No excerpt available.'}</div>
145
+ </article>
146
+ )
147
+ }}
148
+ </For>
149
+ </div>
150
+
151
+ <If condition={showEmpty}>{() => <p>No posts found in this category.</p>}</If>
152
+
153
+ <If condition={hasMore}>
154
+ {() => (
155
+ <div class="pagination-container">
156
+ <button class="load-more-btn" on:click={loadMore}>
157
+ Load More
158
+ </button>
159
+ </div>
160
+ )}
161
+ </If>
162
+ </div>
163
+ )
164
+ }
@@ -0,0 +1,35 @@
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 ({ categories = [] } = {}, ...children) {
22
+ if (!children.length) return null
23
+ return (
24
+ <div class="category-view">
25
+ <div class="category-list">
26
+ <button class="category-tag active">All</button>
27
+ {categories.map((cat) => (
28
+ <button class="category-tag">{cat}</button>
29
+ ))}
30
+ </div>
31
+
32
+ <div class="post-list">{...children}</div>
33
+ </div>
34
+ )
35
+ }
@@ -0,0 +1,151 @@
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 { signal, $, For, If, onCondition } from 'refui'
22
+ import pages from 'methanol:pages'
23
+ import { formatDate } from '../src/date-utils.js'
24
+
25
+ const rawPages = Array.isArray(pages) ? pages : []
26
+ const isPostPage = (p) => {
27
+ if (!p?.routeHref) return false
28
+ if (p.routeHref === '/' || p.routeHref === '/404' || p.routeHref === '/offline') return false
29
+ if (p.routeHref.startsWith('/.methanol')) return false
30
+ return true
31
+ }
32
+ const getCollection = (p) => {
33
+ if (p.collection) return p.collection
34
+ if (!p.dir) return null
35
+ return p.dir.split('/')[0]
36
+ }
37
+ const resolvePosts = () => {
38
+ const list = rawPages.filter(isPostPage)
39
+ list.sort((a, b) => {
40
+ const dateA = new Date(a.frontmatter?.date || a.stats?.createdAt || 0)
41
+ const dateB = new Date(b.frontmatter?.date || b.stats?.createdAt || 0)
42
+ return dateB - dateA
43
+ })
44
+ return list.map((p) => ({
45
+ title: p.title,
46
+ routeHref: p.routeHref,
47
+ frontmatter: p.frontmatter || {},
48
+ stats: p.stats || {},
49
+ excerpt: p.excerpt || p.frontmatter?.excerpt || '',
50
+ collection: getCollection(p)
51
+ }))
52
+ }
53
+ const allPosts = resolvePosts()
54
+ const defaultCollections = Array.from(new Set(allPosts.map((p) => p.collection).filter(Boolean))).sort()
55
+
56
+ export default function ({ collectionTitles = {} } = {}) {
57
+ const currentCollection = signal('')
58
+ const activeCollection = onCondition(currentCollection)
59
+ const visibleCount = signal(10)
60
+ const collectionList = Object.keys(collectionTitles || {}).length
61
+ ? Object.keys(collectionTitles).sort()
62
+ : defaultCollections
63
+ const resolveLabel = (value) => collectionTitles?.[value] || value
64
+
65
+ if (typeof window !== 'undefined') {
66
+ const params = new URLSearchParams(window.location.search)
67
+ const col = params.get('collection')
68
+ if (col) currentCollection.value = col
69
+
70
+ window.addEventListener('popstate', () => {
71
+ const p = new URLSearchParams(window.location.search)
72
+ currentCollection.value = p.get('collection') || ''
73
+ })
74
+ }
75
+
76
+ const filteredPosts = $(() => {
77
+ const col = currentCollection.value
78
+ if (!col) return allPosts
79
+ return allPosts.filter((p) => p.collection === col)
80
+ })
81
+
82
+ const visiblePosts = $(() => filteredPosts.value.slice(0, visibleCount.value))
83
+ const hasMore = $(() => visibleCount.value < filteredPosts.value.length)
84
+ const showEmpty = $(() => filteredPosts.value.length === 0)
85
+
86
+ const loadMore = () => {
87
+ visibleCount.value += 10
88
+ }
89
+
90
+ const setCollection = (col) => {
91
+ currentCollection.value = col
92
+ visibleCount.value = 10
93
+ const url = new URL(window.location)
94
+ if (col) {
95
+ url.searchParams.set('collection', col)
96
+ } else {
97
+ url.searchParams.delete('collection')
98
+ }
99
+ window.history.pushState({}, '', url)
100
+ }
101
+
102
+ return (
103
+ <div class="category-view">
104
+ <div class="category-list">
105
+ <button class="category-tag" class:active={activeCollection('')} on:click={() => setCollection('')}>
106
+ All
107
+ </button>
108
+ <For entries={collectionList}>
109
+ {({ item: col }) => (
110
+ <button class="category-tag" class:active={activeCollection(col)} on:click={() => setCollection(col)}>
111
+ {resolveLabel(col)}
112
+ </button>
113
+ )}
114
+ </For>
115
+ </div>
116
+
117
+ <div class="post-list">
118
+ <For entries={visiblePosts}>
119
+ {({ item: p }) => {
120
+ const dateStr = formatDate(p.frontmatter?.date || p.stats?.createdAt)
121
+
122
+ return (
123
+ <article class="post-item">
124
+ <div class="post-meta">
125
+ {dateStr && <span class="post-date">{dateStr}</span>}
126
+ {p.collection && <span class="post-categories"> &middot; {resolveLabel(p.collection)}</span>}
127
+ </div>
128
+ <h2 class="post-item-title">
129
+ <a href={p.routeHref}>{p.title || 'Untitled'}</a>
130
+ </h2>
131
+ <div class="post-excerpt">{p.frontmatter.excerpt || p.excerpt || 'No excerpt available.'}</div>
132
+ </article>
133
+ )
134
+ }}
135
+ </For>
136
+ </div>
137
+
138
+ <If condition={showEmpty}>{() => <p>No posts found in this collection.</p>}</If>
139
+
140
+ <If condition={hasMore}>
141
+ {() => (
142
+ <div class="pagination-container">
143
+ <button class="load-more-btn" on:click={loadMore}>
144
+ Load More
145
+ </button>
146
+ </div>
147
+ )}
148
+ </If>
149
+ </div>
150
+ )
151
+ }
@@ -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 ({ collectionTitles = {} } = {}, ...children) {
22
+ if (!children.length) return null
23
+ const resolveLabel = (value) => collectionTitles?.[value] || value
24
+ const collections = Object.keys(collectionTitles || {}).sort()
25
+ return (
26
+ <div class="category-view">
27
+ <div class="category-list">
28
+ <button class="category-tag active">All</button>
29
+ {collections.map((col) => (
30
+ <button class="category-tag">{resolveLabel(col)}</button>
31
+ ))}
32
+ </div>
33
+
34
+ <div class="post-list">{...children}</div>
35
+ </div>
36
+ )
37
+ }