scavold 0.2.0-rc.2 → 0.2.0-rc.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/CHANGELOG.md CHANGED
@@ -7,6 +7,42 @@ All notable changes to Scavold are documented here. The format follows
7
7
  While the version stays below `1.0.0` and carries a pre-release suffix, breaking
8
8
  changes may occur in any release.
9
9
 
10
+ ## [0.2.0-rc.3] — 2026-08-09
11
+
12
+ ### Added
13
+
14
+ - Media that is not an image is published. Files an author uploads into the media folder
15
+ — PDFs, videos, archives — are copied into the static output keeping their path, and
16
+ references to them are rewritten to the URL the built site serves: Markdown links,
17
+ and container arguments declared as `media-file` such as a video's `src` or `poster`.
18
+ Previously only Markdown image syntax was handled, so a linked document or a video
19
+ resolved to a dead URL although the editor offered exactly that reference. Where a
20
+ single URL is needed instead of a srcset — a `poster`, a link to an image — the
21
+ largest generated variant is used.
22
+
23
+ ### Fixed
24
+
25
+ - Flag arguments on a container reach components that declare them as props. Bare words
26
+ used to become the container's `class` only, while `useVideo()` reads its booleans
27
+ from `data-*` props — so the documented `::: video src=… overlay autoplay loop`
28
+ rendered a plain inline player with no background mode, no autoplay and no loop, and
29
+ failed silently. Flags are now emitted as an empty `data-<flag>` attribute in addition
30
+ to the class. A flag a component does not declare as a prop shows up in the markup as
31
+ an empty attribute (`<section class="highlight" data-highlight>`).
32
+
33
+ ### Added
34
+
35
+ - Container blocks work with any name, declared or not: an undeclared name is rendered
36
+ by `ScavoldContainer` instead of leaving its fences in the page as literal text. To
37
+ keep a typo from silently becoming a `<div>`, the build reports each undeclared name
38
+ once; declaring it in `.cratly.config.yaml` silences the report and adds typed
39
+ properties for the editor.
40
+
41
+ ### Fixed
42
+
43
+ - `registerContainers()` and the component reference described `ScavoldContainer` as a
44
+ catch-all fallback, which it was not — no rule was registered for unknown names.
45
+
10
46
  ## [0.2.0-rc.2] — 2026-08-06
11
47
 
12
48
  ### Fixed
@@ -55,5 +91,6 @@ First published release. Version `0.1.0` existed in-tree only.
55
91
  - Only images are processed out of `media_folder`; other file types (video, documents)
56
92
  are never copied into the build output and have to live in the static folder.
57
93
 
94
+ [0.2.0-rc.3]: https://gitlab.com/cepharum-foss/cratly/scavold/-/compare/v0.2.0-rc.2...v0.2.0-rc.3
58
95
  [0.2.0-rc.2]: https://gitlab.com/cepharum-foss/cratly/scavold/-/compare/v0.2.0-rc.1...v0.2.0-rc.2
59
96
  [0.2.0-rc.1]: https://gitlab.com/cepharum-foss/cratly/scavold/-/tags/v0.2.0-rc.1
package/COMPONENTS.md CHANGED
@@ -495,6 +495,18 @@ Fallback component for Markdown container blocks whose name has no dedicated
495
495
  registered component. Renders as the matching HTML sectioning element if the
496
496
  container name is one (`section`, `aside`, etc.), otherwise as a `<div>`.
497
497
 
498
+ Any container name works without being declared first — undeclared names are caught
499
+ by this component. Because a typo would otherwise become a silent `<div>`, the build
500
+ reports each undeclared name once:
501
+
502
+ ```
503
+ [scavold] container ':::sectoin' is not declared in .cratly.config.yaml — rendering
504
+ it with ScavoldContainer. Declare it to silence this, or fix the name if it is a typo.
505
+ ```
506
+
507
+ Declaring the container in `.cratly.config.yaml` silences the report and gives it
508
+ typed properties in the cratly editor.
509
+
498
510
  Not intended for direct use. Theme developers should instead create a dedicated
499
511
  component for each container name they declare in `.cratly.config.yaml`.
500
512
 
@@ -697,6 +709,13 @@ All `key=value` pairs from the opening line are passed as additional props with
697
709
  `data-` prefix (e.g. `background=/img.jpg` → prop `data-background`). Declare them
698
710
  explicitly in `defineProps` to use them.
699
711
 
712
+ Flags arrive **both ways**: as part of `class` and as an empty `data-<flag>` prop, so
713
+ a boolean container parameter can be declared as a prop (this is how `useVideo` reads
714
+ `overlay`, `autoplay` and `loop`) while purely presentational flags keep working as
715
+ class names. A flag a component does not declare falls through into the markup as an
716
+ empty attribute — `::: section highlight` renders `<section class="highlight"
717
+ data-highlight>`.
718
+
700
719
  #### Returns
701
720
 
702
721
  | Name | Type | Description |
package/README.md CHANGED
@@ -13,7 +13,7 @@ Scavold is currently a **pre-release**: breaking changes may occur between relea
13
13
  so pin the version you develop against rather than following the range blindly.
14
14
 
15
15
  ```sh
16
- bun add vitepress vue scavold@^0.2.0-rc.2
16
+ bun add vitepress vue scavold@^0.2.0-rc.3
17
17
  ```
18
18
 
19
19
  Responsive image generation uses [sharp](https://sharp.pixelplumbing.com/), which
package/lib/config.js CHANGED
@@ -5,7 +5,7 @@ import { readdirSync, readFileSync } from "node:fs";
5
5
  import { clearCache, compileHierarchy, compileRedirects, sourceFolder } from "./pages.js";
6
6
  import { useMedia } from "./media.js";
7
7
  import { patchRenderer } from "./markdown.js";
8
- import { registerContainers, resolveContainerMap } from "./containers.js";
8
+ import { extractContainerMediaSrcs, mediaPropsOf, registerContainers, resolveContainerMap } from "./containers.js";
9
9
  import { buildSectionManifest, writeSectionManifest } from "./sectionManifest.js";
10
10
  import { isExternalUrl, servableRedirectTarget } from "./redirectTarget.js";
11
11
 
@@ -167,38 +167,42 @@ export function resolveFolders( rawConfig = {}, declared = {} ) {
167
167
  * @param {object} [options]
168
168
  * @param {Object<string,string>} [options.containers] explicit container-name →
169
169
  * component-name overrides, merged on top of Scavold defaults and any names
170
- * declared in .crate.config.yaml
170
+ * declared in .cratly.config.yaml
171
171
  */
172
172
  export async function augmentConfig( rawConfig, options = {} ) {
173
- // Read .crate.config.yaml for structural config and container declarations (Tier 2)
174
- let crateContainers = {};
175
- let crateSrcDir = null;
176
- let crateMediaDir = null;
177
- let crateStaticDir = null;
178
- let crateImageSizes = null;
179
- let crateImageWidths = null;
173
+ // Read .cratly.config.yaml for structural config and container declarations (Tier 2)
174
+ let declaredContainers = {};
175
+ let declaredPagesFolder = null;
176
+ let declaredMediaFolder = null;
177
+ let declaredStaticFolder = null;
178
+ let declaredImageSizes = null;
179
+ let declaredImageWidths = null;
180
180
 
181
181
  try {
182
182
  const { readFile } = await import( "node:fs/promises" );
183
183
  const YAML = ( await import( "yaml" ) ).default;
184
184
  const raw = await readFile( join( process.cwd(), ".cratly.config.yaml" ), "utf-8" );
185
- const crate = YAML.parse( raw ) ?? {};
186
- crateContainers = crate.containers ?? {};
187
- crateSrcDir = crate.pages_folder ?? null;
188
- crateMediaDir = crate.media_folder ?? null;
189
- crateStaticDir = crate.static_folder ?? null;
190
- crateImageSizes = crate.image_sizes ?? null;
191
- crateImageWidths = crate.image_widths ?? null;
185
+ const cratlyConfig = YAML.parse( raw ) ?? {};
186
+ declaredContainers = cratlyConfig.containers ?? {};
187
+ declaredPagesFolder = cratlyConfig.pages_folder ?? null;
188
+ declaredMediaFolder = cratlyConfig.media_folder ?? null;
189
+ declaredStaticFolder = cratlyConfig.static_folder ?? null;
190
+ declaredImageSizes = cratlyConfig.image_sizes ?? null;
191
+ declaredImageWidths = cratlyConfig.image_widths ?? null;
192
192
  } catch {
193
193
  // file absent or unparseable — proceed with defaults
194
194
  }
195
195
 
196
- const containerMap = resolveContainerMap( crateContainers, options.containers );
196
+ const containerMap = resolveContainerMap( declaredContainers, options.containers );
197
+
198
+ // Which container arguments hold a media path — needed twice: to process those
199
+ // files before rendering, and to rewrite the arguments to their published URL.
200
+ const mediaProps = mediaPropsOf( buildSectionManifest( declaredContainers, { adapterVersion: SCAVOLD_VERSION } ) );
197
201
 
198
202
  const { srcDir, mediaDir, staticFolder } = resolveFolders( rawConfig, {
199
- pagesFolder: crateSrcDir,
200
- mediaFolder: crateMediaDir,
201
- staticFolder: crateStaticDir,
203
+ pagesFolder: declaredPagesFolder,
204
+ mediaFolder: declaredMediaFolder,
205
+ staticFolder: declaredStaticFolder,
202
206
  } );
203
207
 
204
208
  const vitePublicDir = rawConfig.vite?.publicDir
@@ -217,8 +221,8 @@ export async function augmentConfig( rawConfig, options = {} ) {
217
221
  };
218
222
 
219
223
  const media = useMedia( resolvedConfig, {
220
- ...( crateImageSizes != null && { sizes: crateImageSizes } ),
221
- ...( crateImageWidths != null && { widths: crateImageWidths } ),
224
+ ...( declaredImageSizes != null && { sizes: declaredImageSizes } ),
225
+ ...( declaredImageWidths != null && { widths: declaredImageWidths } ),
222
226
  } );
223
227
 
224
228
  async function warmUpMedia() {
@@ -227,7 +231,7 @@ export async function augmentConfig( rawConfig, options = {} ) {
227
231
  const pageFiles = ( await Array.fromAsync( glob( "**/*.md", { cwd: pagesDir } ) ) )
228
232
  .map( f => join( pagesDir, f ) );
229
233
 
230
- await media.warmUp( pageFiles );
234
+ await media.warmUp( pageFiles, source => extractContainerMediaSrcs( source, mediaProps ) );
231
235
  }
232
236
 
233
237
  // VitePress's internal markdown LRU cache clear function — not public API
@@ -310,7 +314,7 @@ export async function augmentConfig( rawConfig, options = {} ) {
310
314
  // at build start and once when the dev server starts listening.
311
315
  async function emitSectionManifest() {
312
316
  try {
313
- const manifest = buildSectionManifest( crateContainers, { adapterVersion: SCAVOLD_VERSION } );
317
+ const manifest = buildSectionManifest( declaredContainers, { adapterVersion: SCAVOLD_VERSION } );
314
318
  await writeSectionManifest( process.cwd(), manifest );
315
319
  } catch ( error ) {
316
320
  console.warn( "[scavold] could not write .cratly/sections.json —", error.message );
@@ -417,7 +421,25 @@ export async function augmentConfig( rawConfig, options = {} ) {
417
421
  return defaultHandler( tokens, idx, options, ...args );
418
422
  } );
419
423
 
420
- registerContainers( md, containerMap );
424
+ // A link whose target is a file in the media folder — a PDF an author
425
+ // uploaded and linked for download, say — points at the path the editor
426
+ // offers, which is relative to that folder. Rewrite it to the URL the
427
+ // built site serves. Targets that are not media files (ordinary page
428
+ // links, anchors, remote URLs) resolve to null and stay untouched.
429
+ patchRenderer( md, "link_open", ( defaultHandler, tokens, idx, ...args ) => {
430
+ const published = media.collectAsset( tokens[idx].attrGet( "href" ) );
431
+
432
+ if ( published ) {
433
+ tokens[idx].attrSet( "href", published );
434
+ }
435
+
436
+ return defaultHandler( tokens, idx, ...args );
437
+ } );
438
+
439
+ registerContainers( md, containerMap, {
440
+ mediaProps,
441
+ resolveMedia: url => media.collectAsset( url ),
442
+ } );
421
443
  },
422
444
  },
423
445
  };
package/lib/containers.js CHANGED
@@ -53,21 +53,41 @@ function resolveTag( name, map ) {
53
53
  * @param {string} containerName the original container name from markdown
54
54
  * @returns {string}
55
55
  */
56
- function renderToken( tag, token, containerName ) {
56
+ function renderToken( tag, token, containerName, { mediaProps = {}, resolveMedia } = {} ) {
57
57
  if ( token.nesting !== 1 ) {
58
58
  return `</${tag}>\n`;
59
59
  }
60
60
 
61
61
  const args = parseKeyValuePairs( token.info.trim().replace( /^\S+\s*/, "" ) );
62
62
 
63
- const classes = Object.keys( args )
64
- .filter( key => args[key] === true )
65
- .join( " " );
63
+ // Arguments declared as `media-file` carry a path relative to the media folder, as
64
+ // the editor writes them. Turn them into the URL the built site serves.
65
+ if ( resolveMedia ) {
66
+ for ( const key of mediaProps[containerName] ?? [] ) {
67
+ const value = args[key];
66
68
 
69
+ if ( typeof value === "string" ) {
70
+ args[key] = resolveMedia( value ) ?? value;
71
+ }
72
+ }
73
+ }
74
+
75
+ const flags = Object.keys( args ).filter( key => args[key] === true );
76
+
77
+ const classes = flags.join( " " );
78
+
79
+ // Every argument is emitted as a data-* attribute, flags included — a flag is a
80
+ // boolean parameter of the container, and a component declaring it as a prop
81
+ // (see useVideo) has to be able to receive it. Flags additionally stay classes,
82
+ // which is what purely presentational ones like `dark` or `centered` are used for.
83
+ // A component that declares the prop consumes the attribute; one that does not
84
+ // lets it fall through into the markup.
67
85
  const attrs = Object.entries( args )
68
86
  .filter( ( [ , value ] ) => value && value !== true )
69
87
  .map( ( [ key, value ] ) => `data-${key}="${value}"` );
70
88
 
89
+ attrs.push( ...flags.map( flag => `data-${flag}=""` ) );
90
+
71
91
  attrs.push( `data-container="${containerName}"` );
72
92
 
73
93
  if ( classes ) {
@@ -80,36 +100,85 @@ function renderToken( tag, token, containerName ) {
80
100
  /**
81
101
  * Registers markdown-it-container plugins for all container names in the
82
102
  * provided map, plus ScavoldContainer as the catch-all fallback for any name
83
- * not in the map.
103
+ * not in the map. Undeclared names are reported once each, so a typo does not
104
+ * silently turn into a `<div>`.
84
105
  *
85
106
  * @param {import('markdown-it')} md markdown-it instance
86
107
  * @param {Object<string,string>} containerMap resolved name→component map
108
+ * @param {object} [options]
109
+ * @param {function} [options.warn] sink for the undeclared-name reports
87
110
  */
88
- export function registerContainers( md, containerMap ) {
111
+ export function registerContainers( md, containerMap, { warn = console.warn, mediaProps, resolveMedia } = {} ) {
112
+ const renderOptions = { mediaProps, resolveMedia };
113
+
89
114
  for ( const [ name, tag ] of Object.entries( containerMap ) ) {
90
115
  md.use( ContainerPlugin, name, {
91
- render: ( tokens, index ) => renderToken( tag, tokens[index], name ),
116
+ render: ( tokens, index ) => renderToken( tag, tokens[index], name, renderOptions ),
92
117
  } );
93
118
  }
119
+
120
+ // Catch-all, registered last so every known name is matched by its own rule
121
+ // first. Without it an undeclared name is not a container at all and its fences
122
+ // end up as literal text in the page.
123
+ const reported = new Set();
124
+
125
+ md.use( ContainerPlugin, "scavold-unknown", {
126
+ validate: params => {
127
+ const name = containerNameOf( params );
128
+
129
+ return Boolean( name ) && !( name in containerMap );
130
+ },
131
+ render: ( tokens, index ) => {
132
+ const name = containerNameOf( tokens[index].info );
133
+
134
+ // A typo would otherwise become a silent <div> — the fences used to stay
135
+ // visible in the page, which was crude but at least noticeable. Report each
136
+ // unknown name once per build; declaring it in .cratly.config.yaml silences
137
+ // this and gives it typed properties in the editor.
138
+ if ( tokens[index].nesting === 1 && !reported.has( name ) ) {
139
+ reported.add( name );
140
+ warn(
141
+ "[scavold] container ':::%s' is not declared in .cratly.config.yaml — " +
142
+ "rendering it with ScavoldContainer. Declare it to silence this, or fix the name if it is a typo.",
143
+ name
144
+ );
145
+ }
146
+
147
+ return renderToken( resolveTag( name, containerMap ), tokens[index], name, renderOptions );
148
+ },
149
+ } );
150
+ }
151
+
152
+ /**
153
+ * Extracts the container name from a container fence's info string, i.e. the first
154
+ * word after the colons. Returns an empty string when it is not a usable name.
155
+ *
156
+ * @param {string} params info string of the opening fence, e.g. "teaser dark x=1"
157
+ * @returns {string}
158
+ */
159
+ function containerNameOf( params ) {
160
+ const [ name = "" ] = String( params ).trim().split( /\s+/, 1 );
161
+
162
+ return /^[a-z][\w-]*$/i.test( name ) ? name : "";
94
163
  }
95
164
 
96
165
  /**
97
166
  * Merges the Scavold default container map with site-level declarations from
98
- * .crate.config.yaml and an explicit developer-supplied override map.
167
+ * .cratly.config.yaml and an explicit developer-supplied override map.
99
168
  *
100
169
  * Resolution order (last wins):
101
170
  * 1. Scavold built-in sectioning element defaults
102
- * 2. Names declared in crateConfig.containers (mapped to Scavold{Name})
171
+ * 2. Names declared in the containers block of .cratly.config.yaml (mapped to Scavold{Name})
103
172
  * 3. Explicit overrides passed directly to augmentConfig
104
173
  *
105
- * @param {object} [crateContainers] containers block from .crate.config.yaml
174
+ * @param {object} [declaredContainers] containers block from .cratly.config.yaml
106
175
  * @param {Object<string,string>} [overrides] explicit name→component overrides
107
176
  * @returns {Object<string,string>}
108
177
  */
109
- export function resolveContainerMap( crateContainers = {}, overrides = {} ) {
178
+ export function resolveContainerMap( declaredContainers = {}, overrides = {} ) {
110
179
  const map = defaultContainerMap();
111
180
 
112
- for ( const [ name, value ] of Object.entries( crateContainers ) ) {
181
+ for ( const [ name, value ] of Object.entries( declaredContainers ) ) {
113
182
  if ( typeof value === "string" ) {
114
183
  // shorthand: "name: ComponentName" — treat string as explicit component override
115
184
  map[name] = value;
@@ -126,3 +195,64 @@ export function resolveContainerMap( crateContainers = {}, overrides = {} ) {
126
195
 
127
196
  return map;
128
197
  }
198
+
199
+ /**
200
+ * Collects the values of container arguments declared as `media-file`, so the media
201
+ * pipeline can process them before rendering. Markdown syntax does not reveal these
202
+ * references — `:::video src=/clip.mp4` is a fence, not an image — which is why they
203
+ * need their own extraction pass.
204
+ *
205
+ * @param {string} source markdown source of one page
206
+ * @param {Object<string,string[]>} mediaProps container name → names of its media-file props
207
+ * @returns {string[]} referenced media paths, as written by the author
208
+ */
209
+ export function extractContainerMediaSrcs( source, mediaProps = {} ) {
210
+ const srcs = [];
211
+
212
+ for ( const line of String( source ).split( /\r?\n/ ) ) {
213
+ const match = /^:{3,}\s*(\S+)(.*)$/.exec( line );
214
+
215
+ if ( !match ) {
216
+ continue;
217
+ }
218
+
219
+ const keys = mediaProps[match[1]];
220
+
221
+ if ( !keys?.length ) {
222
+ continue;
223
+ }
224
+
225
+ const args = parseKeyValuePairs( match[2].trim() );
226
+
227
+ for ( const key of keys ) {
228
+ if ( typeof args[key] === "string" ) {
229
+ srcs.push( args[key] );
230
+ }
231
+ }
232
+ }
233
+
234
+ return srcs;
235
+ }
236
+
237
+ /**
238
+ * Derives the media-file argument names per container from a section-type manifest,
239
+ * covering both Scavold's built-in containers and the site's own declarations.
240
+ *
241
+ * @param {{sections?: Object<string,{props?: Object<string,{type?: string}>}>}} manifest
242
+ * @returns {Object<string,string[]>} container name → names of its media-file props
243
+ */
244
+ export function mediaPropsOf( manifest ) {
245
+ const map = {};
246
+
247
+ for ( const [ name, section ] of Object.entries( manifest?.sections ?? {} ) ) {
248
+ const keys = Object.entries( section?.props ?? {} )
249
+ .filter( ( [ , def ] ) => def?.type === "media-file" )
250
+ .map( ( [ key ] ) => key );
251
+
252
+ if ( keys.length ) {
253
+ map[name] = keys;
254
+ }
255
+ }
256
+
257
+ return map;
258
+ }
package/lib/media.js CHANGED
@@ -1,11 +1,28 @@
1
1
  import { createHash } from "node:crypto";
2
- import { join, basename, extname } from "node:path";
3
- import { existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { join, basename, dirname, extname } from "node:path";
3
+ import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
4
4
  import { readFile } from "node:fs/promises";
5
5
  import sharp from "sharp";
6
6
 
7
7
  const DEFAULT_WIDTHS = [ 320, 640, 960, 1280, 1920 ];
8
8
 
9
+ /**
10
+ * File types the image pipeline can scale. Everything else in the media folder is
11
+ * published by copying it verbatim — authors upload PDFs, videos and other downloads
12
+ * into the same folder, and the editor offers them for reference just like images.
13
+ */
14
+ const IMAGE_EXTENSIONS = new Set( [ "jpg", "jpeg", "png", "webp", "avif", "gif", "tiff" ] );
15
+
16
+ /**
17
+ * Decides whether a media file goes through the image pipeline.
18
+ *
19
+ * @param {string} filePath path or URL of the media file
20
+ * @returns {boolean}
21
+ */
22
+ export function isProcessableImage( filePath ) {
23
+ return IMAGE_EXTENSIONS.has( extname( filePath ).toLowerCase().replace( ".", "" ) );
24
+ }
25
+
9
26
  /**
10
27
  * Returns the public output folder for generated image variants.
11
28
  *
@@ -111,6 +128,26 @@ export function extractImageSrcs( source ) {
111
128
  return srcs;
112
129
  }
113
130
 
131
+ /**
132
+ * Extracts local link targets from a markdown source string, skipping image syntax.
133
+ * Most of these are ordinary page links; the media pipeline treats them as optional
134
+ * candidates and ignores the ones that do not resolve to a file in the media folder.
135
+ *
136
+ * @param {string} source markdown source
137
+ * @returns {string[]} list of link targets found
138
+ */
139
+ export function extractLinkTargets( source ) {
140
+ const targets = [];
141
+
142
+ for ( const match of source.matchAll( /(!?)\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g ) ) {
143
+ if ( match[1] !== "!" ) {
144
+ targets.push( match[2] );
145
+ }
146
+ }
147
+
148
+ return targets;
149
+ }
150
+
114
151
  /**
115
152
  * Sets up media handling for a Scavold-based VitePress site.
116
153
  *
@@ -134,6 +171,9 @@ export function useMedia( config, options = {} ) {
134
171
  /** @type {Map<string, {src: string, srcset: string, webpSrcset: string, sizes: string}>} */
135
172
  const cache = new Map();
136
173
 
174
+ /** Published URL per non-image media reference, e.g. "/handbook.pdf" → "/media/handbook.pdf". */
175
+ const assets = new Map();
176
+
137
177
  /**
138
178
  * Resolves a markdown image src to an absolute filesystem path, or null for
139
179
  * remote URLs.
@@ -157,8 +197,8 @@ export function useMedia( config, options = {} ) {
157
197
  * @param {string} imageUrl
158
198
  * @returns {Promise<void>}
159
199
  */
160
- async function process( imageUrl ) {
161
- if ( cache.has( imageUrl ) ) {
200
+ async function process( imageUrl, { optional = false } = {} ) {
201
+ if ( cache.has( imageUrl ) || assets.has( imageUrl ) ) {
162
202
  return;
163
203
  }
164
204
 
@@ -169,7 +209,26 @@ export function useMedia( config, options = {} ) {
169
209
  }
170
210
 
171
211
  if ( !existsSync( filePath ) ) {
172
- console.error( "image %s is missing (resolved to %s)", imageUrl, filePath );
212
+ // Optional candidates are link targets: most links point at pages, not at
213
+ // media, so a miss is the normal case and not worth reporting.
214
+ if ( !optional ) {
215
+ console.error( "media file %s is missing (resolved to %s)", imageUrl, filePath );
216
+ }
217
+
218
+ return;
219
+ }
220
+
221
+ // Anything the image pipeline cannot scale — PDFs, videos, archives — is
222
+ // published by copying it into the same output folder, keeping its path so the
223
+ // reference the editor offers stays valid.
224
+ if ( !isProcessableImage( filePath ) ) {
225
+ const relative = imageUrl.replace( /^\//, "" );
226
+ const target = join( outDir, relative );
227
+
228
+ mkdirSync( dirname( target ), { recursive: true } );
229
+ copyFileSync( filePath, target );
230
+ assets.set( imageUrl, `/media/${relative}` );
231
+
173
232
  return;
174
233
  }
175
234
 
@@ -190,10 +249,14 @@ export function useMedia( config, options = {} ) {
190
249
  * Call this from the VitePress buildStart hook.
191
250
  *
192
251
  * @param {string[]} pageFiles absolute paths to markdown source files
252
+ * @param {function(string): string[]} [extraSrcs] returns further media references
253
+ * found in a page source — used for container arguments declared as
254
+ * `media-file`, which markdown syntax does not reveal
193
255
  * @returns {Promise<void>}
194
256
  */
195
- async warmUp( pageFiles ) {
257
+ async warmUp( pageFiles, extraSrcs = () => [] ) {
196
258
  const srcs = new Set();
259
+ const candidates = new Set();
197
260
 
198
261
  await Promise.all( pageFiles.map( async file => {
199
262
  const source = await readFile( file, "utf-8" );
@@ -201,9 +264,20 @@ export function useMedia( config, options = {} ) {
201
264
  for ( const src of extractImageSrcs( source ) ) {
202
265
  srcs.add( src );
203
266
  }
267
+
268
+ for ( const src of extraSrcs( source ) ) {
269
+ srcs.add( src );
270
+ }
271
+
272
+ for ( const target of extractLinkTargets( source ) ) {
273
+ candidates.add( target );
274
+ }
204
275
  } ) );
205
276
 
206
- await Promise.all( [ ...srcs ].map( src => process( src ) ) );
277
+ await Promise.all( [
278
+ ...[ ...srcs ].map( src => process( src ) ),
279
+ ...[ ...candidates ].filter( t => !srcs.has( t ) ).map( t => process( t, { optional: true } ) ),
280
+ ] );
207
281
  },
208
282
 
209
283
  /**
@@ -227,5 +301,21 @@ export function useMedia( config, options = {} ) {
227
301
 
228
302
  return data;
229
303
  },
304
+
305
+ /**
306
+ * Returns the published URL for any media reference — the copied file for
307
+ * non-images, the fallback variant for images. Used where a single URL is
308
+ * needed rather than a srcset: markdown links, and container arguments
309
+ * declared as `media-file` such as a video's `src` or `poster`.
310
+ *
311
+ * Returns null for references that are not media files, so callers can leave
312
+ * ordinary links and remote URLs untouched. Must be called after warmUp().
313
+ *
314
+ * @param {string} url reference as written by the author, resolved against the media folder
315
+ * @returns {string|null}
316
+ */
317
+ collectAsset( url ) {
318
+ return assets.get( url ) ?? cache.get( url )?.src ?? null;
319
+ },
230
320
  };
231
321
  }
@@ -120,19 +120,19 @@ function propsFromDeclaration( decl ) {
120
120
  * `containers` declarations from .cratly.config.yaml — adding custom sections
121
121
  * and overriding labels/hints/props of built-ins.
122
122
  *
123
- * @param {object} [crateContainers] the `containers` block from .cratly.config.yaml
123
+ * @param {object} [declaredContainers] the `containers` block from .cratly.config.yaml
124
124
  * @param {object} [options]
125
125
  * @param {string} [options.adapterVersion] version reported under `adapter.version`
126
126
  * @returns {object} manifest conforming to SECTION_SCHEMA_URL
127
127
  */
128
- export function buildSectionManifest( crateContainers = {}, { adapterVersion } = {} ) {
128
+ export function buildSectionManifest( declaredContainers = {}, { adapterVersion } = {} ) {
129
129
  const sections = {};
130
130
 
131
131
  for ( const [ name, def ] of Object.entries( BUILTIN_SECTIONS ) ) {
132
132
  sections[name] = structuredClone( def );
133
133
  }
134
134
 
135
- for ( const [ name, value ] of Object.entries( crateContainers ) ) {
135
+ for ( const [ name, value ] of Object.entries( declaredContainers ) ) {
136
136
  const decl = typeof value === "string" || value == null ? {} : value;
137
137
  const entry = sections[name] ?? {};
138
138
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scavold",
3
- "version": "0.2.0-rc.2",
3
+ "version": "0.2.0-rc.3",
4
4
  "type": "module",
5
5
  "description": "VitePress theme framework — a scaffold for building custom VitePress themes with Vue at the core",
6
6
  "keywords": [