@asteroidcms/core-utils 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -132,7 +132,7 @@ declare function getContentReadTime(content: string, options?: GetContentReadTim
132
132
  *
133
133
  * Idempotent: parseRichText(parseRichText(x, opts), opts) === parseRichText(x, opts).
134
134
  */
135
- type RichTextClassKey = "p" | "br" | "hr" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "ul" | "ol" | "li" | "blockquote" | "pre" | "code" | "inlineCode" | "a" | "strong" | "em" | "u" | "s" | "kbd" | "table" | "tableWrapper" | "thead" | "tbody" | "tr" | "th" | "td" | "figure" | "figcaption" | "img" | "span" | "callout" | "calloutTitle";
135
+ type RichTextClassKey = "p" | "br" | "hr" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "ul" | "ol" | "li" | "blockquote" | "pre" | "code" | "inlineCode" | "a" | "strong" | "em" | "u" | "s" | "kbd" | "table" | "tableWrapper" | "thead" | "tbody" | "tr" | "th" | "td" | "figure" | "figcaption" | "img" | "span" | "callout" | "calloutTitle" | "collapsible" | "collapsibleTitle";
136
136
  type RichTextClassMap = Partial<Record<RichTextClassKey, string>> & {
137
137
  /** Variant overrides keyed as `${matchKey}:${variant}`, e.g. "callout:warning". */
138
138
  variants?: Record<string, string>;
@@ -141,6 +141,12 @@ interface ParseRichTextOptions {
141
141
  classMap?: RichTextClassMap;
142
142
  /** Tag allowlist override. Defaults to a safe semantic set. */
143
143
  allowlist?: ReadonlyArray<string>;
144
+ /**
145
+ * When `true` (default), inject slugified `id` attributes on `<h1>`–`<h6>`
146
+ * tags that don't already have one. Lets ToC anchors resolve from the
147
+ * server-rendered markup without a client-side mutation step.
148
+ */
149
+ autoHeadingIds?: boolean;
144
150
  }
145
151
  declare function parseRichText(html: string, options?: ParseRichTextOptions): string;
146
152
  /**
@@ -150,4 +156,41 @@ declare function parseRichText(html: string, options?: ParseRichTextOptions): st
150
156
  */
151
157
  declare function removeEmptyParagraphs(html: string): string;
152
158
 
153
- export { type AsteroidCMSConfig, type CmsSearchCondition, type ContentStatus, type FieldSelector, type ParseRichTextOptions, type ReferenceExpansion, type ResolvedAsteroidCMSConfig, type RichTextClassKey, type RichTextClassMap, type UseCmsContentOptions, buildCmsQuery, cmsImage, createApolloClient, fetchCmsContent, getContentReadTime, parseRichText, removeEmptyParagraphs };
159
+ /**
160
+ * Heading extraction helpers used to build tables of contents (ToC) from
161
+ * rich-text HTML. Server-safe — no React, no DOM dependency in the HTML
162
+ * variant. The DOM variant assigns missing `id`s in-place so anchor links
163
+ * resolve immediately.
164
+ */
165
+ type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
166
+ interface ExtractedHeading {
167
+ id: string;
168
+ text: string;
169
+ level: HeadingLevel;
170
+ }
171
+ interface ExtractHeadingsOptions {
172
+ /** Levels to include. Defaults to `[2, 3]` — typical doc page outline. */
173
+ levels?: ReadonlyArray<HeadingLevel>;
174
+ /** Custom slug function. Defaults to a lowercase/kebab/diacritic-safe slug. */
175
+ slugify?: (text: string, index: number) => string;
176
+ }
177
+ declare function slugify(text: string): string;
178
+ /**
179
+ * Parse headings out of a raw HTML string. Returns headings in document
180
+ * order with stable, de-duplicated IDs.
181
+ *
182
+ * If a heading already has an `id` attribute, it's preserved verbatim
183
+ * (and reserved so later slugs don't collide with it).
184
+ */
185
+ declare function extractHeadingsFromHtml(html: string, options?: ExtractHeadingsOptions): ExtractedHeading[];
186
+ /**
187
+ * Walk a rendered DOM subtree, collect headings, and assign missing `id`s
188
+ * in-place so anchor links resolve immediately. Also sets `scrollMarginTop`
189
+ * on each heading when `scrollMarginTop` is provided so navigation lands
190
+ * cleanly below a sticky header.
191
+ */
192
+ declare function extractHeadingsFromElement(root: HTMLElement, options?: ExtractHeadingsOptions & {
193
+ scrollMarginTop?: number;
194
+ }): ExtractedHeading[];
195
+
196
+ export { type AsteroidCMSConfig, type CmsSearchCondition, type ContentStatus, type ExtractHeadingsOptions, type ExtractedHeading, type FieldSelector, type HeadingLevel, type ParseRichTextOptions, type ReferenceExpansion, type ResolvedAsteroidCMSConfig, type RichTextClassKey, type RichTextClassMap, type UseCmsContentOptions, buildCmsQuery, cmsImage, createApolloClient, extractHeadingsFromElement, extractHeadingsFromHtml, fetchCmsContent, getContentReadTime, parseRichText, removeEmptyParagraphs, slugify };
package/dist/index.d.ts CHANGED
@@ -132,7 +132,7 @@ declare function getContentReadTime(content: string, options?: GetContentReadTim
132
132
  *
133
133
  * Idempotent: parseRichText(parseRichText(x, opts), opts) === parseRichText(x, opts).
134
134
  */
135
- type RichTextClassKey = "p" | "br" | "hr" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "ul" | "ol" | "li" | "blockquote" | "pre" | "code" | "inlineCode" | "a" | "strong" | "em" | "u" | "s" | "kbd" | "table" | "tableWrapper" | "thead" | "tbody" | "tr" | "th" | "td" | "figure" | "figcaption" | "img" | "span" | "callout" | "calloutTitle";
135
+ type RichTextClassKey = "p" | "br" | "hr" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "ul" | "ol" | "li" | "blockquote" | "pre" | "code" | "inlineCode" | "a" | "strong" | "em" | "u" | "s" | "kbd" | "table" | "tableWrapper" | "thead" | "tbody" | "tr" | "th" | "td" | "figure" | "figcaption" | "img" | "span" | "callout" | "calloutTitle" | "collapsible" | "collapsibleTitle";
136
136
  type RichTextClassMap = Partial<Record<RichTextClassKey, string>> & {
137
137
  /** Variant overrides keyed as `${matchKey}:${variant}`, e.g. "callout:warning". */
138
138
  variants?: Record<string, string>;
@@ -141,6 +141,12 @@ interface ParseRichTextOptions {
141
141
  classMap?: RichTextClassMap;
142
142
  /** Tag allowlist override. Defaults to a safe semantic set. */
143
143
  allowlist?: ReadonlyArray<string>;
144
+ /**
145
+ * When `true` (default), inject slugified `id` attributes on `<h1>`–`<h6>`
146
+ * tags that don't already have one. Lets ToC anchors resolve from the
147
+ * server-rendered markup without a client-side mutation step.
148
+ */
149
+ autoHeadingIds?: boolean;
144
150
  }
145
151
  declare function parseRichText(html: string, options?: ParseRichTextOptions): string;
146
152
  /**
@@ -150,4 +156,41 @@ declare function parseRichText(html: string, options?: ParseRichTextOptions): st
150
156
  */
151
157
  declare function removeEmptyParagraphs(html: string): string;
152
158
 
153
- export { type AsteroidCMSConfig, type CmsSearchCondition, type ContentStatus, type FieldSelector, type ParseRichTextOptions, type ReferenceExpansion, type ResolvedAsteroidCMSConfig, type RichTextClassKey, type RichTextClassMap, type UseCmsContentOptions, buildCmsQuery, cmsImage, createApolloClient, fetchCmsContent, getContentReadTime, parseRichText, removeEmptyParagraphs };
159
+ /**
160
+ * Heading extraction helpers used to build tables of contents (ToC) from
161
+ * rich-text HTML. Server-safe — no React, no DOM dependency in the HTML
162
+ * variant. The DOM variant assigns missing `id`s in-place so anchor links
163
+ * resolve immediately.
164
+ */
165
+ type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
166
+ interface ExtractedHeading {
167
+ id: string;
168
+ text: string;
169
+ level: HeadingLevel;
170
+ }
171
+ interface ExtractHeadingsOptions {
172
+ /** Levels to include. Defaults to `[2, 3]` — typical doc page outline. */
173
+ levels?: ReadonlyArray<HeadingLevel>;
174
+ /** Custom slug function. Defaults to a lowercase/kebab/diacritic-safe slug. */
175
+ slugify?: (text: string, index: number) => string;
176
+ }
177
+ declare function slugify(text: string): string;
178
+ /**
179
+ * Parse headings out of a raw HTML string. Returns headings in document
180
+ * order with stable, de-duplicated IDs.
181
+ *
182
+ * If a heading already has an `id` attribute, it's preserved verbatim
183
+ * (and reserved so later slugs don't collide with it).
184
+ */
185
+ declare function extractHeadingsFromHtml(html: string, options?: ExtractHeadingsOptions): ExtractedHeading[];
186
+ /**
187
+ * Walk a rendered DOM subtree, collect headings, and assign missing `id`s
188
+ * in-place so anchor links resolve immediately. Also sets `scrollMarginTop`
189
+ * on each heading when `scrollMarginTop` is provided so navigation lands
190
+ * cleanly below a sticky header.
191
+ */
192
+ declare function extractHeadingsFromElement(root: HTMLElement, options?: ExtractHeadingsOptions & {
193
+ scrollMarginTop?: number;
194
+ }): ExtractedHeading[];
195
+
196
+ export { type AsteroidCMSConfig, type CmsSearchCondition, type ContentStatus, type ExtractHeadingsOptions, type ExtractedHeading, type FieldSelector, type HeadingLevel, type ParseRichTextOptions, type ReferenceExpansion, type ResolvedAsteroidCMSConfig, type RichTextClassKey, type RichTextClassMap, type UseCmsContentOptions, buildCmsQuery, cmsImage, createApolloClient, extractHeadingsFromElement, extractHeadingsFromHtml, fetchCmsContent, getContentReadTime, parseRichText, removeEmptyParagraphs, slugify };
package/dist/index.js CHANGED
@@ -270,7 +270,9 @@ var DEFAULT_ALLOWLIST = [
270
270
  "section",
271
271
  "article",
272
272
  "div",
273
- "span"
273
+ "span",
274
+ "details",
275
+ "summary"
274
276
  ];
275
277
  var ALLOWED_ATTRS = {
276
278
  a: ["href", "title", "target", "rel"],
@@ -280,7 +282,8 @@ var ALLOWED_ATTRS = {
280
282
  col: ["span", "width"],
281
283
  colgroup: ["span"],
282
284
  table: ["border", "cellpadding", "cellspacing"],
283
- span: ["style"]
285
+ span: ["style"],
286
+ details: ["open"]
284
287
  };
285
288
  var ALLOWED_STYLE_PROPS = {
286
289
  span: ["font-size"]
@@ -314,7 +317,8 @@ var GLOBAL_ALLOWED_ATTRS = [
314
317
  "data-title",
315
318
  "data-callout-title",
316
319
  "data-language",
317
- "data-filename"
320
+ "data-filename",
321
+ "data-icon"
318
322
  ];
319
323
  var URL_ATTRS = /* @__PURE__ */ new Set(["href", "src"]);
320
324
  function parseRichText(html, options = {}) {
@@ -324,7 +328,36 @@ function parseRichText(html, options = {}) {
324
328
  working = upgradeStandaloneImages(working);
325
329
  working = upgradeAuthoredBlockquotes(working);
326
330
  working = flattenTableCellParagraphs(working);
327
- return sanitizeAndStyle(working, options);
331
+ working = sanitizeAndStyle(working, options);
332
+ if (options.autoHeadingIds !== false) {
333
+ working = injectHeadingIds(working);
334
+ }
335
+ return working;
336
+ }
337
+ function injectHeadingIds(html) {
338
+ const used = /* @__PURE__ */ new Map();
339
+ const existingRe = /<h[1-6]\b[^>]*\bid\s*=\s*("([^"]*)"|'([^']*)'|(\S+))/gi;
340
+ let em;
341
+ while ((em = existingRe.exec(html)) !== null) {
342
+ const id = em[2] ?? em[3] ?? em[4] ?? "";
343
+ if (id) used.set(id, (used.get(id) ?? 0) + 1);
344
+ }
345
+ return html.replace(
346
+ /<(h[1-6])\b([^>]*)>([\s\S]*?)<\/\1>/gi,
347
+ (full, tag, attrs, inner) => {
348
+ if (/\bid\s*=/i.test(attrs)) return full;
349
+ const text = inner.replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/\s+/g, " ").trim();
350
+ if (!text) return full;
351
+ const base = slugifyHeading(text) || tag;
352
+ const n = used.get(base) ?? 0;
353
+ used.set(base, n + 1);
354
+ const id = n === 0 ? base : `${base}-${n}`;
355
+ return `<${tag}${attrs} id="${id}">${inner}</${tag}>`;
356
+ }
357
+ );
358
+ }
359
+ function slugifyHeading(text) {
360
+ return text.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().trim().replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
328
361
  }
329
362
  function flattenTableCellParagraphs(html) {
330
363
  return html.replace(
@@ -699,6 +732,8 @@ function classKeyForTag(tag, attrs, openStack) {
699
732
  if (tag === "p" && "data-callout-title" in attrs) return "calloutTitle";
700
733
  if (tag === "code" && openStack[openStack.length - 1] !== "pre")
701
734
  return "inlineCode";
735
+ if (tag === "details") return "collapsible";
736
+ if (tag === "summary") return "collapsibleTitle";
702
737
  const known = [
703
738
  "p",
704
739
  "br",
@@ -866,6 +901,79 @@ function sanitizeAndStyle(html, options) {
866
901
  return out.join("");
867
902
  }
868
903
 
869
- export { buildCmsQuery, cmsImage, createApolloClient, fetchCmsContent, getContentReadTime, parseRichText, removeEmptyParagraphs };
904
+ // src/utils/extractHeadings.ts
905
+ var DEFAULT_LEVELS = [2, 3];
906
+ function slugify(text) {
907
+ return text.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().trim().replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
908
+ }
909
+ function decodeBasicEntities(s) {
910
+ return s.replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'");
911
+ }
912
+ function stripTags(s) {
913
+ return decodeBasicEntities(s.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
914
+ }
915
+ function uniqueId(base, used) {
916
+ const seed = base || "section";
917
+ const n = used.get(seed) ?? 0;
918
+ used.set(seed, n + 1);
919
+ return n === 0 ? seed : `${seed}-${n}`;
920
+ }
921
+ function extractHeadingsFromHtml(html, options = {}) {
922
+ if (!html) return [];
923
+ const levels = options.levels ?? DEFAULT_LEVELS;
924
+ const slug = options.slugify ?? slugify;
925
+ const used = /* @__PURE__ */ new Map();
926
+ const out = [];
927
+ const re = /<h([1-6])\b([^>]*)>([\s\S]*?)<\/h\1>/gi;
928
+ let m;
929
+ let i = 0;
930
+ while ((m = re.exec(html)) !== null) {
931
+ const level = Number(m[1]);
932
+ if (!levels.includes(level)) continue;
933
+ const attrs = m[2] ?? "";
934
+ const inner = m[3] ?? "";
935
+ const text = stripTags(inner);
936
+ if (!text) continue;
937
+ const explicitIdMatch = attrs.match(/\bid\s*=\s*("([^"]*)"|'([^']*)'|(\S+))/i);
938
+ let id;
939
+ if (explicitIdMatch) {
940
+ id = explicitIdMatch[2] ?? explicitIdMatch[3] ?? explicitIdMatch[4] ?? "";
941
+ if (id) used.set(id, (used.get(id) ?? 0) + 1);
942
+ } else {
943
+ id = uniqueId(slug(text, i), used);
944
+ }
945
+ out.push({ id, text, level });
946
+ i++;
947
+ }
948
+ return out;
949
+ }
950
+ function extractHeadingsFromElement(root, options = {}) {
951
+ const levels = options.levels ?? DEFAULT_LEVELS;
952
+ const slug = options.slugify ?? slugify;
953
+ const selector = levels.map((l) => `h${l}`).join(",");
954
+ const nodes = root.querySelectorAll(selector);
955
+ const used = /* @__PURE__ */ new Map();
956
+ nodes.forEach((n) => {
957
+ if (n.id) used.set(n.id, (used.get(n.id) ?? 0) + 1);
958
+ });
959
+ const out = [];
960
+ let i = 0;
961
+ nodes.forEach((node) => {
962
+ const level = Number(node.tagName.slice(1));
963
+ const text = (node.textContent ?? "").replace(/\s+/g, " ").trim();
964
+ if (!text) return;
965
+ if (!node.id) {
966
+ node.id = uniqueId(slug(text, i), used);
967
+ }
968
+ if (options.scrollMarginTop != null) {
969
+ node.style.scrollMarginTop = `${options.scrollMarginTop}px`;
970
+ }
971
+ out.push({ id: node.id, text, level });
972
+ i++;
973
+ });
974
+ return out;
975
+ }
976
+
977
+ export { buildCmsQuery, cmsImage, createApolloClient, extractHeadingsFromElement, extractHeadingsFromHtml, fetchCmsContent, getContentReadTime, parseRichText, removeEmptyParagraphs, slugify };
870
978
  //# sourceMappingURL=index.js.map
871
979
  //# sourceMappingURL=index.js.map