@jxsuite/compiler 2.0.8 → 3.1.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/README.md +8 -8
- package/dist/cli.js +209 -202
- package/dist/compiler.js +118 -115
- package/package.json +12 -11
- package/src/shared.ts +60 -35
- package/src/site/base-path.ts +175 -0
- package/src/site/bundler.ts +28 -4
- package/src/site/client-runtime.ts +51 -1
- package/src/site/context-injection.ts +30 -128
- package/src/site/headers-emitter.ts +20 -0
- package/src/site/i18n.ts +13 -28
- package/src/site/image-optimizer.ts +31 -1
- package/src/site/image-transform.ts +141 -7
- package/src/site/layout-resolver.ts +30 -192
- package/src/site/pages-discovery.ts +9 -49
- package/src/site/service-worker.ts +22 -5
- package/src/site/site-build.ts +152 -44
- package/src/site/well-known.ts +14 -3
- package/src/targets/compile-element.ts +51 -1
- package/src/targets/compile-server.ts +14 -7
- package/src/types.ts +5 -11
- package/src/site/head-merger.ts +0 -251
package/src/site/head-merger.ts
DELETED
|
@@ -1,251 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Head-merger.js — $head merge pipeline
|
|
3
|
-
*
|
|
4
|
-
* Merges <head> element arrays from three levels:
|
|
5
|
-
*
|
|
6
|
-
* 1. Site.json.$head — global (e.g., favicon, global stylesheet)
|
|
7
|
-
* 2. Layout.$head — layout-level (e.g., shared nav scripts)
|
|
8
|
-
* 3. Page.$head — page-specific (e.g., per-page meta tags)
|
|
9
|
-
*
|
|
10
|
-
* Per site-architecture spec §8:
|
|
11
|
-
*
|
|
12
|
-
* - Later levels override earlier levels for the same element
|
|
13
|
-
* - Deduplication by tagName + key attribute (name, property, rel+href)
|
|
14
|
-
* - Charset and viewport are auto-injected if missing
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import type { JxHeadEntry } from "@jxsuite/schema/types";
|
|
18
|
-
import type { HeadMergeContext } from "../types.ts";
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Merge $head arrays from site, layout, and page levels.
|
|
22
|
-
*
|
|
23
|
-
* @param {JxHeadEntry[]} [siteHead] - Site.json $head entries
|
|
24
|
-
* @param {JxHeadEntry[]} [layoutHead] - Layout $head entries (may be empty)
|
|
25
|
-
* @param {JxHeadEntry[]} [pageHead] - Page $head entries (may be empty)
|
|
26
|
-
* @param {HeadMergeContext} [context] - { title, lang, charset, url, pageUrl }
|
|
27
|
-
* @returns {JxHeadEntry[]} Merged, deduplicated $head array
|
|
28
|
-
*/
|
|
29
|
-
export function mergeHead(
|
|
30
|
-
siteHead: JxHeadEntry[] = [],
|
|
31
|
-
layoutHead: JxHeadEntry[] = [],
|
|
32
|
-
pageHead: JxHeadEntry[] = [],
|
|
33
|
-
context: HeadMergeContext = {},
|
|
34
|
-
) {
|
|
35
|
-
// Start with auto-injected defaults
|
|
36
|
-
const defaults: JxHeadEntry[] = [
|
|
37
|
-
{ attributes: { charset: context.charset ?? "utf8" }, tagName: "meta" },
|
|
38
|
-
{
|
|
39
|
-
attributes: {
|
|
40
|
-
content: "width=device-width, initial-scale=1",
|
|
41
|
-
name: "viewport",
|
|
42
|
-
},
|
|
43
|
-
tagName: "meta",
|
|
44
|
-
},
|
|
45
|
-
];
|
|
46
|
-
|
|
47
|
-
// Merge layers: site → layout → page (later wins)
|
|
48
|
-
const merged = new Map<string, JxHeadEntry>();
|
|
49
|
-
|
|
50
|
-
for (const entry of [...defaults, ...siteHead, ...layoutHead, ...pageHead]) {
|
|
51
|
-
const key = headEntryKey(entry);
|
|
52
|
-
merged.set(key, entry);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// Insert <title> if present
|
|
56
|
-
const title = context.title ?? context.siteName ?? "Jx Site";
|
|
57
|
-
merged.set("title", { children: [title], tagName: "title" });
|
|
58
|
-
|
|
59
|
-
// Add canonical URL if provided
|
|
60
|
-
if (context.pageUrl && context.siteUrl) {
|
|
61
|
-
const canonical = new URL(context.pageUrl, context.siteUrl).href;
|
|
62
|
-
/*
|
|
63
|
-
* An author-supplied canonical wins, like every other auto-injected entry (§8.4). This used a
|
|
64
|
-
* hand-written `link:canonical` key that `headEntryKey` never produces, so the author's entry
|
|
65
|
-
* and this one landed under different keys and the page got BOTH — which is the one thing a
|
|
66
|
-
* canonical link must not be.
|
|
67
|
-
*/
|
|
68
|
-
const hasAuthored = [...merged.values()].some(
|
|
69
|
-
(e) =>
|
|
70
|
-
e && typeof e === "object" && e.tagName === "link" && e.attributes?.rel === "canonical",
|
|
71
|
-
);
|
|
72
|
-
if (!hasAuthored) {
|
|
73
|
-
const entry: JxHeadEntry = {
|
|
74
|
-
attributes: { href: canonical, rel: "canonical" },
|
|
75
|
-
tagName: "link",
|
|
76
|
-
};
|
|
77
|
-
merged.set(headEntryKey(entry), entry);
|
|
78
|
-
}
|
|
79
|
-
// Auto OpenGraph identity (site-architecture §8.4) — author-supplied values win.
|
|
80
|
-
if (!merged.has("meta:og:url")) {
|
|
81
|
-
merged.set("meta:og:url", {
|
|
82
|
-
attributes: { content: canonical, property: "og:url" },
|
|
83
|
-
tagName: "meta",
|
|
84
|
-
});
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
if (context.siteName && !merged.has("meta:og:site_name")) {
|
|
88
|
-
merged.set("meta:og:site_name", {
|
|
89
|
-
attributes: { content: context.siteName, property: "og:site_name" },
|
|
90
|
-
tagName: "meta",
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/*
|
|
95
|
-
* Locale alternates (site-architecture.md §13.5). Keyed through `headEntryKey` like everything
|
|
96
|
-
* else, which is what keeps a set of them from collapsing: they share `rel="alternate"` and
|
|
97
|
-
* differ only in `hreflang`, and `x-default` conventionally shares its `href` with the default
|
|
98
|
-
* locale's entry. Before the key accounted for the qualifying attribute this whole feature was
|
|
99
|
-
* impossible — the set became one link.
|
|
100
|
-
*
|
|
101
|
-
* An author-supplied alternate for the same `hreflang` wins, like every other auto entry.
|
|
102
|
-
*/
|
|
103
|
-
for (const alternate of context.alternates ?? []) {
|
|
104
|
-
const entry: JxHeadEntry = {
|
|
105
|
-
attributes: { href: alternate.href, hreflang: alternate.hreflang, rel: "alternate" },
|
|
106
|
-
tagName: "link",
|
|
107
|
-
};
|
|
108
|
-
const key = headEntryKey(entry);
|
|
109
|
-
if (!merged.has(key)) {
|
|
110
|
-
merged.set(key, entry);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
return [...merged.values()];
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Generate a deduplication key for a <head> element. Elements with the same key are considered
|
|
119
|
-
* duplicates; the last one wins.
|
|
120
|
-
*
|
|
121
|
-
* @param {JxHeadEntry} entry
|
|
122
|
-
* @returns {string}
|
|
123
|
-
*/
|
|
124
|
-
function headEntryKey(entry: JxHeadEntry) {
|
|
125
|
-
if (!entry || typeof entry !== "object") {
|
|
126
|
-
return String(entry);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const tag = entry.tagName ?? "unknown";
|
|
130
|
-
const attrs = entry.attributes ?? {};
|
|
131
|
-
|
|
132
|
-
// <title> — singleton
|
|
133
|
-
if (tag === "title") {
|
|
134
|
-
return "title";
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// <meta charset> — singleton
|
|
138
|
-
if (attrs.charset) {
|
|
139
|
-
return "meta:charset";
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// <meta name="...""> — keyed by name
|
|
143
|
-
if (tag === "meta" && attrs.name) {
|
|
144
|
-
return `meta:${attrs.name}`;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// <meta property="..."> — keyed by property (Open Graph)
|
|
148
|
-
if (tag === "meta" && attrs.property) {
|
|
149
|
-
return `meta:${attrs.property}`;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/*
|
|
153
|
-
* <link> — keyed by rel + href + the attribute that distinguishes two links sharing both.
|
|
154
|
-
*
|
|
155
|
-
* `rel` + `href` alone is not identity, and the cases where it fails are the ones a site actually
|
|
156
|
-
* needs: `hreflang="x-default"` conventionally points at the SAME href as the default locale's
|
|
157
|
-
* alternate, and an RSS and an Atom feed are both `rel="alternate"` differing only in `type`.
|
|
158
|
-
* `icon` differs by `sizes` and `stylesheet` by `media` for the same reason.
|
|
159
|
-
*/
|
|
160
|
-
if (tag === "link" && attrs.rel) {
|
|
161
|
-
const qualifier = attrs.hreflang ?? attrs.type ?? attrs.media ?? attrs.sizes ?? "";
|
|
162
|
-
return `link:${attrs.rel}:${attrs.href ?? ""}:${String(qualifier)}`;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// <script src="..."> — keyed by src
|
|
166
|
-
if (tag === "script" && attrs.src) {
|
|
167
|
-
return `script:${attrs.src}`;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// <style> — unique per content hash
|
|
171
|
-
if (tag === "style") {
|
|
172
|
-
const raw = Array.isArray(entry.children) ? entry.children.join("") : entry.textContent;
|
|
173
|
-
const content = typeof raw === "object" && raw !== null ? JSON.stringify(raw) : (raw ?? "");
|
|
174
|
-
return `style:${simpleHash(content)}`;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// Fallback — use full JSON serialization
|
|
178
|
-
return `${tag}:${JSON.stringify(entry)}`;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* Simple string hash for deduplication (not cryptographic).
|
|
183
|
-
*
|
|
184
|
-
* @param {string} str
|
|
185
|
-
* @returns {string}
|
|
186
|
-
*/
|
|
187
|
-
function simpleHash(str: string) {
|
|
188
|
-
let hash = 0;
|
|
189
|
-
for (let i = 0; i < str.length; i++) {
|
|
190
|
-
// oxlint-disable-next-line no-bitwise -- classic 31x string hash; the shift is the algorithm
|
|
191
|
-
hash = Math.trunc((hash << 5) - hash + (str.codePointAt(i) ?? 0));
|
|
192
|
-
}
|
|
193
|
-
return hash.toString(36);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* Render a merged $head array to HTML string for insertion into <head>.
|
|
198
|
-
*
|
|
199
|
-
* @param {JxHeadEntry[]} headEntries - Merged head entries
|
|
200
|
-
* @returns {string} HTML string
|
|
201
|
-
*/
|
|
202
|
-
export function renderHead(headEntries: JxHeadEntry[]) {
|
|
203
|
-
return headEntries.map((e: JxHeadEntry) => renderHeadEntry(e)).join("\n ");
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Render a single $head entry to an HTML string.
|
|
208
|
-
*
|
|
209
|
-
* @param {JxHeadEntry} entry
|
|
210
|
-
* @returns {string}
|
|
211
|
-
*/
|
|
212
|
-
function renderHeadEntry(entry: JxHeadEntry) {
|
|
213
|
-
if (typeof entry === "string") {
|
|
214
|
-
return entry;
|
|
215
|
-
}
|
|
216
|
-
if (!entry || typeof entry !== "object") {
|
|
217
|
-
return "";
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
const tag = entry.tagName;
|
|
221
|
-
const attrs = entry.attributes ?? {};
|
|
222
|
-
const attrStr = Object.entries(attrs)
|
|
223
|
-
.map(([k, v]) => (v === true ? k : `${k}="${escapeAttr(v)}"`))
|
|
224
|
-
.join(" ");
|
|
225
|
-
|
|
226
|
-
const open = attrStr ? `<${tag} ${attrStr}>` : `<${tag}>`;
|
|
227
|
-
|
|
228
|
-
// Void elements (no closing tag)
|
|
229
|
-
const VOID = new Set(["meta", "link", "base", "br", "hr", "img", "input"]);
|
|
230
|
-
if (VOID.has(tag)) {
|
|
231
|
-
return open;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/*
|
|
235
|
-
* Elements with content. `textContent` may be an OBJECT: that is how a JSON-LD block is authored
|
|
236
|
-
* (§8.5), and serializing it here is what the spec has always promised. Left as-is it reached the
|
|
237
|
-
* page as the string "[object Object]".
|
|
238
|
-
*/
|
|
239
|
-
const raw = Array.isArray(entry.children) ? entry.children.join("") : entry.textContent;
|
|
240
|
-
const content =
|
|
241
|
-
raw !== null && typeof raw === "object" ? JSON.stringify(raw, null, 2) : (raw ?? "");
|
|
242
|
-
return `${open}${content}</${tag}>`;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* @param {unknown} val
|
|
247
|
-
* @returns {string}
|
|
248
|
-
*/
|
|
249
|
-
function escapeAttr(val: unknown) {
|
|
250
|
-
return String(val).replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<");
|
|
251
|
-
}
|