@asteroidcms/core-utils 0.1.8 → 0.2.1

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
@@ -30,6 +30,17 @@ npm install @apollo/client-integration-nextjs # for nextjs (optional)
30
30
 
31
31
  ---
32
32
 
33
+ ## Entry points
34
+
35
+ | Entry point | What it exports |
36
+ | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
37
+ | `@asteroidcms/core-utils` | Core utilities: `fetchCmsContent`, `cmsMutate`, `buildCmsQuery`, `buildCmsMutation`, `cmsImage`, `parseRichText`, `getContentReadTime`, `extractHeadingsFromHtml`, `createApolloClient`, `AsteroidCMSProvider`. |
38
+ | `@asteroidcms/core-utils/client` | Client components and hooks (browser / React): `AsteroidCMSProvider`, `useCmsContent`, `useCmsMutate`, `useCmsImage`, `RichTextContent`, `extractHeadingsFromElement`. |
39
+ | `@asteroidcms/core-utils/next` | Next.js metadata helpers and SEO head component. Optional peer dep on `next`. |
40
+ | `@asteroidcms/core-utils/server` | Server components: `AsteroidArticlesListingServer`, `AsteroidArticlePageServer`, `defineArticleSource`, `createCmsServerClient`, `generateListingMetadata`, `generateArticleMetadata`. Also exports `fetchArticles`, `fetchArticle`, `fetchRelatedArticles`, `buildSearchConditions`. Server-only -- the CMS API key never reaches the browser. |
41
+
42
+ ---
43
+
33
44
  ## Quick start
34
45
 
35
46
  Wrap your app once:
@@ -364,6 +375,8 @@ import { cmsImage } from "@asteroidcms/core-utils";
364
375
  cmsImage(id, { cmsUrl: "https://cms-api.example.com" });
365
376
  ```
366
377
 
378
+ **Note:** Article render-prop callbacks (`renderPostCard` for listing, `renderRelatedPosts` for related posts, `renderContent`/`renderHeader` for article body) in both `AsteroidArticlesListing` (client) and `AsteroidArticlesListingServer` / `AsteroidArticlePageServer` (server) receive an injected `cmsImage(idOrUrl)` resolver. Prefer it over calling `useCmsImage()` directly so the same render function works in client and server components. A server equivalent for article listing and article page exists under `@asteroidcms/core-utils/server`.
379
+
367
380
  ---
368
381
 
369
382
  ## `getContentReadTime`
@@ -475,6 +488,160 @@ const client = createApolloClient({
475
488
 
476
489
  ---
477
490
 
491
+ ## `@asteroidcms/core-utils/server`
492
+
493
+ Server-only entry. Guarded by `server-only` so it fails loudly if imported in a client module.
494
+
495
+ See [docs/web-sdk-react/13-server-article-components.md](./docs/web-sdk-react/13-server-article-components.md) for the full guide.
496
+
497
+ ### `createCmsServerClient` + `defineArticleSource`
498
+
499
+ Define a source once in a server-only module and import it from any route that needs it.
500
+
501
+ ```ts
502
+ // cms/articleSource.ts
503
+ import { createCmsServerClient, defineArticleSource } from "@asteroidcms/core-utils/server";
504
+ import type { AsteroidSeoConfig } from "@asteroidcms/core-utils";
505
+
506
+ const cmsClient = createCmsServerClient({
507
+ cmsUrl: process.env.CMS_API_BASE_URL!,
508
+ apiKey: process.env.CMS_API_KEY!, // server-only, NOT NEXT_PUBLIC
509
+ revalidate: 300,
510
+ });
511
+
512
+ const articleSeo: AsteroidSeoConfig = {
513
+ siteName: "Acme",
514
+ baseUrl: "https://acme.example",
515
+ cmsUrl: process.env.CMS_API_BASE_URL!,
516
+ defaultDescription: "News and updates.",
517
+ articlePath: "/news",
518
+ contentLabel: "News",
519
+ };
520
+
521
+ export const articleSource = defineArticleSource({
522
+ client: cmsClient,
523
+ schemaSlug: "news",
524
+ listSelect: ["slug", "title", "description", "featured_image", "published_date",
525
+ { field: "category", single: true, select: ["slug", "name"] }],
526
+ detailSelect: ["slug", "title", "description", "content", "tags", "featured_image", "published_date",
527
+ { field: "category", single: true, select: ["slug", "name"] },
528
+ { field: "author", single: true, select: ["name"] }],
529
+ seo: articleSeo,
530
+ relatedLimit: 3,
531
+ });
532
+ ```
533
+
534
+ `createCmsServerClient` memoizes per request via React `cache` when available (React Server Components / React 19 / Next.js bundled React). On React 18 stable without `cache` it degrades to no per-request dedup but remains correct.
535
+
536
+ `defineArticleSource` required fields: `client`, `schemaSlug`, `listSelect`, `detailSelect`, `seo`. Optional: `searchFields`, `articleType`, `status`, `relatedLimit`, `groupPostsByCategory`.
537
+
538
+ ### `AsteroidArticlesListingServer`
539
+
540
+ Read `searchParams` in the page and pass the query as `searchQuery`.
541
+
542
+ ```tsx
543
+ // app/news/page.tsx
544
+ import { AsteroidArticlesListingServer, generateListingMetadata } from "@asteroidcms/core-utils/server";
545
+ import { articleSource } from "@/cms/articleSource";
546
+
547
+ export const generateMetadata = () => generateListingMetadata(articleSource);
548
+
549
+ export default async function NewsPage({
550
+ searchParams,
551
+ }: {
552
+ searchParams: Promise<{ q?: string }>;
553
+ }) {
554
+ const { q } = await searchParams;
555
+ return (
556
+ <AsteroidArticlesListingServer
557
+ source={articleSource}
558
+ searchQuery={q}
559
+ renderPostCard={({ post, cmsImage }) => (
560
+ <a href={`/news/${post.slug}`}>
561
+ <h2>{post.title}</h2>
562
+ </a>
563
+ )}
564
+ />
565
+ );
566
+ }
567
+ ```
568
+
569
+ Required prop: `renderPostCard` receives `{ post, cmsImage }`. Optional: `renderFeaturedCard`, `renderEmpty`, `renderSearch`, `categorySlug`, `searchParamKey`, `searchBoxProps`, and more.
570
+
571
+ ### `AsteroidArticlePageServer`
572
+
573
+ ```tsx
574
+ // app/news/[slug]/page.tsx
575
+ import { AsteroidArticlePageServer, generateArticleMetadata } from "@asteroidcms/core-utils/server";
576
+ import { articleSource } from "@/cms/articleSource";
577
+
578
+ export async function generateMetadata({
579
+ params,
580
+ }: {
581
+ params: Promise<{ slug: string }>;
582
+ }) {
583
+ return generateArticleMetadata(articleSource, params);
584
+ }
585
+
586
+ export default async function ArticlePage({
587
+ params,
588
+ }: {
589
+ params: Promise<{ slug: string }>;
590
+ }) {
591
+ const { slug } = await params;
592
+ return (
593
+ <AsteroidArticlePageServer
594
+ source={articleSource}
595
+ slug={slug}
596
+ renderHeader={({ post }) => <h1>{post.title}</h1>}
597
+ renderContent={({ post }) => (
598
+ <div dangerouslySetInnerHTML={{ __html: post.content ?? "" }} />
599
+ )}
600
+ renderRelatedPosts={({ relatedPosts, cmsImage }) => (
601
+ <ul>
602
+ {relatedPosts.map((related) => (
603
+ <li key={related.slug}>
604
+ <a href={`/news/${related.slug}`}>{related.title}</a>
605
+ </li>
606
+ ))}
607
+ </ul>
608
+ )}
609
+ renderError={({ reason }) =>
610
+ reason === "not-found" ? <p>Not found.</p> : <p>Error loading post.</p>
611
+ }
612
+ />
613
+ );
614
+ }
615
+ ```
616
+
617
+ Render-prop slots: `renderHeader`, `renderContent`, `renderRelatedPosts` (receives `{ post, relatedPosts, cmsImage }`), `renderError`. Every render prop receives an injected `cmsImage(idOrUrl)` resolver -- do not call `useCmsImage()` inside render props.
618
+
619
+ ### Metadata helpers
620
+
621
+ ```ts
622
+ import { generateListingMetadata, generateArticleMetadata } from "@asteroidcms/core-utils/server";
623
+
624
+ // Listing page -- positional args: source first
625
+ export const generateMetadata = () => generateListingMetadata(articleSource);
626
+
627
+ // Category page
628
+ export async function generateMetadata({ params }) {
629
+ const { category } = await params;
630
+ return generateListingMetadata(articleSource, { categorySlug: category });
631
+ }
632
+
633
+ // Article page -- positional args: source first, then params or slug
634
+ export async function generateMetadata({ params }) {
635
+ return generateArticleMetadata(articleSource, params);
636
+ }
637
+ ```
638
+
639
+ ### Low-level fetch helpers
640
+
641
+ `fetchArticles`, `fetchArticle`, `fetchRelatedArticles`, and `buildSearchConditions` are also exported for custom fetch logic outside the ready-made server components.
642
+
643
+ ---
644
+
478
645
  ## Development
479
646
 
480
647
  ```bash
package/dist/client.cjs CHANGED
@@ -9,6 +9,7 @@ var error = require('@apollo/client/link/error');
9
9
  var jsxRuntime = require('react/jsx-runtime');
10
10
  var reactDom = require('react-dom');
11
11
  var hljs = require('highlight.js/lib/common');
12
+ var navigation = require('next/navigation');
12
13
 
13
14
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
14
15
 
@@ -1923,93 +1924,88 @@ function buildCollectionJsonLd(props) {
1923
1924
  inLanguage: "en-US"
1924
1925
  };
1925
1926
  }
1927
+ function renderArticleBody(args) {
1928
+ const { post, cmsImage: cmsImage2, relatedPosts, seoNode, jsonLdNode, renderProps: r } = args;
1929
+ const slot = { post, cmsImage: cmsImage2 };
1930
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1931
+ seoNode,
1932
+ jsonLdNode,
1933
+ r.backLink,
1934
+ r.renderPreArticle?.(slot),
1935
+ r.renderHeader?.(slot),
1936
+ r.renderMeta?.(slot),
1937
+ r.renderDescription?.(slot),
1938
+ r.renderFeaturedImage?.(slot),
1939
+ r.renderToc?.(slot),
1940
+ r.renderContent?.(slot),
1941
+ r.renderMidArticle?.(slot),
1942
+ r.renderTags?.(slot),
1943
+ r.renderAuthorDetails?.(slot),
1944
+ r.renderRelatedPosts?.({ post, relatedPosts, cmsImage: cmsImage2 }),
1945
+ r.renderCTA?.(slot),
1946
+ r.renderPostArticle?.(slot)
1947
+ ] });
1948
+ }
1926
1949
  function AsteroidArticlePage(props) {
1927
1950
  const {
1928
1951
  slug,
1929
1952
  useArticle,
1930
1953
  seo,
1931
1954
  articleType,
1932
- backLink,
1955
+ noindex,
1956
+ relatedPosts = [],
1933
1957
  renderRoot,
1934
1958
  renderSkeleton,
1935
1959
  renderError,
1936
- renderHeader,
1937
- renderMeta,
1938
- renderDescription,
1939
- renderFeaturedImage,
1940
- renderToc,
1941
- renderContent,
1942
- renderPreArticle,
1943
- renderMidArticle,
1944
- renderPostArticle,
1945
- renderTags,
1946
- renderAuthorDetails,
1947
- renderRelatedPosts,
1948
- renderCTA,
1949
1960
  renderJsonLd,
1950
- noindex,
1951
- children
1961
+ children,
1962
+ ...bodyRenderProps
1952
1963
  } = props;
1953
1964
  const { data: article, loading, error } = useArticle(slug);
1954
1965
  const cmsConfig = react.useContext(AsteroidCMSContext);
1966
+ const cmsImage2 = useCmsImage();
1955
1967
  const seoConfig = seo && !seo.cmsUrl && cmsConfig?.cmsUrl ? { ...seo, cmsUrl: cmsConfig.cmsUrl } : seo;
1956
- if (children) {
1957
- return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: children({ data: article, loading, error }) });
1958
- }
1968
+ if (children) return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: children({ data: article, loading, error }) });
1959
1969
  if (loading) {
1960
1970
  const body2 = renderSkeleton?.() ?? null;
1961
- return renderRoot ? renderRoot({ children: body2 }) : /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: body2 });
1971
+ return renderRoot ? /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: renderRoot({ children: body2 }) }) : /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: body2 });
1962
1972
  }
1963
1973
  if (!article || error) {
1964
1974
  const body2 = renderError?.({ error, reason: error ? "error" : "not-found" }) ?? null;
1965
- return renderRoot ? renderRoot({ children: body2 }) : /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: body2 });
1975
+ return renderRoot ? /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: renderRoot({ children: body2 }) }) : /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: body2 });
1966
1976
  }
1967
- const seoValues = seoConfig && article ? buildArticleSeoValues(article, seoConfig, slug, { noindex }) : null;
1968
- const body = /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1969
- seoValues ? /* @__PURE__ */ jsxRuntime.jsx(Seo, { ...seoValuesToClientProps(seoValues) }) : null,
1970
- seoConfig && article ? renderJsonLd?.({ post: article }) ?? /* @__PURE__ */ jsxRuntime.jsx(
1971
- JsonLd,
1972
- {
1973
- data: buildArticleJsonLd({
1974
- title: article.title,
1975
- description: article.description || seoConfig.defaultDescription || "",
1976
- url: `${(seoConfig.baseUrl || "").replace(/\/$/, "")}${seoConfig.articlePath ?? "/blog"}/${slug}`,
1977
- siteName: seoConfig.siteName,
1978
- siteUrl: (seoConfig.baseUrl || "").replace(/\/$/, ""),
1979
- articleType,
1980
- image: seoValues?.image,
1981
- authorName: article.author?.name,
1982
- publishedTime: article.published_date || void 0,
1983
- tags: article.tags?.split(",").map((t) => t.trim()).filter(Boolean),
1984
- category: article.category?.name
1985
- })
1986
- }
1987
- ) : null,
1988
- backLink,
1989
- renderPreArticle?.({ post: article }),
1990
- renderHeader?.({ post: article }),
1991
- renderMeta?.({ post: article }),
1992
- renderDescription?.({ post: article }),
1993
- renderFeaturedImage?.({ post: article }),
1994
- renderToc?.({ post: article }),
1995
- renderContent?.({ post: article }),
1996
- renderMidArticle?.({ post: article }),
1997
- renderTags?.({ post: article }),
1998
- renderAuthorDetails?.({ post: article }),
1999
- renderRelatedPosts?.({ post: article }),
2000
- renderCTA?.({ post: article }),
2001
- renderPostArticle?.({ post: article })
2002
- ] });
2003
- return renderRoot ? renderRoot({ children: body }) : /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: body });
2004
- }
2005
- function useDebouncedValue(value, delay) {
2006
- const [debouncedValue, setDebouncedValue] = react.useState(value);
2007
- react.useEffect(() => {
2008
- const timer = setTimeout(() => setDebouncedValue(value), delay);
2009
- return () => clearTimeout(timer);
2010
- }, [value, delay]);
2011
- return debouncedValue;
1977
+ const seoValues = seoConfig ? buildArticleSeoValues(article, seoConfig, slug, { noindex }) : null;
1978
+ const seoNode = seoValues ? /* @__PURE__ */ jsxRuntime.jsx(Seo, { ...seoValuesToClientProps(seoValues) }) : null;
1979
+ const jsonLdNode = seoConfig ? renderJsonLd?.({ post: article }) ?? /* @__PURE__ */ jsxRuntime.jsx(
1980
+ JsonLd,
1981
+ {
1982
+ data: buildArticleJsonLd({
1983
+ title: article.title,
1984
+ description: article.description || seoConfig.defaultDescription || "",
1985
+ url: `${(seoConfig.baseUrl || "").replace(/\/$/, "")}${seoConfig.articlePath ?? "/blog"}/${slug}`,
1986
+ siteName: seoConfig.siteName,
1987
+ siteUrl: (seoConfig.baseUrl || "").replace(/\/$/, ""),
1988
+ articleType,
1989
+ image: seoValues?.image,
1990
+ authorName: article.author?.name,
1991
+ publishedTime: article.published_date || void 0,
1992
+ tags: article.tags?.split(",").map((t) => t.trim()).filter(Boolean),
1993
+ category: article.category?.name
1994
+ })
1995
+ }
1996
+ ) : null;
1997
+ const body = renderArticleBody({
1998
+ post: article,
1999
+ cmsImage: cmsImage2,
2000
+ relatedPosts,
2001
+ seoNode,
2002
+ jsonLdNode,
2003
+ renderProps: bodyRenderProps
2004
+ });
2005
+ return renderRoot ? /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: renderRoot({ children: body }) }) : /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: body });
2012
2006
  }
2007
+
2008
+ // src/components/articles/articles.state.ts
2013
2009
  function defaultGetCategoryName(post) {
2014
2010
  return post.category?.name?.trim() || void 0;
2015
2011
  }
@@ -2027,10 +2023,7 @@ function defaultGroupPostsByCategory(posts) {
2027
2023
  }
2028
2024
  return Array.from(groups.values());
2029
2025
  }
2030
- function applyPostFilters(posts, {
2031
- categorySlug,
2032
- articleSlug
2033
- }) {
2026
+ function applyPostFilters(posts, { categorySlug, articleSlug }) {
2034
2027
  let filtered = posts;
2035
2028
  if (categorySlug) {
2036
2029
  filtered = filtered.filter((post) => post.category?.slug === categorySlug);
@@ -2045,65 +2038,42 @@ function splitFeaturedAndRest(posts) {
2045
2038
  const rest = featured ? posts.filter((post) => post.slug !== featured.slug) : [...posts];
2046
2039
  return { featured, rest };
2047
2040
  }
2048
- function useAsteroidArticlesState({
2049
- usePosts,
2050
- categorySlug,
2051
- articleSlug,
2052
- searchDebounceMs = 800,
2053
- groupPostsByCategory = defaultGroupPostsByCategory
2054
- }) {
2055
- const [searchQuery, setSearchQuery] = react.useState("");
2056
- const debouncedSearchQuery = useDebouncedValue(searchQuery, searchDebounceMs);
2057
- const { posts: rawPosts, loading, error } = usePosts(debouncedSearchQuery);
2058
- const posts = react.useMemo(
2059
- () => applyPostFilters(rawPosts, { categorySlug, articleSlug }),
2060
- [rawPosts, categorySlug, articleSlug]
2061
- );
2062
- const { featured, rest } = react.useMemo(
2063
- () => splitFeaturedAndRest(posts),
2064
- [posts]
2065
- );
2066
- const isSearching = debouncedSearchQuery.trim().length > 0;
2067
- const categoryGroups = react.useMemo(() => {
2068
- if (isSearching) {
2069
- if (posts.length === 0) return [];
2070
- return [
2071
- {
2072
- categoryName: `Search results for "${debouncedSearchQuery.trim()}"`,
2073
- categorySlug: "search-results",
2074
- posts
2075
- }
2076
- ];
2041
+ function buildArticlesViewState(rawPosts, options) {
2042
+ const {
2043
+ categorySlug,
2044
+ articleSlug,
2045
+ searchQuery = "",
2046
+ groupPostsByCategory = defaultGroupPostsByCategory
2047
+ } = options;
2048
+ const posts = applyPostFilters(rawPosts, { categorySlug, articleSlug });
2049
+ const { featured, rest } = splitFeaturedAndRest(posts);
2050
+ const trimmedQuery = searchQuery.trim();
2051
+ const isSearching = trimmedQuery.length > 0;
2052
+ const categoryGroups = isSearching ? posts.length === 0 ? [] : [
2053
+ {
2054
+ categoryName: `Search results for "${trimmedQuery}"`,
2055
+ categorySlug: "search-results",
2056
+ posts
2077
2057
  }
2078
- return groupPostsByCategory(rest);
2079
- }, [isSearching, posts, debouncedSearchQuery, rest, groupPostsByCategory]);
2080
- const hasError = Boolean(error);
2081
- const isEmpty = !featured && rest.length === 0;
2058
+ ] : groupPostsByCategory(rest);
2082
2059
  return {
2083
2060
  posts,
2084
2061
  featured,
2085
2062
  rest,
2086
2063
  categoryGroups,
2087
- loading,
2088
- error,
2089
- hasError,
2090
- isEmpty,
2064
+ isEmpty: !featured && rest.length === 0,
2091
2065
  isSearching,
2092
- searchQuery,
2093
- debouncedSearchQuery,
2094
- setSearchQuery
2066
+ searchQuery: trimmedQuery
2095
2067
  };
2096
2068
  }
2097
- function AsteroidArticlesListing(props) {
2069
+ function renderArticlesListingBody(args) {
2070
+ const { state, loading, hasError, error, cmsImage: cmsImage2, searchNode, seoNode, jsonLdNode, renderProps } = args;
2098
2071
  const {
2099
2072
  eyebrow,
2100
2073
  title,
2101
2074
  description,
2102
- seo,
2103
- categorySlug,
2104
2075
  renderRoot,
2105
2076
  renderHeader,
2106
- renderSearch,
2107
2077
  renderFeaturedCard,
2108
2078
  renderPostCard,
2109
2079
  renderCategoryHeading,
@@ -2111,73 +2081,27 @@ function AsteroidArticlesListing(props) {
2111
2081
  renderCategoryGroup,
2112
2082
  renderSkeleton,
2113
2083
  renderEmpty,
2114
- renderContent,
2115
- renderJsonLd,
2116
- noindex,
2117
- children
2118
- } = props;
2119
- const state = useAsteroidArticlesState(props);
2120
- const categoryName = categorySlug ? state.posts[0]?.category?.name?.trim() : void 0;
2121
- if (children) {
2122
- return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: children(state) });
2123
- }
2124
- const seoNode = seo ? /* @__PURE__ */ jsxRuntime.jsx(
2125
- Seo,
2126
- {
2127
- ...seoValuesToClientProps(
2128
- buildArticleListingSeoValues(seo, {
2129
- categoryName,
2130
- categorySlug,
2131
- noindex
2132
- })
2133
- )
2134
- }
2135
- ) : null;
2136
- const jsonLdNode = seo ? renderJsonLd?.(state) ?? /* @__PURE__ */ jsxRuntime.jsx(
2137
- JsonLd,
2138
- {
2139
- data: buildCollectionJsonLd({
2140
- name: categoryName || `${seo.siteName} ${seo.contentLabel ?? "Articles"}`,
2141
- description: seo.defaultDescription || "",
2142
- url: `${(seo.baseUrl || "").replace(/\/$/, "")}${seo.articlePath ?? "/blog"}${categorySlug ? `/category/${categorySlug}` : ""}`,
2143
- siteUrl: (seo.baseUrl || "").replace(/\/$/, "")
2144
- })
2145
- }
2146
- ) : null;
2147
- const handleSearchSubmit = (event) => {
2148
- event.preventDefault();
2149
- };
2150
- const searchNode = renderSearch ? renderSearch({
2151
- value: state.searchQuery,
2152
- onChange: state.setSearchQuery,
2153
- onSubmit: handleSearchSubmit
2154
- }) : null;
2084
+ renderContent
2085
+ } = renderProps;
2155
2086
  const headerNode = renderHeader ? renderHeader({ eyebrow, title, description, search: searchNode }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2156
2087
  eyebrow,
2157
2088
  title,
2158
2089
  description,
2159
2090
  searchNode
2160
2091
  ] });
2161
- const featuredNode = state.featured && !state.isSearching && renderFeaturedCard ? renderFeaturedCard({ post: state.featured }) : null;
2162
- const noSearchResultsNode = state.isSearching && !state.loading && state.posts.length === 0 ? renderEmpty?.({
2163
- reason: "no-results",
2164
- searchQuery: state.debouncedSearchQuery.trim()
2165
- }) ?? null : null;
2092
+ const featuredNode = state.featured && !state.isSearching && renderFeaturedCard ? renderFeaturedCard({ post: state.featured, cmsImage: cmsImage2 }) : null;
2093
+ const noSearchResultsNode = state.isSearching && !loading && state.posts.length === 0 ? renderEmpty?.({ reason: "no-results", searchQuery: state.searchQuery }) ?? null : null;
2166
2094
  const groupsNode = noSearchResultsNode ? null : state.categoryGroups.map((group) => {
2167
- const postCards = group.posts.map((post, index) => /* @__PURE__ */ jsxRuntime.jsx(react.Fragment, { children: renderPostCard({ post, index, group }) }, post.slug ?? index));
2095
+ const postCards = group.posts.map((post, index) => /* @__PURE__ */ jsxRuntime.jsx(react.Fragment, { children: renderPostCard({ post, index, group, cmsImage: cmsImage2 }) }, post.slug ?? index));
2168
2096
  const gridNode = renderPostGrid ? renderPostGrid({ posts: group.posts, group, children: postCards }) : /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: postCards });
2169
2097
  const headingNode = renderCategoryHeading ? renderCategoryHeading({ group }) : group.categoryName;
2170
2098
  const defaultContent = /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2171
2099
  headingNode,
2172
2100
  gridNode
2173
2101
  ] });
2174
- return renderCategoryGroup ? renderCategoryGroup({ group, defaultContent }) : defaultContent;
2102
+ return /* @__PURE__ */ jsxRuntime.jsx(react.Fragment, { children: renderCategoryGroup ? renderCategoryGroup({ group, defaultContent }) : defaultContent }, group.categorySlug);
2175
2103
  });
2176
- const contentNode = !state.loading && !state.hasError && (state.featured || state.rest.length > 0) ? renderContent ? renderContent({
2177
- featured: featuredNode,
2178
- groups: groupsNode,
2179
- noSearchResults: noSearchResultsNode
2180
- }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2104
+ const contentNode = !loading && !hasError && (state.featured || state.rest.length > 0) ? renderContent ? renderContent({ featured: featuredNode, groups: groupsNode, noSearchResults: noSearchResultsNode }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2181
2105
  featuredNode,
2182
2106
  noSearchResultsNode,
2183
2107
  groupsNode
@@ -2186,17 +2110,129 @@ function AsteroidArticlesListing(props) {
2186
2110
  seoNode,
2187
2111
  jsonLdNode,
2188
2112
  headerNode,
2189
- state.loading ? renderSkeleton?.() : null,
2190
- !state.loading && (state.hasError || state.isEmpty) ? renderEmpty?.({
2191
- reason: state.hasError ? "error" : "no-posts",
2192
- searchQuery: state.debouncedSearchQuery.trim(),
2193
- error: state.error
2113
+ loading ? renderSkeleton?.() : null,
2114
+ !loading && (hasError || state.isEmpty) ? renderEmpty?.({
2115
+ reason: hasError ? "error" : state.isSearching ? "no-results" : "no-posts",
2116
+ searchQuery: state.searchQuery,
2117
+ error
2194
2118
  }) : null,
2195
2119
  contentNode
2196
2120
  ] });
2197
2121
  return renderRoot ? renderRoot({ children: body }) : /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: body });
2198
2122
  }
2123
+ function useDebouncedValue(value, delay) {
2124
+ const [debounced, setDebounced] = react.useState(value);
2125
+ react.useEffect(() => {
2126
+ const timer = setTimeout(() => setDebounced(value), delay);
2127
+ return () => clearTimeout(timer);
2128
+ }, [value, delay]);
2129
+ return debounced;
2130
+ }
2131
+ function useAsteroidArticlesState(props) {
2132
+ const { usePosts, categorySlug, articleSlug, searchDebounceMs = 800, groupPostsByCategory } = props;
2133
+ const [inputValue, setSearchQuery] = react.useState("");
2134
+ const debouncedSearchQuery = useDebouncedValue(inputValue, searchDebounceMs);
2135
+ const { posts: rawPosts, loading, error } = usePosts(debouncedSearchQuery);
2136
+ const view = react.useMemo(
2137
+ () => buildArticlesViewState(rawPosts, {
2138
+ categorySlug,
2139
+ articleSlug,
2140
+ searchQuery: debouncedSearchQuery,
2141
+ groupPostsByCategory
2142
+ }),
2143
+ [rawPosts, categorySlug, articleSlug, debouncedSearchQuery, groupPostsByCategory]
2144
+ );
2145
+ return {
2146
+ ...view,
2147
+ loading,
2148
+ error,
2149
+ hasError: Boolean(error),
2150
+ inputValue,
2151
+ setSearchQuery
2152
+ };
2153
+ }
2154
+ function AsteroidArticlesListing(props) {
2155
+ const { seo, categorySlug, noindex, renderSearch, renderJsonLd, children, ...renderProps } = props;
2156
+ const state = useAsteroidArticlesState(props);
2157
+ const cmsImage2 = useCmsImage();
2158
+ if (children) return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: children(state) });
2159
+ const categoryName = categorySlug ? state.posts[0]?.category?.name?.trim() : void 0;
2160
+ const seoNode = seo ? /* @__PURE__ */ jsxRuntime.jsx(
2161
+ Seo,
2162
+ {
2163
+ ...seoValuesToClientProps(
2164
+ buildArticleListingSeoValues(seo, { categoryName, categorySlug, noindex })
2165
+ )
2166
+ }
2167
+ ) : null;
2168
+ const jsonLdNode = seo ? renderJsonLd?.(state) ?? /* @__PURE__ */ jsxRuntime.jsx(
2169
+ JsonLd,
2170
+ {
2171
+ data: buildCollectionJsonLd({
2172
+ name: categoryName || `${seo.siteName} ${seo.contentLabel ?? "Articles"}`,
2173
+ description: seo.defaultDescription || "",
2174
+ url: `${(seo.baseUrl || "").replace(/\/$/, "")}${seo.articlePath ?? "/blog"}${categorySlug ? `/category/${categorySlug}` : ""}`,
2175
+ siteUrl: (seo.baseUrl || "").replace(/\/$/, "")
2176
+ })
2177
+ }
2178
+ ) : null;
2179
+ const searchNode = renderSearch ? renderSearch({
2180
+ value: state.inputValue,
2181
+ onChange: state.setSearchQuery,
2182
+ onSubmit: (event) => event.preventDefault()
2183
+ }) : null;
2184
+ return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: renderArticlesListingBody({
2185
+ state,
2186
+ loading: state.loading,
2187
+ hasError: state.hasError,
2188
+ error: state.error,
2189
+ cmsImage: cmsImage2,
2190
+ searchNode,
2191
+ seoNode,
2192
+ jsonLdNode,
2193
+ renderProps
2194
+ }) });
2195
+ }
2196
+ function ArticleSearchBox({
2197
+ paramKey = "q",
2198
+ placeholder = "Search articles...",
2199
+ debounceMs = 500,
2200
+ className,
2201
+ render
2202
+ }) {
2203
+ const router = navigation.useRouter();
2204
+ const pathname = navigation.usePathname();
2205
+ const searchParams = navigation.useSearchParams();
2206
+ const initial = searchParams.get(paramKey) ?? "";
2207
+ const [value, setValue] = react.useState(initial);
2208
+ const searchParamsRef = react.useRef(searchParams);
2209
+ searchParamsRef.current = searchParams;
2210
+ react.useEffect(() => {
2211
+ const timer = setTimeout(() => {
2212
+ const params = new URLSearchParams(Array.from(searchParamsRef.current.entries()));
2213
+ const trimmed = value.trim();
2214
+ if (trimmed) params.set(paramKey, trimmed);
2215
+ else params.delete(paramKey);
2216
+ const query = params.toString();
2217
+ router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
2218
+ }, debounceMs);
2219
+ return () => clearTimeout(timer);
2220
+ }, [value, debounceMs, paramKey, pathname]);
2221
+ const onSubmit = (event) => event.preventDefault();
2222
+ if (render) return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: render({ value, onChange: setValue, onSubmit }) });
2223
+ return /* @__PURE__ */ jsxRuntime.jsx("form", { onSubmit, className, children: /* @__PURE__ */ jsxRuntime.jsx(
2224
+ "input",
2225
+ {
2226
+ type: "search",
2227
+ value,
2228
+ placeholder,
2229
+ "aria-label": placeholder,
2230
+ onChange: (event) => setValue(event.target.value)
2231
+ }
2232
+ ) });
2233
+ }
2199
2234
 
2235
+ exports.ArticleSearchBox = ArticleSearchBox;
2200
2236
  exports.AsteroidArticlePage = AsteroidArticlePage;
2201
2237
  exports.AsteroidArticlesListing = AsteroidArticlesListing;
2202
2238
  exports.AsteroidCMSProvider = AsteroidCMSProvider;