@cedros/data-react 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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,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
+ }
@@ -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
  }
@@ -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,11 +1,12 @@
1
1
  import type { SiteNavigationItem } from "./SiteLayout.js";
2
2
  import { type DocsIndexEntry } from "./contentIndex.js";
3
3
  import { type DocsSidebarSection } from "./docsNavigation.js";
4
+ import { type DocsTemplateShellOptions } from "./docsTemplateShell.js";
4
5
  export interface DocsIndexItem extends DocsIndexEntry {
5
6
  }
6
- export interface DocsIndexTemplateProps {
7
- siteTitle: string;
8
- navigation: SiteNavigationItem[];
7
+ export interface DocsIndexTemplateProps extends DocsTemplateShellOptions {
8
+ siteTitle?: string;
9
+ navigation?: SiteNavigationItem[];
9
10
  docs: DocsIndexItem[];
10
11
  title?: string;
11
12
  description?: string;
@@ -22,10 +23,10 @@ export interface DocsIndexTemplateProps {
22
23
  sidebarSections?: DocsSidebarSection[];
23
24
  sidebarTitle?: string;
24
25
  }
25
- export declare function DocsIndexTemplate({ siteTitle, navigation, docs, title, description, basePath, currentPath, query, category, tag, sort, page, pageSize, categories, tags, sidebarSections, sidebarTitle }: DocsIndexTemplateProps): React.JSX.Element;
26
- export interface DocArticleTemplateProps {
27
- siteTitle: string;
28
- navigation: SiteNavigationItem[];
26
+ export declare function DocsIndexTemplate({ siteTitle, navigation, docs, title, description, basePath, currentPath, query, category, tag, sort, page, pageSize, categories, tags, sidebarSections, sidebarTitle, headless, renderLayout }: DocsIndexTemplateProps): React.JSX.Element;
27
+ export interface DocArticleTemplateProps extends DocsTemplateShellOptions {
28
+ siteTitle?: string;
29
+ navigation?: SiteNavigationItem[];
29
30
  title: string;
30
31
  bodyMarkdown?: string;
31
32
  bodyHtml?: string;
@@ -57,4 +58,4 @@ export interface DocArticleTemplateProps {
57
58
  };
58
59
  editHref?: string;
59
60
  }
60
- export declare function DocArticleTemplate({ siteTitle, navigation, title, bodyMarkdown, bodyHtml, allowUnsafeHtmlFallback, lastUpdated, readingMinutes, basePath, currentPath, searchQuery, docs, sidebarSections, sidebarTitle, breadcrumbs, toc, previousDoc, nextDoc, editHref }: DocArticleTemplateProps): React.JSX.Element;
61
+ export declare function DocArticleTemplate({ siteTitle, navigation, title, bodyMarkdown, bodyHtml, allowUnsafeHtmlFallback, lastUpdated, readingMinutes, basePath, currentPath, searchQuery, docs, sidebarSections, sidebarTitle, breadcrumbs, toc, previousDoc, nextDoc, editHref, headless, renderLayout }: DocArticleTemplateProps): React.JSX.Element;
@@ -1,17 +1,17 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { SiteLayout } from "./SiteLayout.js";
3
2
  import { buildContentListHref, collectFilterValues, prepareDocsIndex } from "./contentIndex.js";
4
3
  import { Breadcrumbs, ContentPagination } from "./contentUi.js";
5
- import { buildDocsSidebarSections, withActiveDocsSidebar } from "./docsNavigation.js";
4
+ import { buildHierarchicalDocsSidebarSections, buildDocsSidebarSections, withActiveDocsSidebar } from "./docsNavigation.js";
6
5
  import { DocsSidebar } from "./DocsSidebar.js";
6
+ import { renderDocsTemplateShell } from "./docsTemplateShell.js";
7
7
  import { MarkdownContent } from "./MarkdownContent.js";
8
8
  import { TocScrollSpy } from "./tocScrollSpy.js";
9
- export function DocsIndexTemplate({ siteTitle, navigation, docs, title = "Documentation", description = "", basePath = "/docs", currentPath, query = "", category = "", tag = "", sort = "title-asc", page = 1, pageSize = 10, categories, tags, sidebarSections, sidebarTitle = "Docs" }) {
9
+ export function DocsIndexTemplate({ siteTitle, navigation, docs, title = "Documentation", description = "", basePath = "/docs", currentPath, query = "", category = "", tag = "", sort = "title-asc", page = 1, pageSize = 10, categories, tags, sidebarSections, sidebarTitle = "Docs", headless = false, renderLayout }) {
10
10
  const normalizedFilters = collectFilterValues(docs);
11
11
  const resolvedCategories = categories ?? normalizedFilters.categories;
12
12
  const resolvedTags = tags ?? normalizedFilters.tags;
13
13
  const activePath = currentPath ?? basePath;
14
- const resolvedSidebarSections = withActiveDocsSidebar(sidebarSections ?? buildDocsSidebarSections(docs, basePath), activePath);
14
+ const resolvedSidebarSections = withActiveDocsSidebar(sidebarSections ?? buildDefaultDocsSidebarSections(docs, basePath), activePath);
15
15
  const result = prepareDocsIndex(docs, {
16
16
  query,
17
17
  category,
@@ -20,21 +20,31 @@ export function DocsIndexTemplate({ siteTitle, navigation, docs, title = "Docume
20
20
  page,
21
21
  pageSize
22
22
  });
23
- return (_jsx(SiteLayout, { siteTitle: siteTitle, navigation: navigation, children: _jsxs("section", { className: "cedros-site__docs-page", children: [_jsx(DocsSidebar, { title: sidebarTitle, basePath: basePath, searchQuery: query, sections: resolvedSidebarSections }), _jsxs("div", { className: "cedros-site__docs-main", children: [_jsxs("section", { className: "cedros-site__card", children: [_jsx("span", { className: "cedros-site__pill", children: "docs" }), _jsx("h1", { className: "cedros-site__title", style: { marginTop: "0.6rem" }, children: title }), description && _jsx("p", { className: "cedros-site__subtitle", children: description }), _jsxs("p", { className: "cedros-site__entry-meta", style: { marginTop: "0.6rem" }, children: [result.totalItems, " results"] })] }), _jsx(DocsIndexControls, { basePath: basePath, query: query, category: category, tag: tag, sort: sort, categories: resolvedCategories, tags: resolvedTags }), result.totalItems === 0 && (_jsxs("section", { className: "cedros-site__card cedros-site__empty-state", children: [_jsx("h2", { className: "cedros-site__title", style: { fontSize: "1.25rem" }, children: "No documentation pages found" }), _jsx("p", { className: "cedros-site__subtitle", style: { marginTop: "0.6rem" }, children: "Try a broader query or clear filters." })] })), result.totalItems > 0 && (_jsxs(_Fragment, { children: [_jsx("section", { className: "cedros-site__content-grid cedros-site__content-grid--docs", children: result.items.map((doc) => (_jsxs("article", { className: "cedros-site__card cedros-site__entry-card", children: [_jsx("h2", { className: "cedros-site__entry-title", children: _jsx("a", { href: `${basePath}/${doc.slug}`, children: doc.title }) }), doc.description && _jsx("p", { className: "cedros-site__subtitle", children: doc.description }), (doc.lastUpdated || doc.category) && (_jsx("p", { className: "cedros-site__entry-meta", children: [doc.category, doc.lastUpdated ? `Updated ${doc.lastUpdated}` : ""]
24
- .filter(Boolean)
25
- .join(" • ") })), doc.tags && doc.tags.length > 0 && (_jsx("div", { className: "cedros-site__tag-list", children: doc.tags.map((entryTag) => (_jsx("a", { href: buildContentListHref(basePath, {
26
- q: query,
27
- category,
28
- tag: entryTag,
29
- sort
30
- }), className: "cedros-site__pill", children: entryTag }, entryTag))) }))] }, doc.slug))) }), _jsx(ContentPagination, { basePath: basePath, page: result.page, totalPages: result.totalPages, query: { q: query, category, tag, sort } })] }))] })] }) }));
23
+ return renderDocsTemplateShell((_jsxs("section", { className: "cedros-site__docs-page", children: [_jsx(DocsSidebar, { title: sidebarTitle, basePath: basePath, searchQuery: query, sections: resolvedSidebarSections }), _jsxs("div", { className: "cedros-site__docs-main", children: [_jsxs("section", { className: "cedros-site__card", children: [_jsx("span", { className: "cedros-site__pill", children: "docs" }), _jsx("h1", { className: "cedros-site__title", style: { marginTop: "0.6rem" }, children: title }), description && _jsx("p", { className: "cedros-site__subtitle", children: description }), _jsxs("p", { className: "cedros-site__entry-meta", style: { marginTop: "0.6rem" }, children: [result.totalItems, " results"] })] }), _jsx(DocsIndexControls, { basePath: basePath, query: query, category: category, tag: tag, sort: sort, categories: resolvedCategories, tags: resolvedTags }), result.totalItems === 0 && (_jsxs("section", { className: "cedros-site__card cedros-site__empty-state", children: [_jsx("h2", { className: "cedros-site__title", style: { fontSize: "1.25rem" }, children: "No documentation pages found" }), _jsx("p", { className: "cedros-site__subtitle", style: { marginTop: "0.6rem" }, children: "Try a broader query or clear filters." })] })), result.totalItems > 0 && (_jsxs(_Fragment, { children: [_jsx("section", { className: "cedros-site__content-grid cedros-site__content-grid--docs", children: result.items.map((doc) => (_jsxs("article", { className: "cedros-site__card cedros-site__entry-card", children: [_jsx("h2", { className: "cedros-site__entry-title", children: _jsx("a", { href: `${basePath}/${doc.slug}`, children: doc.title }) }), doc.description && _jsx("p", { className: "cedros-site__subtitle", children: doc.description }), (doc.lastUpdated || doc.category) && (_jsx("p", { className: "cedros-site__entry-meta", children: [doc.category, doc.lastUpdated ? `Updated ${doc.lastUpdated}` : ""]
24
+ .filter(Boolean)
25
+ .join(" • ") })), doc.tags && doc.tags.length > 0 && (_jsx("div", { className: "cedros-site__tag-list", children: doc.tags.map((entryTag) => (_jsx("a", { href: buildContentListHref(basePath, {
26
+ q: query,
27
+ category,
28
+ tag: entryTag,
29
+ sort
30
+ }), className: "cedros-site__pill", children: entryTag }, entryTag))) }))] }, doc.slug))) }), _jsx(ContentPagination, { basePath: basePath, page: result.page, totalPages: result.totalPages, query: { q: query, category, tag, sort } })] }))] })] })), {
31
+ siteTitle,
32
+ navigation,
33
+ headless,
34
+ renderLayout
35
+ });
31
36
  }
32
- export function DocArticleTemplate({ siteTitle, navigation, title, bodyMarkdown, bodyHtml, allowUnsafeHtmlFallback = false, lastUpdated, readingMinutes, basePath = "/docs", currentPath, searchQuery = "", docs = [], sidebarSections, sidebarTitle = "Docs", breadcrumbs = [], toc = [], previousDoc, nextDoc, editHref }) {
37
+ export function DocArticleTemplate({ siteTitle, navigation, title, bodyMarkdown, bodyHtml, allowUnsafeHtmlFallback = false, lastUpdated, readingMinutes, basePath = "/docs", currentPath, searchQuery = "", docs = [], sidebarSections, sidebarTitle = "Docs", breadcrumbs = [], toc = [], previousDoc, nextDoc, editHref, headless = false, renderLayout }) {
33
38
  const activePath = currentPath ?? breadcrumbs[breadcrumbs.length - 1]?.href;
34
- const resolvedSidebarSections = withActiveDocsSidebar(sidebarSections ?? buildDocsSidebarSections(docs, basePath), activePath);
35
- return (_jsx(SiteLayout, { siteTitle: siteTitle, navigation: navigation, children: _jsxs("section", { className: "cedros-site__docs-page cedros-site__docs-page--article", children: [_jsx(DocsSidebar, { title: sidebarTitle, basePath: basePath, searchQuery: searchQuery, sections: resolvedSidebarSections }), _jsxs("article", { className: "cedros-site__card cedros-site__article cedros-site__docs-article", children: [_jsx(Breadcrumbs, { trail: breadcrumbs }), _jsxs("div", { className: "cedros-site__article-header", children: [_jsx("span", { className: "cedros-site__pill", children: "docs" }), _jsx("h1", { className: "cedros-site__title", children: title }), (lastUpdated || readingMinutes) && (_jsx("p", { className: "cedros-site__entry-meta", children: [lastUpdated ? `Last updated ${lastUpdated}` : "", readingTime(readingMinutes)]
36
- .filter(Boolean)
37
- .join(" • ") }))] }), _jsx(MarkdownContent, { bodyMarkdown: bodyMarkdown, bodyHtml: bodyHtml, allowUnsafeHtmlFallback: allowUnsafeHtmlFallback }), (previousDoc || nextDoc || editHref) && (_jsxs("footer", { className: "cedros-site__doc-footer", children: [previousDoc && (_jsxs("a", { href: previousDoc.href, className: "cedros-site__nav-link", children: ["\u2190 ", previousDoc.title] })), nextDoc && (_jsxs("a", { href: nextDoc.href, className: "cedros-site__nav-link", children: [nextDoc.title, " \u2192"] })), editHref && (_jsx("a", { href: editHref, className: "cedros-site__nav-link", children: "Suggest edit" }))] }))] }), toc.length > 0 && (_jsxs("aside", { className: "cedros-site__card cedros-site__toc", children: [_jsx("h2", { className: "cedros-site__toc-title", children: "On this page" }), _jsx(TocScrollSpy, { entries: toc })] }))] }) }));
39
+ const resolvedSidebarSections = withActiveDocsSidebar(sidebarSections ?? buildDefaultDocsSidebarSections(docs, basePath), activePath);
40
+ return renderDocsTemplateShell((_jsxs("section", { className: "cedros-site__docs-page cedros-site__docs-page--article", children: [_jsx(DocsSidebar, { title: sidebarTitle, basePath: basePath, searchQuery: searchQuery, sections: resolvedSidebarSections }), _jsxs("article", { className: "cedros-site__card cedros-site__article cedros-site__docs-article", children: [_jsx(Breadcrumbs, { trail: breadcrumbs }), _jsxs("div", { className: "cedros-site__article-header", children: [_jsx("span", { className: "cedros-site__pill", children: "docs" }), _jsx("h1", { className: "cedros-site__title", children: title }), (lastUpdated || readingMinutes) && (_jsx("p", { className: "cedros-site__entry-meta", children: [lastUpdated ? `Last updated ${lastUpdated}` : "", readingTime(readingMinutes)]
41
+ .filter(Boolean)
42
+ .join(" • ") }))] }), _jsx(MarkdownContent, { bodyMarkdown: bodyMarkdown, bodyHtml: bodyHtml, allowUnsafeHtmlFallback: allowUnsafeHtmlFallback }), (previousDoc || nextDoc || editHref) && (_jsxs("footer", { className: "cedros-site__doc-footer", children: [previousDoc && (_jsxs("a", { href: previousDoc.href, className: "cedros-site__nav-link", children: ["\u2190 ", previousDoc.title] })), nextDoc && (_jsxs("a", { href: nextDoc.href, className: "cedros-site__nav-link", children: [nextDoc.title, " \u2192"] })), editHref && (_jsx("a", { href: editHref, className: "cedros-site__nav-link", children: "Suggest edit" }))] }))] }), toc.length > 0 && (_jsxs("aside", { className: "cedros-site__card cedros-site__toc", children: [_jsx("h2", { className: "cedros-site__toc-title", children: "On this page" }), _jsx(TocScrollSpy, { entries: toc })] }))] })), {
43
+ siteTitle,
44
+ navigation,
45
+ headless,
46
+ renderLayout
47
+ });
38
48
  }
39
49
  function DocsIndexControls({ basePath, query, category, tag, sort, categories, tags }) {
40
50
  return (_jsxs("form", { method: "get", action: basePath, className: "cedros-site__controls cedros-site__card", children: [_jsxs("label", { className: "cedros-site__control", children: [_jsx("span", { children: "Search" }), _jsx("input", { type: "search", name: "q", defaultValue: query, placeholder: "Search docs" })] }), _jsxs("label", { className: "cedros-site__control", children: [_jsx("span", { children: "Category" }), _jsxs("select", { name: "category", defaultValue: category, children: [_jsx("option", { value: "", children: "All" }), categories.map((entry) => (_jsx("option", { value: entry, children: entry }, entry)))] })] }), _jsxs("label", { className: "cedros-site__control", children: [_jsx("span", { children: "Tag" }), _jsxs("select", { name: "tag", defaultValue: tag, children: [_jsx("option", { value: "", children: "All" }), tags.map((entry) => (_jsx("option", { value: entry, children: entry }, entry)))] })] }), _jsxs("label", { className: "cedros-site__control", children: [_jsx("span", { children: "Sort" }), _jsxs("select", { name: "sort", defaultValue: sort, children: [_jsx("option", { value: "title-asc", children: "Title A-Z" }), _jsx("option", { value: "title-desc", children: "Title Z-A" }), _jsx("option", { value: "updated-desc", children: "Recently updated" }), _jsx("option", { value: "updated-asc", children: "Least recently updated" })] })] }), _jsxs("div", { className: "cedros-site__control-actions", children: [_jsx("button", { className: "cedros-site__nav-link", type: "submit", children: "Apply" }), _jsx("a", { className: "cedros-site__nav-link", href: basePath, children: "Clear" })] })] }));
@@ -45,3 +55,8 @@ function readingTime(minutes) {
45
55
  }
46
56
  return `${minutes} min read`;
47
57
  }
58
+ function buildDefaultDocsSidebarSections(docs, basePath) {
59
+ return docs.some((doc) => doc.slug.includes("/"))
60
+ ? buildHierarchicalDocsSidebarSections(docs, basePath)
61
+ : buildDocsSidebarSections(docs, basePath);
62
+ }
@@ -40,6 +40,10 @@ export interface DocsIndexEntry {
40
40
  category?: string;
41
41
  tags?: string[];
42
42
  lastUpdated?: string;
43
+ bodyMarkdown?: string;
44
+ bodyHtml?: string;
45
+ bodyText?: string;
46
+ searchText?: string;
43
47
  }
44
48
  export interface DocsIndexQuery {
45
49
  query?: string;
@@ -125,7 +125,7 @@ function matchesDocsQuery(entry, query) {
125
125
  if (!normalizedQuery) {
126
126
  return true;
127
127
  }
128
- const haystack = [entry.title, entry.description, entry.category, ...(entry.tags ?? [])].join(" ").toLowerCase();
128
+ const haystack = buildDocsSearchText(entry);
129
129
  return haystack.includes(normalizedQuery);
130
130
  }
131
131
  function compareBlogEntries(left, right, sort) {
@@ -159,6 +159,20 @@ function toTimestamp(value) {
159
159
  const parsed = Date.parse(value ?? "");
160
160
  return Number.isNaN(parsed) ? 0 : parsed;
161
161
  }
162
+ function buildDocsSearchText(entry) {
163
+ return [
164
+ entry.title,
165
+ entry.description,
166
+ entry.category,
167
+ ...(entry.tags ?? []),
168
+ entry.searchText,
169
+ entry.bodyText,
170
+ entry.bodyMarkdown,
171
+ entry.bodyHtml
172
+ ]
173
+ .join(" ")
174
+ .toLowerCase();
175
+ }
162
176
  function paginate(items, pageInput, pageSizeInput) {
163
177
  const pageSize = sanitizePositiveInt(pageSizeInput, 10);
164
178
  const totalItems = items.length;
@@ -4,7 +4,7 @@ export interface DocsSidebarItem {
4
4
  label: string;
5
5
  href: string;
6
6
  isActive?: boolean;
7
- depth?: 0 | 1 | 2;
7
+ depth?: number;
8
8
  badge?: string;
9
9
  }
10
10
  export interface DocsSidebarSection {
@@ -14,5 +14,34 @@ export interface DocsSidebarSection {
14
14
  /** When true, section renders collapsed by default in the sidebar. */
15
15
  collapsed?: boolean;
16
16
  }
17
+ export interface DocsTreeNode<T extends DocsIndexEntry = DocsIndexEntry> {
18
+ key: string;
19
+ slug: string;
20
+ label: string;
21
+ href?: string;
22
+ entry?: T;
23
+ children: DocsTreeNode<T>[];
24
+ }
25
+ export interface DocsTreeSection<T extends DocsIndexEntry = DocsIndexEntry> {
26
+ key: string;
27
+ label: string;
28
+ nodes: DocsTreeNode<T>[];
29
+ }
30
+ export interface DocsPagerLink<T extends DocsIndexEntry = DocsIndexEntry> {
31
+ title: string;
32
+ href: string;
33
+ entry: T;
34
+ }
35
+ export interface DocsPrevNext<T extends DocsIndexEntry = DocsIndexEntry> {
36
+ previousDoc?: DocsPagerLink<T>;
37
+ nextDoc?: DocsPagerLink<T>;
38
+ }
39
+ export interface FetchDocEntryOptions {
40
+ basePath?: string;
41
+ }
17
42
  export declare function buildDocsSidebarSections(docs: DocsIndexEntry[], basePath?: string): DocsSidebarSection[];
43
+ export declare function fetchDocEntry<T extends DocsIndexEntry>(docs: T[], slugOrPath: string, options?: FetchDocEntryOptions): T | undefined;
44
+ export declare function buildDocsTree<T extends DocsIndexEntry>(docs: T[], basePath?: string): DocsTreeSection<T>[];
45
+ export declare function buildHierarchicalDocsSidebarSections<T extends DocsIndexEntry>(docs: T[], basePath?: string): DocsSidebarSection[];
46
+ export declare function buildDocsPrevNext<T extends DocsIndexEntry>(docs: T[], currentSlugOrPath: string, options?: FetchDocEntryOptions): DocsPrevNext<T>;
18
47
  export declare function withActiveDocsSidebar(sections: DocsSidebarSection[], currentPath?: string): DocsSidebarSection[];
@@ -8,7 +8,7 @@ export function buildDocsSidebarSections(docs, basePath = "/docs") {
8
8
  current.items.push({
9
9
  key: doc.slug,
10
10
  label: doc.title,
11
- href: `${basePath}/${doc.slug}`
11
+ href: buildDocHref(basePath, doc.slug)
12
12
  });
13
13
  grouped.set(sectionKey, current);
14
14
  });
@@ -20,6 +20,52 @@ export function buildDocsSidebarSections(docs, basePath = "/docs") {
20
20
  }))
21
21
  .sort((left, right) => left.label.localeCompare(right.label));
22
22
  }
23
+ export function fetchDocEntry(docs, slugOrPath, options = {}) {
24
+ const normalizedLookup = normalizeDocLookup(slugOrPath, options.basePath);
25
+ return docs.find((doc) => {
26
+ const normalizedDocSlug = normalizeDocSlug(doc.slug);
27
+ return normalizedDocSlug === normalizedLookup || (!normalizedLookup && normalizedDocSlug === "index");
28
+ });
29
+ }
30
+ export function buildDocsTree(docs, basePath = "/docs") {
31
+ const sections = [];
32
+ const grouped = new Map();
33
+ docs.forEach((doc) => {
34
+ const sectionLabel = doc.category ?? "Overview";
35
+ const sectionKey = sectionLabel.toLowerCase().replace(/\s+/gu, "-");
36
+ const section = grouped.get(sectionKey) ?? { key: sectionKey, label: sectionLabel, nodes: [] };
37
+ if (!grouped.has(sectionKey)) {
38
+ grouped.set(sectionKey, section);
39
+ sections.push(section);
40
+ }
41
+ insertDocTreeNode(section.nodes, doc, basePath);
42
+ });
43
+ return sections;
44
+ }
45
+ export function buildHierarchicalDocsSidebarSections(docs, basePath = "/docs") {
46
+ return buildDocsTree(docs, basePath)
47
+ .map((section) => ({
48
+ key: section.key,
49
+ label: section.label,
50
+ items: flattenDocsTreeNodes(section.nodes)
51
+ }))
52
+ .filter((section) => section.items.length > 0);
53
+ }
54
+ export function buildDocsPrevNext(docs, currentSlugOrPath, options = {}) {
55
+ const current = fetchDocEntry(docs, currentSlugOrPath, options);
56
+ if (!current) {
57
+ return {};
58
+ }
59
+ const currentSlug = normalizeDocSlug(current.slug);
60
+ const currentIndex = docs.findIndex((doc) => normalizeDocSlug(doc.slug) === currentSlug);
61
+ if (currentIndex === -1) {
62
+ return {};
63
+ }
64
+ return {
65
+ previousDoc: buildDocsPagerLink(docs[currentIndex - 1], options.basePath),
66
+ nextDoc: buildDocsPagerLink(docs[currentIndex + 1], options.basePath)
67
+ };
68
+ }
23
69
  export function withActiveDocsSidebar(sections, currentPath) {
24
70
  if (!currentPath) {
25
71
  return sections.map((section) => ({
@@ -48,3 +94,88 @@ export function withActiveDocsSidebar(sections, currentPath) {
48
94
  })
49
95
  }));
50
96
  }
97
+ function buildDocHref(basePath, slug) {
98
+ const normalizedBasePath = normalizeRoutePath(basePath);
99
+ const normalizedSlug = normalizeDocSlug(slug);
100
+ if (!normalizedSlug) {
101
+ return normalizedBasePath;
102
+ }
103
+ if (normalizedBasePath === "/") {
104
+ return `/${normalizedSlug}`;
105
+ }
106
+ return `${normalizedBasePath}/${normalizedSlug}`;
107
+ }
108
+ function normalizeDocSlug(slug) {
109
+ return slug.trim().replace(/^\/+|\/+$/gu, "");
110
+ }
111
+ function normalizeDocLookup(value, basePath = "/docs") {
112
+ const normalizedValue = normalizeRoutePath(value).replace(/^\/+/u, "");
113
+ const normalizedBasePath = normalizeRoutePath(basePath).replace(/^\/+/u, "");
114
+ if (!normalizedBasePath) {
115
+ return normalizedValue;
116
+ }
117
+ if (normalizedValue === normalizedBasePath) {
118
+ return "";
119
+ }
120
+ if (normalizedValue.startsWith(`${normalizedBasePath}/`)) {
121
+ return normalizedValue.slice(normalizedBasePath.length + 1);
122
+ }
123
+ return normalizedValue;
124
+ }
125
+ function insertDocTreeNode(nodes, doc, basePath) {
126
+ const segments = normalizedDocSegments(doc.slug);
127
+ let cursor = nodes;
128
+ segments.forEach((segment, index) => {
129
+ const slug = segments.slice(0, index + 1).join("/");
130
+ let node = cursor.find((candidate) => candidate.slug === slug);
131
+ if (!node) {
132
+ node = {
133
+ key: slug || "index",
134
+ slug,
135
+ label: humanizeDocSegment(segment),
136
+ children: []
137
+ };
138
+ cursor.push(node);
139
+ }
140
+ if (index === segments.length - 1) {
141
+ node.entry = doc;
142
+ node.href = buildDocHref(basePath, doc.slug);
143
+ node.label = doc.title;
144
+ }
145
+ cursor = node.children;
146
+ });
147
+ }
148
+ function normalizedDocSegments(slug) {
149
+ const normalized = normalizeDocSlug(slug);
150
+ return normalized ? normalized.split("/").filter(Boolean) : ["index"];
151
+ }
152
+ function flattenDocsTreeNodes(nodes, depth = 0) {
153
+ return nodes.flatMap((node) => {
154
+ const items = [];
155
+ if (node.entry && node.href) {
156
+ items.push({
157
+ key: node.key,
158
+ label: node.label,
159
+ href: node.href,
160
+ depth: Math.min(depth, 2)
161
+ });
162
+ }
163
+ items.push(...flattenDocsTreeNodes(node.children, depth + 1));
164
+ return items;
165
+ });
166
+ }
167
+ function buildDocsPagerLink(doc, basePath = "/docs") {
168
+ if (!doc) {
169
+ return undefined;
170
+ }
171
+ return {
172
+ title: doc.title,
173
+ href: buildDocHref(basePath, doc.slug),
174
+ entry: doc
175
+ };
176
+ }
177
+ function humanizeDocSegment(segment) {
178
+ return segment
179
+ .replace(/[-_]+/gu, " ")
180
+ .replace(/\b\w/gu, (char) => char.toUpperCase());
181
+ }
@@ -0,0 +1,8 @@
1
+ import { type SiteNavigationItem } from "./SiteLayout.js";
2
+ export interface DocsTemplateShellOptions {
3
+ siteTitle?: string;
4
+ navigation?: SiteNavigationItem[];
5
+ headless?: boolean;
6
+ renderLayout?: (content: React.JSX.Element) => React.JSX.Element;
7
+ }
8
+ export declare function renderDocsTemplateShell(content: React.JSX.Element, options: DocsTemplateShellOptions): React.JSX.Element;
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { SiteLayout } from "./SiteLayout.js";
3
+ export function renderDocsTemplateShell(content, options) {
4
+ if (options.renderLayout) {
5
+ return options.renderLayout(content);
6
+ }
7
+ if (options.headless) {
8
+ return content;
9
+ }
10
+ if (options.siteTitle === undefined || options.navigation === undefined) {
11
+ throw new Error("Docs templates require `siteTitle` and `navigation` unless `headless` or `renderLayout` is provided.");
12
+ }
13
+ return (_jsx(SiteLayout, { siteTitle: options.siteTitle, navigation: options.navigation, children: content }));
14
+ }
@@ -5,7 +5,7 @@ export { DashboardShell, type DashboardShellProps, type DashboardNavItem } from
5
5
  export { DashboardOverviewTemplate, type DashboardOverviewTemplateProps, type DashboardPanel, type DashboardStat } from "./DashboardOverviewTemplate.js";
6
6
  export { isRouteActive, normalizeRoutePath, withActiveRouteState, type RouteAwareItem, type RouteMatcherOptions } from "./routing.js";
7
7
  export { buildContentListHref, collectFilterValues, collectDimensionValues, matchesFilterDimensions, prepareBlogIndex, prepareDocsIndex, type BlogIndexEntry, type BlogIndexQuery, type DocsIndexEntry, type DocsIndexQuery, type FilterDimension, type FilterDimensionValues, type PaginationResult } from "./contentIndex.js";
8
- export { buildDocsSidebarSections, withActiveDocsSidebar, type DocsSidebarItem, type DocsSidebarSection } from "./docsNavigation.js";
8
+ export { buildDocsSidebarSections, buildDocsTree, buildHierarchicalDocsSidebarSections, buildDocsPrevNext, fetchDocEntry, withActiveDocsSidebar, type DocsTreeNode, type DocsTreeSection, type DocsPagerLink, type DocsPrevNext, type FetchDocEntryOptions, type DocsSidebarItem, type DocsSidebarSection } from "./docsNavigation.js";
9
9
  export { MarkdownContent, type MarkdownContentProps } from "./MarkdownContent.js";
10
10
  export { extractTocFromMarkdown, type TocEntry } from "./tocExtractor.js";
11
11
  export { DocsSidebar, type DocsSidebarProps } from "./DocsSidebar.js";
@@ -5,7 +5,7 @@ export { DashboardShell } from "./DashboardShell.js";
5
5
  export { DashboardOverviewTemplate } from "./DashboardOverviewTemplate.js";
6
6
  export { isRouteActive, normalizeRoutePath, withActiveRouteState } from "./routing.js";
7
7
  export { buildContentListHref, collectFilterValues, collectDimensionValues, matchesFilterDimensions, prepareBlogIndex, prepareDocsIndex } from "./contentIndex.js";
8
- export { buildDocsSidebarSections, withActiveDocsSidebar } from "./docsNavigation.js";
8
+ export { buildDocsSidebarSections, buildDocsTree, buildHierarchicalDocsSidebarSections, buildDocsPrevNext, fetchDocEntry, withActiveDocsSidebar } from "./docsNavigation.js";
9
9
  export { MarkdownContent } from "./MarkdownContent.js";
10
10
  export { extractTocFromMarkdown } from "./tocExtractor.js";
11
11
  export { DocsSidebar } from "./DocsSidebar.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cedros/data-react",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "React components, page templates, and Next.js integration for cedros-data",
5
5
  "type": "module",
6
6
  "main": "./dist/react/index.js",