@karaoke-cms/astro 0.9.7 → 0.10.3
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 +75 -1
- package/client.d.ts +14 -1
- package/package.json +5 -4
- package/src/codegen/generate-docs-instance.ts +210 -0
- package/src/collections.ts +33 -1
- package/src/components/Menu.astro +1 -1
- package/src/components/regions/RecentPosts.astro +7 -4
- package/src/components/regions/SiteFooter.astro +3 -1
- package/src/define-module.ts +3 -48
- package/src/define-theme.ts +3 -34
- package/src/index.ts +190 -29
- package/src/pages/rss.xml.ts +17 -10
- package/src/remark/remark-obsidian-embeds.ts +260 -0
- package/src/types.ts +40 -155
- package/src/utils/resolve-menus.ts +6 -3
- package/src/validate-config.js +83 -7
package/src/index.ts
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import type { AstroIntegration } from 'astro';
|
|
2
2
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
3
3
|
import { fileURLToPath } from 'url';
|
|
4
|
-
import { resolve, isAbsolute, dirname } from 'path';
|
|
4
|
+
import { resolve, isAbsolute, dirname, join } from 'path';
|
|
5
5
|
import { wikiLinkPlugin } from 'remark-wiki-link';
|
|
6
|
+
import { remarkObsidianEmbeds, pendingMediaCopies } from './remark/remark-obsidian-embeds.js';
|
|
6
7
|
import sitemap from '@astrojs/sitemap';
|
|
7
8
|
import { validateModules } from './validate-config.js';
|
|
8
9
|
import { resolveModules } from './utils/resolve-modules.js';
|
|
9
10
|
import { resolveLayout } from './utils/resolve-layout.js';
|
|
10
11
|
import { resolveCollections } from './utils/resolve-collections.js';
|
|
11
12
|
import { resolveMenus } from './utils/resolve-menus.js';
|
|
12
|
-
import
|
|
13
|
+
import { generateDocsInstancePages } from './codegen/generate-docs-instance.js';
|
|
14
|
+
import type { KaraokeConfig, ModuleInstance, ThemeInstance, ResolvedModules, ResolvedLayout, ResolvedCollections, ResolvedMenus } from './types.js';
|
|
13
15
|
|
|
14
16
|
/**
|
|
15
17
|
* karaoke() — the main Astro integration for karaoke-cms.
|
|
@@ -56,6 +58,7 @@ export default function karaoke(config: KaraokeConfig = {}): AstroIntegration {
|
|
|
56
58
|
|
|
57
59
|
const theme = config.theme;
|
|
58
60
|
let _resolvedCollections: ResolvedCollections | undefined;
|
|
61
|
+
let _docsInstances: Array<{ id: string; collection: string; mount: string; folder: string; label: string; layout: string; sidebarStyle: string }> = [];
|
|
59
62
|
|
|
60
63
|
return {
|
|
61
64
|
name: '@karaoke-cms/astro',
|
|
@@ -66,13 +69,36 @@ export default function karaoke(config: KaraokeConfig = {}): AstroIntegration {
|
|
|
66
69
|
? (isAbsolute(config.vault) ? config.vault : resolve(rootDir, config.vault))
|
|
67
70
|
: rootDir;
|
|
68
71
|
const isProd = command === 'build';
|
|
72
|
+
|
|
73
|
+
if (!existsSync(vaultDir)) {
|
|
74
|
+
console.warn(
|
|
75
|
+
`[karaoke-cms] Vault path does not exist: ${vaultDir}\n` +
|
|
76
|
+
` Check KARAOKE_VAULT in your .env or .env.default. The site will build with empty collections.`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
69
80
|
_resolvedCollections = resolveCollections(vaultDir, isProd, config.collections);
|
|
70
|
-
|
|
71
|
-
|
|
81
|
+
|
|
82
|
+
// ── Gather modules from both config.modules and theme.implementedModules ──
|
|
83
|
+
// config.modules are user-declared; theme.implementedModules are theme-built-in.
|
|
84
|
+
// Dedup by id: config wins (first seen).
|
|
85
|
+
const configModules = (config.modules ?? []).filter((m: ModuleInstance) => m.enabled !== false);
|
|
86
|
+
const themeModules = isThemeInstance(config.theme)
|
|
87
|
+
? (config.theme.implementedModules ?? []).filter((m: ModuleInstance) => m.enabled !== false)
|
|
88
|
+
: [];
|
|
89
|
+
const seenIds = new Set<string>();
|
|
90
|
+
const allModules = [...configModules, ...themeModules].filter((m: ModuleInstance) => {
|
|
91
|
+
if (seenIds.has(m.id)) return false;
|
|
92
|
+
seenIds.add(m.id);
|
|
93
|
+
return true;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const _resolvedMenus = resolveMenus(vaultDir, allModules);
|
|
72
97
|
|
|
73
98
|
// ── Scaffold module pages and CSS on first dev run ────────────────
|
|
99
|
+
// Only scaffold for user-declared config.modules (theme modules handle their own CSS).
|
|
74
100
|
if (command === 'dev') {
|
|
75
|
-
for (const mod of
|
|
101
|
+
for (const mod of configModules) {
|
|
76
102
|
if (mod.scaffoldPages) {
|
|
77
103
|
for (const { src, dest } of mod.scaffoldPages) {
|
|
78
104
|
if (!existsSync(src)) continue;
|
|
@@ -109,24 +135,39 @@ export default function karaoke(config: KaraokeConfig = {}): AstroIntegration {
|
|
|
109
135
|
}
|
|
110
136
|
}
|
|
111
137
|
|
|
112
|
-
// ──
|
|
113
|
-
const
|
|
138
|
+
// ── Code-generate per-instance docs pages ─────────────────────────
|
|
139
|
+
const docsInstances = allModules
|
|
140
|
+
.filter((m: ModuleInstance) => m.collection != null)
|
|
141
|
+
.map((m: ModuleInstance) => ({
|
|
142
|
+
id: m.id,
|
|
143
|
+
collection: m.collection!.name,
|
|
144
|
+
mount: m.mount,
|
|
145
|
+
folder: m.collection!.folder,
|
|
146
|
+
label: m.collection!.label,
|
|
147
|
+
layout: m.collection!.layout,
|
|
148
|
+
sidebarStyle: m.collection!.sidebarStyle,
|
|
149
|
+
}));
|
|
150
|
+
_docsInstances = docsInstances;
|
|
151
|
+
const generatedRoutes = generateDocsInstancePages(rootDir, docsInstances);
|
|
114
152
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
}
|
|
153
|
+
// ── Load theme and inject module routes ───────────────────────────
|
|
154
|
+
// Pass only configModules to the theme — it already knows its implementedModules.
|
|
155
|
+
const themeIntegration = theme.toAstroIntegration(configModules);
|
|
120
156
|
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
157
|
+
// Inject non-docs module routes (blog, search, etc.)
|
|
158
|
+
for (const mod of allModules) {
|
|
159
|
+
if (!mod.collection) {
|
|
124
160
|
for (const route of mod.routes) {
|
|
125
|
-
injectRoute(
|
|
161
|
+
injectRoute(route);
|
|
126
162
|
}
|
|
127
163
|
}
|
|
128
164
|
}
|
|
129
165
|
|
|
166
|
+
// Inject generated docs routes (file:// URLs to .astro/generated/... files)
|
|
167
|
+
for (const route of generatedRoutes) {
|
|
168
|
+
injectRoute(route);
|
|
169
|
+
}
|
|
170
|
+
|
|
130
171
|
// ── Core routes — always present regardless of theme ─────────────
|
|
131
172
|
injectRoute({ pattern: '/rss.xml', entrypoint: '@karaoke-cms/astro/pages/rss.xml.ts' });
|
|
132
173
|
|
|
@@ -136,13 +177,23 @@ export default function karaoke(config: KaraokeConfig = {}): AstroIntegration {
|
|
|
136
177
|
injectRoute({ pattern: '/karaoke-cms/[...slug]', entrypoint: '@karaoke-cms/astro/pages/karaoke-cms/[...slug].astro' });
|
|
137
178
|
}
|
|
138
179
|
|
|
180
|
+
// Collect optional Astro integrations from modules (e.g. module-seo's Vite plugin)
|
|
181
|
+
const moduleIntegrations = allModules
|
|
182
|
+
.map((m: ModuleInstance) => m.integration)
|
|
183
|
+
.filter((i): i is AstroIntegration => i !== undefined);
|
|
184
|
+
|
|
139
185
|
updateConfig({
|
|
140
|
-
integrations: [sitemap(), themeIntegration],
|
|
186
|
+
integrations: [sitemap(), themeIntegration, ...moduleIntegrations],
|
|
141
187
|
vite: {
|
|
142
|
-
plugins: [
|
|
188
|
+
plugins: [
|
|
189
|
+
virtualConfigPlugin(config, resolved, layout, _resolvedCollections, _resolvedMenus, vaultDir, allModules),
|
|
190
|
+
virtualDocsSectionsPlugin(docsInstances),
|
|
191
|
+
mediaPlugin(pendingMediaCopies),
|
|
192
|
+
],
|
|
143
193
|
},
|
|
144
194
|
markdown: {
|
|
145
195
|
remarkPlugins: [
|
|
196
|
+
[remarkObsidianEmbeds, { vaultDir }],
|
|
146
197
|
[wikiLinkPlugin, {
|
|
147
198
|
pageResolver: (name: string) => [name.toLowerCase().replace(/ /g, '-')],
|
|
148
199
|
hrefTemplate: (permalink: string) => `/${permalink}/`,
|
|
@@ -154,14 +205,22 @@ export default function karaoke(config: KaraokeConfig = {}): AstroIntegration {
|
|
|
154
205
|
},
|
|
155
206
|
|
|
156
207
|
'astro:build:done'({ pages }) {
|
|
157
|
-
if
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
208
|
+
// Skip if neither the legacy docs collection nor any docs module instance is active.
|
|
209
|
+
if (!_resolvedCollections?.docs?.enabled && _docsInstances.length === 0) return;
|
|
210
|
+
// Check if at least one docs-style page was rendered.
|
|
211
|
+
// If _docsInstances is populated, codegen routes are active — check each mount.
|
|
212
|
+
// Fallback to legacy /docs regex for backward compat with theme-provided docs routes.
|
|
213
|
+
const pagePathnameSet = new Set((pages ?? []).map(p => p.pathname ?? ''));
|
|
214
|
+
const hasAnyDocsPage = _docsInstances.length > 0
|
|
215
|
+
? _docsInstances.some(inst =>
|
|
216
|
+
[...pagePathnameSet].some(p => p.startsWith(inst.mount.replace(/^\//, '')))
|
|
217
|
+
)
|
|
218
|
+
: [...pagePathnameSet].some(p => p.match(/^docs\b/));
|
|
219
|
+
if (!hasAnyDocsPage) {
|
|
161
220
|
console.warn(
|
|
162
|
-
'[karaoke-cms] Your vault has a docs collection enabled but
|
|
163
|
-
'
|
|
164
|
-
'Consider
|
|
221
|
+
'[karaoke-cms] Your vault has a docs collection enabled but no docs route was ' +
|
|
222
|
+
'rendered. Published docs entries will not be accessible. ' +
|
|
223
|
+
'Consider adding docs() to your theme\'s implements or config.modules.',
|
|
165
224
|
);
|
|
166
225
|
}
|
|
167
226
|
},
|
|
@@ -178,11 +237,15 @@ function virtualConfigPlugin(
|
|
|
178
237
|
resolved: ResolvedModules,
|
|
179
238
|
layout: ResolvedLayout,
|
|
180
239
|
resolvedCollections: ResolvedCollections,
|
|
181
|
-
|
|
240
|
+
initialResolvedMenus: ResolvedMenus,
|
|
241
|
+
vaultDir: string,
|
|
242
|
+
modules: ModuleInstance[],
|
|
182
243
|
) {
|
|
183
244
|
const VIRTUAL_ID = 'virtual:karaoke-cms/config';
|
|
184
245
|
const RESOLVED_ID = '\0' + VIRTUAL_ID;
|
|
185
246
|
|
|
247
|
+
let currentMenus = initialResolvedMenus;
|
|
248
|
+
|
|
186
249
|
return {
|
|
187
250
|
name: 'vite-plugin-karaoke-cms-config',
|
|
188
251
|
resolveId(id: string) {
|
|
@@ -196,17 +259,115 @@ export const siteDescription = ${JSON.stringify(config.description ?? '')};
|
|
|
196
259
|
export const resolvedModules = ${JSON.stringify(resolved)};
|
|
197
260
|
export const resolvedLayout = ${JSON.stringify(layout)};
|
|
198
261
|
export const resolvedCollections = ${JSON.stringify(resolvedCollections)};
|
|
199
|
-
export const resolvedMenus = ${JSON.stringify(
|
|
262
|
+
export const resolvedMenus = ${JSON.stringify(currentMenus)};
|
|
200
263
|
export const blogMount = ${JSON.stringify(
|
|
201
264
|
(config.modules ?? []).find((m: { id: string; mount: string }) => m.id === 'blog')?.mount ?? '/blog'
|
|
202
265
|
)};
|
|
203
266
|
`;
|
|
204
267
|
}
|
|
205
268
|
},
|
|
269
|
+
configureServer(server: any) {
|
|
270
|
+
const menusPath = join(vaultDir, 'karaoke-cms/config/menus.yaml');
|
|
271
|
+
server.watcher.add(menusPath);
|
|
272
|
+
server.watcher.on('change', (file: string) => {
|
|
273
|
+
if (file !== menusPath) return;
|
|
274
|
+
currentMenus = resolveMenus(vaultDir, modules);
|
|
275
|
+
const mod = server.moduleGraph.getModuleById(RESOLVED_ID);
|
|
276
|
+
if (mod) server.moduleGraph.invalidateModule(mod);
|
|
277
|
+
server.ws.send({ type: 'full-reload' });
|
|
278
|
+
});
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Vite plugin providing `virtual:karaoke-cms/docs-sections`.
|
|
285
|
+
* Generated doc pages, tags/[tag].astro, RecentPosts, and rss.xml import from it
|
|
286
|
+
* to discover all active docs sections at build time.
|
|
287
|
+
*/
|
|
288
|
+
function virtualDocsSectionsPlugin(docsInstances: Array<{ id: string; collection: string; mount: string; folder: string; label: string; layout: string; sidebarStyle: string }>) {
|
|
289
|
+
const VIRTUAL_ID = 'virtual:karaoke-cms/docs-sections';
|
|
290
|
+
const RESOLVED_ID = '\0' + VIRTUAL_ID;
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
name: 'vite-plugin-karaoke-cms-docs-sections',
|
|
294
|
+
resolveId(id: string) {
|
|
295
|
+
if (id === VIRTUAL_ID) return RESOLVED_ID;
|
|
296
|
+
},
|
|
297
|
+
load(id: string) {
|
|
298
|
+
if (id === RESOLVED_ID) {
|
|
299
|
+
return `export const docsSections = ${JSON.stringify(docsInstances)};`;
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Vite plugin serving vault media files (audio, video, and wiki-linked frontmatter images)
|
|
307
|
+
* from /media/<filename> during dev and emitting them to dist/media/ during build.
|
|
308
|
+
*/
|
|
309
|
+
function mediaPlugin(mediaCopies: Set<string>) {
|
|
310
|
+
const MIME: Record<string, string> = {
|
|
311
|
+
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png',
|
|
312
|
+
gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', avif: 'image/avif',
|
|
313
|
+
mp3: 'audio/mpeg', m4a: 'audio/mp4', ogg: 'audio/ogg', wav: 'audio/wav', flac: 'audio/flac',
|
|
314
|
+
mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const findAbsPath = (basename: string): string | undefined => {
|
|
318
|
+
for (const abs of mediaCopies) {
|
|
319
|
+
if (abs.endsWith('/' + basename) || abs.endsWith('\\' + basename)) return abs;
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
name: 'vite-plugin-karaoke-media',
|
|
325
|
+
configureServer(server: any) {
|
|
326
|
+
server.middlewares.use((req: any, res: any, next: any) => {
|
|
327
|
+
if (!req.url?.startsWith('/media/')) return next();
|
|
328
|
+
const basename = req.url.slice('/media/'.length).split('?')[0];
|
|
329
|
+
const absPath = findAbsPath(basename);
|
|
330
|
+
if (!absPath) return next();
|
|
331
|
+
const ext = basename.split('.').pop()?.toLowerCase() ?? '';
|
|
332
|
+
res.setHeader('Content-Type', MIME[ext] ?? 'application/octet-stream');
|
|
333
|
+
res.end(readFileSync(absPath));
|
|
334
|
+
});
|
|
335
|
+
},
|
|
336
|
+
generateBundle(this: any) {
|
|
337
|
+
for (const absPath of mediaCopies) {
|
|
338
|
+
const basename = absPath.split('/').pop()!;
|
|
339
|
+
this.emitFile({ type: 'asset', fileName: `media/${basename}`, source: readFileSync(absPath) });
|
|
340
|
+
}
|
|
341
|
+
},
|
|
206
342
|
};
|
|
207
343
|
}
|
|
208
344
|
|
|
209
345
|
export { defineModule } from './define-module.js';
|
|
210
346
|
export { defineTheme } from './define-theme.js';
|
|
211
|
-
export
|
|
212
|
-
export type {
|
|
347
|
+
export { resolveWikiImage } from './remark/remark-obsidian-embeds.js';
|
|
348
|
+
export type {
|
|
349
|
+
// CSS contract
|
|
350
|
+
CssContractSlot,
|
|
351
|
+
CssContract,
|
|
352
|
+
// Named primitive types
|
|
353
|
+
RouteDefinition,
|
|
354
|
+
CollectionSpec,
|
|
355
|
+
ScaffoldPage,
|
|
356
|
+
// Module system
|
|
357
|
+
ModuleInstance,
|
|
358
|
+
ThemeInstance,
|
|
359
|
+
ModuleMenuEntry,
|
|
360
|
+
// Factory definitions
|
|
361
|
+
ModuleDefinition,
|
|
362
|
+
ThemeDefinition,
|
|
363
|
+
ThemeFactoryConfig,
|
|
364
|
+
// Configuration
|
|
365
|
+
KaraokeConfig,
|
|
366
|
+
ResolvedModules,
|
|
367
|
+
ResolvedLayout,
|
|
368
|
+
ResolvedCollections,
|
|
369
|
+
RegionComponent,
|
|
370
|
+
ResolvedMenus,
|
|
371
|
+
ResolvedMenu,
|
|
372
|
+
ResolvedMenuEntry,
|
|
373
|
+
} from './types.js';
|
package/src/pages/rss.xml.ts
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import rss from '@astrojs/rss';
|
|
2
2
|
import { getCollection } from 'astro:content';
|
|
3
3
|
import { SITE_TITLE, SITE_DESCRIPTION } from '../consts';
|
|
4
|
+
import { docsSections } from 'virtual:karaoke-cms/docs-sections';
|
|
4
5
|
import type { APIContext } from 'astro';
|
|
5
6
|
|
|
6
7
|
export async function GET(context: APIContext) {
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
const blogPosts = await getCollection('blog', ({ data }) => data.publish === true);
|
|
9
|
+
|
|
10
|
+
// Fetch from all active docs collections; use allSettled so one missing collection
|
|
11
|
+
// doesn't abort the entire RSS feed (e.g., if content.config.ts wasn't updated yet).
|
|
12
|
+
const docsSettled = await Promise.allSettled(
|
|
13
|
+
docsSections.map(s => getCollection(s.collection as any, ({ data }) => data.publish === true))
|
|
14
|
+
);
|
|
15
|
+
const docsResults = docsSettled.map(r => r.status === 'fulfilled' ? r.value : []);
|
|
11
16
|
|
|
12
17
|
const blogItems = blogPosts.map(post => ({
|
|
13
18
|
title: post.data.title,
|
|
@@ -16,12 +21,14 @@ export async function GET(context: APIContext) {
|
|
|
16
21
|
link: `/blog/${post.id}/`,
|
|
17
22
|
}));
|
|
18
23
|
|
|
19
|
-
const docItems =
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
const docItems = docsSections.flatMap((s, i) =>
|
|
25
|
+
(docsResults[i] ?? []).map(doc => ({
|
|
26
|
+
title: doc.data.title,
|
|
27
|
+
pubDate: doc.data.date,
|
|
28
|
+
description: doc.data.description,
|
|
29
|
+
link: `${s.mount}/${doc.id}/`,
|
|
30
|
+
}))
|
|
31
|
+
);
|
|
25
32
|
|
|
26
33
|
const items = [...blogItems, ...docItems].sort(
|
|
27
34
|
(a, b) => (b.pubDate?.valueOf() ?? 0) - (a.pubDate?.valueOf() ?? 0),
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* remark-obsidian-embeds
|
|
3
|
+
*
|
|
4
|
+
* Transforms Obsidian-style ![[file]] embed syntax into standard mdast nodes:
|
|
5
|
+
* - Images (.png, .jpg, etc.) → mdast `image` node with relative path
|
|
6
|
+
* → triggers Astro's remark-collect-images + image optimization pipeline
|
|
7
|
+
* - Audio (.mp3, .m4a, etc.) → mdast `html` node with <audio controls>
|
|
8
|
+
* - Video (.mp4, .webm, etc.) → mdast `html` node with <video controls>
|
|
9
|
+
* - Unsupported types or unresolved files → raw text preserved
|
|
10
|
+
*
|
|
11
|
+
* Path resolution order (mirrors Obsidian's own lookup):
|
|
12
|
+
* 1. Same folder as the note
|
|
13
|
+
* 2. Vault attachmentFolderPath from .obsidian/app.json (vault-root-relative)
|
|
14
|
+
* 3. Vault root
|
|
15
|
+
*
|
|
16
|
+
* IMPORTANT: In Obsidian, paths in attachmentFolderPath starting with '/'
|
|
17
|
+
* are vault-root-relative (NOT filesystem-absolute). Always join with vaultDir.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, readFileSync } from 'fs'
|
|
21
|
+
import { dirname, join, relative, resolve } from 'path'
|
|
22
|
+
|
|
23
|
+
// Matches ![[filename]] and ![[filename|hint]]
|
|
24
|
+
// Group 1: filename (may include subfolder like "folder/image.png")
|
|
25
|
+
// Group 2: hint (optional — e.g., "200", "alt text", "300x200")
|
|
26
|
+
const EMBED_RE = /!\[\[([^\]#|]+?)(?:\|([^\]]*))?\]\]/g
|
|
27
|
+
|
|
28
|
+
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.avif'])
|
|
29
|
+
const AUDIO_EXTS = new Set(['.mp3', '.m4a', '.ogg', '.wav', '.flac'])
|
|
30
|
+
const VIDEO_EXTS = new Set(['.mp4', '.webm', '.mov'])
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Set of absolute vault paths for audio/video files that need to be served.
|
|
34
|
+
* Populated at remark-transform time, drained by the Vite plugin at build time.
|
|
35
|
+
*/
|
|
36
|
+
export const pendingMediaCopies = new Set<string>()
|
|
37
|
+
|
|
38
|
+
interface ObsidianAppConfig {
|
|
39
|
+
attachmentFolderPath?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const obsidianConfigCache = new Map<string, ObsidianAppConfig>()
|
|
43
|
+
|
|
44
|
+
function getObsidianConfig(vaultDir: string): ObsidianAppConfig {
|
|
45
|
+
if (!obsidianConfigCache.has(vaultDir)) {
|
|
46
|
+
try {
|
|
47
|
+
const configPath = join(vaultDir, '.obsidian', 'app.json')
|
|
48
|
+
const config = existsSync(configPath)
|
|
49
|
+
? (JSON.parse(readFileSync(configPath, 'utf8')) as ObsidianAppConfig)
|
|
50
|
+
: {}
|
|
51
|
+
obsidianConfigCache.set(vaultDir, config)
|
|
52
|
+
} catch {
|
|
53
|
+
obsidianConfigCache.set(vaultDir, {})
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return obsidianConfigCache.get(vaultDir)!
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function extOf(filename: string): string {
|
|
60
|
+
const dot = filename.lastIndexOf('.')
|
|
61
|
+
return dot >= 0 ? filename.slice(dot).toLowerCase() : ''
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function resolveVaultAsset(
|
|
65
|
+
filename: string,
|
|
66
|
+
noteDir: string,
|
|
67
|
+
vaultDir: string,
|
|
68
|
+
): string | null {
|
|
69
|
+
const config = getObsidianConfig(vaultDir)
|
|
70
|
+
|
|
71
|
+
const candidates: string[] = [
|
|
72
|
+
join(noteDir, filename),
|
|
73
|
+
...(config.attachmentFolderPath
|
|
74
|
+
// In Obsidian, even paths starting with '/' are vault-root-relative
|
|
75
|
+
? [join(vaultDir, config.attachmentFolderPath, filename)]
|
|
76
|
+
: []),
|
|
77
|
+
join(vaultDir, filename),
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
// Deduplicate (noteDir might equal vaultDir for root-level notes)
|
|
81
|
+
const seen = new Set<string>()
|
|
82
|
+
for (const candidate of candidates) {
|
|
83
|
+
const abs = resolve(candidate)
|
|
84
|
+
if (seen.has(abs)) continue
|
|
85
|
+
seen.add(abs)
|
|
86
|
+
if (existsSync(abs)) return abs
|
|
87
|
+
}
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface EmbedHint {
|
|
92
|
+
alt: string
|
|
93
|
+
width?: number
|
|
94
|
+
height?: number
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function parseHint(hint: string | undefined): EmbedHint {
|
|
98
|
+
if (!hint) return { alt: '' }
|
|
99
|
+
const trimmed = hint.trim()
|
|
100
|
+
// WxH format: "300x200" → width + height
|
|
101
|
+
const wxh = /^(\d+)x(\d+)$/i.exec(trimmed)
|
|
102
|
+
if (wxh) return { alt: '', width: parseInt(wxh[1], 10), height: parseInt(wxh[2], 10) }
|
|
103
|
+
// Numeric-only: "200" → width only
|
|
104
|
+
if (/^\d+$/.test(trimmed)) return { alt: '', width: parseInt(trimmed, 10) }
|
|
105
|
+
// String hint → alt text
|
|
106
|
+
return { alt: trimmed }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Process a text node value, splitting it on ![[...]] embeds.
|
|
111
|
+
* Returns null if no embeds are found (caller keeps the original node).
|
|
112
|
+
* Returns an array of replacement nodes otherwise.
|
|
113
|
+
*/
|
|
114
|
+
function processText(
|
|
115
|
+
text: string,
|
|
116
|
+
noteDir: string,
|
|
117
|
+
vaultDir: string,
|
|
118
|
+
): any[] | null {
|
|
119
|
+
const parts: any[] = []
|
|
120
|
+
let lastIndex = 0
|
|
121
|
+
let found = false
|
|
122
|
+
|
|
123
|
+
EMBED_RE.lastIndex = 0
|
|
124
|
+
let match: RegExpExecArray | null
|
|
125
|
+
while ((match = EMBED_RE.exec(text)) !== null) {
|
|
126
|
+
found = true
|
|
127
|
+
const [fullMatch, rawFilename, hint] = match
|
|
128
|
+
const matchStart = match.index
|
|
129
|
+
const filename = rawFilename.trim()
|
|
130
|
+
|
|
131
|
+
// Text before this embed
|
|
132
|
+
if (matchStart > lastIndex) {
|
|
133
|
+
parts.push({ type: 'text', value: text.slice(lastIndex, matchStart) })
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const ext = extOf(filename)
|
|
137
|
+
const absPath = resolveVaultAsset(filename, noteDir, vaultDir)
|
|
138
|
+
|
|
139
|
+
if (!absPath) {
|
|
140
|
+
// File not found — preserve raw text so the author can see it's broken
|
|
141
|
+
parts.push({ type: 'text', value: fullMatch })
|
|
142
|
+
} else if (IMAGE_EXTS.has(ext)) {
|
|
143
|
+
const { alt, width, height } = parseHint(hint)
|
|
144
|
+
// Relative path from note directory → what Astro's remark-collect-images expects
|
|
145
|
+
const url = relative(noteDir, absPath)
|
|
146
|
+
const node: any = { type: 'image', url, alt, title: null }
|
|
147
|
+
if (width !== undefined) {
|
|
148
|
+
node.data = { hProperties: { width, ...(height !== undefined ? { height } : {}) } }
|
|
149
|
+
}
|
|
150
|
+
parts.push(node)
|
|
151
|
+
} else if (AUDIO_EXTS.has(ext)) {
|
|
152
|
+
const basename = filename.split('/').pop() ?? filename
|
|
153
|
+
pendingMediaCopies.add(absPath)
|
|
154
|
+
parts.push({
|
|
155
|
+
type: 'html',
|
|
156
|
+
value: `<audio controls src="/media/${basename}"><p>Audio: ${basename}</p></audio>`,
|
|
157
|
+
})
|
|
158
|
+
} else if (VIDEO_EXTS.has(ext)) {
|
|
159
|
+
const basename = filename.split('/').pop() ?? filename
|
|
160
|
+
pendingMediaCopies.add(absPath)
|
|
161
|
+
parts.push({
|
|
162
|
+
type: 'html',
|
|
163
|
+
value: `<video controls src="/media/${basename}"><p>Video: ${basename}</p></video>`,
|
|
164
|
+
})
|
|
165
|
+
} else {
|
|
166
|
+
// Unsupported type — preserve raw text
|
|
167
|
+
parts.push({ type: 'text', value: fullMatch })
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
lastIndex = matchStart + fullMatch.length
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!found) return null
|
|
174
|
+
|
|
175
|
+
// Remaining text after last embed
|
|
176
|
+
if (lastIndex < text.length) {
|
|
177
|
+
parts.push({ type: 'text', value: text.slice(lastIndex) })
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return parts
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Minimal tree walker — visits all nodes of a given type.
|
|
185
|
+
* Avoids external `unist-util-visit` dependency.
|
|
186
|
+
*/
|
|
187
|
+
function walkTree(node: any, type: string, fn: (node: any) => void): void {
|
|
188
|
+
if (node.type === type) fn(node)
|
|
189
|
+
if (Array.isArray(node.children)) {
|
|
190
|
+
for (const child of node.children) walkTree(child, type, fn)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Rewrite a featured_image value the same way the remark plugin does at render time.
|
|
196
|
+
* Safe to call from Astro templates — returns /media/<basename> for [[wiki links]],
|
|
197
|
+
* leaves relative/absolute paths and URLs unchanged, returns undefined for no value.
|
|
198
|
+
*/
|
|
199
|
+
export function resolveWikiImage(value: string | undefined): string | undefined {
|
|
200
|
+
if (!value) return undefined
|
|
201
|
+
const m = /^\[\[([^\]#|]+?)(?:\|[^\]]*)?\]\]$/.exec(value)
|
|
202
|
+
if (!m) return value
|
|
203
|
+
const basename = m[1].trim().split('/').pop() ?? m[1].trim()
|
|
204
|
+
return `/media/${basename}`
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export interface ObsidianEmbedsOptions {
|
|
208
|
+
vaultDir: string
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function remarkObsidianEmbeds(options: ObsidianEmbedsOptions) {
|
|
212
|
+
return function transformer(tree: any, file: any): void {
|
|
213
|
+
// Derive note directory from VFile path (populated by Astro's glob loader)
|
|
214
|
+
// Fall back to vaultDir if file.path is unavailable (e.g., in test contexts)
|
|
215
|
+
const notePath: string | undefined = file?.path
|
|
216
|
+
const noteDir = notePath ? dirname(resolve(notePath)) : options.vaultDir
|
|
217
|
+
|
|
218
|
+
// Resolve [[wiki links]] in featured_image frontmatter field.
|
|
219
|
+
// file.data.astro.frontmatter is populated by Astro before remark plugins run.
|
|
220
|
+
const fm = (file as any)?.data?.astro?.frontmatter
|
|
221
|
+
if (fm?.featured_image && typeof fm.featured_image === 'string') {
|
|
222
|
+
const wikiMatch = /^\[\[([^\]#|]+?)(?:\|[^\]]*)?\]\]$/.exec(fm.featured_image)
|
|
223
|
+
if (wikiMatch) {
|
|
224
|
+
const filename = wikiMatch[1].trim()
|
|
225
|
+
const absPath = resolveVaultAsset(filename, noteDir, options.vaultDir)
|
|
226
|
+
if (absPath) {
|
|
227
|
+
const basename = filename.split('/').pop() ?? filename
|
|
228
|
+
pendingMediaCopies.add(absPath)
|
|
229
|
+
fm.featured_image = `/media/${basename}`
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Visit paragraph nodes only — ![[...]] inside code/inlineCode are never paragraphs,
|
|
235
|
+
// so this naturally skips code blocks without needing ancestor traversal.
|
|
236
|
+
walkTree(tree, 'paragraph', (para: any) => {
|
|
237
|
+
let changed = false
|
|
238
|
+
const newChildren: any[] = []
|
|
239
|
+
|
|
240
|
+
for (const child of para.children) {
|
|
241
|
+
if (child.type !== 'text') {
|
|
242
|
+
newChildren.push(child)
|
|
243
|
+
continue
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const replacements = processText(child.value, noteDir, options.vaultDir)
|
|
247
|
+
if (replacements === null) {
|
|
248
|
+
newChildren.push(child)
|
|
249
|
+
} else {
|
|
250
|
+
newChildren.push(...replacements)
|
|
251
|
+
changed = true
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (changed) {
|
|
256
|
+
para.children = newChildren
|
|
257
|
+
}
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
}
|