@fullstackdatasolutions/articles 1.2.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +313 -1
  3. package/dist/index.cjs +308 -79
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +267 -16
  6. package/dist/index.d.ts +267 -16
  7. package/dist/index.js +300 -79
  8. package/dist/index.js.map +1 -1
  9. package/dist/nextjs.cjs +324 -13
  10. package/dist/nextjs.cjs.map +1 -1
  11. package/dist/nextjs.d.cts +179 -2
  12. package/dist/nextjs.d.ts +179 -2
  13. package/dist/nextjs.js +324 -13
  14. package/dist/nextjs.js.map +1 -1
  15. package/dist/server.cjs +677 -51
  16. package/dist/server.cjs.map +1 -1
  17. package/dist/server.d.cts +333 -12
  18. package/dist/server.d.ts +333 -12
  19. package/dist/server.js +662 -51
  20. package/dist/server.js.map +1 -1
  21. package/package.json +1 -1
  22. package/src/ArticleAnswer.tsx +35 -0
  23. package/src/ArticleSchemas.tsx +263 -23
  24. package/src/AuthorArticlesPage.tsx +38 -8
  25. package/src/__tests__/ArticleAnswer.test.tsx +25 -0
  26. package/src/__tests__/ArticleSchemas.test.tsx +516 -0
  27. package/src/__tests__/AuthorArticlesPage.test.tsx +76 -0
  28. package/src/__tests__/authorUtils.test.ts +50 -0
  29. package/src/__tests__/linkClassification.test.ts +55 -0
  30. package/src/__tests__/markdown.test.ts +77 -1
  31. package/src/__tests__/nextjs.test.ts +31 -15
  32. package/src/__tests__/renderMdx.test.tsx +162 -3
  33. package/src/__tests__/seoUtils.test.ts +279 -0
  34. package/src/__tests__/server-articles.test.ts +413 -1
  35. package/src/__tests__/validateArticles.test.ts +167 -6
  36. package/src/articleTypes.ts +57 -0
  37. package/src/articlesConfig.ts +176 -1
  38. package/src/authorUtils.ts +19 -1
  39. package/src/errorReporting.ts +1 -0
  40. package/src/index.ts +17 -1
  41. package/src/linkClassification.ts +30 -0
  42. package/src/markdown.ts +103 -25
  43. package/src/nextjs.ts +7 -4
  44. package/src/renderMdx.tsx +43 -6
  45. package/src/seoUtils.ts +247 -26
  46. package/src/server-articles.ts +375 -24
  47. package/src/server.ts +35 -4
  48. package/src/validateArticles.ts +157 -12
package/dist/nextjs.cjs CHANGED
@@ -113,8 +113,7 @@ function reportArticlesError(report) {
113
113
  articlesErrorHandler(report);
114
114
  }
115
115
 
116
- // src/markdown.ts
117
- var DEFAULT_LINK_TARGET_STRATEGY = "external-new-tab";
116
+ // src/linkClassification.ts
118
117
  function isNonBrowserNavigationLink(href) {
119
118
  return /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(href) && !href.startsWith("http://") && !href.startsWith("https://");
120
119
  }
@@ -132,6 +131,9 @@ function isExternalHttpLink(href, siteUrl) {
132
131
  if (!siteOrigin) return true;
133
132
  return getOrigin(href) !== siteOrigin;
134
133
  }
134
+
135
+ // src/markdown.ts
136
+ var DEFAULT_LINK_TARGET_STRATEGY = "external-new-tab";
135
137
  function shouldOpenInNewTab(href, options = {}) {
136
138
  var _a;
137
139
  if (!href || href.startsWith("#") || isNonBrowserNavigationLink(href)) return false;
@@ -431,6 +433,64 @@ function extractToc(markdown) {
431
433
  return headings;
432
434
  });
433
435
  }
436
+ var QUESTION_OPENERS = /^(what|how|why|when|where|who|which|can|should|does|do|is|are|will|would|must)\b/i;
437
+ function deriveFaqFromHeadings(markdown) {
438
+ const items = [];
439
+ const collector = createFaqCollector(items);
440
+ let inFence = false;
441
+ for (const line of markdown.split("\n")) {
442
+ if (FENCE.test(line)) {
443
+ inFence = !inFence;
444
+ continue;
445
+ }
446
+ if (inFence) continue;
447
+ collector.consume(line);
448
+ }
449
+ collector.flush();
450
+ return items;
451
+ }
452
+ var FENCE = /^\s*(```|~~~)/;
453
+ var ANY_HEADING = /^#{1,6}\s/;
454
+ function isSpace(char) {
455
+ return char === " " || char === " ";
456
+ }
457
+ function trimTrailingHashes(value) {
458
+ let end = value.length;
459
+ while (end > 0 && value[end - 1] === "#") end--;
460
+ return value.slice(0, end).trimEnd();
461
+ }
462
+ function readQuestionHeading(line) {
463
+ if (!line.startsWith("##") || line.startsWith("###")) return null;
464
+ if (!isSpace(line[2])) return null;
465
+ const text = trimTrailingHashes(line.slice(3).trim());
466
+ if (!text.endsWith("?") || !QUESTION_OPENERS.test(text)) return null;
467
+ return text;
468
+ }
469
+ function createFaqCollector(items) {
470
+ let pending = null;
471
+ let buffer = [];
472
+ const flush = () => {
473
+ if (pending && buffer.length > 0) {
474
+ items.push({ question: pending, answer: buffer.join(" ").trim() });
475
+ }
476
+ pending = null;
477
+ buffer = [];
478
+ };
479
+ const consume = (line) => {
480
+ if (ANY_HEADING.test(line)) {
481
+ flush();
482
+ pending = readQuestionHeading(line);
483
+ return;
484
+ }
485
+ if (!pending) return;
486
+ if (line.trim() === "") {
487
+ if (buffer.length > 0) flush();
488
+ return;
489
+ }
490
+ buffer.push(line.trim());
491
+ };
492
+ return { consume, flush };
493
+ }
434
494
 
435
495
  // src/server-articles.ts
436
496
  var articlesDirectory = import_node_path.default.join(
@@ -563,6 +623,58 @@ function parseHowToSteps(raw) {
563
623
  function parseOptionalString(raw) {
564
624
  return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : void 0;
565
625
  }
626
+ function deriveFaq(markdownContent, config) {
627
+ if ((config == null ? void 0 : config.deriveFaqFromHeadings) !== true) return void 0;
628
+ const derived = deriveFaqFromHeadings(markdownContent);
629
+ return derived.length ? derived : void 0;
630
+ }
631
+ function resolveLastmod(rawLastmod, rawDate, filePath, config) {
632
+ const declared = parseDateField(rawLastmod);
633
+ if (declared) return declared;
634
+ if ((config == null ? void 0 : config.lastmodFallback) !== "fileMtime") return void 0;
635
+ try {
636
+ return parseDateField(import_node_fs.default.statSync(filePath).mtime);
637
+ } catch (e) {
638
+ return parseDateField(rawDate);
639
+ }
640
+ }
641
+ function resolveAiCrawl(raw, config) {
642
+ if (typeof raw === "boolean") return raw;
643
+ return (config == null ? void 0 : config.aiCrawlDefault) === true;
644
+ }
645
+ function parseEntityReferences(raw, config) {
646
+ if (!Array.isArray(raw)) return void 0;
647
+ const items = raw.map((item) => {
648
+ var _a, _b;
649
+ if (typeof item === "string") {
650
+ const key = item.trim();
651
+ if (!key) return null;
652
+ return (_b = (_a = config == null ? void 0 : config.entities) == null ? void 0 : _a[key]) != null ? _b : { name: key };
653
+ }
654
+ if (typeof item === "object" && item !== null && typeof item.name === "string") {
655
+ const entity = item;
656
+ const name = entity.name.trim();
657
+ if (!name) return null;
658
+ return entity.sameAs ? { name, sameAs: entity.sameAs } : { name };
659
+ }
660
+ return null;
661
+ }).filter((item) => item !== null);
662
+ return items.length ? items : void 0;
663
+ }
664
+ function parseCitations(raw) {
665
+ if (!Array.isArray(raw)) return void 0;
666
+ const items = raw.map((item) => {
667
+ if (typeof item === "string") return item.trim() ? { name: item.trim() } : null;
668
+ if (typeof item === "object" && item !== null && typeof item.name === "string") {
669
+ const citation = item;
670
+ const name = citation.name.trim();
671
+ if (!name) return null;
672
+ return citation.url ? { name, url: citation.url } : { name };
673
+ }
674
+ return null;
675
+ }).filter((item) => item !== null);
676
+ return items.length ? items : void 0;
677
+ }
566
678
  function parseSeriesOrder(raw) {
567
679
  return typeof raw === "number" && Number.isFinite(raw) ? raw : void 0;
568
680
  }
@@ -635,6 +747,7 @@ function getArticleAuthors(article, config) {
635
747
  }
636
748
  function getArticleSummary(slug, config) {
637
749
  return __async(this, null, function* () {
750
+ var _a;
638
751
  try {
639
752
  const found = findArticleFile(slug);
640
753
  if (!found) return null;
@@ -651,7 +764,7 @@ function getArticleSummary(slug, config) {
651
764
  title: data.title || slug.replaceAll("-", " "),
652
765
  excerpt: data.excerpt || "",
653
766
  date: parseDateField(data.date),
654
- lastmod: parseDateField(data.lastmod),
767
+ lastmod: resolveLastmod(data.lastmod, data.date, found.filePath, config),
655
768
  author,
656
769
  authors,
657
770
  authorSlug: primaryAuthorProfile == null ? void 0 : primaryAuthorProfile.slug,
@@ -664,14 +777,17 @@ function getArticleSummary(slug, config) {
664
777
  tags: data.tags || [],
665
778
  contentType: found.contentType,
666
779
  draft: data.draft === true,
667
- faq: parseFaqItems(data.faq),
780
+ faq: (_a = parseFaqItems(data.faq)) != null ? _a : deriveFaq(markdownContent, config),
668
781
  howTo: parseHowToSteps(data.howTo),
782
+ answer: parseOptionalString(data.answer),
783
+ about: parseEntityReferences(data.about, config),
784
+ citation: parseCitations(data.citation),
669
785
  canonicalUrl: typeof data.canonicalUrl === "string" ? data.canonicalUrl : void 0,
670
786
  articleType: typeof data.articleType === "string" ? data.articleType : void 0,
671
787
  series: typeof data.series === "string" ? data.series : void 0,
672
788
  seriesSlug: parseOptionalString(data.seriesSlug),
673
789
  seriesOrder: parseSeriesOrder(data.seriesOrder),
674
- aiCrawl: data.aiCrawl === true,
790
+ aiCrawl: resolveAiCrawl(data.aiCrawl, config),
675
791
  searchTitle: parseOptionalString(data.searchTitle),
676
792
  searchDescription: parseOptionalString(data.searchDescription),
677
793
  socialTitle: parseOptionalString(data.socialTitle),
@@ -730,10 +846,10 @@ var getAllArticles = (0, import_react.cache)((config) => __async(void 0, null, f
730
846
  return new Date(b.date).getTime() - new Date(a.date).getTime();
731
847
  });
732
848
  }));
733
- function getArticleMarkdown(slug) {
849
+ function getArticleMarkdown(slug, config) {
734
850
  return __async(this, null, function* () {
735
851
  try {
736
- const summary = yield getArticleSummary(slug);
852
+ const summary = yield getArticleSummary(slug, config);
737
853
  if (!(summary == null ? void 0 : summary.aiCrawl)) return null;
738
854
  const found = findArticleFile(slug);
739
855
  if (!found) return null;
@@ -751,12 +867,180 @@ function getArticleMarkdown(slug) {
751
867
  }
752
868
  });
753
869
  }
754
- function getArticleMarkdownResponse(slug, config) {
870
+ var AI_CRAWLERS = [
871
+ "GPTBot",
872
+ "ChatGPT-User",
873
+ "OAI-SearchBot",
874
+ "CCBot",
875
+ "ClaudeBot",
876
+ "Claude-User",
877
+ "Claude-SearchBot",
878
+ "anthropic-ai",
879
+ "PerplexityBot",
880
+ "Perplexity-User",
881
+ "Google-Extended",
882
+ "Applebot-Extended",
883
+ "Bytespider",
884
+ "Amazonbot",
885
+ "meta-externalagent",
886
+ "cohere-ai",
887
+ "DuckAssistBot",
888
+ "MistralAI-User"
889
+ ];
890
+ function matchAiCrawler(userAgent) {
891
+ var _a;
892
+ if (!userAgent) return null;
893
+ const normalized = userAgent.toLowerCase();
894
+ return (_a = AI_CRAWLERS.find((crawler) => normalized.includes(crawler.toLowerCase()))) != null ? _a : null;
895
+ }
896
+ function buildMarkdownTwinHeader(article, config, body) {
897
+ var _a, _b;
898
+ const siteUrl = config.siteUrl.replace(/\/$/, "");
899
+ const firstLine = (_b = (_a = body.trimStart().split("\n", 1)[0]) == null ? void 0 : _a.trim()) != null ? _b : "";
900
+ const bodyRepeatsTitle = firstLine.toLowerCase() === `# ${article.title}`.toLowerCase();
901
+ const facts = [
902
+ `Source: ${siteUrl}/articles/${article.slug}`,
903
+ article.date ? `Published: ${article.date}` : "",
904
+ article.lastmod ? `Updated: ${article.lastmod}` : "",
905
+ config.showAuthor !== false && article.author ? `Author: ${article.author}` : "",
906
+ `Site: ${config.siteName}`
907
+ ].filter(Boolean);
908
+ const blocks = [
909
+ bodyRepeatsTitle ? "" : `# ${article.title}`,
910
+ article.excerpt ? `> ${article.excerpt}` : "",
911
+ facts.join("\n"),
912
+ article.answer ? `**Short answer:** ${article.answer}` : "",
913
+ "---"
914
+ ].filter((block) => block !== "");
915
+ return `${blocks.join("\n\n")}
916
+
917
+ `;
918
+ }
919
+ function reportAiCrawl(slug, config, headers) {
920
+ var _a, _b;
921
+ if (!config.onAiCrawl) return;
922
+ const userAgent = (_a = headers == null ? void 0 : headers.get("user-agent")) != null ? _a : "";
923
+ try {
924
+ config.onAiCrawl({ slug, crawler: (_b = matchAiCrawler(userAgent)) != null ? _b : "unknown", userAgent });
925
+ } catch (error) {
926
+ reportArticlesError({
927
+ code: "ai-crawl-handler-failed",
928
+ message: "onAiCrawl handler threw.",
929
+ error,
930
+ context: { slug }
931
+ });
932
+ }
933
+ }
934
+ function buildListingMarkdown(heading, intro, articles, config) {
935
+ const siteUrl = config.siteUrl.replace(/\/$/, "");
936
+ const crawlable = articles.filter((article) => article.aiCrawl === true);
937
+ const entries = crawlable.map((article) => {
938
+ var _a;
939
+ const summary = (_a = article.answer) != null ? _a : article.excerpt;
940
+ const line = `- [${article.title}](${siteUrl}/articles/${article.slug}.md)`;
941
+ return summary ? `${line}: ${summary}` : line;
942
+ });
943
+ return [
944
+ `# ${heading}`,
945
+ "",
946
+ ...intro.flatMap((line) => [line, ""]),
947
+ `Source: ${siteUrl}`,
948
+ `Site: ${config.siteName}`,
949
+ "",
950
+ "---",
951
+ "",
952
+ ...entries.length > 0 ? entries : ["_No articles available._"],
953
+ ""
954
+ ].join("\n");
955
+ }
956
+ function getCategoryMarkdown(categorySlug, config) {
957
+ return __async(this, null, function* () {
958
+ var _a;
959
+ const articles = yield getArticlesByCategory(categorySlug, config);
960
+ if (articles.length === 0) return null;
961
+ const name = (_a = articles[0].categories.find((c) => categoryToSlug(c) === categorySlug)) != null ? _a : categorySlug;
962
+ const description = resolveCategoryDescription(categorySlug, config);
963
+ return buildListingMarkdown(name, description ? [description] : [], articles, config);
964
+ });
965
+ }
966
+ function resolveCategoryDescription(categorySlug, config) {
967
+ var _a, _b;
968
+ const entry = (_a = config.categoryDescriptions) == null ? void 0 : _a[categorySlug];
969
+ if (!entry) return void 0;
970
+ return typeof entry === "string" ? entry : (_b = entry.long) != null ? _b : entry.short;
971
+ }
972
+ function getAuthorMarkdown(authorSlug, config) {
973
+ return __async(this, null, function* () {
974
+ var _a, _b, _c, _d, _e, _f, _g;
975
+ const author = getAuthorBySlug(authorSlug, config);
976
+ if (!author) return null;
977
+ const articles = yield getArticlesByAuthor(authorSlug, config);
978
+ const intro = [
979
+ (_a = author.promise) != null ? _a : "",
980
+ (_b = author.bio) != null ? _b : "",
981
+ ...((_c = author.servesWho) == null ? void 0 : _c.length) ? [`Writes for: ${author.servesWho.join(", ")}`] : [],
982
+ ...((_d = author.knowsAbout) == null ? void 0 : _d.length) ? [`Writes about: ${author.knowsAbout.join(", ")}`] : [],
983
+ ...((_e = author.credentials) == null ? void 0 : _e.length) ? ["## Stated experience", ...author.credentials.map((item) => `- ${item}`)] : [],
984
+ ...((_f = author.proof) == null ? void 0 : _f.length) ? [
985
+ "## Proof points",
986
+ ...author.proof.map(
987
+ (item) => item.url ? `- [${item.claim}](${item.url})` : `- ${item.claim}`
988
+ )
989
+ ] : [],
990
+ ...((_g = author.originStory) == null ? void 0 : _g.length) ? [
991
+ "## Background",
992
+ ...author.originStory.flatMap((section) => [
993
+ ...section.heading ? [`### ${section.heading}`] : [],
994
+ ...section.paragraphs
995
+ ])
996
+ ] : []
997
+ ].filter((line) => line.trim() !== "");
998
+ return buildListingMarkdown(author.name, intro, articles, config);
999
+ });
1000
+ }
1001
+ function getSeriesMarkdown(seriesSlug, config) {
1002
+ return __async(this, null, function* () {
1003
+ var _a;
1004
+ const articles = yield getArticlesBySeries(seriesSlug, config);
1005
+ if (articles.length === 0) return null;
1006
+ const name = (_a = articles[0].series) != null ? _a : seriesSlug;
1007
+ return buildListingMarkdown(name, [], articles, config);
1008
+ });
1009
+ }
1010
+ function getMarkdownTwinResponse(slug, config, options) {
1011
+ return __async(this, null, function* () {
1012
+ const listing = yield resolveListingMarkdown(slug, config);
1013
+ if (listing !== void 0) {
1014
+ if (listing === null) return new Response("Not Found", { status: 404 });
1015
+ reportAiCrawl(slug, config, options == null ? void 0 : options.headers);
1016
+ return new Response(listing, { headers: LISTING_MARKDOWN_HEADERS });
1017
+ }
1018
+ return getArticleMarkdownResponse(slug, config, options);
1019
+ });
1020
+ }
1021
+ var LISTING_MARKDOWN_HEADERS = {
1022
+ "Content-Type": "text/markdown; charset=utf-8",
1023
+ "Cache-Control": "public, max-age=3600, s-maxage=3600"
1024
+ };
1025
+ function resolveListingMarkdown(slug, config) {
1026
+ return __async(this, null, function* () {
1027
+ const [prefix, ...rest] = slug.split("/");
1028
+ const key = rest.join("/");
1029
+ if (!key) return void 0;
1030
+ if (prefix === "category") return getCategoryMarkdown(key, config);
1031
+ if (prefix === "authors") return getAuthorMarkdown(key, config);
1032
+ if (prefix === "series") return getSeriesMarkdown(key, config);
1033
+ return void 0;
1034
+ });
1035
+ }
1036
+ function getArticleMarkdownResponse(slug, config, options) {
755
1037
  return __async(this, null, function* () {
756
- const markdown = yield getArticleMarkdown(slug);
1038
+ const markdown = yield getArticleMarkdown(slug, config);
757
1039
  if (markdown === null) return new Response("Not Found", { status: 404 });
758
- const article = yield getArticleMetadata(slug);
759
- return new Response(markdown, {
1040
+ const article = yield getArticleMetadata(slug, config);
1041
+ reportAiCrawl(slug, config, options == null ? void 0 : options.headers);
1042
+ const body = article && config.markdownTwinHeader !== false ? `${buildMarkdownTwinHeader(article, config, markdown)}${markdown.trimStart()}` : markdown;
1043
+ return new Response(body, {
760
1044
  headers: __spreadValues({
761
1045
  "Content-Type": "text/markdown; charset=utf-8",
762
1046
  "Cache-Control": "public, max-age=3600, s-maxage=3600"
@@ -784,16 +1068,43 @@ function getArticleAiHeaders(article, config) {
784
1068
  function categoryToSlug(category) {
785
1069
  return category.toLowerCase().replaceAll(/\s+/g, "-").replaceAll(/[^a-z0-9-]/g, "");
786
1070
  }
1071
+ function getArticlesByCategory(categorySlug, config) {
1072
+ return __async(this, null, function* () {
1073
+ const articles = yield getAllArticles(config);
1074
+ return articles.filter(
1075
+ (article) => article.categories.some((cat) => categoryToSlug(cat) === categorySlug)
1076
+ );
1077
+ });
1078
+ }
1079
+ function getArticlesByAuthor(authorSlug, config) {
1080
+ return __async(this, null, function* () {
1081
+ const articles = yield getAllArticles(config);
1082
+ return articles.filter(
1083
+ (article) => getArticleAuthors(article, config).some((author) => author.slug === authorSlug)
1084
+ );
1085
+ });
1086
+ }
1087
+ function getArticlesBySeries(seriesSlug, config) {
1088
+ return __async(this, null, function* () {
1089
+ const articles = yield getAllArticles(config);
1090
+ return articles.filter((article) => article.seriesSlug === seriesSlug).sort((a, b) => {
1091
+ var _a, _b;
1092
+ const orderA = (_a = a.seriesOrder) != null ? _a : Number.POSITIVE_INFINITY;
1093
+ const orderB = (_b = b.seriesOrder) != null ? _b : Number.POSITIVE_INFINITY;
1094
+ return orderA - orderB;
1095
+ });
1096
+ });
1097
+ }
787
1098
 
788
1099
  // src/nextjs.ts
789
1100
  function createArticleMarkdownHandler(config) {
790
- function GET(_request, context) {
1101
+ function GET(request, context) {
791
1102
  return __async(this, null, function* () {
792
1103
  const params = yield context.params;
793
1104
  const slugParts = params["slug"];
794
1105
  const slug = Array.isArray(slugParts) ? slugParts.join("/") : slugParts != null ? slugParts : "";
795
1106
  const cleanSlug = slug.endsWith(".md") ? slug.slice(0, -3) : slug;
796
- return getArticleMarkdownResponse(cleanSlug, config);
1107
+ return getMarkdownTwinResponse(cleanSlug, config, { headers: request.headers });
797
1108
  });
798
1109
  }
799
1110
  return { GET };