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 +96 -0
- package/COMPONENTS.md +19 -0
- package/README.md +3 -3
- package/lib/config.js +79 -29
- package/lib/containers.js +142 -12
- package/lib/media.js +97 -7
- package/lib/sectionManifest.js +3 -3
- package/package.json +2 -1
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
|
|
13
|
-
|
|
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
|
|
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 .
|
|
170
|
+
* declared in .cratly.config.yaml
|
|
142
171
|
*/
|
|
143
172
|
export async function augmentConfig( rawConfig, options = {} ) {
|
|
144
|
-
// Read .
|
|
145
|
-
let
|
|
146
|
-
let
|
|
147
|
-
let
|
|
148
|
-
let
|
|
149
|
-
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;
|
|
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
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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(
|
|
196
|
+
const containerMap = resolveContainerMap( declaredContainers, options.containers );
|
|
166
197
|
|
|
167
|
-
//
|
|
168
|
-
|
|
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
|
-
...(
|
|
193
|
-
...(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scavold",
|
|
3
|
-
"version": "0.2.0-rc.
|
|
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",
|