@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.js CHANGED
@@ -80,8 +80,7 @@ function reportArticlesError(report) {
80
80
  articlesErrorHandler(report);
81
81
  }
82
82
 
83
- // src/markdown.ts
84
- var DEFAULT_LINK_TARGET_STRATEGY = "external-new-tab";
83
+ // src/linkClassification.ts
85
84
  function isNonBrowserNavigationLink(href) {
86
85
  return /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(href) && !href.startsWith("http://") && !href.startsWith("https://");
87
86
  }
@@ -99,6 +98,9 @@ function isExternalHttpLink(href, siteUrl) {
99
98
  if (!siteOrigin) return true;
100
99
  return getOrigin(href) !== siteOrigin;
101
100
  }
101
+
102
+ // src/markdown.ts
103
+ var DEFAULT_LINK_TARGET_STRATEGY = "external-new-tab";
102
104
  function shouldOpenInNewTab(href, options = {}) {
103
105
  var _a;
104
106
  if (!href || href.startsWith("#") || isNonBrowserNavigationLink(href)) return false;
@@ -398,6 +400,64 @@ function extractToc(markdown) {
398
400
  return headings;
399
401
  });
400
402
  }
403
+ var QUESTION_OPENERS = /^(what|how|why|when|where|who|which|can|should|does|do|is|are|will|would|must)\b/i;
404
+ function deriveFaqFromHeadings(markdown) {
405
+ const items = [];
406
+ const collector = createFaqCollector(items);
407
+ let inFence = false;
408
+ for (const line of markdown.split("\n")) {
409
+ if (FENCE.test(line)) {
410
+ inFence = !inFence;
411
+ continue;
412
+ }
413
+ if (inFence) continue;
414
+ collector.consume(line);
415
+ }
416
+ collector.flush();
417
+ return items;
418
+ }
419
+ var FENCE = /^\s*(```|~~~)/;
420
+ var ANY_HEADING = /^#{1,6}\s/;
421
+ function isSpace(char) {
422
+ return char === " " || char === " ";
423
+ }
424
+ function trimTrailingHashes(value) {
425
+ let end = value.length;
426
+ while (end > 0 && value[end - 1] === "#") end--;
427
+ return value.slice(0, end).trimEnd();
428
+ }
429
+ function readQuestionHeading(line) {
430
+ if (!line.startsWith("##") || line.startsWith("###")) return null;
431
+ if (!isSpace(line[2])) return null;
432
+ const text = trimTrailingHashes(line.slice(3).trim());
433
+ if (!text.endsWith("?") || !QUESTION_OPENERS.test(text)) return null;
434
+ return text;
435
+ }
436
+ function createFaqCollector(items) {
437
+ let pending = null;
438
+ let buffer = [];
439
+ const flush = () => {
440
+ if (pending && buffer.length > 0) {
441
+ items.push({ question: pending, answer: buffer.join(" ").trim() });
442
+ }
443
+ pending = null;
444
+ buffer = [];
445
+ };
446
+ const consume = (line) => {
447
+ if (ANY_HEADING.test(line)) {
448
+ flush();
449
+ pending = readQuestionHeading(line);
450
+ return;
451
+ }
452
+ if (!pending) return;
453
+ if (line.trim() === "") {
454
+ if (buffer.length > 0) flush();
455
+ return;
456
+ }
457
+ buffer.push(line.trim());
458
+ };
459
+ return { consume, flush };
460
+ }
401
461
 
402
462
  // src/server-articles.ts
403
463
  var articlesDirectory = path.join(
@@ -530,6 +590,58 @@ function parseHowToSteps(raw) {
530
590
  function parseOptionalString(raw) {
531
591
  return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : void 0;
532
592
  }
593
+ function deriveFaq(markdownContent, config) {
594
+ if ((config == null ? void 0 : config.deriveFaqFromHeadings) !== true) return void 0;
595
+ const derived = deriveFaqFromHeadings(markdownContent);
596
+ return derived.length ? derived : void 0;
597
+ }
598
+ function resolveLastmod(rawLastmod, rawDate, filePath, config) {
599
+ const declared = parseDateField(rawLastmod);
600
+ if (declared) return declared;
601
+ if ((config == null ? void 0 : config.lastmodFallback) !== "fileMtime") return void 0;
602
+ try {
603
+ return parseDateField(fs.statSync(filePath).mtime);
604
+ } catch (e) {
605
+ return parseDateField(rawDate);
606
+ }
607
+ }
608
+ function resolveAiCrawl(raw, config) {
609
+ if (typeof raw === "boolean") return raw;
610
+ return (config == null ? void 0 : config.aiCrawlDefault) === true;
611
+ }
612
+ function parseEntityReferences(raw, config) {
613
+ if (!Array.isArray(raw)) return void 0;
614
+ const items = raw.map((item) => {
615
+ var _a, _b;
616
+ if (typeof item === "string") {
617
+ const key = item.trim();
618
+ if (!key) return null;
619
+ return (_b = (_a = config == null ? void 0 : config.entities) == null ? void 0 : _a[key]) != null ? _b : { name: key };
620
+ }
621
+ if (typeof item === "object" && item !== null && typeof item.name === "string") {
622
+ const entity = item;
623
+ const name = entity.name.trim();
624
+ if (!name) return null;
625
+ return entity.sameAs ? { name, sameAs: entity.sameAs } : { name };
626
+ }
627
+ return null;
628
+ }).filter((item) => item !== null);
629
+ return items.length ? items : void 0;
630
+ }
631
+ function parseCitations(raw) {
632
+ if (!Array.isArray(raw)) return void 0;
633
+ const items = raw.map((item) => {
634
+ if (typeof item === "string") return item.trim() ? { name: item.trim() } : null;
635
+ if (typeof item === "object" && item !== null && typeof item.name === "string") {
636
+ const citation = item;
637
+ const name = citation.name.trim();
638
+ if (!name) return null;
639
+ return citation.url ? { name, url: citation.url } : { name };
640
+ }
641
+ return null;
642
+ }).filter((item) => item !== null);
643
+ return items.length ? items : void 0;
644
+ }
533
645
  function parseSeriesOrder(raw) {
534
646
  return typeof raw === "number" && Number.isFinite(raw) ? raw : void 0;
535
647
  }
@@ -602,6 +714,7 @@ function getArticleAuthors(article, config) {
602
714
  }
603
715
  function getArticleSummary(slug, config) {
604
716
  return __async(this, null, function* () {
717
+ var _a;
605
718
  try {
606
719
  const found = findArticleFile(slug);
607
720
  if (!found) return null;
@@ -618,7 +731,7 @@ function getArticleSummary(slug, config) {
618
731
  title: data.title || slug.replaceAll("-", " "),
619
732
  excerpt: data.excerpt || "",
620
733
  date: parseDateField(data.date),
621
- lastmod: parseDateField(data.lastmod),
734
+ lastmod: resolveLastmod(data.lastmod, data.date, found.filePath, config),
622
735
  author,
623
736
  authors,
624
737
  authorSlug: primaryAuthorProfile == null ? void 0 : primaryAuthorProfile.slug,
@@ -631,14 +744,17 @@ function getArticleSummary(slug, config) {
631
744
  tags: data.tags || [],
632
745
  contentType: found.contentType,
633
746
  draft: data.draft === true,
634
- faq: parseFaqItems(data.faq),
747
+ faq: (_a = parseFaqItems(data.faq)) != null ? _a : deriveFaq(markdownContent, config),
635
748
  howTo: parseHowToSteps(data.howTo),
749
+ answer: parseOptionalString(data.answer),
750
+ about: parseEntityReferences(data.about, config),
751
+ citation: parseCitations(data.citation),
636
752
  canonicalUrl: typeof data.canonicalUrl === "string" ? data.canonicalUrl : void 0,
637
753
  articleType: typeof data.articleType === "string" ? data.articleType : void 0,
638
754
  series: typeof data.series === "string" ? data.series : void 0,
639
755
  seriesSlug: parseOptionalString(data.seriesSlug),
640
756
  seriesOrder: parseSeriesOrder(data.seriesOrder),
641
- aiCrawl: data.aiCrawl === true,
757
+ aiCrawl: resolveAiCrawl(data.aiCrawl, config),
642
758
  searchTitle: parseOptionalString(data.searchTitle),
643
759
  searchDescription: parseOptionalString(data.searchDescription),
644
760
  socialTitle: parseOptionalString(data.socialTitle),
@@ -697,10 +813,10 @@ var getAllArticles = cache((config) => __async(void 0, null, function* () {
697
813
  return new Date(b.date).getTime() - new Date(a.date).getTime();
698
814
  });
699
815
  }));
700
- function getArticleMarkdown(slug) {
816
+ function getArticleMarkdown(slug, config) {
701
817
  return __async(this, null, function* () {
702
818
  try {
703
- const summary = yield getArticleSummary(slug);
819
+ const summary = yield getArticleSummary(slug, config);
704
820
  if (!(summary == null ? void 0 : summary.aiCrawl)) return null;
705
821
  const found = findArticleFile(slug);
706
822
  if (!found) return null;
@@ -718,12 +834,180 @@ function getArticleMarkdown(slug) {
718
834
  }
719
835
  });
720
836
  }
721
- function getArticleMarkdownResponse(slug, config) {
837
+ var AI_CRAWLERS = [
838
+ "GPTBot",
839
+ "ChatGPT-User",
840
+ "OAI-SearchBot",
841
+ "CCBot",
842
+ "ClaudeBot",
843
+ "Claude-User",
844
+ "Claude-SearchBot",
845
+ "anthropic-ai",
846
+ "PerplexityBot",
847
+ "Perplexity-User",
848
+ "Google-Extended",
849
+ "Applebot-Extended",
850
+ "Bytespider",
851
+ "Amazonbot",
852
+ "meta-externalagent",
853
+ "cohere-ai",
854
+ "DuckAssistBot",
855
+ "MistralAI-User"
856
+ ];
857
+ function matchAiCrawler(userAgent) {
858
+ var _a;
859
+ if (!userAgent) return null;
860
+ const normalized = userAgent.toLowerCase();
861
+ return (_a = AI_CRAWLERS.find((crawler) => normalized.includes(crawler.toLowerCase()))) != null ? _a : null;
862
+ }
863
+ function buildMarkdownTwinHeader(article, config, body) {
864
+ var _a, _b;
865
+ const siteUrl = config.siteUrl.replace(/\/$/, "");
866
+ const firstLine = (_b = (_a = body.trimStart().split("\n", 1)[0]) == null ? void 0 : _a.trim()) != null ? _b : "";
867
+ const bodyRepeatsTitle = firstLine.toLowerCase() === `# ${article.title}`.toLowerCase();
868
+ const facts = [
869
+ `Source: ${siteUrl}/articles/${article.slug}`,
870
+ article.date ? `Published: ${article.date}` : "",
871
+ article.lastmod ? `Updated: ${article.lastmod}` : "",
872
+ config.showAuthor !== false && article.author ? `Author: ${article.author}` : "",
873
+ `Site: ${config.siteName}`
874
+ ].filter(Boolean);
875
+ const blocks = [
876
+ bodyRepeatsTitle ? "" : `# ${article.title}`,
877
+ article.excerpt ? `> ${article.excerpt}` : "",
878
+ facts.join("\n"),
879
+ article.answer ? `**Short answer:** ${article.answer}` : "",
880
+ "---"
881
+ ].filter((block) => block !== "");
882
+ return `${blocks.join("\n\n")}
883
+
884
+ `;
885
+ }
886
+ function reportAiCrawl(slug, config, headers) {
887
+ var _a, _b;
888
+ if (!config.onAiCrawl) return;
889
+ const userAgent = (_a = headers == null ? void 0 : headers.get("user-agent")) != null ? _a : "";
890
+ try {
891
+ config.onAiCrawl({ slug, crawler: (_b = matchAiCrawler(userAgent)) != null ? _b : "unknown", userAgent });
892
+ } catch (error) {
893
+ reportArticlesError({
894
+ code: "ai-crawl-handler-failed",
895
+ message: "onAiCrawl handler threw.",
896
+ error,
897
+ context: { slug }
898
+ });
899
+ }
900
+ }
901
+ function buildListingMarkdown(heading, intro, articles, config) {
902
+ const siteUrl = config.siteUrl.replace(/\/$/, "");
903
+ const crawlable = articles.filter((article) => article.aiCrawl === true);
904
+ const entries = crawlable.map((article) => {
905
+ var _a;
906
+ const summary = (_a = article.answer) != null ? _a : article.excerpt;
907
+ const line = `- [${article.title}](${siteUrl}/articles/${article.slug}.md)`;
908
+ return summary ? `${line}: ${summary}` : line;
909
+ });
910
+ return [
911
+ `# ${heading}`,
912
+ "",
913
+ ...intro.flatMap((line) => [line, ""]),
914
+ `Source: ${siteUrl}`,
915
+ `Site: ${config.siteName}`,
916
+ "",
917
+ "---",
918
+ "",
919
+ ...entries.length > 0 ? entries : ["_No articles available._"],
920
+ ""
921
+ ].join("\n");
922
+ }
923
+ function getCategoryMarkdown(categorySlug, config) {
924
+ return __async(this, null, function* () {
925
+ var _a;
926
+ const articles = yield getArticlesByCategory(categorySlug, config);
927
+ if (articles.length === 0) return null;
928
+ const name = (_a = articles[0].categories.find((c) => categoryToSlug(c) === categorySlug)) != null ? _a : categorySlug;
929
+ const description = resolveCategoryDescription(categorySlug, config);
930
+ return buildListingMarkdown(name, description ? [description] : [], articles, config);
931
+ });
932
+ }
933
+ function resolveCategoryDescription(categorySlug, config) {
934
+ var _a, _b;
935
+ const entry = (_a = config.categoryDescriptions) == null ? void 0 : _a[categorySlug];
936
+ if (!entry) return void 0;
937
+ return typeof entry === "string" ? entry : (_b = entry.long) != null ? _b : entry.short;
938
+ }
939
+ function getAuthorMarkdown(authorSlug, config) {
940
+ return __async(this, null, function* () {
941
+ var _a, _b, _c, _d, _e, _f, _g;
942
+ const author = getAuthorBySlug(authorSlug, config);
943
+ if (!author) return null;
944
+ const articles = yield getArticlesByAuthor(authorSlug, config);
945
+ const intro = [
946
+ (_a = author.promise) != null ? _a : "",
947
+ (_b = author.bio) != null ? _b : "",
948
+ ...((_c = author.servesWho) == null ? void 0 : _c.length) ? [`Writes for: ${author.servesWho.join(", ")}`] : [],
949
+ ...((_d = author.knowsAbout) == null ? void 0 : _d.length) ? [`Writes about: ${author.knowsAbout.join(", ")}`] : [],
950
+ ...((_e = author.credentials) == null ? void 0 : _e.length) ? ["## Stated experience", ...author.credentials.map((item) => `- ${item}`)] : [],
951
+ ...((_f = author.proof) == null ? void 0 : _f.length) ? [
952
+ "## Proof points",
953
+ ...author.proof.map(
954
+ (item) => item.url ? `- [${item.claim}](${item.url})` : `- ${item.claim}`
955
+ )
956
+ ] : [],
957
+ ...((_g = author.originStory) == null ? void 0 : _g.length) ? [
958
+ "## Background",
959
+ ...author.originStory.flatMap((section) => [
960
+ ...section.heading ? [`### ${section.heading}`] : [],
961
+ ...section.paragraphs
962
+ ])
963
+ ] : []
964
+ ].filter((line) => line.trim() !== "");
965
+ return buildListingMarkdown(author.name, intro, articles, config);
966
+ });
967
+ }
968
+ function getSeriesMarkdown(seriesSlug, config) {
969
+ return __async(this, null, function* () {
970
+ var _a;
971
+ const articles = yield getArticlesBySeries(seriesSlug, config);
972
+ if (articles.length === 0) return null;
973
+ const name = (_a = articles[0].series) != null ? _a : seriesSlug;
974
+ return buildListingMarkdown(name, [], articles, config);
975
+ });
976
+ }
977
+ function getMarkdownTwinResponse(slug, config, options) {
978
+ return __async(this, null, function* () {
979
+ const listing = yield resolveListingMarkdown(slug, config);
980
+ if (listing !== void 0) {
981
+ if (listing === null) return new Response("Not Found", { status: 404 });
982
+ reportAiCrawl(slug, config, options == null ? void 0 : options.headers);
983
+ return new Response(listing, { headers: LISTING_MARKDOWN_HEADERS });
984
+ }
985
+ return getArticleMarkdownResponse(slug, config, options);
986
+ });
987
+ }
988
+ var LISTING_MARKDOWN_HEADERS = {
989
+ "Content-Type": "text/markdown; charset=utf-8",
990
+ "Cache-Control": "public, max-age=3600, s-maxage=3600"
991
+ };
992
+ function resolveListingMarkdown(slug, config) {
993
+ return __async(this, null, function* () {
994
+ const [prefix, ...rest] = slug.split("/");
995
+ const key = rest.join("/");
996
+ if (!key) return void 0;
997
+ if (prefix === "category") return getCategoryMarkdown(key, config);
998
+ if (prefix === "authors") return getAuthorMarkdown(key, config);
999
+ if (prefix === "series") return getSeriesMarkdown(key, config);
1000
+ return void 0;
1001
+ });
1002
+ }
1003
+ function getArticleMarkdownResponse(slug, config, options) {
722
1004
  return __async(this, null, function* () {
723
- const markdown = yield getArticleMarkdown(slug);
1005
+ const markdown = yield getArticleMarkdown(slug, config);
724
1006
  if (markdown === null) return new Response("Not Found", { status: 404 });
725
- const article = yield getArticleMetadata(slug);
726
- return new Response(markdown, {
1007
+ const article = yield getArticleMetadata(slug, config);
1008
+ reportAiCrawl(slug, config, options == null ? void 0 : options.headers);
1009
+ const body = article && config.markdownTwinHeader !== false ? `${buildMarkdownTwinHeader(article, config, markdown)}${markdown.trimStart()}` : markdown;
1010
+ return new Response(body, {
727
1011
  headers: __spreadValues({
728
1012
  "Content-Type": "text/markdown; charset=utf-8",
729
1013
  "Cache-Control": "public, max-age=3600, s-maxage=3600"
@@ -751,16 +1035,43 @@ function getArticleAiHeaders(article, config) {
751
1035
  function categoryToSlug(category) {
752
1036
  return category.toLowerCase().replaceAll(/\s+/g, "-").replaceAll(/[^a-z0-9-]/g, "");
753
1037
  }
1038
+ function getArticlesByCategory(categorySlug, config) {
1039
+ return __async(this, null, function* () {
1040
+ const articles = yield getAllArticles(config);
1041
+ return articles.filter(
1042
+ (article) => article.categories.some((cat) => categoryToSlug(cat) === categorySlug)
1043
+ );
1044
+ });
1045
+ }
1046
+ function getArticlesByAuthor(authorSlug, config) {
1047
+ return __async(this, null, function* () {
1048
+ const articles = yield getAllArticles(config);
1049
+ return articles.filter(
1050
+ (article) => getArticleAuthors(article, config).some((author) => author.slug === authorSlug)
1051
+ );
1052
+ });
1053
+ }
1054
+ function getArticlesBySeries(seriesSlug, config) {
1055
+ return __async(this, null, function* () {
1056
+ const articles = yield getAllArticles(config);
1057
+ return articles.filter((article) => article.seriesSlug === seriesSlug).sort((a, b) => {
1058
+ var _a, _b;
1059
+ const orderA = (_a = a.seriesOrder) != null ? _a : Number.POSITIVE_INFINITY;
1060
+ const orderB = (_b = b.seriesOrder) != null ? _b : Number.POSITIVE_INFINITY;
1061
+ return orderA - orderB;
1062
+ });
1063
+ });
1064
+ }
754
1065
 
755
1066
  // src/nextjs.ts
756
1067
  function createArticleMarkdownHandler(config) {
757
- function GET(_request, context) {
1068
+ function GET(request, context) {
758
1069
  return __async(this, null, function* () {
759
1070
  const params = yield context.params;
760
1071
  const slugParts = params["slug"];
761
1072
  const slug = Array.isArray(slugParts) ? slugParts.join("/") : slugParts != null ? slugParts : "";
762
1073
  const cleanSlug = slug.endsWith(".md") ? slug.slice(0, -3) : slug;
763
- return getArticleMarkdownResponse(cleanSlug, config);
1074
+ return getMarkdownTwinResponse(cleanSlug, config, { headers: request.headers });
764
1075
  });
765
1076
  }
766
1077
  return { GET };