@quintype/seo 1.51.0 → 1.52.2

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/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
+ ### [1.52.2](https://github.com/quintype/quintype-node-seo/compare/v1.52.1...v1.52.2) (2026-06-24)
6
+
7
+ ### [1.52.1](https://github.com/quintype/quintype-node-seo/compare/v1.52.0...v1.52.1) (2026-06-24)
8
+
9
+ ## [1.52.0](https://github.com/quintype/quintype-node-seo/compare/v1.51.0...v1.52.0) (2026-06-24)
10
+
5
11
  ## [1.51.0](https://github.com/quintype/quintype-node-seo/compare/v1.50.3...v1.51.0) (2026-06-03)
6
12
 
7
13
 
package/dist/index.cjs.js CHANGED
@@ -809,6 +809,46 @@ function getCompleteTextOfOneCard(storyElements, stripHtmlFromArticleBody) {
809
809
  return getTextElementsOfOneCard(storyElements).map(item => stripHtmlFromArticleBody ? getPlainText(item.text) : item.text).join(".");
810
810
  }
811
811
 
812
+ function getFaqQuestionItem(question = "", answer = "") {
813
+ const name = getPlainText(question).trim();
814
+ const text = getPlainText(answer).trim();
815
+
816
+ if (!name || !text) return null;
817
+
818
+ return {
819
+ "@type": "Question",
820
+ name,
821
+ acceptedAnswer: {
822
+ "@type": "Answer",
823
+ text
824
+ }
825
+ };
826
+ }
827
+
828
+ function getFaqMainEntity(story = {}) {
829
+ const cards = get__default["default"](story, ["cards"], []);
830
+ const faqItems = [];
831
+
832
+ cards.forEach(card => {
833
+ const storyElements = get__default["default"](card, ["story-elements"], []);
834
+
835
+ storyElements.forEach(element => {
836
+ if (element.type !== "text") return;
837
+ if (element.subtype !== "q-and-a") return;
838
+
839
+ const metadataQuestion = get__default["default"](element, ["metadata", "question"], "");
840
+ const metadataAnswer = get__default["default"](element, ["metadata", "answer"], "");
841
+
842
+ if (metadataQuestion && metadataAnswer) {
843
+ const metadataFaqItem = getFaqQuestionItem(metadataQuestion, metadataAnswer);
844
+ if (metadataFaqItem) faqItems.push(metadataFaqItem);
845
+ }
846
+ });
847
+ });
848
+
849
+ return faqItems;
850
+ }
851
+
812
852
  function generateLiveBlogPostingData(structuredData = {}, story = {}, publisherConfig = {}, timezone) {
813
853
  const imageWidth = 1200;
814
854
  const imageHeight = 675;
@@ -1486,6 +1526,20 @@ function StructuredDataTags({ structuredData = {} }, config, pageType, response
1486
1526
  return products.length ? products : null;
1487
1527
  }
1488
1528
 
1529
+ function generateFaqTags() {
1530
+ const mainEntity = getFaqMainEntity(story);
1531
+ if (!mainEntity.length) return null;
1532
+
1533
+ return ldJson("FAQPage", { mainEntity });
1534
+ }
1535
+
1536
+ if (!isStructuredDataEmpty && (pageType === "story-page" || pageType === "story-page-amp")) {
1537
+ const faqTags = generateFaqTags();
1538
+ if (faqTags) {
1539
+ tags.push(faqTags);
1540
+ }
1541
+ }
1542
+
1489
1543
  // All Pages have: Publisher, Site
1490
1544
  // Story Page have : Article/NewsArticle/LiveBlog/Review as appropriate
1491
1545
  return tags;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quintype/seo",
3
- "version": "1.51.0",
3
+ "version": "1.52.2",
4
4
  "description": "SEO Modules for Quintype",
5
5
  "main": "dist/index.cjs.js",
6
6
  "repository": "https://github.com/quintype/quintype-node-seo",
@@ -290,6 +290,46 @@ function getCompleteTextOfOneCard(storyElements, stripHtmlFromArticleBody) {
290
290
  .join(".");
291
291
  }
292
292
 
293
+ function getFaqQuestionItem(question = "", answer = "") {
294
+ const name = getPlainText(question).trim();
295
+ const text = getPlainText(answer).trim();
296
+
297
+ if (!name || !text) return null;
298
+
299
+ return {
300
+ "@type": "Question",
301
+ name,
302
+ acceptedAnswer: {
303
+ "@type": "Answer",
304
+ text,
305
+ },
306
+ };
307
+ }
308
+
309
+ function getFaqMainEntity(story = {}) {
310
+ const cards = get(story, ["cards"], []);
311
+ const faqItems = [];
312
+
313
+ cards.forEach((card) => {
314
+ const storyElements = get(card, ["story-elements"], []);
315
+
316
+ storyElements.forEach((element) => {
317
+ if (element.type !== "text") return;
318
+ if (element.subtype !== "q-and-a") return;
319
+
320
+ const metadataQuestion = get(element, ["metadata", "question"], "");
321
+ const metadataAnswer = get(element, ["metadata", "answer"], "");
322
+
323
+ if (metadataQuestion && metadataAnswer) {
324
+ const metadataFaqItem = getFaqQuestionItem(metadataQuestion, metadataAnswer);
325
+ if (metadataFaqItem) faqItems.push(metadataFaqItem);
326
+ }
327
+ });
328
+ });
329
+
330
+ return faqItems;
331
+ }
332
+
293
333
  function generateLiveBlogPostingData(structuredData = {}, story = {}, publisherConfig = {}, timezone) {
294
334
  const imageWidth = 1200;
295
335
  const imageHeight = 675;
@@ -1050,6 +1090,20 @@ export function StructuredDataTags({ structuredData = {} }, config, pageType, re
1050
1090
  return products.length ? products : null;
1051
1091
  }
1052
1092
 
1093
+ function generateFaqTags() {
1094
+ const mainEntity = getFaqMainEntity(story);
1095
+ if (!mainEntity.length) return null;
1096
+
1097
+ return ldJson("FAQPage", { mainEntity });
1098
+ }
1099
+
1100
+ if (!isStructuredDataEmpty && (pageType === "story-page" || pageType === "story-page-amp")) {
1101
+ const faqTags = generateFaqTags();
1102
+ if (faqTags) {
1103
+ tags.push(faqTags);
1104
+ }
1105
+ }
1106
+
1053
1107
  // All Pages have: Publisher, Site
1054
1108
  // Story Page have : Article/NewsArticle/LiveBlog/Review as appropriate
1055
1109
  return tags;
@@ -662,6 +662,88 @@ describe("StructuredDataTags", function () {
662
662
  });
663
663
  });
664
664
 
665
+ describe("with FAQ schema data", function () {
666
+ it("adds FAQPage schema for q-and-a subtype when both question and answer are in metadata", function () {
667
+ const cards = [
668
+ {
669
+ "story-elements": [
670
+ {
671
+ type: "text",
672
+ subtype: "q-and-a",
673
+ metadata: {
674
+ question: "What is FAQ Schema?",
675
+ answer: "FAQ Schema is structured data that helps search engines understand FAQ content.",
676
+ },
677
+ },
678
+ {
679
+ type: "text",
680
+ subtype: "q-and-a",
681
+ metadata: {
682
+ question: "Is FAQ markup mandatory?",
683
+ answer: "No, it is optional.",
684
+ },
685
+ },
686
+ ],
687
+ },
688
+ ];
689
+
690
+ const string = getSeoMetadata(
691
+ getSeoConfig({ newsArticle: false }),
692
+ {},
693
+ "story-page",
694
+ sampleStoryData(null, cards, sampleAuthorsData()),
695
+ { url: url.parse("/") }
696
+ );
697
+ const ampPageString = getSeoMetadata(
698
+ getSeoConfig({ newsArticle: false }),
699
+ {},
700
+ "story-page-amp",
701
+ sampleStoryData(null, cards, sampleAuthorsData()),
702
+ { url: url.parse("/") }
703
+ );
704
+
705
+ assertContains('"@type":"FAQPage"', string);
706
+ assertContains('"name":"What is FAQ Schema?"', string);
707
+ assertContains('"acceptedAnswer":{"@type":"Answer","text":"FAQ Schema is structured data that helps search engines understand FAQ content."}', string);
708
+ assertContains('"name":"Is FAQ markup mandatory?"', string);
709
+ assertContains('"text":"No, it is optional."', string);
710
+ assertContains('"@type":"FAQPage"', ampPageString);
711
+ });
712
+
713
+ it("does not add FAQPage schema when q-and-a metadata is missing question or answer", function () {
714
+ const cards = [
715
+ {
716
+ "story-elements": [
717
+ {
718
+ type: "text",
719
+ subtype: "q-and-a",
720
+ metadata: {
721
+ question: "What is FAQ Schema?",
722
+ },
723
+ },
724
+ {
725
+ type: "text",
726
+ subtype: "q-and-a",
727
+ metadata: {
728
+ answer: "Only answer, no question.",
729
+ },
730
+ },
731
+ ],
732
+ },
733
+ ];
734
+
735
+ const string = getSeoMetadata(
736
+ getSeoConfig({ newsArticle: false }),
737
+ {},
738
+ "story-page",
739
+ sampleStoryData(null, cards, sampleAuthorsData()),
740
+ { url: url.parse("/") }
741
+ );
742
+
743
+ assertDoesNotContains('"@type":"FAQPage"', string);
744
+ });
745
+ });
746
+
665
747
  describe("articleBody content", function () {
666
748
  const cards = [
667
749
  {