@jxsuite/site 0.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/package.json +33 -0
- package/src/context.ts +174 -0
- package/src/head-merger.ts +267 -0
- package/src/layout.ts +207 -0
- package/src/routes.ts +244 -0
- package/src/site-style.ts +117 -0
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jxsuite/site",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "What a Jx project's page at a route consists of — routing, layout, context, head and the document that boots it",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/jxsuite/jx.git",
|
|
9
|
+
"directory": "packages/site"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src/"
|
|
13
|
+
],
|
|
14
|
+
"type": "module",
|
|
15
|
+
"exports": {
|
|
16
|
+
"./routes": "./src/routes.ts",
|
|
17
|
+
"./layout": "./src/layout.ts",
|
|
18
|
+
"./context": "./src/context.ts",
|
|
19
|
+
"./head-merger": "./src/head-merger.ts",
|
|
20
|
+
"./site-style": "./src/site-style.ts"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"provenance": false
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "bun test --isolate",
|
|
27
|
+
"test:coverage": "bun test --isolate --coverage"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@jxsuite/runtime": "workspace:^",
|
|
31
|
+
"@jxsuite/schema": "workspace:^"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* $page and $site context injection
|
|
3
|
+
*
|
|
4
|
+
* Injects project-level and page-level context variables into a page's state before compilation.
|
|
5
|
+
* These are available as $site.* and $page.* in template expressions.
|
|
6
|
+
*
|
|
7
|
+
* Per site-architecture spec §10: $site.name — from project.json name $site.url — from project.json
|
|
8
|
+
* url $site.state.* — site-wide reactive state $page.url — current page URL path $page.title — page
|
|
9
|
+
* title $page.params — dynamic route parameters (if any) $page.locale/$page.dir — the route's
|
|
10
|
+
* language and writing direction (§13.4) $page.alternates — this page in every language it exists
|
|
11
|
+
* in (§13.5)
|
|
12
|
+
*
|
|
13
|
+
* The site build is not the only thing that has to produce this state. A page rendered live — on
|
|
14
|
+
* the studio's canvas, or by the cloud's preview origin — binds `${$site.name}` and `${$page.url}`
|
|
15
|
+
* exactly as a built page does, and a host that improvised its own subset would differ from the
|
|
16
|
+
* build in whichever field it forgot. So the injection lives here, with no platform imports; the
|
|
17
|
+
* one thing that genuinely needed a filesystem, rebasing relative `imports` onto the page's own
|
|
18
|
+
* directory, is injected as {@link ImportRebaser} and simply absent where there are no
|
|
19
|
+
* directories.
|
|
20
|
+
*
|
|
21
|
+
* @docs framework/site/i18n
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { JxDocument, JxElement, JxStateDefinition, ProjectConfig } from "@jxsuite/schema/types";
|
|
25
|
+
import { localeDirection, localeLabel, localeOfRoute } from "@jxsuite/schema/locale";
|
|
26
|
+
import type { ResolvedI18n } from "@jxsuite/schema/locale";
|
|
27
|
+
|
|
28
|
+
/** One member of a route's translation set — the shape `<head>` and `$page.alternates` share. */
|
|
29
|
+
export interface TranslationMember {
|
|
30
|
+
locale: string;
|
|
31
|
+
urlPattern: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The route a page is being injected for. */
|
|
35
|
+
export interface SiteRoute {
|
|
36
|
+
urlPattern: string;
|
|
37
|
+
sourcePath?: string;
|
|
38
|
+
_pathParams?: Record<string, string>;
|
|
39
|
+
[key: string]: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Rewrite one project-level relative `imports` entry so it resolves from the PAGE's directory.
|
|
44
|
+
*
|
|
45
|
+
* Only a host with real directories can answer this — and only one needs to. A live renderer serves
|
|
46
|
+
* the project at a root where the authored path already means what it says, so it passes nothing
|
|
47
|
+
* and the entry is used as written.
|
|
48
|
+
*/
|
|
49
|
+
export type ImportRebaser = (src: string, route: SiteRoute) => string;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Inject $site and $page context into a page document's state.
|
|
53
|
+
*
|
|
54
|
+
* @param {JxDocument} doc - The page document (mutated)
|
|
55
|
+
* @param {ProjectConfig} projectConfig - Loaded project configuration
|
|
56
|
+
* @param {SiteRoute} route - The resolved route for this page
|
|
57
|
+
* @param {ImportRebaser | null} [rebaseImport] - Rebases relative `imports` onto the page directory
|
|
58
|
+
* @param {ResolvedI18n | null} [i18n] - Validated locale config, when the project declares one
|
|
59
|
+
* @param {readonly TranslationMember[]} [translations] - This route's translation set, itself
|
|
60
|
+
* included
|
|
61
|
+
* @returns {JxDocument} The mutated document
|
|
62
|
+
*/
|
|
63
|
+
export function injectContext(
|
|
64
|
+
doc: JxDocument,
|
|
65
|
+
projectConfig: ProjectConfig,
|
|
66
|
+
route: SiteRoute,
|
|
67
|
+
rebaseImport: ImportRebaser | null = null,
|
|
68
|
+
i18n: ResolvedI18n | null = null,
|
|
69
|
+
translations: readonly TranslationMember[] = [],
|
|
70
|
+
) {
|
|
71
|
+
if (!doc.state) {
|
|
72
|
+
doc.state = {};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// $site context — read-only project-level data
|
|
76
|
+
doc.state.$site = {
|
|
77
|
+
name: projectConfig.name ?? "Jx Site",
|
|
78
|
+
url: projectConfig.url ?? "",
|
|
79
|
+
...(i18n === null ? {} : { defaultLocale: i18n.defaultLocale, locales: i18n.locales }),
|
|
80
|
+
...projectConfig.state,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/*
|
|
84
|
+
* $page context — read-only page-level data.
|
|
85
|
+
*
|
|
86
|
+
* The locale lives here rather than as a top-level `$locale` because that is what it is: a
|
|
87
|
+
* property of the route, not a third ambient namespace beside $site and $page. A document's own
|
|
88
|
+
* `$lang` still wins over the route's locale, so this is the resolved answer, not the prefix.
|
|
89
|
+
*/
|
|
90
|
+
const pageLang =
|
|
91
|
+
(typeof doc.$lang === "string" ? doc.$lang : undefined) ??
|
|
92
|
+
localeOfRoute(route.urlPattern, i18n) ??
|
|
93
|
+
projectConfig.defaults?.lang;
|
|
94
|
+
/*
|
|
95
|
+
* The same translation set `<head>` advertises, in the shape a template can render: a language
|
|
96
|
+
* switcher is the one part of a multilingual site the framework cannot write for the author, and
|
|
97
|
+
* without this it could only be hand-maintained — a hardcoded list of URLs that goes stale the
|
|
98
|
+
* moment a page gains or loses a translation, silently, in the one place a reader would use it.
|
|
99
|
+
*
|
|
100
|
+
* Site-absolute URLs, not the absolute hrefs `<head>` needs: a switcher is an internal link, and
|
|
101
|
+
* it has to work before `url` is configured. `current` marks the member this route *is*, taken
|
|
102
|
+
* from the route rather than from `$page.locale`, because a document whose `$lang` disagrees with
|
|
103
|
+
* its directory is still served from that directory.
|
|
104
|
+
*/
|
|
105
|
+
const alternates = translations.map((member) => ({
|
|
106
|
+
code: member.locale,
|
|
107
|
+
current: member.urlPattern === route.urlPattern,
|
|
108
|
+
dir: localeDirection(member.locale),
|
|
109
|
+
label: localeLabel(member.locale),
|
|
110
|
+
url: member.urlPattern,
|
|
111
|
+
}));
|
|
112
|
+
doc.state.$page = {
|
|
113
|
+
params: route._pathParams ?? {},
|
|
114
|
+
title: doc.title ?? doc._pageTitle ?? projectConfig.name ?? "",
|
|
115
|
+
url: route.urlPattern,
|
|
116
|
+
...(alternates.length > 0 && { alternates }),
|
|
117
|
+
...(pageLang === undefined ? {} : { dir: localeDirection(pageLang), locale: pageLang }),
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// Merge project-level state into page state (page wins on conflicts)
|
|
121
|
+
if (projectConfig.state) {
|
|
122
|
+
for (const [key, value] of Object.entries(projectConfig.state)) {
|
|
123
|
+
if (key !== "$site" && key !== "$page" && !(key in doc.state)) {
|
|
124
|
+
doc.state[key] = value as JxStateDefinition;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Merge project-level $media into page $media
|
|
130
|
+
if (projectConfig.$media) {
|
|
131
|
+
doc.$media = { ...projectConfig.$media, ...doc.$media };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Merge project-level imports into page imports (page wins on collision)
|
|
135
|
+
if (projectConfig.imports && Object.keys(projectConfig.imports).length > 0) {
|
|
136
|
+
if (!doc.imports) {
|
|
137
|
+
doc.imports = {};
|
|
138
|
+
}
|
|
139
|
+
for (const [name, srcPath] of Object.entries(projectConfig.imports)) {
|
|
140
|
+
if (!(name in doc.imports)) {
|
|
141
|
+
const src = srcPath as string;
|
|
142
|
+
// Only rebase relative paths — bare/npm specifiers pass through unmodified
|
|
143
|
+
doc.imports[name] =
|
|
144
|
+
rebaseImport && (src.startsWith("./") || src.startsWith("../"))
|
|
145
|
+
? rebaseImport(src, route)
|
|
146
|
+
: src;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Merge project-level $elements into page $elements (union, dedup)
|
|
152
|
+
if (projectConfig.$elements?.length) {
|
|
153
|
+
if (!doc.$elements?.length) {
|
|
154
|
+
doc.$elements = [...projectConfig.$elements];
|
|
155
|
+
} else {
|
|
156
|
+
const seen = new Set<string>();
|
|
157
|
+
const merged: (string | JxElement)[] = [];
|
|
158
|
+
for (const entry of [
|
|
159
|
+
...projectConfig.$elements,
|
|
160
|
+
...(doc.$elements as (string | JxElement)[]),
|
|
161
|
+
]) {
|
|
162
|
+
const key =
|
|
163
|
+
typeof entry === "string" ? entry : /** @type {{ $ref?: string }} */ entry?.$ref;
|
|
164
|
+
if (key && !seen.has(key)) {
|
|
165
|
+
seen.add(key);
|
|
166
|
+
merged.push(entry);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
doc.$elements = merged;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return doc;
|
|
174
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* $head merge pipeline — the three levels a page's `<head>` is assembled from.
|
|
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
|
+
* Pure tree and string work with no platform imports, and it lives here rather than in
|
|
17
|
+
* `@jxsuite/compiler` so that hosts which cannot import that package — a browser bundle, a
|
|
18
|
+
* Cloudflare Worker serving a live preview — assemble the same `<head>` the build does.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { JxHeadEntry } from "@jxsuite/schema/types";
|
|
22
|
+
|
|
23
|
+
/** What a page knows about itself while its `<head>` is being merged. */
|
|
24
|
+
export interface HeadMergeContext {
|
|
25
|
+
title?: string;
|
|
26
|
+
siteName?: string;
|
|
27
|
+
lang?: string;
|
|
28
|
+
charset?: string;
|
|
29
|
+
url?: string;
|
|
30
|
+
siteUrl?: string;
|
|
31
|
+
pageUrl?: string;
|
|
32
|
+
/** `rel="alternate"` links for this page's translations (site-architecture.md §13.5). */
|
|
33
|
+
alternates?: readonly { hreflang: string; href: string }[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Merge $head arrays from site, layout, and page levels.
|
|
38
|
+
*
|
|
39
|
+
* @param {JxHeadEntry[]} [siteHead] - Site.json $head entries
|
|
40
|
+
* @param {JxHeadEntry[]} [layoutHead] - Layout $head entries (may be empty)
|
|
41
|
+
* @param {JxHeadEntry[]} [pageHead] - Page $head entries (may be empty)
|
|
42
|
+
* @param {HeadMergeContext} [context] - { title, lang, charset, url, pageUrl }
|
|
43
|
+
* @returns {JxHeadEntry[]} Merged, deduplicated $head array
|
|
44
|
+
*/
|
|
45
|
+
export function mergeHead(
|
|
46
|
+
siteHead: JxHeadEntry[] = [],
|
|
47
|
+
layoutHead: JxHeadEntry[] = [],
|
|
48
|
+
pageHead: JxHeadEntry[] = [],
|
|
49
|
+
context: HeadMergeContext = {},
|
|
50
|
+
) {
|
|
51
|
+
// Start with auto-injected defaults
|
|
52
|
+
const defaults: JxHeadEntry[] = [
|
|
53
|
+
{ attributes: { charset: context.charset ?? "utf8" }, tagName: "meta" },
|
|
54
|
+
{
|
|
55
|
+
attributes: {
|
|
56
|
+
content: "width=device-width, initial-scale=1",
|
|
57
|
+
name: "viewport",
|
|
58
|
+
},
|
|
59
|
+
tagName: "meta",
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
// Merge layers: site → layout → page (later wins)
|
|
64
|
+
const merged = new Map<string, JxHeadEntry>();
|
|
65
|
+
|
|
66
|
+
for (const entry of [...defaults, ...siteHead, ...layoutHead, ...pageHead]) {
|
|
67
|
+
const key = headEntryKey(entry);
|
|
68
|
+
merged.set(key, entry);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Insert <title> if present
|
|
72
|
+
const title = context.title ?? context.siteName ?? "Jx Site";
|
|
73
|
+
merged.set("title", { children: [title], tagName: "title" });
|
|
74
|
+
|
|
75
|
+
// Add canonical URL if provided
|
|
76
|
+
if (context.pageUrl && context.siteUrl) {
|
|
77
|
+
const canonical = new URL(context.pageUrl, context.siteUrl).href;
|
|
78
|
+
/*
|
|
79
|
+
* An author-supplied canonical wins, like every other auto-injected entry (§8.4). This used a
|
|
80
|
+
* hand-written `link:canonical` key that `headEntryKey` never produces, so the author's entry
|
|
81
|
+
* and this one landed under different keys and the page got BOTH — which is the one thing a
|
|
82
|
+
* canonical link must not be.
|
|
83
|
+
*/
|
|
84
|
+
const hasAuthored = [...merged.values()].some(
|
|
85
|
+
(e) =>
|
|
86
|
+
e && typeof e === "object" && e.tagName === "link" && e.attributes?.rel === "canonical",
|
|
87
|
+
);
|
|
88
|
+
if (!hasAuthored) {
|
|
89
|
+
const entry: JxHeadEntry = {
|
|
90
|
+
attributes: { href: canonical, rel: "canonical" },
|
|
91
|
+
tagName: "link",
|
|
92
|
+
};
|
|
93
|
+
merged.set(headEntryKey(entry), entry);
|
|
94
|
+
}
|
|
95
|
+
// Auto OpenGraph identity (site-architecture §8.4) — author-supplied values win.
|
|
96
|
+
if (!merged.has("meta:og:url")) {
|
|
97
|
+
merged.set("meta:og:url", {
|
|
98
|
+
attributes: { content: canonical, property: "og:url" },
|
|
99
|
+
tagName: "meta",
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (context.siteName && !merged.has("meta:og:site_name")) {
|
|
104
|
+
merged.set("meta:og:site_name", {
|
|
105
|
+
attributes: { content: context.siteName, property: "og:site_name" },
|
|
106
|
+
tagName: "meta",
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/*
|
|
111
|
+
* Locale alternates (site-architecture.md §13.5). Keyed through `headEntryKey` like everything
|
|
112
|
+
* else, which is what keeps a set of them from collapsing: they share `rel="alternate"` and
|
|
113
|
+
* differ only in `hreflang`, and `x-default` conventionally shares its `href` with the default
|
|
114
|
+
* locale's entry. Before the key accounted for the qualifying attribute this whole feature was
|
|
115
|
+
* impossible — the set became one link.
|
|
116
|
+
*
|
|
117
|
+
* An author-supplied alternate for the same `hreflang` wins, like every other auto entry.
|
|
118
|
+
*/
|
|
119
|
+
for (const alternate of context.alternates ?? []) {
|
|
120
|
+
const entry: JxHeadEntry = {
|
|
121
|
+
attributes: { href: alternate.href, hreflang: alternate.hreflang, rel: "alternate" },
|
|
122
|
+
tagName: "link",
|
|
123
|
+
};
|
|
124
|
+
const key = headEntryKey(entry);
|
|
125
|
+
if (!merged.has(key)) {
|
|
126
|
+
merged.set(key, entry);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return [...merged.values()];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Generate a deduplication key for a <head> element. Elements with the same key are considered
|
|
135
|
+
* duplicates; the last one wins.
|
|
136
|
+
*
|
|
137
|
+
* @param {JxHeadEntry} entry
|
|
138
|
+
* @returns {string}
|
|
139
|
+
*/
|
|
140
|
+
function headEntryKey(entry: JxHeadEntry) {
|
|
141
|
+
if (!entry || typeof entry !== "object") {
|
|
142
|
+
return String(entry);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const tag = entry.tagName ?? "unknown";
|
|
146
|
+
const attrs = entry.attributes ?? {};
|
|
147
|
+
|
|
148
|
+
// <title> — singleton
|
|
149
|
+
if (tag === "title") {
|
|
150
|
+
return "title";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// <meta charset> — singleton
|
|
154
|
+
if (attrs.charset) {
|
|
155
|
+
return "meta:charset";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// <meta name="...""> — keyed by name
|
|
159
|
+
if (tag === "meta" && attrs.name) {
|
|
160
|
+
return `meta:${attrs.name}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// <meta property="..."> — keyed by property (Open Graph)
|
|
164
|
+
if (tag === "meta" && attrs.property) {
|
|
165
|
+
return `meta:${attrs.property}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/*
|
|
169
|
+
* <link> — keyed by rel + href + the attribute that distinguishes two links sharing both.
|
|
170
|
+
*
|
|
171
|
+
* `rel` + `href` alone is not identity, and the cases where it fails are the ones a site actually
|
|
172
|
+
* needs: `hreflang="x-default"` conventionally points at the SAME href as the default locale's
|
|
173
|
+
* alternate, and an RSS and an Atom feed are both `rel="alternate"` differing only in `type`.
|
|
174
|
+
* `icon` differs by `sizes` and `stylesheet` by `media` for the same reason.
|
|
175
|
+
*/
|
|
176
|
+
if (tag === "link" && attrs.rel) {
|
|
177
|
+
const qualifier = attrs.hreflang ?? attrs.type ?? attrs.media ?? attrs.sizes ?? "";
|
|
178
|
+
return `link:${attrs.rel}:${attrs.href ?? ""}:${String(qualifier)}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// <script src="..."> — keyed by src
|
|
182
|
+
if (tag === "script" && attrs.src) {
|
|
183
|
+
return `script:${attrs.src}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// <style> — unique per content hash
|
|
187
|
+
if (tag === "style") {
|
|
188
|
+
const raw = Array.isArray(entry.children) ? entry.children.join("") : entry.textContent;
|
|
189
|
+
const content = typeof raw === "object" && raw !== null ? JSON.stringify(raw) : (raw ?? "");
|
|
190
|
+
return `style:${simpleHash(content)}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Fallback — use full JSON serialization
|
|
194
|
+
return `${tag}:${JSON.stringify(entry)}`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Simple string hash for deduplication (not cryptographic).
|
|
199
|
+
*
|
|
200
|
+
* @param {string} str
|
|
201
|
+
* @returns {string}
|
|
202
|
+
*/
|
|
203
|
+
function simpleHash(str: string) {
|
|
204
|
+
let hash = 0;
|
|
205
|
+
for (let i = 0; i < str.length; i++) {
|
|
206
|
+
// oxlint-disable-next-line no-bitwise -- classic 31x string hash; the shift is the algorithm
|
|
207
|
+
hash = Math.trunc((hash << 5) - hash + (str.codePointAt(i) ?? 0));
|
|
208
|
+
}
|
|
209
|
+
return hash.toString(36);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Render a merged $head array to HTML string for insertion into <head>.
|
|
214
|
+
*
|
|
215
|
+
* @param {JxHeadEntry[]} headEntries - Merged head entries
|
|
216
|
+
* @returns {string} HTML string
|
|
217
|
+
*/
|
|
218
|
+
export function renderHead(headEntries: JxHeadEntry[]) {
|
|
219
|
+
return headEntries.map((e: JxHeadEntry) => renderHeadEntry(e)).join("\n ");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Render a single $head entry to an HTML string.
|
|
224
|
+
*
|
|
225
|
+
* @param {JxHeadEntry} entry
|
|
226
|
+
* @returns {string}
|
|
227
|
+
*/
|
|
228
|
+
function renderHeadEntry(entry: JxHeadEntry) {
|
|
229
|
+
if (typeof entry === "string") {
|
|
230
|
+
return entry;
|
|
231
|
+
}
|
|
232
|
+
if (!entry || typeof entry !== "object") {
|
|
233
|
+
return "";
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const tag = entry.tagName;
|
|
237
|
+
const attrs = entry.attributes ?? {};
|
|
238
|
+
const attrStr = Object.entries(attrs)
|
|
239
|
+
.map(([k, v]) => (v === true ? k : `${k}="${escapeAttr(v)}"`))
|
|
240
|
+
.join(" ");
|
|
241
|
+
|
|
242
|
+
const open = attrStr ? `<${tag} ${attrStr}>` : `<${tag}>`;
|
|
243
|
+
|
|
244
|
+
// Void elements (no closing tag)
|
|
245
|
+
const VOID = new Set(["meta", "link", "base", "br", "hr", "img", "input"]);
|
|
246
|
+
if (VOID.has(tag)) {
|
|
247
|
+
return open;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/*
|
|
251
|
+
* Elements with content. `textContent` may be an OBJECT: that is how a JSON-LD block is authored
|
|
252
|
+
* (§8.5), and serializing it here is what the spec has always promised. Left as-is it reached the
|
|
253
|
+
* page as the string "[object Object]".
|
|
254
|
+
*/
|
|
255
|
+
const raw = Array.isArray(entry.children) ? entry.children.join("") : entry.textContent;
|
|
256
|
+
const content =
|
|
257
|
+
raw !== null && typeof raw === "object" ? JSON.stringify(raw, null, 2) : (raw ?? "");
|
|
258
|
+
return `${open}${content}</${tag}>`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* @param {unknown} val
|
|
263
|
+
* @returns {string}
|
|
264
|
+
*/
|
|
265
|
+
function escapeAttr(val: unknown) {
|
|
266
|
+
return String(val).replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<");
|
|
267
|
+
}
|
package/src/layout.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layout resolution — $layout loading and slot distribution.
|
|
3
|
+
*
|
|
4
|
+
* Resolves $layout references, loads layout documents, and distributes page content into layout
|
|
5
|
+
* <slot> elements. This is the compile-time equivalent of the runtime's distributeSlots()
|
|
6
|
+
* algorithm.
|
|
7
|
+
*
|
|
8
|
+
* Per site-architecture spec §5:
|
|
9
|
+
*
|
|
10
|
+
* - Layouts are JSON files in the layouts/ directory
|
|
11
|
+
* - Pages reference layouts via "$layout": "./layouts/base.json"
|
|
12
|
+
* - The page's children are distributed into the layout's <slot> elements
|
|
13
|
+
* - Named slots use attributes.slot on page children
|
|
14
|
+
*
|
|
15
|
+
* Reading is injected rather than imported. The rule "a page wrapped in its layout looks like THIS"
|
|
16
|
+
* is the same rule whether the layout comes off a disk, out of a Durable Object's SQLite table, or
|
|
17
|
+
* over `fetch` — and the two hosts that are not the compiler cannot import `node:fs` at all. So the
|
|
18
|
+
* only thing this module asks for is a {@link LayoutLoader}, and everything else is pure.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { JxDocument, JxElement } from "@jxsuite/schema/types";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Read one layout document by the `$layout` reference that named it.
|
|
25
|
+
*
|
|
26
|
+
* The reference is project-relative as authored (`./layouts/base.json`); resolving it against
|
|
27
|
+
* wherever the project actually lives is the host's job, because only the host knows.
|
|
28
|
+
*/
|
|
29
|
+
export type LayoutLoader = (layoutRef: string) => Promise<JxDocument> | JxDocument;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve a page's layout, wrapping the page content in the layout structure.
|
|
33
|
+
*
|
|
34
|
+
* @param {JxDocument} pageDoc - The raw page JSON document
|
|
35
|
+
* @param {Record<string, unknown>} projectConfig - Site configuration (for defaults.layout)
|
|
36
|
+
* @param {LayoutLoader} load - Reads a layout document by its `$layout` reference
|
|
37
|
+
* @returns {Promise<JxDocument>} The merged document (layout wrapping page content)
|
|
38
|
+
*/
|
|
39
|
+
export async function resolveLayout(
|
|
40
|
+
pageDoc: JxDocument,
|
|
41
|
+
projectConfig: Record<string, unknown>,
|
|
42
|
+
load: LayoutLoader,
|
|
43
|
+
): Promise<JxDocument> {
|
|
44
|
+
// Determine which layout to use
|
|
45
|
+
const layoutRef =
|
|
46
|
+
pageDoc.$layout ?? (projectConfig.defaults as Record<string, unknown>)?.layout ?? null;
|
|
47
|
+
|
|
48
|
+
if (!layoutRef) {
|
|
49
|
+
// No layout — return page as-is
|
|
50
|
+
return pageDoc;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let layoutDoc = await load(layoutRef as string);
|
|
54
|
+
|
|
55
|
+
// Check for nested layouts (layout inheriting from another layout)
|
|
56
|
+
if (layoutDoc.$layout) {
|
|
57
|
+
layoutDoc = await resolveLayout(layoutDoc, projectConfig, load);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Distribute page children into layout slots
|
|
61
|
+
const rawChildren = pageDoc.children ?? [];
|
|
62
|
+
const pageChildren =
|
|
63
|
+
typeof rawChildren === "string"
|
|
64
|
+
? [rawChildren]
|
|
65
|
+
: Array.isArray(rawChildren)
|
|
66
|
+
? rawChildren
|
|
67
|
+
: ([] as (JxElement | string)[]);
|
|
68
|
+
const merged = deepClone(layoutDoc);
|
|
69
|
+
|
|
70
|
+
distributeSlots(merged, pageChildren);
|
|
71
|
+
|
|
72
|
+
// Merge page-level properties onto the resolved document
|
|
73
|
+
// Page state extends layout state
|
|
74
|
+
if (pageDoc.state) {
|
|
75
|
+
merged.state = { ...merged.state, ...pageDoc.state };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Page $media extends layout $media
|
|
79
|
+
if (pageDoc.$media) {
|
|
80
|
+
merged.$media = { ...merged.$media, ...pageDoc.$media };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Page style extends layout style
|
|
84
|
+
if (pageDoc.style) {
|
|
85
|
+
merged.style = { ...merged.style, ...pageDoc.style };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Page attributes extend layout attributes
|
|
89
|
+
if (pageDoc.attributes) {
|
|
90
|
+
merged.attributes = { ...merged.attributes, ...pageDoc.attributes };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Preserve page-level metadata
|
|
94
|
+
if (pageDoc.$head) {
|
|
95
|
+
merged._pageHead = pageDoc.$head;
|
|
96
|
+
}
|
|
97
|
+
if (pageDoc.title) {
|
|
98
|
+
merged._pageTitle = pageDoc.title;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Remove $layout from merged doc (already resolved)
|
|
102
|
+
delete merged.$layout;
|
|
103
|
+
|
|
104
|
+
return merged;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Distribute children into <slot> elements within a layout document tree. This is the compile-time
|
|
109
|
+
* equivalent of the runtime's distributeSlots().
|
|
110
|
+
*
|
|
111
|
+
* Algorithm:
|
|
112
|
+
*
|
|
113
|
+
* 1. Find all <slot> elements in the layout tree
|
|
114
|
+
* 2. For each child with attributes.slot, distribute to the matching named slot
|
|
115
|
+
* 3. Remaining children go into the default (unnamed) slot
|
|
116
|
+
* 4. Replace each <slot> element with its distributed children
|
|
117
|
+
*
|
|
118
|
+
* @param {JxElement} node - Layout document tree (mutated in place)
|
|
119
|
+
* @param {(JxElement | string)[]} children - Page children to distribute
|
|
120
|
+
*/
|
|
121
|
+
function distributeSlots(node: JxElement, children: (JxElement | string)[]) {
|
|
122
|
+
if (!node || typeof node !== "object") {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (!Array.isArray(node.children)) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Collect named and default children
|
|
130
|
+
const named = new Map<string, (JxElement | string)[]>(); // Slot name → children[]
|
|
131
|
+
const defaults: (JxElement | string)[] = []; // Children without a slot target
|
|
132
|
+
|
|
133
|
+
for (const child of children) {
|
|
134
|
+
if (child && typeof child === "object" && child.attributes?.slot) {
|
|
135
|
+
const slotName = child.attributes.slot as string;
|
|
136
|
+
if (!named.has(slotName)) {
|
|
137
|
+
named.set(slotName, []);
|
|
138
|
+
}
|
|
139
|
+
(named.get(slotName) as (JxElement | string)[]).push(child);
|
|
140
|
+
} else {
|
|
141
|
+
defaults.push(child);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Walk the tree and replace <slot> elements
|
|
146
|
+
fillSlots(node, named, defaults);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Recursively walk the tree and replace <slot> elements with distributed content.
|
|
151
|
+
*
|
|
152
|
+
* @param {JxElement} node
|
|
153
|
+
* @param {Map<string, (JxElement | string)[]>} named
|
|
154
|
+
* @param {(JxElement | string)[]} defaults
|
|
155
|
+
*/
|
|
156
|
+
function fillSlots(
|
|
157
|
+
node: JxElement,
|
|
158
|
+
named: Map<string, (JxElement | string)[]>,
|
|
159
|
+
defaults: (JxElement | string)[],
|
|
160
|
+
) {
|
|
161
|
+
if (!node || typeof node !== "object") {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (!Array.isArray(node.children)) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const newChildren: (JxElement | string)[] = [];
|
|
169
|
+
|
|
170
|
+
for (const child of node.children) {
|
|
171
|
+
if (child && typeof child === "object" && child.tagName === "slot") {
|
|
172
|
+
const slotName = child.attributes?.name;
|
|
173
|
+
|
|
174
|
+
if (slotName && named.has(slotName as string)) {
|
|
175
|
+
// Named slot — replace with matching children
|
|
176
|
+
newChildren.push(...(named.get(slotName as string) as (JxElement | string)[]));
|
|
177
|
+
named.delete(slotName as string); // Consumed
|
|
178
|
+
} else if (!slotName && defaults.length > 0) {
|
|
179
|
+
// Default slot — replace with unassigned children
|
|
180
|
+
newChildren.push(...defaults);
|
|
181
|
+
// Don't clear defaults — only one default slot should exist,
|
|
182
|
+
// But if there are multiple, the first one wins
|
|
183
|
+
} else {
|
|
184
|
+
// No matching content — keep slot's fallback children
|
|
185
|
+
if (Array.isArray(child.children)) {
|
|
186
|
+
newChildren.push(...child.children);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
} else {
|
|
190
|
+
// Not a slot — recurse into it
|
|
191
|
+
fillSlots(child as JxElement, named, defaults);
|
|
192
|
+
newChildren.push(child);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
node.children = newChildren;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Deep clone a JSON-serializable object.
|
|
201
|
+
*
|
|
202
|
+
* @param {JxDocument} obj
|
|
203
|
+
* @returns {JxDocument}
|
|
204
|
+
*/
|
|
205
|
+
function deepClone(obj: JxDocument) {
|
|
206
|
+
return structuredClone(obj);
|
|
207
|
+
}
|
package/src/routes.ts
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-based routing — the one definition of what URL a page file has.
|
|
3
|
+
*
|
|
4
|
+
* The rules in this file (spec `site-architecture.md` §4) had been written down three times: the
|
|
5
|
+
* compiler's `fileToRoute` inside `pages-discovery.ts`, a mirror in the studio's `page-params.ts`
|
|
6
|
+
* whose own docblock admitted it was a mirror, and — the moment a Worker needed to answer the same
|
|
7
|
+
* question — very nearly a third. Nothing kept the copies in agreement, and a route is exactly the
|
|
8
|
+
* kind of rule where a silent disagreement is a page that 404s in one surface and renders in
|
|
9
|
+
* another.
|
|
10
|
+
*
|
|
11
|
+
* It lives HERE rather than in `@jxsuite/compiler` for the reason `resolveI18n` does (`i18n.ts`):
|
|
12
|
+
* the compiler's dependency graph carries `sharp` and `esbuild`, so neither a browser bundle nor a
|
|
13
|
+
* Cloudflare Worker can import it. This module is pure string math with no imports at all, so every
|
|
14
|
+
* host that has to know what `/blog/:slug` means can share one answer.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Bracket route segments: `[param]` (named) and `[...param]` (catch-all). */
|
|
18
|
+
const PARAM_SEGMENT = /\[\.\.\.(\w+)\]|\[(\w+)\]/g;
|
|
19
|
+
|
|
20
|
+
/** What a page file's path says about its route, before anything is read from the file. */
|
|
21
|
+
export interface RouteShape {
|
|
22
|
+
/** URL pattern with parameters, e.g. `/blog/:slug` or `/docs/*`. */
|
|
23
|
+
urlPattern: string;
|
|
24
|
+
/** Parameter names in declaration order, e.g. `["slug"]`. */
|
|
25
|
+
params: string[];
|
|
26
|
+
/** True when the pattern carries any parameter. */
|
|
27
|
+
isDynamic: boolean;
|
|
28
|
+
/** True when the pattern ends in a `[...rest]` spread. */
|
|
29
|
+
isCatchAll: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A page path with any addressing prefix removed, ready for {@link fileToRoute}.
|
|
34
|
+
*
|
|
35
|
+
* Callers hold these paths in two spaces — the compiler walks `pages/` and holds paths relative to
|
|
36
|
+
* it, while the studio holds a document path relative to the PROJECT (`pages/blog/[slug].json`).
|
|
37
|
+
* Normalising here means neither has to remember which space it is in.
|
|
38
|
+
*/
|
|
39
|
+
export function pageRelativePath(documentPath: string): string {
|
|
40
|
+
return documentPath
|
|
41
|
+
.split("\\")
|
|
42
|
+
.join("/")
|
|
43
|
+
.replace(/^\.\//, "")
|
|
44
|
+
.replace(/^\//, "")
|
|
45
|
+
.replace(/^pages\//, "");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The route a page file's path describes.
|
|
50
|
+
*
|
|
51
|
+
* `blog/[slug].json` → `/blog/:slug`, `docs/[...rest].json` → `/docs/*`, `index.json` → `/`,
|
|
52
|
+
* `about/index.md` → `/about`. The extension is dropped whatever it is: a page may be any format an
|
|
53
|
+
* enabled extension claims, and its route is a property of the NAME, not of the parser.
|
|
54
|
+
*/
|
|
55
|
+
export function fileToRoute(relativePath: string): RouteShape {
|
|
56
|
+
let urlPath = pageRelativePath(relativePath);
|
|
57
|
+
|
|
58
|
+
/* Drop the extension, but only a real one. `.` is legal inside a route segment, and slicing at
|
|
59
|
+
the last dot regardless would turn `pages/v1.2/index.json` into `/v1`. */
|
|
60
|
+
const lastSegment = urlPath.slice(urlPath.lastIndexOf("/") + 1);
|
|
61
|
+
const dot = lastSegment.lastIndexOf(".");
|
|
62
|
+
if (dot > 0) {
|
|
63
|
+
urlPath = urlPath.slice(0, urlPath.length - (lastSegment.length - dot));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Index files map to their parent directory.
|
|
67
|
+
if (urlPath.endsWith("/index")) {
|
|
68
|
+
urlPath = urlPath.slice(0, -6) || "/";
|
|
69
|
+
} else if (urlPath === "index") {
|
|
70
|
+
urlPath = "/";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!urlPath.startsWith("/")) {
|
|
74
|
+
urlPath = `/${urlPath}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const params: string[] = [];
|
|
78
|
+
let isDynamic = false;
|
|
79
|
+
let isCatchAll = false;
|
|
80
|
+
const urlPattern = urlPath.replaceAll(
|
|
81
|
+
PARAM_SEGMENT,
|
|
82
|
+
(_match: string, spread: string | undefined, named: string | undefined) => {
|
|
83
|
+
isDynamic = true;
|
|
84
|
+
if (spread) {
|
|
85
|
+
isCatchAll = true;
|
|
86
|
+
params.push(spread);
|
|
87
|
+
return "*";
|
|
88
|
+
}
|
|
89
|
+
params.push(named!);
|
|
90
|
+
return `:${named}`;
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
return { isCatchAll, isDynamic, params, urlPattern };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* URL pattern for a page document path — {@link fileToRoute} when only the pattern is wanted.
|
|
99
|
+
*
|
|
100
|
+
* A null or empty path answers `/` rather than throwing: the studio asks this of whatever document
|
|
101
|
+
* happens to be open, including none.
|
|
102
|
+
*/
|
|
103
|
+
export function documentUrlPattern(documentPath?: string | null): string {
|
|
104
|
+
if (!documentPath) {
|
|
105
|
+
return "/";
|
|
106
|
+
}
|
|
107
|
+
return fileToRoute(documentPath).urlPattern;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Param names declared by a document path's bracket segments, e.g. `["sku"]`. */
|
|
111
|
+
export function dynamicRouteParams(documentPath?: string | null): string[] {
|
|
112
|
+
if (!documentPath) {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
return fileToRoute(documentPath).params;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Whether a path under `pages/` becomes a route at all.
|
|
120
|
+
*
|
|
121
|
+
* An underscore prefix opts a file OUT — that is how a page directory holds a local component
|
|
122
|
+
* beside the pages that use it — and it opts out a whole directory the same way, so
|
|
123
|
+
* `_partials/card.json` is not a route even though `card.json` alone would be.
|
|
124
|
+
*/
|
|
125
|
+
export function isRoutedPageFile(relativePath: string): boolean {
|
|
126
|
+
const segments = pageRelativePath(relativePath).split("/");
|
|
127
|
+
return segments.length > 0 && segments.every((segment) => segment !== "" && segment[0] !== "_");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Route priority (spec §4.4, Astro's rules): static beats dynamic, dynamic beats catch-all.
|
|
132
|
+
*
|
|
133
|
+
* Sorting a route table with this is what makes `matchRoute` a first-match scan — `/about` is found
|
|
134
|
+
* before `/[slug]` can claim it, whatever order the directory walk produced.
|
|
135
|
+
*/
|
|
136
|
+
export function compareRoutes(a: RouteShape, b: RouteShape): number {
|
|
137
|
+
if (a.isDynamic !== b.isDynamic) {
|
|
138
|
+
return a.isDynamic ? 1 : -1;
|
|
139
|
+
}
|
|
140
|
+
if (a.isCatchAll !== b.isCatchAll) {
|
|
141
|
+
return a.isCatchAll ? 1 : -1;
|
|
142
|
+
}
|
|
143
|
+
return a.urlPattern.localeCompare(b.urlPattern);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** One route matched against a URL, with the parameter values that matched it. */
|
|
147
|
+
export interface RouteMatch<T extends RouteShape> {
|
|
148
|
+
route: T;
|
|
149
|
+
params: Record<string, string>;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The first route in `routes` that matches `pathname`, or null.
|
|
154
|
+
*
|
|
155
|
+
* `routes` is expected to be in {@link compareRoutes} order; the scan takes the first hit rather
|
|
156
|
+
* than the best one, because specificity is a property of the TABLE and re-deciding it per request
|
|
157
|
+
* is how two surfaces end up disagreeing about which page a URL is.
|
|
158
|
+
*
|
|
159
|
+
* A trailing slash is not significant — `build.trailingSlash` decides how a route is WRITTEN, and a
|
|
160
|
+
* reader who types the other form is asking for the same page.
|
|
161
|
+
*/
|
|
162
|
+
export function matchRoute<T extends RouteShape>(
|
|
163
|
+
routes: readonly T[],
|
|
164
|
+
pathname: string,
|
|
165
|
+
): RouteMatch<T> | null {
|
|
166
|
+
const parts = splitPath(pathname);
|
|
167
|
+
for (const route of routes) {
|
|
168
|
+
const params = matchPattern(route.urlPattern, parts);
|
|
169
|
+
if (params) {
|
|
170
|
+
return { params, route };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Path segments, with the empty leading/trailing entries a slash produces removed. */
|
|
177
|
+
function splitPath(pathname: string): string[] {
|
|
178
|
+
return pathname.split("/").filter((segment) => segment !== "");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Parameter values if `pattern` matches `parts`, else null. */
|
|
182
|
+
function matchPattern(pattern: string, parts: string[]): Record<string, string> | null {
|
|
183
|
+
const patternParts = splitPath(pattern);
|
|
184
|
+
const params: Record<string, string> = {};
|
|
185
|
+
|
|
186
|
+
for (const [index, expected] of patternParts.entries()) {
|
|
187
|
+
if (expected === "*") {
|
|
188
|
+
/* A catch-all consumes the rest, including nothing at all: `/docs/*` serves `/docs`. Its
|
|
189
|
+
parameter is the remainder as one path, which is what the page's `$params` receives. */
|
|
190
|
+
params["*"] = parts.slice(index).join("/");
|
|
191
|
+
return params;
|
|
192
|
+
}
|
|
193
|
+
const actual = parts[index];
|
|
194
|
+
if (actual === undefined) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
if (expected.startsWith(":")) {
|
|
198
|
+
params[expected.slice(1)] = decodeURIComponent(actual);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (expected !== actual) {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return patternParts.length === parts.length ? params : null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** How a site writes its URLs — `project.json`'s `build.trailingSlash`. */
|
|
210
|
+
export type TrailingSlash = "always" | "never";
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The address a route's page will have when it is published.
|
|
214
|
+
*
|
|
215
|
+
* This is the URL the page's OWN markup already points at, which is why Open in Browser opens it
|
|
216
|
+
* rather than the compiler's output path: a built page links to `/blog/hello/` and pulls
|
|
217
|
+
* `/components/demo.css`, both root-absolute, and handed its output path instead a browser resolves
|
|
218
|
+
* those against the server root and every one of them misses.
|
|
219
|
+
*/
|
|
220
|
+
export function routeHref(
|
|
221
|
+
urlPattern: string,
|
|
222
|
+
params: Record<string, string> = {},
|
|
223
|
+
trailingSlash: TrailingSlash = "always",
|
|
224
|
+
): string {
|
|
225
|
+
const filled = urlPattern
|
|
226
|
+
.replaceAll(/:(\w+)/g, (match, name: string) => {
|
|
227
|
+
const value = params[name];
|
|
228
|
+
return value === undefined ? match : encodeURIComponent(value);
|
|
229
|
+
})
|
|
230
|
+
.replace(/\*$/, (match) => {
|
|
231
|
+
const rest = params["*"];
|
|
232
|
+
return rest === undefined
|
|
233
|
+
? match
|
|
234
|
+
: rest
|
|
235
|
+
.split("/")
|
|
236
|
+
.map((segment) => encodeURIComponent(segment))
|
|
237
|
+
.join("/");
|
|
238
|
+
});
|
|
239
|
+
if (filled === "/" || filled === "") {
|
|
240
|
+
return "/";
|
|
241
|
+
}
|
|
242
|
+
const bare = filled.endsWith("/") ? filled.slice(0, -1) : filled;
|
|
243
|
+
return trailingSlash === "always" ? `${bare}/` : bare;
|
|
244
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Site-style stylesheet builder — pure functions extracted for testability.
|
|
4
|
+
*
|
|
5
|
+
* The canvas iframe injects the project's `style` as a real stylesheet instead of inline properties
|
|
6
|
+
* on the root element: inline custom properties would defeat the forced-scheme override selectors
|
|
7
|
+
* (`:root[data-color-scheme]`, spec §9.5), and object-valued `@--name` blocks used to be dropped
|
|
8
|
+
* entirely. Scheme-query blocks dual-emit through the runtime's schemeSelectors so the canvas
|
|
9
|
+
* honors both the OS preference and the preview toggle.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { pureSchemeOf, resolveAtQuery, schemeSelectors, camelToKebab } from "@jxsuite/runtime";
|
|
13
|
+
|
|
14
|
+
/** Id of the injected site-style tag (replace-in-place, never accumulate). */
|
|
15
|
+
export const SITE_STYLE_ID = "jx-site-style";
|
|
16
|
+
|
|
17
|
+
/** Serialize scalar declarations, kebab-casing property names and transposing values. */
|
|
18
|
+
function toDecls(props: Record<string, unknown>, transpose: (value: string) => string): string {
|
|
19
|
+
return Object.entries(props)
|
|
20
|
+
.map(([key, value]) => `${camelToKebab(key)}: ${transpose(String(value))}`)
|
|
21
|
+
.join("; ");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build the site-style sheet text: custom properties on `:root`, plain properties on `body`,
|
|
26
|
+
* conditional `@`-blocks resolved against `mediaQueries` (scheme queries dual-emitted per the
|
|
27
|
+
* forced-scheme contract), and `color-scheme: light dark` declared when a scheme query exists.
|
|
28
|
+
*
|
|
29
|
+
* @param {Record<string, unknown>} siteStyle
|
|
30
|
+
* @param {Record<string, string>} mediaQueries
|
|
31
|
+
* @param {(value: string) => string} transpose - Unit transposer (canvas vh→cqh etc.)
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
export function buildSiteStyleCSS(
|
|
35
|
+
siteStyle: Record<string, unknown>,
|
|
36
|
+
mediaQueries: Record<string, string>,
|
|
37
|
+
transpose: (value: string) => string,
|
|
38
|
+
): string {
|
|
39
|
+
const rules: string[] = [];
|
|
40
|
+
const rootProps: Record<string, unknown> = {};
|
|
41
|
+
const bodyProps: Record<string, unknown> = {};
|
|
42
|
+
const condBlocks: [string, Record<string, unknown>][] = [];
|
|
43
|
+
|
|
44
|
+
for (const [key, value] of Object.entries(siteStyle)) {
|
|
45
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
46
|
+
if (key.startsWith("@")) {
|
|
47
|
+
condBlocks.push([key, value as Record<string, unknown>]);
|
|
48
|
+
}
|
|
49
|
+
// Non-@ objects (nested element selectors) are page-content styling — the resolved doc's
|
|
50
|
+
// Own style pass covers those; the site sheet handles tokens + conditional overrides.
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (key.startsWith(":") || key.startsWith(".") || key.startsWith("[") || key.startsWith("@")) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (key.startsWith("--")) {
|
|
57
|
+
rootProps[key] = value;
|
|
58
|
+
} else {
|
|
59
|
+
bodyProps[key] = value;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const rootCSS = toDecls(rootProps, transpose);
|
|
64
|
+
if (rootCSS) {
|
|
65
|
+
rules.push(`:root { ${rootCSS} }`);
|
|
66
|
+
}
|
|
67
|
+
const bodyCSS = toDecls(bodyProps, transpose);
|
|
68
|
+
if (bodyCSS) {
|
|
69
|
+
rules.push(`body { ${bodyCSS} }`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const [atKey, block] of condBlocks) {
|
|
73
|
+
const query = resolveAtQuery(atKey, mediaQueries);
|
|
74
|
+
if (query === null) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const scheme = pureSchemeOf(query);
|
|
78
|
+
const emit = (selector: string, props: string) => {
|
|
79
|
+
if (!props) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (scheme) {
|
|
83
|
+
const { auto, forced } = schemeSelectors(selector, scheme);
|
|
84
|
+
rules.push(`@media ${query} { ${auto} { ${props} } }`, `${forced} { ${props} }`);
|
|
85
|
+
} else {
|
|
86
|
+
rules.push(`@media ${query} { ${selector} { ${props} } }`);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
const condRoot: Record<string, unknown> = {};
|
|
90
|
+
const condBody: Record<string, unknown> = {};
|
|
91
|
+
const condSubs: [string, Record<string, unknown>][] = [];
|
|
92
|
+
for (const [k, v] of Object.entries(block)) {
|
|
93
|
+
if (v !== null && typeof v === "object" && !Array.isArray(v)) {
|
|
94
|
+
if (!k.startsWith("@")) {
|
|
95
|
+
condSubs.push([k, v as Record<string, unknown>]);
|
|
96
|
+
}
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (k.startsWith("--")) {
|
|
100
|
+
condRoot[k] = v;
|
|
101
|
+
} else {
|
|
102
|
+
condBody[k] = v;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
emit(":root", toDecls(condRoot, transpose));
|
|
106
|
+
emit("body", toDecls(condBody, transpose));
|
|
107
|
+
for (const [sel, sub] of condSubs) {
|
|
108
|
+
emit(sel, toDecls(sub, transpose));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (Object.values(mediaQueries).some((q) => pureSchemeOf(q) !== null)) {
|
|
113
|
+
rules.push(":root { color-scheme: light dark }");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return rules.join("\n");
|
|
117
|
+
}
|