@cedros/data-react 0.1.6 → 0.1.8

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.
Files changed (42) hide show
  1. package/README.md +7 -0
  2. package/dist/docsContract.d.ts +56 -0
  3. package/dist/docsContract.js +136 -0
  4. package/dist/react/contentCollections.d.ts +4 -0
  5. package/dist/react/contentCollections.js +48 -0
  6. package/dist/react/docs.d.ts +14 -0
  7. package/dist/react/docs.js +207 -0
  8. package/dist/react/entries.js +6 -7
  9. package/dist/react/index.d.ts +2 -1
  10. package/dist/react/index.js +1 -0
  11. package/dist/react/server.d.ts +2 -1
  12. package/dist/react/server.js +1 -0
  13. package/dist/react/sitemap.js +7 -20
  14. package/dist/react/slugs.js +7 -20
  15. package/dist/react/types.d.ts +17 -0
  16. package/dist/site-templates/BlogTemplates.js +16 -12
  17. package/dist/site-templates/DocsSidebar.d.ts +1 -1
  18. package/dist/site-templates/DocsSidebar.js +2 -2
  19. package/dist/site-templates/DocsTemplates.d.ts +9 -8
  20. package/dist/site-templates/DocsTemplates.js +32 -18
  21. package/dist/site-templates/SiteFooter.js +1 -1
  22. package/dist/site-templates/SiteLayout.js +1 -1
  23. package/dist/site-templates/TopNav.js +1 -1
  24. package/dist/site-templates/blog-styles.css +259 -0
  25. package/dist/site-templates/blogControls.js +2 -2
  26. package/dist/site-templates/blogTemplateUi.d.ts +10 -0
  27. package/dist/site-templates/blogTemplateUi.js +4 -0
  28. package/dist/site-templates/content-styles.css +127 -309
  29. package/dist/site-templates/contentIndex.d.ts +4 -7
  30. package/dist/site-templates/contentIndex.js +25 -1
  31. package/dist/site-templates/contentUi.js +4 -3
  32. package/dist/site-templates/dashboard-styles.css +109 -0
  33. package/dist/site-templates/docs-layout.css +372 -0
  34. package/dist/site-templates/docs-styles.css +288 -96
  35. package/dist/site-templates/docsNavigation.d.ts +50 -1
  36. package/dist/site-templates/docsNavigation.js +242 -8
  37. package/dist/site-templates/docsTemplateShell.d.ts +8 -0
  38. package/dist/site-templates/docsTemplateShell.js +14 -0
  39. package/dist/site-templates/index.d.ts +2 -1
  40. package/dist/site-templates/index.js +2 -1
  41. package/dist/site-templates/styles.css +283 -201
  42. package/package.json +1 -1
package/README.md CHANGED
@@ -217,13 +217,18 @@ Tipping:
217
217
  Docs templates:
218
218
  - `DocsIndexTemplate`
219
219
  - `DocArticleTemplate` (GitBook-style left nav + right TOC)
220
+ - both accept `headless` to skip the built-in `SiteLayout`, or `renderLayout={(content) => ...}` to wrap the docs UI in an existing app shell
220
221
 
221
222
  Content rendering and helpers:
222
223
  - `MarkdownContent`
223
224
  - `Breadcrumbs`
224
225
  - `ContentPagination`
225
226
  - `withActiveRouteState`
227
+ - `fetchDocEntry`
226
228
  - `buildDocsSidebarSections`
229
+ - `buildDocsTree`
230
+ - `buildHierarchicalDocsSidebarSections`
231
+ - `buildDocsPrevNext`
227
232
  - `withActiveDocsSidebar`
228
233
  - `prepareBlogIndex`
229
234
  - `prepareDocsIndex`
@@ -319,6 +324,8 @@ Docs/blog templates default to `bodyMarkdown`.
319
324
 
320
325
  This keeps markdown as the safe default and avoids unsafe HTML rendering by default.
321
326
 
327
+ Docs search helpers also inspect optional `bodyMarkdown`, `bodyText`, `bodyHtml`, and `searchText` fields on docs entries, so callers can opt into body/full-text matching without changing the query API.
328
+
322
329
  ## Server helpers and API key
323
330
 
324
331
  `@cedros/data-react/server` provides Next.js data-fetching helpers that call cedros-data directly:
@@ -0,0 +1,56 @@
1
+ export interface DocsTocHeading {
2
+ id: string;
3
+ label: string;
4
+ depth?: 2 | 3 | 4;
5
+ }
6
+ export interface DocsBreadcrumb {
7
+ label: string;
8
+ href: string;
9
+ }
10
+ export interface DocsEntryRecord {
11
+ slug: string;
12
+ title: string;
13
+ description?: string;
14
+ category?: string;
15
+ tags?: string[];
16
+ lastUpdated?: string;
17
+ bodyMarkdown?: string;
18
+ bodyHtml?: string;
19
+ bodyText?: string;
20
+ searchText?: string;
21
+ status?: string;
22
+ productSlug?: string;
23
+ productLabel?: string;
24
+ productOrder?: number;
25
+ sectionKey?: string;
26
+ sectionLabel?: string;
27
+ sectionOrder?: number;
28
+ order?: number;
29
+ readingMinutes?: number;
30
+ toc?: DocsTocHeading[];
31
+ breadcrumbs?: DocsBreadcrumb[];
32
+ editHref?: string;
33
+ sourcePath?: string;
34
+ sourceHref?: string;
35
+ navLabel?: string;
36
+ navOrder?: number;
37
+ aliases?: string[];
38
+ badge?: string;
39
+ }
40
+ export interface DocsNormalizationOptions {
41
+ aliases?: Record<string, string>;
42
+ basePath?: string;
43
+ }
44
+ export interface DocsFilterOptions extends DocsNormalizationOptions {
45
+ productSlug?: string;
46
+ sectionKey?: string;
47
+ includeDrafts?: boolean;
48
+ }
49
+ export interface SortDocsEntriesOptions {
50
+ preserveSourceOrder?: boolean;
51
+ }
52
+ export declare function normalizeDocsSlug(value: string, options?: DocsNormalizationOptions): string;
53
+ export declare function normalizeDocsHref(href: string, options?: DocsNormalizationOptions): string;
54
+ export declare function matchesDocsEntryFilter(entry: DocsEntryRecord, options?: DocsFilterOptions): boolean;
55
+ export declare function compareDocsEntriesByOrder(left: DocsEntryRecord, right: DocsEntryRecord): number;
56
+ export declare function sortDocsEntries<T extends DocsEntryRecord>(entries: T[], options?: SortDocsEntriesOptions): T[];
@@ -0,0 +1,136 @@
1
+ const DEFAULT_DOCS_BASE_PATH = "/docs";
2
+ const ABSOLUTE_URL_PATTERN = /^[a-z][a-z\d+\-.]*:\/\//iu;
3
+ export function normalizeDocsSlug(value, options = {}) {
4
+ const { pathname } = splitHref(value);
5
+ const basePath = normalizeBasePath(options.basePath);
6
+ let relativePath = trimSlashes(pathname);
7
+ if (!relativePath) {
8
+ return "";
9
+ }
10
+ const normalizedBase = trimSlashes(basePath);
11
+ if (normalizedBase) {
12
+ if (relativePath === normalizedBase) {
13
+ relativePath = "";
14
+ }
15
+ else if (relativePath.startsWith(`${normalizedBase}/`)) {
16
+ relativePath = relativePath.slice(normalizedBase.length + 1);
17
+ }
18
+ }
19
+ const segments = relativePath
20
+ .split("/")
21
+ .filter(Boolean)
22
+ .map(slugifySegment);
23
+ if (segments.length === 0) {
24
+ return "";
25
+ }
26
+ const aliasMap = normalizeAliasMap(options.aliases);
27
+ if (segments[0] && aliasMap[segments[0]]) {
28
+ segments[0] = aliasMap[segments[0]];
29
+ }
30
+ return segments.join("/");
31
+ }
32
+ export function normalizeDocsHref(href, options = {}) {
33
+ const { origin, suffix } = splitHref(href);
34
+ const slug = normalizeDocsSlug(href, options);
35
+ const basePath = normalizeBasePath(options.basePath);
36
+ const path = slug ? `${basePath}/${slug}`.replace(/\/+/gu, "/") : basePath;
37
+ if (origin) {
38
+ return `${origin}${path}${suffix}`;
39
+ }
40
+ return `${path}${suffix}`;
41
+ }
42
+ export function matchesDocsEntryFilter(entry, options = {}) {
43
+ if (!options.includeDrafts && entry.status?.trim().toLowerCase() === "draft") {
44
+ return false;
45
+ }
46
+ if (options.productSlug) {
47
+ const entryProduct = normalizeDocsSlug(entry.productSlug ?? "", options);
48
+ const expectedProduct = normalizeDocsSlug(options.productSlug, options);
49
+ if (!entryProduct || entryProduct !== expectedProduct) {
50
+ return false;
51
+ }
52
+ }
53
+ if (options.sectionKey) {
54
+ const entrySection = normalizeFragment(entry.sectionKey);
55
+ const expectedSection = normalizeFragment(options.sectionKey);
56
+ if (!entrySection || entrySection !== expectedSection) {
57
+ return false;
58
+ }
59
+ }
60
+ return true;
61
+ }
62
+ export function compareDocsEntriesByOrder(left, right) {
63
+ return (compareOptionalNumber(left.productOrder, right.productOrder) ||
64
+ compareOptionalText(left.productSlug, right.productSlug) ||
65
+ compareOptionalNumber(left.sectionOrder, right.sectionOrder) ||
66
+ compareOptionalText(left.sectionKey, right.sectionKey) ||
67
+ compareOptionalNumber(left.order, right.order) ||
68
+ compareOptionalNumber(left.navOrder, right.navOrder));
69
+ }
70
+ export function sortDocsEntries(entries, options = {}) {
71
+ const preserveSourceOrder = options.preserveSourceOrder !== false;
72
+ return entries
73
+ .map((entry, index) => ({ entry, index }))
74
+ .sort((left, right) => {
75
+ const byOrder = compareDocsEntriesByOrder(left.entry, right.entry);
76
+ if (byOrder !== 0) {
77
+ return byOrder;
78
+ }
79
+ if (preserveSourceOrder) {
80
+ return left.index - right.index;
81
+ }
82
+ return (compareOptionalText(left.entry.navLabel ?? left.entry.title, right.entry.navLabel ?? right.entry.title) ||
83
+ compareOptionalText(left.entry.slug, right.entry.slug) ||
84
+ left.index - right.index);
85
+ })
86
+ .map(({ entry }) => entry);
87
+ }
88
+ function splitHref(value) {
89
+ const [withoutHash, hash = ""] = value.split("#", 2);
90
+ const [withoutQuery, query = ""] = withoutHash.split("?", 2);
91
+ const suffix = `${query ? `?${query}` : ""}${hash ? `#${hash}` : ""}`;
92
+ if (ABSOLUTE_URL_PATTERN.test(withoutQuery)) {
93
+ const url = new URL(withoutQuery);
94
+ return {
95
+ origin: url.origin,
96
+ pathname: url.pathname,
97
+ suffix,
98
+ };
99
+ }
100
+ return {
101
+ origin: "",
102
+ pathname: withoutQuery,
103
+ suffix,
104
+ };
105
+ }
106
+ function normalizeBasePath(basePath = DEFAULT_DOCS_BASE_PATH) {
107
+ const trimmed = `/${trimSlashes(basePath)}`;
108
+ return trimmed === "/" ? DEFAULT_DOCS_BASE_PATH : trimmed;
109
+ }
110
+ function normalizeAliasMap(aliases) {
111
+ if (!aliases) {
112
+ return {};
113
+ }
114
+ return Object.fromEntries(Object.entries(aliases)
115
+ .map(([from, to]) => [slugifySegment(from), slugifySegment(to)])
116
+ .filter(([from, to]) => from && to));
117
+ }
118
+ function trimSlashes(value) {
119
+ return value.trim().replace(/^\/+|\/+$/gu, "");
120
+ }
121
+ function slugifySegment(value) {
122
+ return value
123
+ .trim()
124
+ .toLowerCase()
125
+ .replace(/[^a-z0-9]+/gu, "-")
126
+ .replace(/^-+|-+$/gu, "");
127
+ }
128
+ function normalizeFragment(value) {
129
+ return slugifySegment(value ?? "");
130
+ }
131
+ function compareOptionalNumber(left, right) {
132
+ return (left ?? Number.MAX_SAFE_INTEGER) - (right ?? Number.MAX_SAFE_INTEGER);
133
+ }
134
+ function compareOptionalText(left, right) {
135
+ return (left ?? "").localeCompare(right ?? "");
136
+ }
@@ -0,0 +1,4 @@
1
+ import type { ContentType } from "./types.js";
2
+ export declare function collectionNameForContentType(contentType: ContentType): string;
3
+ export declare function collectionNamesForContentType(contentType: ContentType): string[];
4
+ export declare function queryEntriesByContentType<T>(serverUrl: string, contentType: ContentType, buildBody: (collectionName: string) => Record<string, unknown>, apiKey?: string): Promise<T>;
@@ -0,0 +1,48 @@
1
+ import { fetchJson } from "./fetch.js";
2
+ const CANONICAL_CONTENT_TYPE_COLLECTIONS = {
3
+ page: "pages",
4
+ blog: "blog",
5
+ docs: "docs",
6
+ learn: "learn",
7
+ project: "projects",
8
+ airdrop: "airdrops",
9
+ };
10
+ const CONTENT_TYPE_COLLECTION_ALIASES = {
11
+ blog: ["blogs"],
12
+ learn: ["courses"],
13
+ };
14
+ export function collectionNameForContentType(contentType) {
15
+ return CANONICAL_CONTENT_TYPE_COLLECTIONS[contentType];
16
+ }
17
+ export function collectionNamesForContentType(contentType) {
18
+ const canonicalName = collectionNameForContentType(contentType);
19
+ const aliases = CONTENT_TYPE_COLLECTION_ALIASES[contentType] ?? [];
20
+ return [canonicalName, ...aliases.filter((alias) => alias !== canonicalName)];
21
+ }
22
+ export async function queryEntriesByContentType(serverUrl, contentType, buildBody, apiKey) {
23
+ return queryEntriesByCollectionNames(serverUrl, collectionNamesForContentType(contentType), buildBody, apiKey);
24
+ }
25
+ async function queryEntriesByCollectionNames(serverUrl, collectionNames, buildBody, apiKey) {
26
+ let lastCollectionNotFoundError;
27
+ const lastCollectionName = collectionNames[collectionNames.length - 1];
28
+ for (const collectionName of collectionNames) {
29
+ try {
30
+ return await fetchJson(serverUrl, "/entries/query", {
31
+ method: "POST",
32
+ body: buildBody(collectionName),
33
+ apiKey,
34
+ });
35
+ }
36
+ catch (error) {
37
+ if (!isCollectionNotFoundError(error) || collectionName === lastCollectionName) {
38
+ throw error;
39
+ }
40
+ lastCollectionNotFoundError = error;
41
+ }
42
+ }
43
+ throw (lastCollectionNotFoundError ??
44
+ new Error("@cedros/data-react: no collection names were configured"));
45
+ }
46
+ function isCollectionNotFoundError(error) {
47
+ return error instanceof Error && /collection not found/i.test(error.message);
48
+ }
@@ -0,0 +1,14 @@
1
+ import { type DocsEntryRecord } from "../docsContract.js";
2
+ import type { DocsFetchOptions } from "./types.js";
3
+ /**
4
+ * Loads the normalized docs corpus from cedros-data and applies product/section filters.
5
+ *
6
+ * Returns docs in package ordering, excluding drafts unless `includeDrafts` is true.
7
+ */
8
+ export declare function fetchDocsIndex(options?: DocsFetchOptions): Promise<DocsEntryRecord[]>;
9
+ /**
10
+ * Resolves a single docs article by canonical slug, current route, or any declared alias.
11
+ *
12
+ * Returns `null` when the requested doc does not exist after normalization.
13
+ */
14
+ export declare function fetchDocsEntry(slugOrPath: string, options?: DocsFetchOptions): Promise<DocsEntryRecord | null>;
@@ -0,0 +1,207 @@
1
+ import { fetchDocEntry as findDocEntry } from "../site-templates/docsNavigation.js";
2
+ import { matchesDocsEntryFilter, normalizeDocsHref, normalizeDocsSlug, sortDocsEntries, } from "../docsContract.js";
3
+ import { resolveApiKey, resolveServerUrl } from "./fetch.js";
4
+ import { fetchJson } from "./fetch.js";
5
+ const DEFAULT_DOCS_COLLECTION = "docs";
6
+ const DOCS_BATCH_SIZE = 1_000;
7
+ /**
8
+ * Loads the normalized docs corpus from cedros-data and applies product/section filters.
9
+ *
10
+ * Returns docs in package ordering, excluding drafts unless `includeDrafts` is true.
11
+ */
12
+ export async function fetchDocsIndex(options = {}) {
13
+ const serverUrl = resolveServerUrl(options);
14
+ const apiKey = resolveApiKey(options);
15
+ const collectionName = options.collectionName ?? DEFAULT_DOCS_COLLECTION;
16
+ const records = await fetchAllDocsRecords(serverUrl, collectionName, apiKey);
17
+ const docs = sortDocsEntries(records
18
+ .map((record) => normalizeDocsEntry(record, options))
19
+ .filter((entry) => matchesDocsEntryFilter(entry, options)));
20
+ const offset = Math.max(0, Math.floor(options.offset ?? 0));
21
+ const end = options.limit === undefined
22
+ ? undefined
23
+ : offset + Math.max(0, Math.floor(options.limit));
24
+ return docs.slice(offset, end);
25
+ }
26
+ /**
27
+ * Resolves a single docs article by canonical slug, current route, or any declared alias.
28
+ *
29
+ * Returns `null` when the requested doc does not exist after normalization.
30
+ */
31
+ export async function fetchDocsEntry(slugOrPath, options = {}) {
32
+ const docs = await fetchDocsIndex({
33
+ ...options,
34
+ limit: undefined,
35
+ offset: undefined,
36
+ });
37
+ return findDocEntry(docs, slugOrPath, options) ?? null;
38
+ }
39
+ async function fetchAllDocsRecords(serverUrl, collectionName, apiKey) {
40
+ const records = [];
41
+ let offset = 0;
42
+ for (;;) {
43
+ const batch = await fetchJson(serverUrl, "/entries/query", {
44
+ method: "POST",
45
+ body: {
46
+ collection_name: collectionName,
47
+ limit: DOCS_BATCH_SIZE,
48
+ offset,
49
+ },
50
+ apiKey,
51
+ });
52
+ records.push(...batch);
53
+ if (batch.length < DOCS_BATCH_SIZE) {
54
+ return records;
55
+ }
56
+ offset += batch.length;
57
+ }
58
+ }
59
+ function normalizeDocsEntry(record, options) {
60
+ const payload = record.payload;
61
+ const bodyMarkdown = readStringAny(payload, [
62
+ "bodyMarkdown",
63
+ "body_markdown",
64
+ "markdown",
65
+ "body",
66
+ ]);
67
+ const slug = normalizeDocsSlug(readStringAny(payload, ["slug", "routeSlug", "path", "route"]) ??
68
+ record.entry_key, options) || "index";
69
+ return {
70
+ slug,
71
+ title: readStringAny(payload, ["title", "navTitle"]) ?? humanizeDocsSlug(slug),
72
+ description: readStringAny(payload, ["description"]) ??
73
+ inferDescription(bodyMarkdown),
74
+ category: readStringAny(payload, ["category"]),
75
+ tags: readStringArray(payload, "tags"),
76
+ lastUpdated: readStringAny(payload, [
77
+ "lastUpdated",
78
+ "updatedAt",
79
+ "updated_at",
80
+ "publishedAt",
81
+ ]) ?? record.updated_at,
82
+ bodyMarkdown,
83
+ bodyHtml: readStringAny(payload, ["bodyHtml", "body_html"]),
84
+ bodyText: readStringAny(payload, ["bodyText", "body_text"]),
85
+ searchText: readStringAny(payload, ["searchText", "search_text"]),
86
+ status: readStringAny(payload, ["status"]),
87
+ productSlug: normalizeDocsSlug(readStringAny(payload, ["productSlug", "productKey", "product"]) ?? "", options) || undefined,
88
+ productLabel: readStringAny(payload, ["productLabel", "productTitle"]),
89
+ productOrder: readNumberAny(payload, ["productOrder"]),
90
+ sectionKey: normalizeSectionKey(readStringAny(payload, ["sectionKey", "section", "sidebarSection"])),
91
+ sectionLabel: readStringAny(payload, [
92
+ "sectionLabel",
93
+ "sectionTitle",
94
+ "section",
95
+ "sidebarSection",
96
+ "category",
97
+ ]),
98
+ sectionOrder: readNumberAny(payload, ["sectionOrder"]),
99
+ order: readNumberAny(payload, ["order", "navOrder"]),
100
+ readingMinutes: readNumberAny(payload, ["readingMinutes"]),
101
+ toc: readToc(payload),
102
+ breadcrumbs: readBreadcrumbs(payload, options),
103
+ editHref: readStringAny(payload, ["editHref", "editUrl", "sourceUrl"]),
104
+ sourcePath: readStringAny(payload, ["sourcePath"]),
105
+ sourceHref: readStringAny(payload, ["sourceHref", "sourceUrl"]),
106
+ navLabel: readStringAny(payload, ["navLabel", "navTitle"]),
107
+ navOrder: readNumberAny(payload, ["navOrder", "order"]),
108
+ aliases: readStringArray(payload, "aliases")
109
+ .map((value) => normalizeDocsSlug(value, options))
110
+ .filter(Boolean),
111
+ badge: readStringAny(payload, ["badge"]),
112
+ };
113
+ }
114
+ function readBreadcrumbs(payload, options) {
115
+ const raw = payload.breadcrumbs;
116
+ if (!Array.isArray(raw)) {
117
+ return undefined;
118
+ }
119
+ const breadcrumbs = raw
120
+ .map((value) => asObject(value))
121
+ .filter((entry) => entry !== undefined)
122
+ .map((entry) => {
123
+ const label = readStringAny(entry, ["label", "title"]);
124
+ const href = readStringAny(entry, ["href", "url", "path"]);
125
+ if (!label || !href) {
126
+ return undefined;
127
+ }
128
+ return {
129
+ label,
130
+ href: normalizeDocsHref(href, options),
131
+ };
132
+ })
133
+ .filter((entry) => entry !== undefined);
134
+ return breadcrumbs.length > 0 ? breadcrumbs : undefined;
135
+ }
136
+ function readToc(payload) {
137
+ const raw = payload.toc;
138
+ if (!Array.isArray(raw)) {
139
+ return undefined;
140
+ }
141
+ const headings = raw
142
+ .map((value) => asObject(value))
143
+ .filter((entry) => entry !== undefined)
144
+ .map((entry) => {
145
+ const id = readString(entry, "id");
146
+ const label = readString(entry, "label");
147
+ if (!id || !label) {
148
+ return undefined;
149
+ }
150
+ const depth = readNumber(entry, "depth");
151
+ const heading = {
152
+ id,
153
+ label,
154
+ depth: depth === 2 || depth === 3 || depth === 4
155
+ ? depth
156
+ : undefined,
157
+ };
158
+ return heading;
159
+ })
160
+ .filter((entry) => entry !== undefined);
161
+ return headings.length > 0 ? headings : undefined;
162
+ }
163
+ function inferDescription(markdown) {
164
+ const paragraph = markdown
165
+ ?.split(/\n{2,}/u)
166
+ .map((segment) => segment.replace(/^#+\s+/gmu, "").trim())
167
+ .find((segment) => segment && !segment.startsWith("```"));
168
+ return paragraph || undefined;
169
+ }
170
+ function humanizeDocsSlug(slug) {
171
+ const leaf = slug.split("/").pop() ?? slug;
172
+ return leaf.replace(/[-_]+/gu, " ").replace(/\b\w/gu, (char) => char.toUpperCase());
173
+ }
174
+ function normalizeSectionKey(value) {
175
+ return value
176
+ ?.trim()
177
+ .toLowerCase()
178
+ .replace(/[^a-z0-9]+/gu, "-")
179
+ .replace(/^-+|-+$/gu, "") || undefined;
180
+ }
181
+ function asObject(value) {
182
+ return value !== null && typeof value === "object" ? value : undefined;
183
+ }
184
+ function readString(payload, key) {
185
+ const value = payload[key];
186
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
187
+ }
188
+ function readStringAny(payload, keys) {
189
+ return keys.map((key) => readString(payload, key)).find(Boolean);
190
+ }
191
+ function readNumber(payload, key) {
192
+ const value = payload[key];
193
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
194
+ }
195
+ function readNumberAny(payload, keys) {
196
+ return keys.map((key) => readNumber(payload, key)).find((value) => value !== undefined);
197
+ }
198
+ function readStringArray(payload, key) {
199
+ const value = payload[key];
200
+ if (typeof value === "string" && value.trim()) {
201
+ return [value.trim()];
202
+ }
203
+ if (!Array.isArray(value)) {
204
+ return [];
205
+ }
206
+ return value.filter((entry) => typeof entry === "string" && entry.trim().length > 0);
207
+ }
@@ -1,4 +1,5 @@
1
- import { fetchJson, resolveApiKey, resolveServerUrl } from "./fetch.js";
1
+ import { resolveApiKey, resolveServerUrl } from "./fetch.js";
2
+ import { queryEntriesByContentType } from "./contentCollections.js";
2
3
  /**
3
4
  * Fetches a single blog post by slug, optionally passing visitor_id for metered reads.
4
5
  *
@@ -10,7 +11,6 @@ export async function fetchBlogPost(slug, options) {
10
11
  const serverUrl = resolveServerUrl(options);
11
12
  const apiKey = resolveApiKey(options);
12
13
  const body = {
13
- collection_name: "blog",
14
14
  entry_keys: [slug],
15
15
  limit: 1,
16
16
  offset: 0,
@@ -18,10 +18,9 @@ export async function fetchBlogPost(slug, options) {
18
18
  if (options?.visitorId) {
19
19
  body.visitor_id = options.visitorId;
20
20
  }
21
- const entries = await fetchJson(serverUrl, "/entries/query", {
22
- method: "POST",
23
- body,
24
- apiKey,
25
- });
21
+ const entries = await queryEntriesByContentType(serverUrl, "blog", (collectionName) => ({
22
+ ...body,
23
+ collection_name: collectionName,
24
+ }), apiKey);
26
25
  return entries.length > 0 ? entries[0] : null;
27
26
  }
@@ -9,4 +9,5 @@ export type { ThemeMode, CedrosDataThemeOverrides, CedrosDataThemeValue, } from
9
9
  export { CmsContent, type CmsContentProps } from "./CmsContent.js";
10
10
  export { getOrCreateVisitorId } from "./visitor.js";
11
11
  export { sanitizeCmsHtml, renderCmsMarkdown } from "./sanitize.js";
12
- export type { CmsPageRecord, PageMetadataInput, SiteDataRecord, SanitizeOptions, ContentType, MeteredReadsInfo, } from "./types.js";
12
+ export { normalizeDocsHref, normalizeDocsSlug } from "../docsContract.js";
13
+ export type { CmsPageRecord, PageMetadataInput, SiteDataRecord, SanitizeOptions, ContentType, DocsBreadcrumb, DocsEntryRecord, DocsFetchOptions, DocsNormalizationOptions, DocsTocHeading, MeteredReadsInfo, } from "./types.js";
@@ -8,3 +8,4 @@ export { CedrosDataProvider, useCedrosDataTheme, useCedrosDataThemeOptional, } f
8
8
  export { CmsContent } from "./CmsContent.js";
9
9
  export { getOrCreateVisitorId } from "./visitor.js";
10
10
  export { sanitizeCmsHtml, renderCmsMarkdown } from "./sanitize.js";
11
+ export { normalizeDocsHref, normalizeDocsSlug } from "../docsContract.js";
@@ -8,7 +8,8 @@
8
8
  * Do not import this module in client components.
9
9
  */
10
10
  export { buildPageMetadata, generatePageMetadata, type PageMetadata, } from "./metadata.js";
11
+ export { fetchDocsEntry, fetchDocsIndex } from "./docs.js";
11
12
  export { loadSitemapEntries } from "./sitemap.js";
12
13
  export { fetchBlogPost } from "./entries.js";
13
14
  export { listBlogSlugs, listContentSlugs, listLearnPathIds, } from "./slugs.js";
14
- export type { BuildPageMetadataOptions, CmsPageRecord, MeteredReadsInfo, PageMetadataInput, SiteDataRecord, SitemapEntry, ServerFetchOptions, ContentType, } from "./types.js";
15
+ export type { BuildPageMetadataOptions, CmsPageRecord, MeteredReadsInfo, PageMetadataInput, SiteDataRecord, SitemapEntry, ServerFetchOptions, DocsBreadcrumb, DocsEntryRecord, DocsFetchOptions, DocsNormalizationOptions, DocsTocHeading, ContentType, } from "./types.js";
@@ -8,6 +8,7 @@
8
8
  * Do not import this module in client components.
9
9
  */
10
10
  export { buildPageMetadata, generatePageMetadata, } from "./metadata.js";
11
+ export { fetchDocsEntry, fetchDocsIndex } from "./docs.js";
11
12
  export { loadSitemapEntries } from "./sitemap.js";
12
13
  export { fetchBlogPost } from "./entries.js";
13
14
  export { listBlogSlugs, listContentSlugs, listLearnPathIds, } from "./slugs.js";
@@ -1,13 +1,5 @@
1
- import { fetchJson, resolveApiKey, resolveServerUrl } from "./fetch.js";
2
- /** Map of content types to their collection names in cedros-data. */
3
- const CONTENT_TYPE_COLLECTIONS = {
4
- page: "pages",
5
- blog: "blog",
6
- docs: "docs",
7
- learn: "learn",
8
- project: "projects",
9
- airdrop: "airdrops",
10
- };
1
+ import { resolveApiKey, resolveServerUrl } from "./fetch.js";
2
+ import { queryEntriesByContentType } from "./contentCollections.js";
11
3
  /** Default change frequency and priority for each content type. */
12
4
  const CONTENT_TYPE_DEFAULTS = {
13
5
  page: { changeFrequency: "monthly", priority: 0.8 },
@@ -67,17 +59,12 @@ export async function loadSitemapEntries(options) {
67
59
  return entries;
68
60
  }
69
61
  async function fetchCollectionSlugs(serverUrl, contentType, apiKey) {
70
- const collectionName = CONTENT_TYPE_COLLECTIONS[contentType];
71
62
  const defaults = CONTENT_TYPE_DEFAULTS[contentType];
72
- const records = await fetchJson(serverUrl, "/entries/query", {
73
- method: "POST",
74
- body: {
75
- collection_name: collectionName,
76
- limit: 1000,
77
- offset: 0,
78
- },
79
- apiKey,
80
- });
63
+ const records = await queryEntriesByContentType(serverUrl, contentType, (collectionName) => ({
64
+ collection_name: collectionName,
65
+ limit: 1000,
66
+ offset: 0,
67
+ }), apiKey);
81
68
  return records.map((record) => {
82
69
  const slug = record.payload.slug ??
83
70
  record.payload.route?.replace(/^\//, "") ??
@@ -1,13 +1,5 @@
1
- import { fetchJson, resolveApiKey, resolveServerUrl } from "./fetch.js";
2
- /** Map of content types to their collection names in cedros-data. */
3
- const CONTENT_TYPE_COLLECTIONS = {
4
- page: "pages",
5
- blog: "blog",
6
- docs: "docs",
7
- learn: "learn",
8
- project: "projects",
9
- airdrop: "airdrops",
10
- };
1
+ import { resolveApiKey, resolveServerUrl } from "./fetch.js";
2
+ import { queryEntriesByContentType } from "./contentCollections.js";
11
3
  /**
12
4
  * Returns all slugs for a given content type, for use with
13
5
  * Next.js `generateStaticParams`.
@@ -26,16 +18,11 @@ const CONTENT_TYPE_COLLECTIONS = {
26
18
  export async function listContentSlugs(contentType, options) {
27
19
  const serverUrl = resolveServerUrl(options);
28
20
  const apiKey = resolveApiKey(options);
29
- const collectionName = CONTENT_TYPE_COLLECTIONS[contentType];
30
- const records = await fetchJson(serverUrl, "/entries/query", {
31
- method: "POST",
32
- body: {
33
- collection_name: collectionName,
34
- limit: 1000,
35
- offset: 0,
36
- },
37
- apiKey,
38
- });
21
+ const records = await queryEntriesByContentType(serverUrl, contentType, (collectionName) => ({
22
+ collection_name: collectionName,
23
+ limit: 1000,
24
+ offset: 0,
25
+ }), apiKey);
39
26
  return records.map((record) => record.payload.slug ?? record.entry_key);
40
27
  }
41
28
  /**
@@ -1,3 +1,4 @@
1
+ import type { DocsBreadcrumb, DocsEntryRecord, DocsNormalizationOptions, DocsTocHeading } from "../docsContract.js";
1
2
  /** Content type identifiers used across cedros-data. */
2
3
  export type ContentType = "page" | "blog" | "docs" | "learn" | "project" | "airdrop";
3
4
  /**
@@ -97,3 +98,19 @@ export interface ServerFetchOptions {
97
98
  /** Visitor ID for metered-reads tracking. Use `getOrCreateVisitorId()` on the client. */
98
99
  visitorId?: string;
99
100
  }
101
+ /** Options for loading docs entries from the cedros-data backend. */
102
+ export interface DocsFetchOptions extends ServerFetchOptions, DocsNormalizationOptions {
103
+ /** Override the backend collection. Defaults to "docs". */
104
+ collectionName?: string;
105
+ /** Restrict results to a single product slug. */
106
+ productSlug?: string;
107
+ /** Restrict results to a single section key. */
108
+ sectionKey?: string;
109
+ /** Include draft docs. Defaults to false. */
110
+ includeDrafts?: boolean;
111
+ /** Final limit applied after docs are normalized and filtered. */
112
+ limit?: number;
113
+ /** Final offset applied after docs are normalized and filtered. */
114
+ offset?: number;
115
+ }
116
+ export type { DocsBreadcrumb, DocsEntryRecord, DocsNormalizationOptions, DocsTocHeading, };