@karaoke-cms/astro 0.6.3 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,24 +1,45 @@
1
1
  import type { AstroIntegration } from 'astro';
2
- import { fileURLToPath } from 'url';
3
- import { join, resolve, isAbsolute } from 'path';
2
+ import { fileURLToPath, pathToFileURL } from 'url';
3
+ import { resolve, isAbsolute } from 'path';
4
+ import { createRequire } from 'module';
4
5
  import { wikiLinkPlugin } from 'remark-wiki-link';
5
6
  import sitemap from '@astrojs/sitemap';
6
- import { getTheme, validateModules } from './validate-config.js';
7
+ import { validateModules } from './validate-config.js';
7
8
  import { resolveModules } from './utils/resolve-modules.js';
8
9
  import { resolveLayout } from './utils/resolve-layout.js';
9
10
  import { resolveCollections } from './utils/resolve-collections.js';
10
- import type { KaraokeConfig, ResolvedModules, ResolvedLayout, ResolvedCollections } from './types.js';
11
+ import { resolveMenus } from './utils/resolve-menus.js';
12
+ import type { KaraokeConfig, ResolvedModules, ResolvedLayout, ResolvedCollections, ResolvedMenus, ThemeInstance } from './types.js';
11
13
 
12
- const __dirname = fileURLToPath(new URL('.', import.meta.url));
14
+ function isThemeInstance(theme: unknown): theme is ThemeInstance {
15
+ return typeof theme === 'object' && theme !== null &&
16
+ (theme as ThemeInstance)._type === 'theme-instance';
17
+ }
18
+
19
+ /**
20
+ * Resolve a theme config value to an npm package name.
21
+ * Bare strings ('default', 'minimal') map to @karaoke-cms/theme-* with a deprecation warning.
22
+ */
23
+ function resolveThemePkg(theme: string | undefined): string {
24
+ const raw = theme ?? '@karaoke-cms/theme-default';
25
+ if (raw === 'default' || raw === 'minimal') {
26
+ console.warn(
27
+ `[karaoke-cms] theme: '${raw}' is deprecated. Use theme: '@karaoke-cms/theme-${raw}' instead.`,
28
+ );
29
+ return `@karaoke-cms/theme-${raw}`;
30
+ }
31
+ return raw;
32
+ }
13
33
 
14
34
  /**
15
35
  * karaoke() — the main Astro integration for karaoke-cms.
16
36
  *
17
- * Injects all routes (/, /blog, /blog/[slug], /docs, /docs/[slug],
18
- * /tags, /tags/[tag], /rss.xml, /404), sets up the @theme CSS alias,
19
- * enables wikilinks, and provides a virtual module with your resolved config.
37
+ * Loads the active theme package (an Astro integration) and registers it
38
+ * as a nested integration. The theme owns all content routes (/, /blog, /docs,
39
+ * etc.), the @theme CSS alias, and its own 404 page.
20
40
  *
21
- * The /karaoke-cms and /karaoke-cms/[...slug] routes are injected in dev only.
41
+ * Core always registers: /rss.xml, /karaoke-cms/[...slug] (dev only), wikilinks,
42
+ * sitemap, and the virtual:karaoke-cms/config module.
22
43
  *
23
44
  * @example
24
45
  * // astro.config.mjs
@@ -30,63 +51,78 @@ const __dirname = fileURLToPath(new URL('.', import.meta.url));
30
51
  * site: 'https://your-site.pages.dev',
31
52
  * integrations: [karaoke(karaokeConfig)],
32
53
  * });
33
- *
34
- * Module slots (contract for Base.astro — extensible in v0.7+):
35
- * 'nav' — rendered in the nav bar (e.g., Search)
36
- * 'post-footer' — rendered after post content (e.g., Comments)
37
54
  */
55
+ /** Identity wrapper for karaoke.config.ts — provides type inference. */
56
+ export function defineConfig(config: KaraokeConfig): KaraokeConfig {
57
+ return config;
58
+ }
59
+
38
60
  export default function karaoke(config: KaraokeConfig = {}): AstroIntegration {
39
- const themesDir = join(__dirname, 'themes');
40
- const theme = getTheme(config, themesDir);
41
61
  validateModules(config);
42
62
  const resolved = resolveModules(config);
43
63
  const layout = resolveLayout(config);
44
64
 
65
+ let _resolvedCollections: ResolvedCollections | undefined;
66
+
45
67
  return {
46
68
  name: '@karaoke-cms/astro',
47
69
  hooks: {
48
- 'astro:config:setup'({ injectRoute, updateConfig, command, config: astroConfig }) {
70
+ async 'astro:config:setup'({ injectRoute, updateConfig, command, config: astroConfig }) {
49
71
  const rootDir = fileURLToPath(astroConfig.root);
50
72
  const vaultDir = config.vault
51
73
  ? (isAbsolute(config.vault) ? config.vault : resolve(rootDir, config.vault))
52
74
  : rootDir;
53
75
  const isProd = command === 'build';
54
- const resolvedCollections = resolveCollections(vaultDir, isProd, config.collections);
76
+ _resolvedCollections = resolveCollections(vaultDir, isProd, config.collections);
77
+ const _resolvedMenus = resolveMenus(vaultDir);
78
+
79
+ // ── Load active theme as a nested Astro integration ───────────────
80
+ let themeIntegration: AstroIntegration;
81
+
82
+ if (isThemeInstance(config.theme)) {
83
+ // New API: theme is a ThemeInstance from defineTheme()
84
+ themeIntegration = config.theme.toAstroIntegration();
85
+ } else {
86
+ // Legacy API: theme is a package name string.
87
+ // Resolve from the user's project root so their node_modules is searched.
88
+ const themePkg = resolveThemePkg(config.theme as string | undefined);
89
+ const projectRequire = createRequire(new URL('package.json', astroConfig.root));
90
+ let resolvedThemePath: string;
91
+ try {
92
+ resolvedThemePath = projectRequire.resolve(themePkg);
93
+ } catch {
94
+ throw new Error(
95
+ `[karaoke-cms] Theme package "${themePkg}" is not installed.\n` +
96
+ `Run: pnpm add ${themePkg}`,
97
+ );
98
+ }
99
+ // Use new Function to bypass Vite's import() interception —
100
+ // this runs the native Node.js ESM loader, not Vite's module runner.
101
+ const nativeImport: (specifier: string) => Promise<unknown> =
102
+ new Function('specifier', 'return import(specifier)');
103
+ const themeModule = await nativeImport(pathToFileURL(resolvedThemePath).href) as {
104
+ default: (cfg: KaraokeConfig) => AstroIntegration;
105
+ };
106
+ themeIntegration = themeModule.default(config);
107
+ }
55
108
 
56
- // ── Inject all framework routes ──────────────────────────────────
57
- injectRoute({ pattern: '/', entrypoint: '@karaoke-cms/astro/pages/index.astro' });
58
- injectRoute({ pattern: '/blog', entrypoint: '@karaoke-cms/astro/pages/blog/index.astro' });
59
- injectRoute({ pattern: '/blog/[slug]', entrypoint: '@karaoke-cms/astro/pages/blog/[slug].astro' });
60
- injectRoute({ pattern: '/docs', entrypoint: '@karaoke-cms/astro/pages/docs/index.astro' });
61
- injectRoute({ pattern: '/docs/[slug]', entrypoint: '@karaoke-cms/astro/pages/docs/[slug].astro' });
62
- injectRoute({ pattern: '/tags', entrypoint: '@karaoke-cms/astro/pages/tags/index.astro' });
63
- injectRoute({ pattern: '/tags/[tag]', entrypoint: '@karaoke-cms/astro/pages/tags/[tag].astro' });
64
- injectRoute({ pattern: '/404', entrypoint: '@karaoke-cms/astro/pages/404.astro' });
109
+ // ── Core routes always present regardless of theme ─────────────
65
110
  injectRoute({ pattern: '/rss.xml', entrypoint: '@karaoke-cms/astro/pages/rss.xml.ts' });
66
111
 
67
- // ── Handbook routes — dev only ───────────────────────────────────
68
- if (resolvedCollections['karaoke-cms']?.enabled) {
112
+ // Handbook routes — dev only
113
+ if (_resolvedCollections['karaoke-cms']?.enabled) {
69
114
  injectRoute({ pattern: '/karaoke-cms', entrypoint: '@karaoke-cms/astro/pages/karaoke-cms/index.astro' });
70
115
  injectRoute({ pattern: '/karaoke-cms/[...slug]', entrypoint: '@karaoke-cms/astro/pages/karaoke-cms/[...slug].astro' });
71
116
  }
72
117
 
73
118
  updateConfig({
74
- integrations: [sitemap()],
119
+ integrations: [sitemap(), themeIntegration],
75
120
  vite: {
76
- resolve: {
77
- alias: {
78
- // @theme → packages/astro/src/themes/<active-theme>/
79
- '@theme': join(themesDir, theme),
80
- },
81
- },
82
- plugins: [virtualConfigPlugin(config, resolved, layout, resolvedCollections)],
121
+ plugins: [virtualConfigPlugin(config, resolved, layout, _resolvedCollections, _resolvedMenus)],
83
122
  },
84
123
  markdown: {
85
124
  remarkPlugins: [
86
125
  [wikiLinkPlugin, {
87
- // [[blog/hello-world]] → /blog/hello-world/
88
- // [[docs/getting-started]] → /docs/getting-started/
89
- // [[blog/hello-world|Hello World]] → link text "Hello World"
90
126
  pageResolver: (name: string) => [name.toLowerCase().replace(/ /g, '-')],
91
127
  hrefTemplate: (permalink: string) => `/${permalink}/`,
92
128
  aliasDivider: '|',
@@ -95,6 +131,19 @@ export default function karaoke(config: KaraokeConfig = {}): AstroIntegration {
95
131
  },
96
132
  });
97
133
  },
134
+
135
+ 'astro:build:done'({ pages }) {
136
+ if (!_resolvedCollections?.docs?.enabled) return;
137
+ // pages is { pathname: string }[] — check if any /docs page was rendered
138
+ const hasDocsRoute = (pages ?? []).some(p => p.pathname?.startsWith('docs'));
139
+ if (!hasDocsRoute) {
140
+ console.warn(
141
+ '[karaoke-cms] Your vault has a docs collection enabled but the active theme ' +
142
+ 'has no /docs route. Published docs entries will not be accessible. ' +
143
+ 'Consider using @karaoke-cms/theme-default which includes /docs routes.',
144
+ );
145
+ }
146
+ },
98
147
  },
99
148
  };
100
149
  }
@@ -108,6 +157,7 @@ function virtualConfigPlugin(
108
157
  resolved: ResolvedModules,
109
158
  layout: ResolvedLayout,
110
159
  resolvedCollections: ResolvedCollections,
160
+ resolvedMenus: ResolvedMenus,
111
161
  ) {
112
162
  const VIRTUAL_ID = 'virtual:karaoke-cms/config';
113
163
  const RESOLVED_ID = '\0' + VIRTUAL_ID;
@@ -125,10 +175,14 @@ export const siteDescription = ${JSON.stringify(config.description ?? '')};
125
175
  export const resolvedModules = ${JSON.stringify(resolved)};
126
176
  export const resolvedLayout = ${JSON.stringify(layout)};
127
177
  export const resolvedCollections = ${JSON.stringify(resolvedCollections)};
178
+ export const resolvedMenus = ${JSON.stringify(resolvedMenus)};
128
179
  `;
129
180
  }
130
181
  },
131
182
  };
132
183
  }
133
184
 
134
- export type { KaraokeConfig, ResolvedModules, ResolvedLayout, ResolvedCollections, RegionComponent } from './types.js';
185
+ export { defineModule } from './define-module.js';
186
+ export { defineTheme } from './define-theme.js';
187
+ export type { ModuleInstance, ThemeInstance, ModuleMenuEntry } from './types.js';
188
+ export type { KaraokeConfig, ResolvedModules, ResolvedLayout, ResolvedCollections, RegionComponent, ResolvedMenus, ResolvedMenu, ResolvedMenuEntry } from './types.js';
@@ -1,8 +1,7 @@
1
1
  ---
2
2
  import '@theme/styles.css';
3
3
  import RegionRenderer from '../components/RegionRenderer.astro';
4
- import { resolvedModules, resolvedLayout } from 'virtual:karaoke-cms/config';
5
- import { SITE_TITLE } from '../consts';
4
+ import { siteTitle, resolvedModules, resolvedLayout } from 'virtual:karaoke-cms/config';
6
5
 
7
6
  interface Props {
8
7
  title: string;
@@ -28,14 +27,14 @@ const hasRight = Astro.slots.has('right') || layout.regions.right.components.len
28
27
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
29
28
  <link rel="icon" href="/favicon.ico" />
30
29
  <meta name="generator" content={Astro.generator} />
31
- <title>{title === SITE_TITLE ? title : `${title} — ${SITE_TITLE}`}</title>
30
+ <title>{title === siteTitle ? title : `${title} — ${siteTitle}`}</title>
32
31
  {description && <meta name="description" content={description} />}
33
32
  <meta property="og:title" content={title} />
34
33
  <meta property="og:type" content={type} />
35
34
  <meta property="og:url" content={canonicalUrl} />
36
35
  {description && <meta property="og:description" content={description} />}
37
36
  <meta name="twitter:card" content="summary" />
38
- <link rel="alternate" type="application/rss+xml" title={SITE_TITLE} href="/rss.xml" />
37
+ <link rel="alternate" type="application/rss+xml" title={siteTitle} href="/rss.xml" />
39
38
  </head>
40
39
  <body>
41
40
  <header>
@@ -65,9 +64,11 @@ const hasRight = Astro.slots.has('right') || layout.regions.right.components.len
65
64
  )}
66
65
  </div>
67
66
  <footer>
68
- <slot name="bottom">
69
- <RegionRenderer components={layout.regions.bottom.components} {searchEnabled} />
70
- </slot>
67
+ <div class="footer-inner">
68
+ <slot name="bottom">
69
+ <RegionRenderer components={layout.regions.bottom.components} {searchEnabled} />
70
+ </slot>
71
+ </div>
71
72
  </footer>
72
73
  </body>
73
74
  </html>
package/src/types.ts CHANGED
@@ -29,8 +29,8 @@ export interface KaraokeConfig {
29
29
  title?: string;
30
30
  /** Site description — used in RSS feed and OG meta tags. */
31
31
  description?: string;
32
- /** Theme name must match a folder in themes/. Defaults to 'default'. */
33
- theme?: string;
32
+ /** Theme — package name string (legacy) or a ThemeInstance from defineTheme(). */
33
+ theme?: string | ThemeInstance;
34
34
  modules?: {
35
35
  search?: { enabled?: boolean };
36
36
  comments?: {
@@ -73,3 +73,67 @@ export interface ResolvedLayout {
73
73
  bottom: { components: RegionComponent[] };
74
74
  };
75
75
  }
76
+
77
+ // ── Menu types ─────────────────────────────────────────────────────────────────
78
+
79
+ /** Raw shape of one entry in menus.yaml (used to type the parsed YAML). */
80
+ export interface MenuEntryConfig {
81
+ text: string;
82
+ href?: string;
83
+ weight?: number;
84
+ /** Visibility condition — only 'collection:name' supported in v1. */
85
+ when?: string;
86
+ entries?: MenuEntryConfig[];
87
+ }
88
+
89
+ /** Raw shape of one menu block in menus.yaml. */
90
+ export interface MenuConfig {
91
+ orientation?: 'horizontal' | 'vertical';
92
+ entries?: MenuEntryConfig[];
93
+ }
94
+
95
+ export interface ResolvedMenuEntry {
96
+ text: string;
97
+ href?: string;
98
+ weight: number;
99
+ when?: string;
100
+ entries: ResolvedMenuEntry[];
101
+ }
102
+
103
+ export interface ResolvedMenu {
104
+ name: string;
105
+ orientation: 'horizontal' | 'vertical';
106
+ entries: ResolvedMenuEntry[];
107
+ }
108
+
109
+ export type ResolvedMenus = Record<string, ResolvedMenu>;
110
+
111
+ // ── Module system types ────────────────────────────────────────────────────────
112
+
113
+ /** A menu entry registered by a module instance. */
114
+ export interface ModuleMenuEntry {
115
+ id: string;
116
+ name: string;
117
+ path: string;
118
+ section: string;
119
+ weight: number;
120
+ }
121
+
122
+ /** A resolved module instance — returned by a defineModule() factory. */
123
+ export interface ModuleInstance {
124
+ _type: 'module-instance';
125
+ id: string;
126
+ mount: string;
127
+ routes: Array<{ pattern: string; entrypoint: string }>;
128
+ menuEntries: ModuleMenuEntry[];
129
+ cssContract: readonly string[];
130
+ hasDefaultCss: boolean;
131
+ }
132
+
133
+ /** A resolved theme instance — returned by a defineTheme() factory. */
134
+ export interface ThemeInstance {
135
+ _type: 'theme-instance';
136
+ id: string;
137
+ implementedModuleIds: string[];
138
+ toAstroIntegration: () => import('astro').AstroIntegration;
139
+ }
@@ -0,0 +1,95 @@
1
+ import { readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { parse } from 'yaml';
4
+ import type { MenuConfig, MenuEntryConfig, ResolvedMenuEntry, ResolvedMenu, ResolvedMenus } from '../types.js';
5
+
6
+ /** Reject javascript: and data: hrefs to prevent stored XSS via menus.yaml. */
7
+ function sanitizeHref(href: string): string | undefined {
8
+ const lower = href.trim().toLowerCase();
9
+ if (lower.startsWith('javascript:') || lower.startsWith('data:')) return undefined;
10
+ return href;
11
+ }
12
+
13
+ function normalizeEntries(raw: MenuEntryConfig[], depth = 0): ResolvedMenuEntry[] {
14
+ // Guard against YAML anchor cycles (e.g., entries: &loop ... entries: *loop).
15
+ if (depth > 10) return [];
16
+ return (raw ?? [])
17
+ .map(e => ({
18
+ text: String(e?.text ?? ''),
19
+ href: typeof e?.href === 'string' ? sanitizeHref(e.href) : undefined,
20
+ weight: typeof e?.weight === 'number' ? e.weight : 0,
21
+ when: typeof e?.when === 'string' ? e.when : undefined,
22
+ entries: Array.isArray(e?.entries) ? normalizeEntries(e.entries, depth + 1) : [],
23
+ }))
24
+ .sort((a, b) => a.weight - b.weight);
25
+ }
26
+
27
+ function defaultMain(): ResolvedMenu {
28
+ return {
29
+ name: 'main',
30
+ orientation: 'horizontal',
31
+ entries: [
32
+ { text: 'Blog', href: '/blog', weight: 10, when: 'collection:blog', entries: [] },
33
+ { text: 'Docs', href: '/docs', weight: 20, when: 'collection:docs', entries: [] },
34
+ { text: 'Tags', href: '/tags', weight: 30, entries: [] },
35
+ ],
36
+ };
37
+ }
38
+
39
+ function defaultFooter(): ResolvedMenu {
40
+ return {
41
+ name: 'footer',
42
+ orientation: 'horizontal',
43
+ entries: [
44
+ { text: 'RSS', href: '/rss.xml', weight: 10, entries: [] },
45
+ ],
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Resolve menus from {vault}/karaoke-cms/config/menus.yaml.
51
+ *
52
+ * Always ensures `main` and `footer` exist in the result — generating defaults
53
+ * when absent so that MainMenu.astro and SiteFooter.astro always have something
54
+ * to render. ENOENT (no menus.yaml) is the normal zero-config case; all other
55
+ * errors warn and fall back to defaults.
56
+ */
57
+ export function resolveMenus(vaultDir: string): ResolvedMenus {
58
+ try {
59
+ const path = join(vaultDir, 'karaoke-cms/config/menus.yaml');
60
+ const raw = readFileSync(path, 'utf8');
61
+ const parsed = parse(raw) as { menus?: Record<string, MenuConfig> };
62
+
63
+ if (!parsed?.menus || typeof parsed.menus !== 'object') {
64
+ return { main: defaultMain(), footer: defaultFooter() };
65
+ }
66
+
67
+ const result: ResolvedMenus = {};
68
+ for (const [name, cfg] of Object.entries(parsed.menus)) {
69
+ if (
70
+ cfg?.orientation !== undefined &&
71
+ cfg.orientation !== 'horizontal' &&
72
+ cfg.orientation !== 'vertical'
73
+ ) {
74
+ console.warn(
75
+ `[karaoke-cms] menus.yaml: menu "${name}" has invalid orientation "${cfg.orientation}". Using "horizontal".`,
76
+ );
77
+ }
78
+ result[name] = {
79
+ name,
80
+ orientation: cfg?.orientation === 'vertical' ? 'vertical' : 'horizontal',
81
+ entries: normalizeEntries(cfg?.entries ?? []),
82
+ };
83
+ }
84
+ if (!result.main) result.main = defaultMain();
85
+ if (!result.footer) result.footer = defaultFooter();
86
+ return result;
87
+ } catch (err: unknown) {
88
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
89
+ console.warn(
90
+ `[karaoke-cms] Failed to parse menus.yaml: ${(err as Error).message}. Using default menus.`,
91
+ );
92
+ }
93
+ return { main: defaultMain(), footer: defaultFooter() };
94
+ }
95
+ }
@@ -4,18 +4,6 @@
4
4
  * Extracted here so the logic is unit-testable separately from astro.config.mjs.
5
5
  */
6
6
 
7
- import { readdirSync, statSync } from 'fs'
8
- import { join } from 'path'
9
-
10
- /**
11
- * Resolve the active theme name from karaoke.config.
12
- * Falls back to 'default' if config is absent.
13
- * Throws a clear error if the named theme folder doesn't exist.
14
- *
15
- * @param {import('../karaoke.config').KaraokeConfig | null | undefined} config
16
- * @param {string} themesDir - absolute path to the src/themes directory
17
- * @returns {string} the resolved theme name
18
- */
19
7
  /**
20
8
  * Validate module config at build time.
21
9
  * Throws a clear error if comments is enabled but required fields are missing.
@@ -36,24 +24,3 @@ export function validateModules(config) {
36
24
  }
37
25
  }
38
26
 
39
- export function getTheme(config, themesDir) {
40
- const theme = config?.theme ?? 'default'
41
- let available
42
- try {
43
- available = readdirSync(themesDir)
44
- } catch {
45
- throw new Error(
46
- `karaoke-cms: src/themes/ directory not found at ${themesDir} — is this a karaoke-cms repo?`
47
- )
48
- }
49
- available = available.filter(d => {
50
- try { return statSync(join(themesDir, d)).isDirectory() } catch { return false }
51
- })
52
- if (!available.includes(theme)) {
53
- throw new Error(
54
- `karaoke-cms: theme "${theme}" not found in src/themes/. ` +
55
- `Available: ${available.join(', ')}`
56
- )
57
- }
58
- return theme
59
- }
@@ -1,14 +0,0 @@
1
- ---
2
- import Base from '../layouts/Base.astro';
3
- import { SITE_TITLE } from '../consts';
4
- ---
5
-
6
- <Base title={`Page not found — ${SITE_TITLE}`}>
7
- <div class="post-header">
8
- <h1>Page not found</h1>
9
- <p class="post-meta">The page you're looking for doesn't exist or hasn't been published.</p>
10
- </div>
11
- <div class="prose">
12
- <p><a href="/">Go home →</a></p>
13
- </div>
14
- </Base>
@@ -1,66 +0,0 @@
1
- ---
2
- import { getCollection, render } from 'astro:content';
3
- import Base from '../../layouts/Base.astro';
4
- import ModuleLoader from '../../components/ModuleLoader.astro';
5
- import { SITE_TITLE } from '../../consts';
6
-
7
- export async function getStaticPaths() {
8
- const posts = await getCollection('blog', ({ data }) => data.publish === true);
9
- return posts.map(entry => ({
10
- params: { slug: entry.id },
11
- props: { entry },
12
- }));
13
- }
14
-
15
- const { entry } = Astro.props;
16
- const { Content } = await render(entry);
17
-
18
- // Resolve related entries by ID across both collections
19
- const relatedIds = entry.data.related ?? [];
20
- const related = relatedIds.length > 0
21
- ? (await Promise.all([
22
- getCollection('blog', ({ data }) => data.publish === true),
23
- getCollection('docs', ({ data }) => data.publish === true),
24
- ]))
25
- .flat()
26
- .filter(e => relatedIds.includes(e.id))
27
- : [];
28
- ---
29
-
30
- <Base title={`${entry.data.title} — ${SITE_TITLE}`} description={entry.data.description} type="article">
31
- <article>
32
- <div class="post-header">
33
- <h1>{entry.data.title}</h1>
34
- <div class="post-meta">
35
- {entry.data.date && <span>{entry.data.date.toISOString().slice(0, 10)}</span>}
36
- {entry.data.author && entry.data.date && <span> · </span>}
37
- {entry.data.author && (
38
- <span>{Array.isArray(entry.data.author) ? entry.data.author.join(' · ') : entry.data.author}</span>
39
- )}
40
- {entry.data.reading_time && <span> · {entry.data.reading_time} min read</span>}
41
- </div>
42
- </div>
43
- <div class="prose">
44
- <Content />
45
- </div>
46
- <div class="post-footer">
47
- {entry.data.tags && entry.data.tags.length > 0 && (
48
- <div class="post-tags">
49
- {entry.data.tags.map(tag => <a href={`/tags/${tag}`} class="tag">#{tag}</a>)}
50
- </div>
51
- )}
52
- {related.length > 0 && (
53
- <div class="related-posts">
54
- <p class="related-label">Related</p>
55
- <ul>
56
- {related.map(r => (
57
- <li><a href={`/${r.collection}/${r.id}`}>{r.data.title}</a></li>
58
- ))}
59
- </ul>
60
- </div>
61
- )}
62
- <a href="/blog">← Blog</a>
63
- </div>
64
- </article>
65
- <ModuleLoader comments={entry.data.comments} />
66
- </Base>
@@ -1,31 +0,0 @@
1
- ---
2
- import { getCollection } from 'astro:content';
3
- import Base from '../../layouts/Base.astro';
4
- import { SITE_TITLE } from '../../consts';
5
-
6
- const posts = (await getCollection('blog', ({ data }) => data.publish === true))
7
- .sort((a, b) => (b.data.date?.valueOf() ?? 0) - (a.data.date?.valueOf() ?? 0));
8
- ---
9
-
10
- <Base title={`Blog — ${SITE_TITLE}`}>
11
- <div class="listing-header">
12
- <h1>Blog</h1>
13
- </div>
14
- {posts.length > 0 ? (
15
- <ul class="post-list">
16
- {posts.map(post => (
17
- <li>
18
- {post.data.date && (
19
- <span class="post-date">{post.data.date.toISOString().slice(0, 10)}</span>
20
- )}
21
- <a href={`/blog/${post.id}`}>{post.data.title}</a>
22
- </li>
23
- ))}
24
- </ul>
25
- ) : (
26
- <div class="empty-state">
27
- <p>No posts published yet.</p>
28
- <p>Create a Markdown file in your vault's <code>blog/</code> folder and set <code>publish: true</code> in the frontmatter to make it appear here.</p>
29
- </div>
30
- )}
31
- </Base>
@@ -1,66 +0,0 @@
1
- ---
2
- import { getCollection, render } from 'astro:content';
3
- import Base from '../../layouts/Base.astro';
4
- import ModuleLoader from '../../components/ModuleLoader.astro';
5
- import { SITE_TITLE } from '../../consts';
6
-
7
- export async function getStaticPaths() {
8
- const docs = await getCollection('docs', ({ data }) => data.publish === true);
9
- return docs.map(entry => ({
10
- params: { slug: entry.id },
11
- props: { entry },
12
- }));
13
- }
14
-
15
- const { entry } = Astro.props;
16
- const { Content } = await render(entry);
17
-
18
- // Resolve related entries by ID across both collections
19
- const relatedIds = entry.data.related ?? [];
20
- const related = relatedIds.length > 0
21
- ? (await Promise.all([
22
- getCollection('blog', ({ data }) => data.publish === true),
23
- getCollection('docs', ({ data }) => data.publish === true),
24
- ]))
25
- .flat()
26
- .filter(e => relatedIds.includes(e.id))
27
- : [];
28
- ---
29
-
30
- <Base title={`${entry.data.title} — ${SITE_TITLE}`} description={entry.data.description} type="article">
31
- <article>
32
- <div class="post-header">
33
- <h1>{entry.data.title}</h1>
34
- <div class="post-meta">
35
- {entry.data.date && <span>{entry.data.date.toISOString().slice(0, 10)}</span>}
36
- {entry.data.author && entry.data.date && <span> · </span>}
37
- {entry.data.author && (
38
- <span>{Array.isArray(entry.data.author) ? entry.data.author.join(' · ') : entry.data.author}</span>
39
- )}
40
- {entry.data.reading_time && <span> · {entry.data.reading_time} min read</span>}
41
- </div>
42
- </div>
43
- <div class="prose">
44
- <Content />
45
- </div>
46
- <div class="post-footer">
47
- {entry.data.tags && entry.data.tags.length > 0 && (
48
- <div class="post-tags">
49
- {entry.data.tags.map(tag => <a href={`/tags/${tag}`} class="tag">#{tag}</a>)}
50
- </div>
51
- )}
52
- {related.length > 0 && (
53
- <div class="related-posts">
54
- <p class="related-label">Related</p>
55
- <ul>
56
- {related.map(r => (
57
- <li><a href={`/${r.collection}/${r.id}`}>{r.data.title}</a></li>
58
- ))}
59
- </ul>
60
- </div>
61
- )}
62
- <a href="/docs">← Docs</a>
63
- </div>
64
- </article>
65
- <ModuleLoader comments={entry.data.comments} />
66
- </Base>