@fullstackdatasolutions/articles 1.2.3 → 1.3.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.
Files changed (44) 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 +325 -31
  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 +325 -31
  14. package/dist/nextjs.js.map +1 -1
  15. package/dist/server.cjs +660 -50
  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 +645 -50
  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__/markdown.test.ts +77 -1
  30. package/src/__tests__/nextjs.test.ts +31 -15
  31. package/src/__tests__/seoUtils.test.ts +279 -0
  32. package/src/__tests__/server-articles.test.ts +434 -1
  33. package/src/__tests__/validateArticles.test.ts +167 -6
  34. package/src/articleTypes.ts +57 -0
  35. package/src/articlesConfig.ts +176 -1
  36. package/src/authorUtils.ts +19 -1
  37. package/src/errorReporting.ts +1 -0
  38. package/src/index.ts +17 -1
  39. package/src/markdown.ts +100 -1
  40. package/src/nextjs.ts +7 -4
  41. package/src/seoUtils.ts +247 -26
  42. package/src/server-articles.ts +385 -25
  43. package/src/server.ts +35 -4
  44. package/src/validateArticles.ts +157 -12
package/dist/nextjs.cjs CHANGED
@@ -433,6 +433,64 @@ function extractToc(markdown) {
433
433
  return headings;
434
434
  });
435
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
+ }
436
494
 
437
495
  // src/server-articles.ts
438
496
  var articlesDirectory = import_node_path.default.join(
@@ -565,6 +623,58 @@ function parseHowToSteps(raw) {
565
623
  function parseOptionalString(raw) {
566
624
  return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : void 0;
567
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
+ }
568
678
  function parseSeriesOrder(raw) {
569
679
  return typeof raw === "number" && Number.isFinite(raw) ? raw : void 0;
570
680
  }
@@ -637,6 +747,7 @@ function getArticleAuthors(article, config) {
637
747
  }
638
748
  function getArticleSummary(slug, config) {
639
749
  return __async(this, null, function* () {
750
+ var _a;
640
751
  try {
641
752
  const found = findArticleFile(slug);
642
753
  if (!found) return null;
@@ -653,7 +764,7 @@ function getArticleSummary(slug, config) {
653
764
  title: data.title || slug.replaceAll("-", " "),
654
765
  excerpt: data.excerpt || "",
655
766
  date: parseDateField(data.date),
656
- lastmod: parseDateField(data.lastmod),
767
+ lastmod: resolveLastmod(data.lastmod, data.date, found.filePath, config),
657
768
  author,
658
769
  authors,
659
770
  authorSlug: primaryAuthorProfile == null ? void 0 : primaryAuthorProfile.slug,
@@ -666,14 +777,17 @@ function getArticleSummary(slug, config) {
666
777
  tags: data.tags || [],
667
778
  contentType: found.contentType,
668
779
  draft: data.draft === true,
669
- faq: parseFaqItems(data.faq),
780
+ faq: (_a = parseFaqItems(data.faq)) != null ? _a : deriveFaq(markdownContent, config),
670
781
  howTo: parseHowToSteps(data.howTo),
782
+ answer: parseOptionalString(data.answer),
783
+ about: parseEntityReferences(data.about, config),
784
+ citation: parseCitations(data.citation),
671
785
  canonicalUrl: typeof data.canonicalUrl === "string" ? data.canonicalUrl : void 0,
672
786
  articleType: typeof data.articleType === "string" ? data.articleType : void 0,
673
787
  series: typeof data.series === "string" ? data.series : void 0,
674
788
  seriesSlug: parseOptionalString(data.seriesSlug),
675
789
  seriesOrder: parseSeriesOrder(data.seriesOrder),
676
- aiCrawl: data.aiCrawl === true,
790
+ aiCrawl: resolveAiCrawl(data.aiCrawl, config),
677
791
  searchTitle: parseOptionalString(data.searchTitle),
678
792
  searchDescription: parseOptionalString(data.searchDescription),
679
793
  socialTitle: parseOptionalString(data.socialTitle),
@@ -732,10 +846,10 @@ var getAllArticles = (0, import_react.cache)((config) => __async(void 0, null, f
732
846
  return new Date(b.date).getTime() - new Date(a.date).getTime();
733
847
  });
734
848
  }));
735
- function getArticleMarkdown(slug) {
849
+ function getArticleMarkdown(slug, config) {
736
850
  return __async(this, null, function* () {
737
851
  try {
738
- const summary = yield getArticleSummary(slug);
852
+ const summary = yield getArticleSummary(slug, config);
739
853
  if (!(summary == null ? void 0 : summary.aiCrawl)) return null;
740
854
  const found = findArticleFile(slug);
741
855
  if (!found) return null;
@@ -753,49 +867,229 @@ function getArticleMarkdown(slug) {
753
867
  }
754
868
  });
755
869
  }
756
- 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) {
757
957
  return __async(this, null, function* () {
758
- const markdown = yield getArticleMarkdown(slug);
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) {
1037
+ return __async(this, null, function* () {
1038
+ const markdown = yield getArticleMarkdown(slug, config);
759
1039
  if (markdown === null) return new Response("Not Found", { status: 404 });
760
- const article = yield getArticleMetadata(slug);
761
- return new Response(markdown, {
762
- headers: __spreadValues({
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
+ const canonicalUrl = `${config.siteUrl.replace(/\/$/, "")}/articles/${slug}`;
1044
+ return new Response(body, {
1045
+ headers: {
763
1046
  "Content-Type": "text/markdown; charset=utf-8",
764
- "Cache-Control": "public, max-age=3600, s-maxage=3600"
765
- }, article ? getArticleAiHeaders(article, config) : {})
1047
+ "Cache-Control": "public, max-age=3600, s-maxage=3600",
1048
+ Link: `<${canonicalUrl}>; rel="canonical"`
1049
+ }
766
1050
  });
767
1051
  });
768
1052
  }
769
- function getArticleMarkdownUrl(article, config) {
770
- if (article.aiCrawl !== true) return void 0;
771
- const pathname = `/articles/${article.slug}.md`;
772
- if (!config) return pathname;
773
- return `${config.siteUrl.replace(/\/$/, "")}${pathname}`;
774
- }
775
- function getArticleAiHeaders(article, config) {
776
- const markdownUrl = getArticleMarkdownUrl(article, config);
777
- if (markdownUrl) {
778
- return {
779
- Link: `<${markdownUrl}>; rel="alternate"; type="text/markdown"`
780
- };
781
- }
782
- return {
783
- "X-Robots-Tag": "noai, noimageai"
784
- };
785
- }
786
1053
  function categoryToSlug(category) {
787
1054
  return category.toLowerCase().replaceAll(/\s+/g, "-").replaceAll(/[^a-z0-9-]/g, "");
788
1055
  }
1056
+ function getArticlesByCategory(categorySlug, config) {
1057
+ return __async(this, null, function* () {
1058
+ const articles = yield getAllArticles(config);
1059
+ return articles.filter(
1060
+ (article) => article.categories.some((cat) => categoryToSlug(cat) === categorySlug)
1061
+ );
1062
+ });
1063
+ }
1064
+ function getArticlesByAuthor(authorSlug, config) {
1065
+ return __async(this, null, function* () {
1066
+ const articles = yield getAllArticles(config);
1067
+ return articles.filter(
1068
+ (article) => getArticleAuthors(article, config).some((author) => author.slug === authorSlug)
1069
+ );
1070
+ });
1071
+ }
1072
+ function getArticlesBySeries(seriesSlug, config) {
1073
+ return __async(this, null, function* () {
1074
+ const articles = yield getAllArticles(config);
1075
+ return articles.filter((article) => article.seriesSlug === seriesSlug).sort((a, b) => {
1076
+ var _a, _b;
1077
+ const orderA = (_a = a.seriesOrder) != null ? _a : Number.POSITIVE_INFINITY;
1078
+ const orderB = (_b = b.seriesOrder) != null ? _b : Number.POSITIVE_INFINITY;
1079
+ return orderA - orderB;
1080
+ });
1081
+ });
1082
+ }
789
1083
 
790
1084
  // src/nextjs.ts
791
1085
  function createArticleMarkdownHandler(config) {
792
- function GET(_request, context) {
1086
+ function GET(request, context) {
793
1087
  return __async(this, null, function* () {
794
1088
  const params = yield context.params;
795
1089
  const slugParts = params["slug"];
796
1090
  const slug = Array.isArray(slugParts) ? slugParts.join("/") : slugParts != null ? slugParts : "";
797
1091
  const cleanSlug = slug.endsWith(".md") ? slug.slice(0, -3) : slug;
798
- return getArticleMarkdownResponse(cleanSlug, config);
1092
+ return getMarkdownTwinResponse(cleanSlug, config, { headers: request.headers });
799
1093
  });
800
1094
  }
801
1095
  return { GET };