scavold 0.2.0-rc.1 → 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 ADDED
@@ -0,0 +1,96 @@
1
+ # Changelog
2
+
3
+ All notable changes to Scavold are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versions follow
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ While the version stays below `1.0.0` and carries a pre-release suffix, breaking
8
+ changes may occur in any release.
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
+
46
+ ## [0.2.0-rc.2] — 2026-08-06
47
+
48
+ ### Fixed
49
+
50
+ - `media_folder` from `.cratly.config.yaml` is honoured again. `augmentConfig()` read
51
+ every other folder declaration but this one, so image resolution fell back to the
52
+ pages folder: any image stored in the editor-managed media folder was reported as
53
+ missing and failed the build. No site could use an image from `media/`.
54
+
55
+ ### Changed
56
+
57
+ - The folder precedence rules moved into an exported, pure `resolveFolders()`, so an
58
+ explicit `srcDir` / `mediaDir` in the VitePress config beating the YAML declaration
59
+ is covered by unit tests instead of only by a running site.
60
+
61
+ ## [0.2.0-rc.1] — 2026-08-05
62
+
63
+ First published release. Version `0.1.0` existed in-tree only.
64
+
65
+ ### Added
66
+
67
+ - **Section-type manifest.** The build emits `.cratly/sections.json`, describing the
68
+ typed properties each container accepts, so the cratly editor can render matching
69
+ controls. Container declarations in `.cratly.config.yaml` support `props` (with
70
+ `flags` and `kv` as shorthands) and are merged with Scavold's built-in sections.
71
+ - **Background videos.** The `video` container accepts `overlay` to place the video
72
+ behind the block's content, plus `controls` to bring the native controls back in
73
+ that mode.
74
+ - Type declarations for both entry points (`scavold`, `scavold/config`), a `typecheck`
75
+ script, and the MIT licence text the package always claimed to carry.
76
+
77
+ ### Fixed
78
+
79
+ - `Scavold.UserConfig` in the shipped declarations was a self-referential type alias
80
+ (TS2456) and now aliases VitePress's `UserConfig`.
81
+
82
+ ### Known issues
83
+
84
+ - Flag arguments on the `video` container have no effect: bare words become the
85
+ container's `class`, while `useVideo()` reads its booleans from `data-*` props, which
86
+ only `key=value` arguments produce. Use `overlay=1 autoplay=1 loop=1` until this is
87
+ resolved.
88
+ - Container names that are neither built in nor declared in `.cratly.config.yaml` are
89
+ not parsed as containers at all, despite `ScavoldContainer` being documented as a
90
+ catch-all fallback.
91
+ - Only images are processed out of `media_folder`; other file types (video, documents)
92
+ are never copied into the build output and have to live in the static folder.
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
95
+ [0.2.0-rc.2]: https://gitlab.com/cepharum-foss/cratly/scavold/-/compare/v0.2.0-rc.1...v0.2.0-rc.2
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
@@ -9,11 +9,11 @@ custom containers for Markdown-driven structures on your pages such as galleries
9
9
  bun add vitepress vue scavold
10
10
  ```
11
11
 
12
- Scavold is currently published as a pre-release, so install it explicitly while
13
- `latest` does not exist yet:
12
+ Scavold is currently a **pre-release**: breaking changes may occur between releases,
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@next
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
 
@@ -130,6 +130,35 @@ function parseSizesFromTitle( title ) {
130
130
  return match?.[1] ?? match?.[2] ?? undefined;
131
131
  }
132
132
 
133
+ /**
134
+ * Resolves the three content folders from the VitePress config and the declarations
135
+ * of `.cratly.config.yaml`. An explicit value in the VitePress config always wins,
136
+ * so a site can express a path in code instead of in the YAML.
137
+ *
138
+ * Kept pure and exported so the precedence rules are testable without a filesystem.
139
+ *
140
+ * @param {object} rawConfig VitePress user config as passed to augmentConfig()
141
+ * @param {object} declared folders declared in .cratly.config.yaml (null when absent)
142
+ * @param {string|null} [declared.pagesFolder] `pages_folder`
143
+ * @param {string|null} [declared.mediaFolder] `media_folder`
144
+ * @param {string|null} [declared.staticFolder] `static_folder`
145
+ * @returns {{srcDir: string|undefined, mediaDir: string|undefined, staticFolder: string|null}}
146
+ */
147
+ export function resolveFolders( rawConfig = {}, declared = {} ) {
148
+ const srcDir = rawConfig.srcDir ?? declared.pagesFolder ?? undefined;
149
+
150
+ // Media folder for image resolution. Without either source, useMedia() falls back
151
+ // to srcDir, i.e. images living next to the pages that reference them.
152
+ const mediaDir = rawConfig.mediaDir ?? declared.mediaFolder ?? undefined;
153
+
154
+ // Prevent VitePress/Vite from defaulting publicDir to {srcDir}/public when content
155
+ // lives in a subfolder — static assets must not appear inside the pages folder.
156
+ // Falls back to "public" at the project root only when srcDir is a subfolder.
157
+ const staticFolder = declared.staticFolder ?? ( srcDir && srcDir !== "." ? "public" : null );
158
+
159
+ return { srcDir, mediaDir, staticFolder };
160
+ }
161
+
133
162
  /**
134
163
  * Augments provided VitePress configuration to integrate extended theme
135
164
  * features.
@@ -138,42 +167,44 @@ function parseSizesFromTitle( title ) {
138
167
  * @param {object} [options]
139
168
  * @param {Object<string,string>} [options.containers] explicit container-name →
140
169
  * component-name overrides, merged on top of Scavold defaults and any names
141
- * declared in .crate.config.yaml
170
+ * declared in .cratly.config.yaml
142
171
  */
143
172
  export async function augmentConfig( rawConfig, options = {} ) {
144
- // Read .crate.config.yaml for structural config and container declarations (Tier 2)
145
- let crateContainers = {};
146
- let crateSrcDir = null;
147
- let crateStaticDir = null;
148
- let crateImageSizes = null;
149
- 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;
150
180
 
151
181
  try {
152
182
  const { readFile } = await import( "node:fs/promises" );
153
183
  const YAML = ( await import( "yaml" ) ).default;
154
184
  const raw = await readFile( join( process.cwd(), ".cratly.config.yaml" ), "utf-8" );
155
- const crate = YAML.parse( raw ) ?? {};
156
- crateContainers = crate.containers ?? {};
157
- crateSrcDir = crate.pages_folder ?? null;
158
- crateStaticDir = crate.static_folder ?? null;
159
- crateImageSizes = crate.image_sizes ?? null;
160
- 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;
161
192
  } catch {
162
193
  // file absent or unparseable — proceed with defaults
163
194
  }
164
195
 
165
- const containerMap = resolveContainerMap( crateContainers, options.containers );
196
+ const containerMap = resolveContainerMap( declaredContainers, options.containers );
166
197
 
167
- // Apply srcDir from crate config if not already set in rawConfig
168
- const srcDir = rawConfig.srcDir ?? crateSrcDir ?? undefined;
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 } ) );
201
+
202
+ const { srcDir, mediaDir, staticFolder } = resolveFolders( rawConfig, {
203
+ pagesFolder: declaredPagesFolder,
204
+ mediaFolder: declaredMediaFolder,
205
+ staticFolder: declaredStaticFolder,
206
+ } );
169
207
 
170
- // Prevent VitePress/Vite from defaulting publicDir to {srcDir}/public when
171
- // content lives in a subfolder — static assets must not appear inside the
172
- // pages folder. VitePress passes srcDir as Vite's root, so publicDir must be
173
- // an absolute path to be unambiguous. Derived from static_folder in
174
- // .crate.config.yaml; falls back to "public" at the project root only when
175
- // srcDir is a subfolder and no explicit value is set by the caller.
176
- const staticFolder = crateStaticDir ?? ( srcDir && srcDir !== "." ? "public" : null );
177
208
  const vitePublicDir = rawConfig.vite?.publicDir
178
209
  ?? ( staticFolder ? resolve( process.cwd(), staticFolder ) : undefined );
179
210
 
@@ -182,6 +213,7 @@ export async function augmentConfig( rawConfig, options = {} ) {
182
213
  const resolvedConfig = {
183
214
  ...rawConfig,
184
215
  ...(srcDir !== undefined && { srcDir }),
216
+ ...(mediaDir !== undefined && { mediaDir }),
185
217
  vite: {
186
218
  ...rawConfig.vite,
187
219
  ...(vitePublicDir !== undefined && { publicDir: vitePublicDir }),
@@ -189,8 +221,8 @@ export async function augmentConfig( rawConfig, options = {} ) {
189
221
  };
190
222
 
191
223
  const media = useMedia( resolvedConfig, {
192
- ...( crateImageSizes != null && { sizes: crateImageSizes } ),
193
- ...( crateImageWidths != null && { widths: crateImageWidths } ),
224
+ ...( declaredImageSizes != null && { sizes: declaredImageSizes } ),
225
+ ...( declaredImageWidths != null && { widths: declaredImageWidths } ),
194
226
  } );
195
227
 
196
228
  async function warmUpMedia() {
@@ -199,7 +231,7 @@ export async function augmentConfig( rawConfig, options = {} ) {
199
231
  const pageFiles = ( await Array.fromAsync( glob( "**/*.md", { cwd: pagesDir } ) ) )
200
232
  .map( f => join( pagesDir, f ) );
201
233
 
202
- await media.warmUp( pageFiles );
234
+ await media.warmUp( pageFiles, source => extractContainerMediaSrcs( source, mediaProps ) );
203
235
  }
204
236
 
205
237
  // VitePress's internal markdown LRU cache clear function — not public API
@@ -282,7 +314,7 @@ export async function augmentConfig( rawConfig, options = {} ) {
282
314
  // at build start and once when the dev server starts listening.
283
315
  async function emitSectionManifest() {
284
316
  try {
285
- const manifest = buildSectionManifest( crateContainers, { adapterVersion: SCAVOLD_VERSION } );
317
+ const manifest = buildSectionManifest( declaredContainers, { adapterVersion: SCAVOLD_VERSION } );
286
318
  await writeSectionManifest( process.cwd(), manifest );
287
319
  } catch ( error ) {
288
320
  console.warn( "[scavold] could not write .cratly/sections.json —", error.message );
@@ -389,7 +421,25 @@ export async function augmentConfig( rawConfig, options = {} ) {
389
421
  return defaultHandler( tokens, idx, options, ...args );
390
422
  } );
391
423
 
392
- 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
+ } );
393
443
  },
394
444
  },
395
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.1",
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": [
@@ -49,6 +49,7 @@
49
49
  "lib",
50
50
  "scripts",
51
51
  "index.d.ts",
52
+ "CHANGELOG.md",
52
53
  "COMPONENTS.md",
53
54
  "FRONTMATTER.md",
54
55
  "README.md",