@dogsbay/site-files 0.2.0-beta.119 → 0.2.0-beta.121

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/dist/index.d.ts CHANGED
@@ -12,3 +12,4 @@ export * from "./redirects.js";
12
12
  export * from "./canonical.js";
13
13
  export * from "./hreflang.js";
14
14
  export * from "./translations.js";
15
+ export * from "./namespaces.js";
package/dist/index.js CHANGED
@@ -12,3 +12,4 @@ export * from "./redirects.js";
12
12
  export * from "./canonical.js";
13
13
  export * from "./hreflang.js";
14
14
  export * from "./translations.js";
15
+ export * from "./namespaces.js";
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Named sources: one site built from several content sources, where a source
3
+ * with `name: api` publishes under `/api/` and shows as its own group in the
4
+ * sidebar. The Astro pipeline (dogsbay CLI, import-content.ts) and dogsbay-ssg
5
+ * apply the same rule from here, so the two agree on every URL and nav link.
6
+ *
7
+ * sources: [{ path: ./content }, { name: api, path: ./openapi.yaml }]
8
+ * ./content pages → /<slug> (unnamed: flat, as before)
9
+ * ./openapi.yaml pages → /api/<slug>, nav group "Api"
10
+ *
11
+ * Naming one source never moves another's URLs: an unnamed source stays flat
12
+ * beside named siblings. A single source, named or not, gets no segment.
13
+ */
14
+ /** A content source, as far as the namespace rule reads it. */
15
+ export interface NamespacedSource {
16
+ name?: string;
17
+ }
18
+ /** A nav entry, as far as prefixing reads it (`@dogsbay/types` NavItem fits). */
19
+ export interface PrefixableNavItem {
20
+ href?: string;
21
+ children?: PrefixableNavItem[];
22
+ }
23
+ /** A source's namespace: its `name:`, or undefined (flat URLs). */
24
+ export declare function sourceNamespace(source: NamespacedSource): string | undefined;
25
+ /**
26
+ * Whether namespaces are in play: two or more distinct namespaces across the
27
+ * sources, an unnamed source counting as one. So `[unnamed, api]` activates
28
+ * it (the unnamed one stays flat, `api` gets `/api/`), and `[api]` alone does
29
+ * not.
30
+ */
31
+ export declare function isNamespaceAxisActive(sources: readonly NamespacedSource[]): boolean;
32
+ /**
33
+ * The URL segment a source's pages go under: `[name]` when namespaces are
34
+ * active and the source has a name, otherwise none. (The Astro pipeline adds
35
+ * locale and version segments after it: `/<name>/<locale>/<version>/<slug>`.)
36
+ */
37
+ export declare function namespaceSegments(source: NamespacedSource, sources: readonly NamespacedSource[]): string[];
38
+ /** The sidebar label for a namespace: `api` → "Api", `user-guide` → "User Guide". */
39
+ export declare function namespaceLabel(ns: string): string;
40
+ /** A page slug under prefix segments: (`["api"]`, `pets/list`) → `api/pets/list`; `""` → `api`. */
41
+ export declare function prefixSlug(prefix: readonly string[], slug: string): string;
42
+ /**
43
+ * Nav hrefs with the prefix segments put after the base path:
44
+ * `/docs/pets/` → `/docs/api/pets/` for basePath `/docs`. External URLs, and
45
+ * hrefs not under the base path, are left alone: nothing is rewritten that
46
+ * the rule does not recognise. Returns new items; the input is not changed.
47
+ *
48
+ * @param basePath `""` or `/<seg>` (the site's combined URL prefix), the
49
+ * prefix the importer already wrote into each href.
50
+ */
51
+ export declare function prefixNavHrefs<T extends PrefixableNavItem>(items: readonly T[], prefix: readonly string[], basePath: string): T[];
52
+ /**
53
+ * A named source's nav as it joins the site's sidebar: one group labelled
54
+ * with the namespace, holding the source's own nav. An unnamed source's nav
55
+ * joins flat (`undefined` here means "splice the items in as they are").
56
+ */
57
+ export declare function namespaceNavGroup<T extends PrefixableNavItem>(ns: string | undefined, nav: T[]): {
58
+ label: string;
59
+ children: T[];
60
+ } | undefined;
61
+ /**
62
+ * Prefix INTERNAL-ABSOLUTE refs in a page tree with the source's URL
63
+ * segments, matching the slug rewrite — so a versioned/localised page's
64
+ * links and images resolve under the same `/<segs>/...` prefix as the
65
+ * pages themselves. Covers page links (`href`) and asset refs (`src`),
66
+ * in both markdown nodes (`src`/`href` fields) and raw-HTML nodes
67
+ * (`href="..."`/`src="..."` inside an `html` string).
68
+ *
69
+ * Refs at this pipeline stage are ROOT-RELATIVE and carry NO basePath
70
+ * (importers resolve to `/foo`; basePath is composed downstream), so the
71
+ * match anchor is `/`, not `<basePath>/`. A ref is prefixed iff it is an
72
+ * internal absolute path (starts with `/`), and NOT: external
73
+ * (`scheme:`, `//host`), an anchor (`#`), the site home (`/`), under a
74
+ * GLOBAL prefix (taxonomy index paths — one `/tags/` for the whole site,
75
+ * never version-bucketed), or ALREADY carrying this source's full
76
+ * `/<segs>/` prefix (idempotent). Relative refs are left untouched.
77
+ *
78
+ * The idempotency check is a guess: a source under `/api/` whose own folder
79
+ * is also `api` writes `/api/x`, which reads as already prefixed. An importer
80
+ * that invents folder names (format-openapi's tags) avoids those segments
81
+ * (`reservedSlugs`). Changes `tree` in place.
82
+ *
83
+ * Used for a named source (`["api"]`) and, in the Astro pipeline, versioned
84
+ * and localized ones too (`["api", "fr", "v2"]`).
85
+ */
86
+ export declare function rewriteContentRefs(tree: unknown, prefixSegs: readonly string[], globalPrefixes: ReadonlySet<string>): void;
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Named sources: one site built from several content sources, where a source
3
+ * with `name: api` publishes under `/api/` and shows as its own group in the
4
+ * sidebar. The Astro pipeline (dogsbay CLI, import-content.ts) and dogsbay-ssg
5
+ * apply the same rule from here, so the two agree on every URL and nav link.
6
+ *
7
+ * sources: [{ path: ./content }, { name: api, path: ./openapi.yaml }]
8
+ * ./content pages → /<slug> (unnamed: flat, as before)
9
+ * ./openapi.yaml pages → /api/<slug>, nav group "Api"
10
+ *
11
+ * Naming one source never moves another's URLs: an unnamed source stays flat
12
+ * beside named siblings. A single source, named or not, gets no segment.
13
+ */
14
+ /** A source's namespace: its `name:`, or undefined (flat URLs). */
15
+ export function sourceNamespace(source) {
16
+ return source.name;
17
+ }
18
+ /**
19
+ * Whether namespaces are in play: two or more distinct namespaces across the
20
+ * sources, an unnamed source counting as one. So `[unnamed, api]` activates
21
+ * it (the unnamed one stays flat, `api` gets `/api/`), and `[api]` alone does
22
+ * not.
23
+ */
24
+ export function isNamespaceAxisActive(sources) {
25
+ if (sources.length < 2)
26
+ return false;
27
+ return new Set(sources.map(sourceNamespace)).size >= 2;
28
+ }
29
+ /**
30
+ * The URL segment a source's pages go under: `[name]` when namespaces are
31
+ * active and the source has a name, otherwise none. (The Astro pipeline adds
32
+ * locale and version segments after it: `/<name>/<locale>/<version>/<slug>`.)
33
+ */
34
+ export function namespaceSegments(source, sources) {
35
+ const ns = isNamespaceAxisActive(sources) ? sourceNamespace(source) : undefined;
36
+ return ns === undefined ? [] : [ns];
37
+ }
38
+ /** The sidebar label for a namespace: `api` → "Api", `user-guide` → "User Guide". */
39
+ export function namespaceLabel(ns) {
40
+ return ns
41
+ .split("-")
42
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
43
+ .join(" ");
44
+ }
45
+ /** A page slug under prefix segments: (`["api"]`, `pets/list`) → `api/pets/list`; `""` → `api`. */
46
+ export function prefixSlug(prefix, slug) {
47
+ if (prefix.length === 0)
48
+ return slug;
49
+ if (!slug)
50
+ return prefix.join("/");
51
+ return `${prefix.join("/")}/${slug}`;
52
+ }
53
+ /**
54
+ * Nav hrefs with the prefix segments put after the base path:
55
+ * `/docs/pets/` → `/docs/api/pets/` for basePath `/docs`. External URLs, and
56
+ * hrefs not under the base path, are left alone: nothing is rewritten that
57
+ * the rule does not recognise. Returns new items; the input is not changed.
58
+ *
59
+ * @param basePath `""` or `/<seg>` (the site's combined URL prefix), the
60
+ * prefix the importer already wrote into each href.
61
+ */
62
+ export function prefixNavHrefs(items, prefix, basePath) {
63
+ if (prefix.length === 0)
64
+ return items.map((item) => ({ ...item }));
65
+ const match = basePath ? `${basePath}/` : "/";
66
+ const href = (h) => {
67
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(h) || h.startsWith("mailto:"))
68
+ return h;
69
+ if (!h.startsWith(match))
70
+ return h;
71
+ return `${match}${prefix.join("/")}/${h.slice(match.length)}`;
72
+ };
73
+ return items.map((item) => {
74
+ const next = { ...item };
75
+ if (item.href !== undefined)
76
+ next.href = href(item.href);
77
+ if (item.children && item.children.length > 0) {
78
+ next.children = prefixNavHrefs(item.children, prefix, basePath);
79
+ }
80
+ return next;
81
+ });
82
+ }
83
+ /**
84
+ * A named source's nav as it joins the site's sidebar: one group labelled
85
+ * with the namespace, holding the source's own nav. An unnamed source's nav
86
+ * joins flat (`undefined` here means "splice the items in as they are").
87
+ */
88
+ export function namespaceNavGroup(ns, nav) {
89
+ return ns === undefined ? undefined : { label: namespaceLabel(ns), children: nav };
90
+ }
91
+ /**
92
+ * Prefix INTERNAL-ABSOLUTE refs in a page tree with the source's URL
93
+ * segments, matching the slug rewrite — so a versioned/localised page's
94
+ * links and images resolve under the same `/<segs>/...` prefix as the
95
+ * pages themselves. Covers page links (`href`) and asset refs (`src`),
96
+ * in both markdown nodes (`src`/`href` fields) and raw-HTML nodes
97
+ * (`href="..."`/`src="..."` inside an `html` string).
98
+ *
99
+ * Refs at this pipeline stage are ROOT-RELATIVE and carry NO basePath
100
+ * (importers resolve to `/foo`; basePath is composed downstream), so the
101
+ * match anchor is `/`, not `<basePath>/`. A ref is prefixed iff it is an
102
+ * internal absolute path (starts with `/`), and NOT: external
103
+ * (`scheme:`, `//host`), an anchor (`#`), the site home (`/`), under a
104
+ * GLOBAL prefix (taxonomy index paths — one `/tags/` for the whole site,
105
+ * never version-bucketed), or ALREADY carrying this source's full
106
+ * `/<segs>/` prefix (idempotent). Relative refs are left untouched.
107
+ *
108
+ * The idempotency check is a guess: a source under `/api/` whose own folder
109
+ * is also `api` writes `/api/x`, which reads as already prefixed. An importer
110
+ * that invents folder names (format-openapi's tags) avoids those segments
111
+ * (`reservedSlugs`). Changes `tree` in place.
112
+ *
113
+ * Used for a named source (`["api"]`) and, in the Astro pipeline, versioned
114
+ * and localized ones too (`["api", "fr", "v2"]`).
115
+ */
116
+ export function rewriteContentRefs(tree, prefixSegs, globalPrefixes) {
117
+ if (prefixSegs.length === 0)
118
+ return;
119
+ const seg = prefixSegs.join("/");
120
+ const fullPrefix = `/${seg}/`;
121
+ const prefix = (u) => {
122
+ if (!u)
123
+ return u;
124
+ if (/^[a-z][a-z0-9+.-]*:/i.test(u))
125
+ return u; // scheme (http:, mailto:)
126
+ if (u.startsWith("//") || u.startsWith("#"))
127
+ return u; // protocol-rel / anchor
128
+ if (!u.startsWith("/"))
129
+ return u; // relative
130
+ if (u === "/" || u === "")
131
+ return u; // site home — global
132
+ if (u.startsWith(fullPrefix) || u === `/${seg}`)
133
+ return u; // already prefixed
134
+ // Global (taxonomy) prefixes live in one un-bucketed namespace.
135
+ const firstSeg = u.slice(1).split(/[/#?]/)[0];
136
+ if (globalPrefixes.has(firstSeg))
137
+ return u;
138
+ return `/${seg}${u}`;
139
+ };
140
+ // Raw HTML: rewrite the VALUE of each href="..."/src="..." attribute.
141
+ const rewriteHtml = (html) => html.replace(/\b(href|src)=("|')(.*?)\2/gi, (_m, attr, q, val) => `${attr}=${q}${prefix(val)}${q}`);
142
+ const walk = (node) => {
143
+ if (Array.isArray(node)) {
144
+ for (const item of node)
145
+ walk(item);
146
+ return;
147
+ }
148
+ if (!node || typeof node !== "object")
149
+ return;
150
+ const obj = node;
151
+ if (typeof obj.src === "string")
152
+ obj.src = prefix(obj.src);
153
+ if (typeof obj.href === "string")
154
+ obj.href = prefix(obj.href);
155
+ if (typeof obj.html === "string")
156
+ obj.html = rewriteHtml(obj.html);
157
+ // `::child-links{folder=…}` is a site path too: on a versioned or
158
+ // localized site it names a folder in THIS source's namespace.
159
+ if (obj.type === "child-links" && obj.props && typeof obj.props.folder === "string") {
160
+ const props = obj.props;
161
+ props.folder = prefix(`/${String(props.folder).replace(/^\/+/, "")}`);
162
+ }
163
+ // `scope=section` has no folder to prefix: record the source namespace,
164
+ // or the section resolves to the version segment (`/v2/`).
165
+ if (obj.type === "child-links" && obj.props && obj.props.scope === "section") {
166
+ obj.props.scopeBase = `/${seg}`;
167
+ }
168
+ for (const key of Object.keys(obj)) {
169
+ const val = obj[key];
170
+ if (val && typeof val === "object")
171
+ walk(val);
172
+ }
173
+ };
174
+ walk(tree);
175
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogsbay/site-files",
3
- "version": "0.2.0-beta.119",
3
+ "version": "0.2.0-beta.121",
4
4
  "description": "robots.txt, _headers, llms.txt, sitemaps, redirects and JSON-LD for docs sites; shared by the dogsbay Astro pipeline and dogsbay-ssg, Worker-safe",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",