@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/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 type { KaraokeConfig, ResolvedModules, ResolvedLayout, ResolvedCollections, ResolvedMenus } from './types.js';
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
- const modules = (config.modules ?? []).filter(m => m.enabled !== false);
71
- const _resolvedMenus = resolveMenus(vaultDir, modules);
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 modules) {
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
- // ── Load theme and inject module routes ───────────────────────────
113
- const themeIntegration = theme.toAstroIntegration(modules);
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
- for (const mod of modules) {
116
- for (const route of mod.routes) {
117
- injectRoute(route);
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
- // ── Module routes injected from implements[] on the ThemeInstance ──
122
- if (isThemeInstance(config.theme)) {
123
- for (const mod of config.theme.implementedModules ?? []) {
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({ pattern: route.pattern, entrypoint: route.entrypoint });
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: [virtualConfigPlugin(config, resolved, layout, _resolvedCollections, _resolvedMenus)],
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 (!_resolvedCollections?.docs?.enabled) return;
158
- // pages is { pathname: string }[] — check if any /docs page was rendered
159
- const hasDocsRoute = (pages ?? []).some(p => p.pathname?.startsWith('docs'));
160
- if (!hasDocsRoute) {
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 the active theme ' +
163
- 'has no /docs route. Published docs entries will not be accessible. ' +
164
- 'Consider using @karaoke-cms/theme-default which includes /docs routes.',
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
- resolvedMenus: ResolvedMenus,
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(resolvedMenus)};
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 type { ModuleInstance, ThemeInstance, ModuleMenuEntry } from './types.js';
212
- export type { KaraokeConfig, ResolvedModules, ResolvedLayout, ResolvedCollections, RegionComponent, ResolvedMenus, ResolvedMenu, ResolvedMenuEntry } from './types.js';
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';
@@ -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 [blogPosts, docPages] = await Promise.all([
8
- getCollection('blog', ({ data }) => data.publish === true),
9
- getCollection('docs', ({ data }) => data.publish === true),
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 = docPages.map(doc => ({
20
- title: doc.data.title,
21
- pubDate: doc.data.date,
22
- description: doc.data.description,
23
- link: `/docs/${doc.id}/`,
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
+ }