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 +37 -0
- package/COMPONENTS.md +19 -0
- package/README.md +1 -1
- package/lib/config.js +47 -25
- package/lib/containers.js +142 -12
- package/lib/media.js +97 -7
- package/lib/sectionManifest.js +3 -3
- package/package.json +1 -1
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.
|
|
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 .
|
|
170
|
+
* declared in .cratly.config.yaml
|
|
171
171
|
*/
|
|
172
172
|
export async function augmentConfig( rawConfig, options = {} ) {
|
|
173
|
-
// Read .
|
|
174
|
-
let
|
|
175
|
-
let
|
|
176
|
-
let
|
|
177
|
-
let
|
|
178
|
-
let
|
|
179
|
-
let
|
|
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
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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(
|
|
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:
|
|
200
|
-
mediaFolder:
|
|
201
|
-
staticFolder:
|
|
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
|
-
...(
|
|
221
|
-
...(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
* .
|
|
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
|
|
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} [
|
|
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(
|
|
178
|
+
export function resolveContainerMap( declaredContainers = {}, overrides = {} ) {
|
|
110
179
|
const map = defaultContainerMap();
|
|
111
180
|
|
|
112
|
-
for ( const [ name, value ] of Object.entries(
|
|
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
|
-
|
|
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( [
|
|
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
|
}
|
package/lib/sectionManifest.js
CHANGED
|
@@ -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} [
|
|
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(
|
|
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(
|
|
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
|
|