@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.js CHANGED
@@ -400,6 +400,64 @@ function extractToc(markdown) {
400
400
  return headings;
401
401
  });
402
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
+ }
403
461
 
404
462
  // src/server-articles.ts
405
463
  var articlesDirectory = path.join(
@@ -532,6 +590,58 @@ function parseHowToSteps(raw) {
532
590
  function parseOptionalString(raw) {
533
591
  return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : void 0;
534
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
+ }
535
645
  function parseSeriesOrder(raw) {
536
646
  return typeof raw === "number" && Number.isFinite(raw) ? raw : void 0;
537
647
  }
@@ -604,6 +714,7 @@ function getArticleAuthors(article, config) {
604
714
  }
605
715
  function getArticleSummary(slug, config) {
606
716
  return __async(this, null, function* () {
717
+ var _a;
607
718
  try {
608
719
  const found = findArticleFile(slug);
609
720
  if (!found) return null;
@@ -620,7 +731,7 @@ function getArticleSummary(slug, config) {
620
731
  title: data.title || slug.replaceAll("-", " "),
621
732
  excerpt: data.excerpt || "",
622
733
  date: parseDateField(data.date),
623
- lastmod: parseDateField(data.lastmod),
734
+ lastmod: resolveLastmod(data.lastmod, data.date, found.filePath, config),
624
735
  author,
625
736
  authors,
626
737
  authorSlug: primaryAuthorProfile == null ? void 0 : primaryAuthorProfile.slug,
@@ -633,14 +744,17 @@ function getArticleSummary(slug, config) {
633
744
  tags: data.tags || [],
634
745
  contentType: found.contentType,
635
746
  draft: data.draft === true,
636
- faq: parseFaqItems(data.faq),
747
+ faq: (_a = parseFaqItems(data.faq)) != null ? _a : deriveFaq(markdownContent, config),
637
748
  howTo: parseHowToSteps(data.howTo),
749
+ answer: parseOptionalString(data.answer),
750
+ about: parseEntityReferences(data.about, config),
751
+ citation: parseCitations(data.citation),
638
752
  canonicalUrl: typeof data.canonicalUrl === "string" ? data.canonicalUrl : void 0,
639
753
  articleType: typeof data.articleType === "string" ? data.articleType : void 0,
640
754
  series: typeof data.series === "string" ? data.series : void 0,
641
755
  seriesSlug: parseOptionalString(data.seriesSlug),
642
756
  seriesOrder: parseSeriesOrder(data.seriesOrder),
643
- aiCrawl: data.aiCrawl === true,
757
+ aiCrawl: resolveAiCrawl(data.aiCrawl, config),
644
758
  searchTitle: parseOptionalString(data.searchTitle),
645
759
  searchDescription: parseOptionalString(data.searchDescription),
646
760
  socialTitle: parseOptionalString(data.socialTitle),
@@ -699,10 +813,10 @@ var getAllArticles = cache((config) => __async(void 0, null, function* () {
699
813
  return new Date(b.date).getTime() - new Date(a.date).getTime();
700
814
  });
701
815
  }));
702
- function getArticleMarkdown(slug) {
816
+ function getArticleMarkdown(slug, config) {
703
817
  return __async(this, null, function* () {
704
818
  try {
705
- const summary = yield getArticleSummary(slug);
819
+ const summary = yield getArticleSummary(slug, config);
706
820
  if (!(summary == null ? void 0 : summary.aiCrawl)) return null;
707
821
  const found = findArticleFile(slug);
708
822
  if (!found) return null;
@@ -720,49 +834,229 @@ function getArticleMarkdown(slug) {
720
834
  }
721
835
  });
722
836
  }
723
- 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) {
724
924
  return __async(this, null, function* () {
725
- const markdown = yield getArticleMarkdown(slug);
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) {
1004
+ return __async(this, null, function* () {
1005
+ const markdown = yield getArticleMarkdown(slug, config);
726
1006
  if (markdown === null) return new Response("Not Found", { status: 404 });
727
- const article = yield getArticleMetadata(slug);
728
- return new Response(markdown, {
729
- headers: __spreadValues({
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
+ const canonicalUrl = `${config.siteUrl.replace(/\/$/, "")}/articles/${slug}`;
1011
+ return new Response(body, {
1012
+ headers: {
730
1013
  "Content-Type": "text/markdown; charset=utf-8",
731
- "Cache-Control": "public, max-age=3600, s-maxage=3600"
732
- }, article ? getArticleAiHeaders(article, config) : {})
1014
+ "Cache-Control": "public, max-age=3600, s-maxage=3600",
1015
+ Link: `<${canonicalUrl}>; rel="canonical"`
1016
+ }
733
1017
  });
734
1018
  });
735
1019
  }
736
- function getArticleMarkdownUrl(article, config) {
737
- if (article.aiCrawl !== true) return void 0;
738
- const pathname = `/articles/${article.slug}.md`;
739
- if (!config) return pathname;
740
- return `${config.siteUrl.replace(/\/$/, "")}${pathname}`;
741
- }
742
- function getArticleAiHeaders(article, config) {
743
- const markdownUrl = getArticleMarkdownUrl(article, config);
744
- if (markdownUrl) {
745
- return {
746
- Link: `<${markdownUrl}>; rel="alternate"; type="text/markdown"`
747
- };
748
- }
749
- return {
750
- "X-Robots-Tag": "noai, noimageai"
751
- };
752
- }
753
1020
  function categoryToSlug(category) {
754
1021
  return category.toLowerCase().replaceAll(/\s+/g, "-").replaceAll(/[^a-z0-9-]/g, "");
755
1022
  }
1023
+ function getArticlesByCategory(categorySlug, config) {
1024
+ return __async(this, null, function* () {
1025
+ const articles = yield getAllArticles(config);
1026
+ return articles.filter(
1027
+ (article) => article.categories.some((cat) => categoryToSlug(cat) === categorySlug)
1028
+ );
1029
+ });
1030
+ }
1031
+ function getArticlesByAuthor(authorSlug, config) {
1032
+ return __async(this, null, function* () {
1033
+ const articles = yield getAllArticles(config);
1034
+ return articles.filter(
1035
+ (article) => getArticleAuthors(article, config).some((author) => author.slug === authorSlug)
1036
+ );
1037
+ });
1038
+ }
1039
+ function getArticlesBySeries(seriesSlug, config) {
1040
+ return __async(this, null, function* () {
1041
+ const articles = yield getAllArticles(config);
1042
+ return articles.filter((article) => article.seriesSlug === seriesSlug).sort((a, b) => {
1043
+ var _a, _b;
1044
+ const orderA = (_a = a.seriesOrder) != null ? _a : Number.POSITIVE_INFINITY;
1045
+ const orderB = (_b = b.seriesOrder) != null ? _b : Number.POSITIVE_INFINITY;
1046
+ return orderA - orderB;
1047
+ });
1048
+ });
1049
+ }
756
1050
 
757
1051
  // src/nextjs.ts
758
1052
  function createArticleMarkdownHandler(config) {
759
- function GET(_request, context) {
1053
+ function GET(request, context) {
760
1054
  return __async(this, null, function* () {
761
1055
  const params = yield context.params;
762
1056
  const slugParts = params["slug"];
763
1057
  const slug = Array.isArray(slugParts) ? slugParts.join("/") : slugParts != null ? slugParts : "";
764
1058
  const cleanSlug = slug.endsWith(".md") ? slug.slice(0, -3) : slug;
765
- return getArticleMarkdownResponse(cleanSlug, config);
1059
+ return getMarkdownTwinResponse(cleanSlug, config, { headers: request.headers });
766
1060
  });
767
1061
  }
768
1062
  return { GET };