@asteroidcms/core-utils 0.1.7 → 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.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  <div align="center">
2
2
  <h1>
3
3
  <div style="display: inline-flex; align-items: center; gap: 4px;">
4
- <img src="https://cms.theasteroid.tech/logo/logo_gradient.svg" alt="@asteroidcms" height="25px" />
4
+ <img src="https://cms.theasteroid.tech/logo/logo.svg" alt="@asteroidcms" height="25px" />
5
5
  <span>/core-utils</span>
6
6
  </div>
7
7
  </h1>
package/dist/client.cjs CHANGED
@@ -1734,12 +1734,481 @@ function extractHeadingsFromElement(root, options = {}) {
1734
1734
  });
1735
1735
  return out;
1736
1736
  }
1737
+ function setMeta(attr, key, content) {
1738
+ let element = document.head.querySelector(
1739
+ `meta[${attr}="${key}"]`
1740
+ );
1741
+ if (!element) {
1742
+ if (!content) return;
1743
+ element = document.createElement("meta");
1744
+ element.setAttribute(attr, key);
1745
+ document.head.appendChild(element);
1746
+ } else if (!content) {
1747
+ element.remove();
1748
+ return;
1749
+ }
1750
+ element.setAttribute("content", content);
1751
+ }
1752
+ function setCanonical(url) {
1753
+ let canonical = document.head.querySelector(
1754
+ 'link[rel="canonical"]'
1755
+ );
1756
+ if (!canonical) {
1757
+ canonical = document.createElement("link");
1758
+ canonical.rel = "canonical";
1759
+ document.head.appendChild(canonical);
1760
+ }
1761
+ canonical.href = url;
1762
+ }
1763
+ function JsonLd({ data }) {
1764
+ if (!data) return null;
1765
+ const html = JSON.stringify(data).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
1766
+ return /* @__PURE__ */ jsxRuntime.jsx(
1767
+ "script",
1768
+ {
1769
+ type: "application/ld+json",
1770
+ dangerouslySetInnerHTML: { __html: html }
1771
+ }
1772
+ );
1773
+ }
1774
+ function Seo({
1775
+ title,
1776
+ description,
1777
+ url,
1778
+ siteName,
1779
+ keywords,
1780
+ twitter,
1781
+ image,
1782
+ noindex
1783
+ }) {
1784
+ react.useEffect(() => {
1785
+ document.title = title;
1786
+ setMeta("name", "description", description);
1787
+ setCanonical(url);
1788
+ setMeta("property", "og:title", title);
1789
+ setMeta("property", "og:description", description);
1790
+ setMeta("property", "og:url", url);
1791
+ setMeta("property", "og:site_name", siteName || "");
1792
+ setMeta("name", "keywords", keywords || "");
1793
+ setMeta("name", "robots", noindex ? "noindex" : "index");
1794
+ setMeta("name", "twitter:card", image ? "summary_large_image" : "summary");
1795
+ setMeta("name", "twitter:title", title);
1796
+ setMeta("name", "twitter:description", description);
1797
+ setMeta("name", "twitter:site", twitter || "");
1798
+ setMeta("property", "og:image", image || "");
1799
+ setMeta("name", "twitter:image", image || "");
1800
+ }, [title, description, url, siteName, keywords, twitter, image, noindex]);
1801
+ return null;
1802
+ }
1803
+
1804
+ // src/seo/seo.builders.ts
1805
+ function applyTitleTemplate(config, title) {
1806
+ return config.titleTemplate ? config.titleTemplate(title) : `${title} | ${config.siteName}`;
1807
+ }
1808
+ function buildOgImageUrl(config, params) {
1809
+ if (config.getOgImageUrl) {
1810
+ return config.getOgImageUrl(params);
1811
+ }
1812
+ const palette = config.ogImage?.palette;
1813
+ if (!palette) return void 0;
1814
+ const apiPath = config.ogImage?.apiPath ?? "/api/og";
1815
+ const base = config.baseUrl.replace(/\/$/, "");
1816
+ const searchParams = new URLSearchParams({
1817
+ title: params.title,
1818
+ type: params.type ?? "article",
1819
+ siteName: config.siteName,
1820
+ bg: palette.background,
1821
+ fg: palette.foreground,
1822
+ accent: palette.accent
1823
+ });
1824
+ if (params.subtitle?.trim()) searchParams.set("subtitle", params.subtitle.trim());
1825
+ if (params.eyebrow?.trim()) searchParams.set("eyebrow", params.eyebrow.trim());
1826
+ if (palette.accentMuted) searchParams.set("accentMuted", palette.accentMuted);
1827
+ if (palette.mutedText) searchParams.set("muted", palette.mutedText);
1828
+ return `${base}${apiPath}?${searchParams.toString()}`;
1829
+ }
1830
+ function resolveArticleImage(post, config) {
1831
+ const featuredImage = config.cmsUrl ? cmsImage(post.featured_image, { cmsUrl: config.cmsUrl }) : "";
1832
+ if (featuredImage) return featuredImage;
1833
+ const description = post.meta_description?.trim() || post.description?.trim() || config.defaultDescription;
1834
+ return buildOgImageUrl(config, {
1835
+ title: post.title,
1836
+ subtitle: description,
1837
+ eyebrow: config.contentLabel ?? "Article",
1838
+ type: "article"
1839
+ });
1840
+ }
1841
+ function buildArticleSeoValues(post, config, slug, options) {
1842
+ const articlePath = config.articlePath ?? "/blog";
1843
+ const url = `${config.baseUrl.replace(/\/$/, "")}${articlePath}/${slug}`;
1844
+ const description = post.meta_description?.trim() || post.description?.trim() || config.defaultDescription || `Read the latest from ${config.siteName}.`;
1845
+ return {
1846
+ title: applyTitleTemplate(config, post.title),
1847
+ siteName: config.siteName,
1848
+ twitter: config.twitter ?? "",
1849
+ description,
1850
+ url,
1851
+ keywords: config.defaultKeywords ?? post.title,
1852
+ image: resolveArticleImage(post, config),
1853
+ noindex: options?.noindex ?? config.noindex,
1854
+ manifestUrl: config.manifestUrl
1855
+ };
1856
+ }
1857
+ function buildArticleListingSeoValues(config, options) {
1858
+ const articlePath = config.articlePath ?? "/blog";
1859
+ const base = config.baseUrl.replace(/\/$/, "");
1860
+ const label = config.contentLabel ?? "Articles";
1861
+ const categoryName = options?.categoryName?.trim();
1862
+ const categorySlug = options?.categorySlug?.trim();
1863
+ const titleText = categoryName ? `${categoryName} ${label}` : label;
1864
+ const description = categoryName ? `Explore ${categoryName} ${label.toLowerCase()}, guides, and the latest updates from ${config.siteName}.` : config.defaultDescription || `Browse ${label.toLowerCase()}, insights, and the latest updates from ${config.siteName}.`;
1865
+ const url = categorySlug ? `${base}${articlePath}/category/${categorySlug}` : `${base}${articlePath}`;
1866
+ return {
1867
+ title: applyTitleTemplate(config, titleText),
1868
+ siteName: config.siteName,
1869
+ twitter: config.twitter ?? "",
1870
+ description,
1871
+ url,
1872
+ keywords: config.defaultKeywords ?? (categoryName ? `${categoryName}, ${config.siteName}` : `${config.siteName} ${label.toLowerCase()}`),
1873
+ image: buildOgImageUrl(config, {
1874
+ title: titleText,
1875
+ subtitle: description,
1876
+ eyebrow: categoryName ? "Category" : label,
1877
+ type: "listing"
1878
+ }),
1879
+ noindex: options?.noindex ?? config.noindex,
1880
+ manifestUrl: config.manifestUrl
1881
+ };
1882
+ }
1883
+ function seoValuesToClientProps(values) {
1884
+ return {
1885
+ title: values.title,
1886
+ description: values.description,
1887
+ url: values.url,
1888
+ siteName: values.siteName,
1889
+ keywords: values.keywords,
1890
+ twitter: values.twitter,
1891
+ image: values.image,
1892
+ noindex: values.noindex
1893
+ };
1894
+ }
1895
+
1896
+ // src/seo/jsonld.ts
1897
+ function buildArticleJsonLd(props) {
1898
+ return {
1899
+ "@context": "https://schema.org",
1900
+ "@type": props.articleType ?? "Article",
1901
+ headline: props.title,
1902
+ description: props.description,
1903
+ url: props.url,
1904
+ ...props.image ? { image: props.image } : {},
1905
+ ...props.publishedTime ? { datePublished: props.publishedTime } : {},
1906
+ ...props.category ? { articleSection: props.category } : {},
1907
+ ...props.tags && props.tags.length > 0 ? { keywords: props.tags.join(", ") } : {},
1908
+ author: { "@type": "Person", name: props.authorName || props.siteName },
1909
+ publisher: { "@id": `${props.siteUrl}/#organization` },
1910
+ isPartOf: { "@id": `${props.siteUrl}/#website` },
1911
+ inLanguage: "en-US"
1912
+ };
1913
+ }
1914
+ function buildCollectionJsonLd(props) {
1915
+ return {
1916
+ "@context": "https://schema.org",
1917
+ "@type": "CollectionPage",
1918
+ name: props.name,
1919
+ description: props.description,
1920
+ url: props.url,
1921
+ isPartOf: { "@id": `${props.siteUrl}/#website` },
1922
+ publisher: { "@id": `${props.siteUrl}/#organization` },
1923
+ inLanguage: "en-US"
1924
+ };
1925
+ }
1926
+ function AsteroidArticlePage(props) {
1927
+ const {
1928
+ slug,
1929
+ useArticle,
1930
+ seo,
1931
+ articleType,
1932
+ backLink,
1933
+ renderRoot,
1934
+ renderSkeleton,
1935
+ 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
+ renderJsonLd,
1950
+ noindex,
1951
+ children
1952
+ } = props;
1953
+ const { data: article, loading, error } = useArticle(slug);
1954
+ const cmsConfig = react.useContext(AsteroidCMSContext);
1955
+ 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
+ }
1959
+ if (loading) {
1960
+ const body2 = renderSkeleton?.() ?? null;
1961
+ return renderRoot ? renderRoot({ children: body2 }) : /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: body2 });
1962
+ }
1963
+ if (!article || error) {
1964
+ const body2 = renderError?.({ error, reason: error ? "error" : "not-found" }) ?? null;
1965
+ return renderRoot ? renderRoot({ children: body2 }) : /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: body2 });
1966
+ }
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;
2012
+ }
2013
+ function defaultGetCategoryName(post) {
2014
+ return post.category?.name?.trim() || void 0;
2015
+ }
2016
+ function defaultGroupPostsByCategory(posts) {
2017
+ const groups = /* @__PURE__ */ new Map();
2018
+ for (const post of posts) {
2019
+ const categoryName = defaultGetCategoryName(post) || "Other";
2020
+ const categorySlug = post.category?.slug || "other";
2021
+ const existing = groups.get(categorySlug);
2022
+ if (existing) {
2023
+ existing.posts.push(post);
2024
+ } else {
2025
+ groups.set(categorySlug, { categoryName, categorySlug, posts: [post] });
2026
+ }
2027
+ }
2028
+ return Array.from(groups.values());
2029
+ }
2030
+ function applyPostFilters(posts, {
2031
+ categorySlug,
2032
+ articleSlug
2033
+ }) {
2034
+ let filtered = posts;
2035
+ if (categorySlug) {
2036
+ filtered = filtered.filter((post) => post.category?.slug === categorySlug);
2037
+ }
2038
+ if (articleSlug) {
2039
+ filtered = filtered.filter((post) => post.slug === articleSlug);
2040
+ }
2041
+ return filtered;
2042
+ }
2043
+ function splitFeaturedAndRest(posts) {
2044
+ const featured = posts.find((post) => post.featured) ?? null;
2045
+ const rest = featured ? posts.filter((post) => post.slug !== featured.slug) : [...posts];
2046
+ return { featured, rest };
2047
+ }
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
+ ];
2077
+ }
2078
+ return groupPostsByCategory(rest);
2079
+ }, [isSearching, posts, debouncedSearchQuery, rest, groupPostsByCategory]);
2080
+ const hasError = Boolean(error);
2081
+ const isEmpty = !featured && rest.length === 0;
2082
+ return {
2083
+ posts,
2084
+ featured,
2085
+ rest,
2086
+ categoryGroups,
2087
+ loading,
2088
+ error,
2089
+ hasError,
2090
+ isEmpty,
2091
+ isSearching,
2092
+ searchQuery,
2093
+ debouncedSearchQuery,
2094
+ setSearchQuery
2095
+ };
2096
+ }
2097
+ function AsteroidArticlesListing(props) {
2098
+ const {
2099
+ eyebrow,
2100
+ title,
2101
+ description,
2102
+ seo,
2103
+ categorySlug,
2104
+ renderRoot,
2105
+ renderHeader,
2106
+ renderSearch,
2107
+ renderFeaturedCard,
2108
+ renderPostCard,
2109
+ renderCategoryHeading,
2110
+ renderPostGrid,
2111
+ renderCategoryGroup,
2112
+ renderSkeleton,
2113
+ 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;
2155
+ const headerNode = renderHeader ? renderHeader({ eyebrow, title, description, search: searchNode }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2156
+ eyebrow,
2157
+ title,
2158
+ description,
2159
+ searchNode
2160
+ ] });
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;
2166
+ 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));
2168
+ const gridNode = renderPostGrid ? renderPostGrid({ posts: group.posts, group, children: postCards }) : /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: postCards });
2169
+ const headingNode = renderCategoryHeading ? renderCategoryHeading({ group }) : group.categoryName;
2170
+ const defaultContent = /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2171
+ headingNode,
2172
+ gridNode
2173
+ ] });
2174
+ return renderCategoryGroup ? renderCategoryGroup({ group, defaultContent }) : defaultContent;
2175
+ });
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: [
2181
+ featuredNode,
2182
+ noSearchResultsNode,
2183
+ groupsNode
2184
+ ] }) : null;
2185
+ const body = /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2186
+ seoNode,
2187
+ jsonLdNode,
2188
+ 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
2194
+ }) : null,
2195
+ contentNode
2196
+ ] });
2197
+ return renderRoot ? renderRoot({ children: body }) : /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: body });
2198
+ }
1737
2199
 
2200
+ exports.AsteroidArticlePage = AsteroidArticlePage;
2201
+ exports.AsteroidArticlesListing = AsteroidArticlesListing;
1738
2202
  exports.AsteroidCMSProvider = AsteroidCMSProvider;
2203
+ exports.JsonLd = JsonLd;
1739
2204
  exports.RichTextContent = RichTextContent;
2205
+ exports.Seo = Seo;
2206
+ exports.defaultGetCategoryName = defaultGetCategoryName;
2207
+ exports.defaultGroupPostsByCategory = defaultGroupPostsByCategory;
1740
2208
  exports.extractHeadingsFromElement = extractHeadingsFromElement;
1741
2209
  exports.extractHeadingsFromHtml = extractHeadingsFromHtml;
1742
2210
  exports.slugify = slugify;
2211
+ exports.useAsteroidArticlesState = useAsteroidArticlesState;
1743
2212
  exports.useAsteroidCMSConfig = useAsteroidCMSConfig;
1744
2213
  exports.useCmsContent = useCmsContent;
1745
2214
  exports.useCmsImage = useCmsImage;