@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.
@@ -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("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;");
251
- }