@jxsuite/schema 1.3.0 → 1.4.0
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/class-schema.json +1 -0
- package/defs/class-def.schema.ts +1 -0
- package/package.json +1 -1
- package/schema.json +1 -0
- package/src/asset-paths.ts +137 -2
- package/src/extension-registry.ts +5 -0
- package/src/format-registry.ts +4 -2
package/class-schema.json
CHANGED
package/defs/class-def.schema.ts
CHANGED
package/package.json
CHANGED
package/schema.json
CHANGED
package/src/asset-paths.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Deterministic mapping from Function-def `$src` module specifiers to bundled asset URLs
|
|
3
|
-
*
|
|
2
|
+
* Deterministic mapping from Function-def `$src` module specifiers to bundled asset URLs, plus the
|
|
3
|
+
* asset-mount math that publishes files living outside the project root at a stable site URL.
|
|
4
|
+
* Shared between the compiler's sidecar bundler (which writes the bundle) and extension `lower()`
|
|
4
5
|
* implementations (which emit the URL into client code) so neither needs to depend on the other.
|
|
5
6
|
*
|
|
6
7
|
* The mapping is intentionally hash-free: the same specifier always yields the same URL, letting
|
|
7
8
|
* lowered defs reference bundles the compiler produces later in the build. Distinct specifiers that
|
|
8
9
|
* collide on a slug are rejected by the bundler at build time.
|
|
10
|
+
*
|
|
11
|
+
* Pure string math only — no node imports — so browser hosts can import this module.
|
|
9
12
|
*/
|
|
10
13
|
|
|
11
14
|
/** Prefix for npm package specifiers in `$src` (spec §12): `npm:<pkg>[/subpath]`. */
|
|
@@ -40,3 +43,135 @@ export function sidecarAssetPath(specifier: string): string {
|
|
|
40
43
|
.replaceAll(/^-|-$/g, "");
|
|
41
44
|
return `${SIDECAR_ASSET_DIR}${base}.js`;
|
|
42
45
|
}
|
|
46
|
+
|
|
47
|
+
// ─── Asset mounts (specs/extensions.md §8.5) ─────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A site URL prefix backed by a real directory. Extensions declaring the `assets` capability return
|
|
51
|
+
* mounts so hosts can publish files that live outside the project root (an external content
|
|
52
|
+
* source's co-located images) without knowing anything about the section that owns them.
|
|
53
|
+
*/
|
|
54
|
+
export interface AssetMount {
|
|
55
|
+
/** Site-absolute URL prefix, e.g. `/content/docs` (no trailing slash). */
|
|
56
|
+
urlPrefix: string;
|
|
57
|
+
/** Absolute directory the prefix maps onto. */
|
|
58
|
+
dir: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Normalize a path or prefix for comparison: forward slashes, no trailing slash. */
|
|
62
|
+
function normalizeDir(value: string): string {
|
|
63
|
+
const forward = value.replaceAll("\\", "/");
|
|
64
|
+
return forward.length > 1 && forward.endsWith("/") ? forward.slice(0, -1) : forward;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Normalize a mount's URL prefix: leading slash, no trailing slash. */
|
|
68
|
+
export function normalizeAssetPrefix(prefix: string): string {
|
|
69
|
+
const forward = normalizeDir(prefix);
|
|
70
|
+
return forward.startsWith("/") ? forward : `/${forward}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Mounts sorted so the most specific (longest) candidate matches first. */
|
|
74
|
+
function bySpecificity(mounts: readonly AssetMount[], key: "dir" | "urlPrefix"): AssetMount[] {
|
|
75
|
+
return [...mounts].toSorted((a, b) => b[key].length - a[key].length);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** True when `path` is `dir` itself or sits underneath it (both already normalized). */
|
|
79
|
+
function isUnder(path: string, dir: string): boolean {
|
|
80
|
+
return path === dir || path.startsWith(`${dir}/`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Map an absolute file path to its mounted site URL, or null when no mount contains it. Path
|
|
85
|
+
* segments are percent-encoded so the URL survives HTML attributes; `resolveAssetUrl` reverses it.
|
|
86
|
+
*
|
|
87
|
+
* @param {readonly AssetMount[]} mounts
|
|
88
|
+
* @param {string} absolutePath - Absolute filesystem path
|
|
89
|
+
* @returns {string | null}
|
|
90
|
+
*/
|
|
91
|
+
export function assetUrlFor(mounts: readonly AssetMount[], absolutePath: string): string | null {
|
|
92
|
+
const path = normalizeDir(absolutePath);
|
|
93
|
+
for (const mount of bySpecificity(mounts, "dir")) {
|
|
94
|
+
const dir = normalizeDir(mount.dir);
|
|
95
|
+
if (!isUnder(path, dir) || path === dir) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const rest = path.slice(dir.length + 1);
|
|
99
|
+
const encoded = rest
|
|
100
|
+
.split("/")
|
|
101
|
+
.map((segment) => encodeURIComponent(segment))
|
|
102
|
+
.join("/");
|
|
103
|
+
return `${normalizeAssetPrefix(mount.urlPrefix)}/${encoded}`;
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Map a mounted site URL back to its absolute file path, or null when it matches no mount. Query
|
|
110
|
+
* and hash are dropped, the path is decoded ONCE (a still-encoded dot or slash after that decode is
|
|
111
|
+
* a bypass attempt and is rejected), and `.`/`..` segments are refused outright — so the result can
|
|
112
|
+
* never escape the mount directory.
|
|
113
|
+
*
|
|
114
|
+
* @param {readonly AssetMount[]} mounts
|
|
115
|
+
* @param {string} url - Site-absolute URL (e.g. `/content/docs/images/hero.png`)
|
|
116
|
+
* @returns {string | null}
|
|
117
|
+
*/
|
|
118
|
+
export function resolveAssetUrl(mounts: readonly AssetMount[], url: string): string | null {
|
|
119
|
+
const pathname = url.split("#")[0]!.split("?")[0]!;
|
|
120
|
+
if (!pathname.startsWith("/")) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
let decoded: string;
|
|
124
|
+
try {
|
|
125
|
+
decoded = decodeURIComponent(pathname);
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
if (/%2e|%2f/i.test(decoded)) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
for (const mount of bySpecificity(mounts, "urlPrefix")) {
|
|
133
|
+
const prefix = normalizeAssetPrefix(mount.urlPrefix);
|
|
134
|
+
if (!decoded.startsWith(`${prefix}/`)) {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const rest = decoded.slice(prefix.length + 1);
|
|
138
|
+
const segments = rest.split("/");
|
|
139
|
+
if (segments.some((s) => s === "" || s === "." || s === "..")) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
return `${normalizeDir(mount.dir)}/${segments.join("/")}`;
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Characters that terminate a URL inside markup: quotes, whitespace, CSS `url()` parens, `<>`. */
|
|
148
|
+
const URL_TERMINATOR = String.raw`"'\s<>()\\`;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Collect every mounted URL referenced by a compiled artifact (HTML, CSS, JSON-LD). Scanning output
|
|
152
|
+
* text rather than the document tree catches references wherever they came from — markdown images,
|
|
153
|
+
* hand-authored pages, `$head` metadata, `url()` in stylesheets — with no per-attribute knowledge.
|
|
154
|
+
*
|
|
155
|
+
* @param {string} text - Compiled HTML or CSS
|
|
156
|
+
* @param {readonly AssetMount[]} mounts
|
|
157
|
+
* @returns {string[]} Unique mounted URLs, in first-seen order
|
|
158
|
+
*/
|
|
159
|
+
export function collectAssetUrls(text: string, mounts: readonly AssetMount[]): string[] {
|
|
160
|
+
const found = new Set<string>();
|
|
161
|
+
for (const mount of mounts) {
|
|
162
|
+
const prefix = normalizeAssetPrefix(mount.urlPrefix);
|
|
163
|
+
const pattern = new RegExp(
|
|
164
|
+
`${prefix.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)}/[^${URL_TERMINATOR}]+`,
|
|
165
|
+
"g",
|
|
166
|
+
);
|
|
167
|
+
for (const match of text.matchAll(pattern)) {
|
|
168
|
+
const url = match[0].split("#")[0]!.split("?")[0]!;
|
|
169
|
+
// A trailing slash names a directory, not a file — prose quoting the mount prefix
|
|
170
|
+
// ("published under /content/docs/images/") is a mention, not a reference.
|
|
171
|
+
if (!url.endsWith("/")) {
|
|
172
|
+
found.add(url);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return [...found].filter(Boolean);
|
|
177
|
+
}
|
|
@@ -156,6 +156,11 @@ export class ExtensionRegistry {
|
|
|
156
156
|
return this.#classes.filter((e) => e.capabilities.emit !== undefined);
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/** Classes declaring an `assets` capability (static asset mounts, §8.5), in declaration order. */
|
|
160
|
+
assetProviders(): FormatEntry[] {
|
|
161
|
+
return this.#classes.filter((e) => e.capabilities.assets !== undefined);
|
|
162
|
+
}
|
|
163
|
+
|
|
159
164
|
/** Server-mount classes, sorted by `server.order` (ascending; default 100), then name. */
|
|
160
165
|
serverMounts(): FormatEntry[] {
|
|
161
166
|
return this.#classes
|
package/src/format-registry.ts
CHANGED
|
@@ -22,8 +22,8 @@ export const FORMAT_CAPABILITIES: readonly FormatCapability[] = [
|
|
|
22
22
|
/**
|
|
23
23
|
* All well-known static capability roles across admission blocks (specs/extensions.md §8): format
|
|
24
24
|
* roles plus project-section (`projectData`, `resolvePaths`), compile-away (`lower`), build-time
|
|
25
|
-
* asset emission (`emit`), server-mount (`mount`), and connector
|
|
26
|
-
* `bindings`, `testConnection`) roles.
|
|
25
|
+
* asset emission (`emit`), static asset mounts (`assets`), server-mount (`mount`), and connector
|
|
26
|
+
* (`dialect`, `deploySchema`, `bindings`, `testConnection`) roles.
|
|
27
27
|
*/
|
|
28
28
|
export type ExtensionCapability =
|
|
29
29
|
| FormatCapability
|
|
@@ -31,6 +31,7 @@ export type ExtensionCapability =
|
|
|
31
31
|
| "resolvePaths"
|
|
32
32
|
| "lower"
|
|
33
33
|
| "emit"
|
|
34
|
+
| "assets"
|
|
34
35
|
| "mount"
|
|
35
36
|
| "dialect"
|
|
36
37
|
| "deploySchema"
|
|
@@ -43,6 +44,7 @@ export const EXTENSION_CAPABILITIES: readonly ExtensionCapability[] = [
|
|
|
43
44
|
"resolvePaths",
|
|
44
45
|
"lower",
|
|
45
46
|
"emit",
|
|
47
|
+
"assets",
|
|
46
48
|
"mount",
|
|
47
49
|
"dialect",
|
|
48
50
|
"deploySchema",
|