dh-remixer-sdk 0.0.45-bbd866f → 0.0.45-ca64e2c

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 (52) hide show
  1. package/package.json +6 -2
  2. package/scripts/ssg-helmet/build-sitemap-xml.mjs +12 -16
  3. package/scripts/ssg-helmet/build-sitemap-xml.test.mjs +22 -0
  4. package/scripts/ssg-helmet/business-hours.mjs +65 -0
  5. package/scripts/ssg-helmet/business-hours.test.mjs +50 -0
  6. package/scripts/ssg-helmet/business-ld-json.mjs +138 -0
  7. package/scripts/ssg-helmet/business-ld-json.test.mjs +213 -0
  8. package/scripts/ssg-helmet/extract-routes.mjs +102 -1
  9. package/scripts/ssg-helmet/geocoded-address.mjs +156 -0
  10. package/scripts/ssg-helmet/geocoded-address.test.mjs +325 -0
  11. package/scripts/ssg-helmet/html-text.mjs +210 -0
  12. package/scripts/ssg-helmet/html-text.test.mjs +93 -0
  13. package/scripts/ssg-helmet/image-size.mjs +50 -0
  14. package/scripts/ssg-helmet/image-size.test.mjs +29 -0
  15. package/scripts/ssg-helmet/index.mjs +21 -14
  16. package/scripts/ssg-helmet/inject-metadata-head.mjs +86 -23
  17. package/scripts/ssg-helmet/inject-metadata-head.test.mjs +151 -0
  18. package/scripts/ssg-helmet/integration-fences.mjs +1 -1
  19. package/scripts/ssg-helmet/map-address.test.mjs +142 -0
  20. package/scripts/ssg-helmet/no-settings.test.mjs +112 -0
  21. package/scripts/ssg-helmet/parse-html-for-contact-info.mjs +79 -72
  22. package/scripts/ssg-helmet/pipeline.test.mjs +233 -0
  23. package/scripts/ssg-helmet/prerenderer.mjs +43 -0
  24. package/scripts/ssg-helmet/private-site.test.mjs +101 -0
  25. package/scripts/ssg-helmet/review.mjs +709 -0
  26. package/scripts/ssg-helmet/review.test.mjs +250 -0
  27. package/scripts/ssg-helmet/robots-txt.mjs +67 -3
  28. package/scripts/ssg-helmet/robots-txt.test.mjs +64 -0
  29. package/scripts/ssg-helmet/route-patterns.mjs +51 -0
  30. package/scripts/ssg-helmet/route-patterns.test.mjs +188 -0
  31. package/scripts/ssg-helmet/run-clock.mjs +14 -0
  32. package/scripts/ssg-helmet/screenshots.mjs +71 -0
  33. package/scripts/ssg-helmet/screenshots.test.mjs +100 -0
  34. package/scripts/ssg-helmet/seo-only-files.test.mjs +67 -0
  35. package/scripts/ssg-helmet/seo-settings.mjs +120 -0
  36. package/scripts/ssg-helmet/seo-settings.test.mjs +94 -0
  37. package/scripts/ssg-helmet/site-files.mjs +185 -0
  38. package/scripts/ssg-helmet/site-files.test.mjs +169 -0
  39. package/scripts/ssg-helmet/site-wide-canonical.test.mjs +91 -0
  40. package/scripts/ssg-helmet/vars.mjs +2 -1
  41. package/scripts/ssg-helmet/write-llms.mjs +145 -0
  42. package/scripts/ssg-helmet/write-llms.test.mjs +143 -0
  43. package/templates/base/filemap.json +1 -0
  44. package/templates/base/index.tsx +2 -0
  45. package/templates/base/remixer/auth.ts +19 -0
  46. package/templates/base/remixer/context.ts +20 -0
  47. package/templates/base/remixer/core.ts +17 -6
  48. package/templates/base/remixer/data.ts +6 -0
  49. package/templates/base/remixer/dpop.ts +2 -1
  50. package/templates/base/remixer/errors.ts +29 -0
  51. package/templates/base/remixer/seo.tsx +157 -0
  52. package/templates/base/remixer.ts +3 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dh-remixer-sdk",
3
- "version": "0.0.45-bbd866f",
3
+ "version": "0.0.45-ca64e2c",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "templates",
@@ -29,6 +29,10 @@
29
29
  "eslint": "9.0.0",
30
30
  "eslint-plugin-import": "2.32.0",
31
31
  "eslint-plugin-n": "17.24.0",
32
- "globals": "17.4.0"
32
+ "globals": "17.4.0",
33
+ "jsdom": "26.1.0",
34
+ "react": "19.2.0",
35
+ "react-dom": "19.2.0",
36
+ "react-helmet-async": "3.0.0"
33
37
  }
34
38
  }
@@ -1,3 +1,5 @@
1
+ import { canonicalUrl, normalizeRoutePath } from "./seo-settings.mjs";
2
+
1
3
  const PRIORITY_HOME = 1.0;
2
4
  const PRIORITY_NORMAL = 0.8;
3
5
  const PRIORITY_TEMPLATED = 0.6;
@@ -6,18 +8,12 @@ const CHANGEFREQ_HOME = "daily";
6
8
  const CHANGEFREQ_NORMAL = "weekly";
7
9
  const CHANGEFREQ_TEMPLATED = "weekly";
8
10
 
9
- const ensureTrailingSlash = (p) => {
10
- const [base, suffix] = p.split(/([?#].*)/); // keep query/hash intact
11
- const normalized = base.endsWith("/") ? base : base + "/";
12
- return suffix ? normalized + suffix : normalized;
13
- };
14
-
15
11
  export function buildSitemapXml(origin, routes) {
16
12
  if (!/^https?:\/\//i.test(origin)) origin = "https://" + origin;
17
13
  const cleanOrigin = origin.replace(/\/+$/, "");
18
14
  const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
19
15
 
20
- const abs = (route) => `${cleanOrigin}${route === "/" ? "/" : route}`;
16
+ const loc = (route) => canonicalUrl(cleanOrigin, route);
21
17
  const esc = (s) =>
22
18
  String(s)
23
19
  .replace(/&/g, "&")
@@ -29,28 +25,28 @@ export function buildSitemapXml(origin, routes) {
29
25
  const entries = [];
30
26
  for (const item of routes) {
31
27
  if (typeof item === "string") {
32
- entries.push({ path: item, kind: item === "/" ? "home" : "normal" });
28
+ entries.push({ url: loc(item), kind: normalizeRoutePath(item) === "/" ? "home" : "normal" });
33
29
  } else if (item && typeof item === "object") {
34
30
  for (const concrete of Object.values(item)) {
35
31
  if (!Array.isArray(concrete)) continue;
36
- for (const r of concrete) entries.push({ path: r, kind: "templated" });
32
+ for (const r of concrete) entries.push({ url: loc(r), kind: "templated" });
37
33
  }
38
34
  }
39
35
  }
40
36
 
41
37
  const rank = { home: 3, normal: 2, templated: 1 };
42
- const byPath = new Map();
38
+ const byUrl = new Map();
43
39
  for (const e of entries) {
44
- const prev = byPath.get(e.path);
45
- if (!prev || rank[e.kind] > rank[prev.kind]) byPath.set(e.path, e);
40
+ const prev = byUrl.get(e.url);
41
+ if (!prev || rank[e.kind] > rank[prev.kind]) byUrl.set(e.url, e);
46
42
  }
47
43
 
48
- const ordered = Array.from(byPath.values()).sort((a, b) => {
44
+ const ordered = Array.from(byUrl.values()).sort((a, b) => {
49
45
  const rk = rank[b.kind] - rank[a.kind];
50
- return rk || a.path.localeCompare(b.path);
46
+ return rk || a.url.localeCompare(b.url);
51
47
  });
52
48
 
53
- const node = ({ path, kind }) => {
49
+ const node = ({ url, kind }) => {
54
50
  const priority =
55
51
  kind === "home"
56
52
  ? PRIORITY_HOME
@@ -66,7 +62,7 @@ export function buildSitemapXml(origin, routes) {
66
62
  : CHANGEFREQ_NORMAL;
67
63
 
68
64
  return ` <url>
69
- <loc>${esc(abs(ensureTrailingSlash(path)))}</loc>
65
+ <loc>${esc(url)}</loc>
70
66
  <lastmod>${today}</lastmod>
71
67
  <changefreq>${changefreq}</changefreq>
72
68
  <priority>${priority.toFixed(1)}</priority>
@@ -0,0 +1,22 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { buildSitemapXml } from "./build-sitemap-xml.mjs";
4
+
5
+ const ORIGIN = "https://flowremixer.dreamhosters.com";
6
+ const locs = (xml) => [...xml.matchAll(/<loc>([^<]*)<\/loc>/g)].map(([, loc]) => loc);
7
+
8
+ test("the root is listed with a slash and every other page without one", () => {
9
+ assert.deepEqual(locs(buildSitemapXml(ORIGIN, ["/", "/about", "/shop/", "/contact?ref=nav#form"])), [
10
+ `${ORIGIN}/`,
11
+ `${ORIGIN}/about`,
12
+ `${ORIGIN}/contact`,
13
+ `${ORIGIN}/shop`,
14
+ ]);
15
+ });
16
+
17
+ test("templated pages use the same canonical form and a page listed twice appears once", () => {
18
+ const xml = buildSitemapXml("flowremixer.dreamhosters.com/", ["/", "/about/", "/about", { "/product/:id": ["/product/nox-1/", "/product/nox-2"] }]);
19
+
20
+ assert.deepEqual(locs(xml), [`${ORIGIN}/`, `${ORIGIN}/about`, `${ORIGIN}/product/nox-1`, `${ORIGIN}/product/nox-2`]);
21
+ assert.match(xml, new RegExp(`<loc>${ORIGIN}/product/nox-1</loc>\\s*<lastmod>[^<]+</lastmod>\\s*<changefreq>weekly</changefreq>\\s*<priority>0.6</priority>`));
22
+ });
@@ -0,0 +1,65 @@
1
+ const DAY_CODES = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
2
+ const DAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
3
+ const DAY_ABBREVIATIONS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
4
+ const OPENING_TIME_RE = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
5
+ const CLOSING_TIME_RE = /^(?:(?:[01]\d|2[0-3]):[0-5]\d|24:00)$/;
6
+
7
+ function entryProblem(entry) {
8
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return "is not a days/opens/closes entry";
9
+ if (!Array.isArray(entry.days) || entry.days.length === 0) return "has no days";
10
+ const unknown = entry.days.find((day) => !DAY_CODES.includes(day));
11
+ if (unknown !== undefined) return `has an unknown day code ${JSON.stringify(unknown)}`;
12
+ if (typeof entry.opens !== "string" || !OPENING_TIME_RE.test(entry.opens)) {
13
+ return `has a bad opening time ${JSON.stringify(entry.opens ?? null)}`;
14
+ }
15
+ if (typeof entry.closes !== "string" || !CLOSING_TIME_RE.test(entry.closes)) {
16
+ return `has a bad closing time ${JSON.stringify(entry.closes ?? null)}`;
17
+ }
18
+ if (entry.opens === entry.closes) return "opens and closes at the same time";
19
+ return null;
20
+ }
21
+
22
+ export function businessHours(hours) {
23
+ const valid = [];
24
+ const invalid = [];
25
+ (Array.isArray(hours) ? hours : []).forEach((entry, index) => {
26
+ const problem = entryProblem(entry);
27
+ if (problem) invalid.push({ index, problem });
28
+ else valid.push(entry);
29
+ });
30
+ return { valid, invalid };
31
+ }
32
+
33
+ const dayIndexes = (days) =>
34
+ DAY_CODES.map((code, index) => (days.includes(code) ? index : -1)).filter((index) => index >= 0);
35
+
36
+ const schemaClosingTime = ({ opens, closes }) => {
37
+ if (closes !== "24:00") return closes;
38
+ return opens === "00:00" ? "23:59" : "00:00";
39
+ };
40
+
41
+ export const openingHoursSpecification = (entries) =>
42
+ entries.map((entry) => ({
43
+ "@type": "OpeningHoursSpecification",
44
+ dayOfWeek: dayIndexes(entry.days).map((index) => DAY_NAMES[index]),
45
+ opens: entry.opens,
46
+ closes: schemaClosingTime(entry),
47
+ }));
48
+
49
+ function dayRanges(days) {
50
+ const ranges = [];
51
+ for (const index of dayIndexes(days)) {
52
+ const last = ranges.at(-1);
53
+ if (last && index === last.end + 1) last.end = index;
54
+ else ranges.push({ start: index, end: index });
55
+ }
56
+ return ranges
57
+ .map(({ start, end }) => {
58
+ if (start === end) return DAY_ABBREVIATIONS[start];
59
+ const separator = end === start + 1 ? ", " : "–";
60
+ return `${DAY_ABBREVIATIONS[start]}${separator}${DAY_ABBREVIATIONS[end]}`;
61
+ })
62
+ .join(", ");
63
+ }
64
+
65
+ export const hoursLine = (entry) => `${dayRanges(entry.days)} ${entry.opens}–${entry.closes}`;
@@ -0,0 +1,50 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { businessHours, hoursLine, openingHoursSpecification } from "./business-hours.mjs";
4
+
5
+ const HOURS = [
6
+ { days: ["Mo", "Tu", "We", "Th"], opens: "16:00", closes: "24:00" },
7
+ { days: ["Sa", "Fr"], opens: "16:00", closes: "02:00" },
8
+ { days: ["Su"], opens: "00:00", closes: "24:00" },
9
+ ];
10
+
11
+ test("structured hours become opening hours specifications as stored", () => {
12
+ assert.deepEqual(openingHoursSpecification(businessHours(HOURS).valid), [
13
+ { "@type": "OpeningHoursSpecification", dayOfWeek: ["Monday", "Tuesday", "Wednesday", "Thursday"], opens: "16:00", closes: "00:00" },
14
+ { "@type": "OpeningHoursSpecification", dayOfWeek: ["Friday", "Saturday"], opens: "16:00", closes: "02:00" },
15
+ { "@type": "OpeningHoursSpecification", dayOfWeek: ["Sunday"], opens: "00:00", closes: "23:59" },
16
+ ]);
17
+ });
18
+
19
+ test("structurally invalid entries are set aside with the reason", () => {
20
+ const { valid, invalid } = businessHours([
21
+ HOURS[0],
22
+ { days: ["Mx"], opens: "09:00", closes: "17:00" },
23
+ { days: ["Mo"], opens: "9am", closes: "17:00" },
24
+ { days: ["Mo"], opens: "09:00", closes: "25:00" },
25
+ { days: [], opens: "09:00", closes: "17:00" },
26
+ { days: ["Mo"], opens: "09:00", closes: "09:00" },
27
+ "Mon–Fri 9am–5pm",
28
+ ]);
29
+
30
+ assert.deepEqual(valid, [HOURS[0]]);
31
+ assert.deepEqual(invalid, [
32
+ { index: 1, problem: 'has an unknown day code "Mx"' },
33
+ { index: 2, problem: 'has a bad opening time "9am"' },
34
+ { index: 3, problem: 'has a bad closing time "25:00"' },
35
+ { index: 4, problem: "has no days" },
36
+ { index: 5, problem: "opens and closes at the same time" },
37
+ { index: 6, problem: "is not a days/opens/closes entry" },
38
+ ]);
39
+ });
40
+
41
+ test("a missing or empty hours list is no hours", () => {
42
+ assert.deepEqual(businessHours(undefined), { valid: [], invalid: [] });
43
+ assert.deepEqual(businessHours([]), { valid: [], invalid: [] });
44
+ });
45
+
46
+ test("hours read as English day abbreviations for llms.txt", () => {
47
+ assert.equal(hoursLine(HOURS[0]), "Mon–Thu 16:00–24:00");
48
+ assert.equal(hoursLine(HOURS[1]), "Fri, Sat 16:00–02:00");
49
+ assert.equal(hoursLine({ days: ["Mo", "We", "Fr", "Sa", "Su"], opens: "09:00", closes: "17:00" }), "Mon, Wed, Fri–Sun 09:00–17:00");
50
+ });
@@ -0,0 +1,138 @@
1
+ import { textValue } from "./seo-settings.mjs";
2
+ import { businessHours, openingHoursSpecification } from "./business-hours.mjs";
3
+ import { locatedAddress } from "./geocoded-address.mjs";
4
+
5
+ const SCHEMA_CONTEXT = "https://schema.org";
6
+ const PRICE_RANGES = new Set(["$", "$$", "$$$"]);
7
+ const BUSINESS_SCHEMA_TYPES = {
8
+ restaurant: "Restaurant",
9
+ cafe: "CafeOrCoffeeShop",
10
+ bar: "BarOrPub",
11
+ bakery: "Bakery",
12
+ shop: "Store",
13
+ "online store": "OnlineStore",
14
+ florist: "Florist",
15
+ "salon or spa": "BeautySalon",
16
+ "fitness studio": "ExerciseGym",
17
+ "health clinic": "MedicalClinic",
18
+ "repair shop": "LocalBusiness",
19
+ "home services": "HomeAndConstructionBusiness",
20
+ "professional services": "ProfessionalService",
21
+ photographer: "ProfessionalService",
22
+ "event venue": "EventVenue",
23
+ nonprofit: "NGO",
24
+ "music venue": ["LocalBusiness", "MusicVenue"],
25
+ "service business": "ProfessionalService",
26
+ };
27
+
28
+ const typeKey = (type) =>
29
+ String(type ?? "")
30
+ .normalize("NFD")
31
+ .replace(/[̀-ͯ]/g, "")
32
+ .trim()
33
+ .toLowerCase();
34
+
35
+ const isEmpty = (value) =>
36
+ value === undefined ||
37
+ value === null ||
38
+ value === "" ||
39
+ (Array.isArray(value) && value.length === 0) ||
40
+ (typeof value === "object" &&
41
+ !Array.isArray(value) &&
42
+ Object.keys(value).every((key) => key === "@type" || isEmpty(value[key])));
43
+
44
+ const compact = (node) =>
45
+ Object.fromEntries(Object.entries(node).filter(([, value]) => !isEmpty(value)));
46
+
47
+ const httpUrls = (links) =>
48
+ (Array.isArray(links) ? links : [])
49
+ .map(textValue)
50
+ .filter((link) => link && /^https?:\/\//i.test(link));
51
+
52
+ export function hasBusinessFacts(business) {
53
+ if (!business || typeof business !== "object") return false;
54
+ return (
55
+ ["name", "type", "address", "phone", "email", "hoursNote", "priceRange", "serviceArea", "siteIconUrl"].some(
56
+ (key) => textValue(business[key]),
57
+ ) ||
58
+ httpUrls(business.links).length > 0 ||
59
+ (Array.isArray(business.hours) && business.hours.length > 0)
60
+ );
61
+ }
62
+
63
+ export function businessSchemaType(type, { hasLocalDetails, fallback = "Organization" } = {}) {
64
+ const mapped = BUSINESS_SCHEMA_TYPES[typeKey(type)];
65
+ if (mapped) return mapped;
66
+ return hasLocalDetails ? "LocalBusiness" : fallback;
67
+ }
68
+
69
+ function scrapedCoordinates(scraped) {
70
+ const latitude = scraped?.geo?.latitude;
71
+ const longitude = scraped?.geo?.longitude;
72
+ if (latitude === undefined || longitude === undefined) return undefined;
73
+ return { "@type": "GeoCoordinates", latitude, longitude };
74
+ }
75
+
76
+ export function businessNode({ business, origin, scraped, geocoded, fallbackType }) {
77
+ const hours = openingHoursSpecification(businessHours(business.hours).valid);
78
+ const priceRange = PRICE_RANGES.has(business.priceRange) ? business.priceRange : undefined;
79
+ const address = textValue(business.address);
80
+ const located = locatedAddress(address, geocoded);
81
+ const scrapedAddress = scraped?.address ? compact(scraped.address) : undefined;
82
+
83
+ return compact({
84
+ "@type": businessSchemaType(business.type, {
85
+ hasLocalDetails: hours.length > 0 || Boolean(priceRange),
86
+ fallback: fallbackType,
87
+ }),
88
+ "@id": `${origin}/#business`,
89
+ name: textValue(business.name),
90
+ url: `${origin}/`,
91
+ logo: textValue(business.siteIconUrl),
92
+ telephone: textValue(business.phone) ?? scraped?.telephone,
93
+ email: textValue(business.email),
94
+ address: located.address ?? scrapedAddress,
95
+ geo: address ? located.geo : scrapedCoordinates(scraped),
96
+ areaServed: textValue(business.serviceArea) ?? scraped?.areaServed?.[0]?.name,
97
+ openingHoursSpecification: hours,
98
+ priceRange,
99
+ sameAs: httpUrls(business.links),
100
+ });
101
+ }
102
+
103
+ export function faqNode(pairs, pageUrl) {
104
+ return {
105
+ "@type": "FAQPage",
106
+ "@id": `${pageUrl}#faq`,
107
+ mainEntity: pairs.map(({ question, answer }) => ({
108
+ "@type": "Question",
109
+ name: question,
110
+ acceptedAnswer: { "@type": "Answer", text: answer },
111
+ })),
112
+ };
113
+ }
114
+
115
+ export function businessPageLdJson({ page, business, questions, pageUrl }) {
116
+ const graph = [
117
+ compact({
118
+ "@type": "WebPage",
119
+ "@id": `${pageUrl}#webpage`,
120
+ url: pageUrl,
121
+ name: page.name,
122
+ description: page.description,
123
+ about: { "@id": business["@id"] },
124
+ }),
125
+ business,
126
+ ];
127
+ if (questions.length) graph.push(faqNode(questions, pageUrl));
128
+ return { "@context": SCHEMA_CONTEXT, "@graph": graph };
129
+ }
130
+
131
+ export function withFaq(pageLdJson, questions, pageUrl) {
132
+ if (!questions.length) return pageLdJson;
133
+ const { "@context": context, ...page } = pageLdJson;
134
+ return {
135
+ "@context": context ?? SCHEMA_CONTEXT,
136
+ "@graph": [page, faqNode(questions, pageUrl)],
137
+ };
138
+ }
@@ -0,0 +1,213 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ businessNode,
5
+ businessPageLdJson,
6
+ businessSchemaType,
7
+ hasBusinessFacts,
8
+ withFaq,
9
+ } from "./business-ld-json.mjs";
10
+
11
+ const ORIGIN = "https://wiltonhouse.com";
12
+ const BUSINESS = {
13
+ name: "Wilton House",
14
+ type: "Bar",
15
+ address: "123 Main St, Portland, OR",
16
+ phone: "+1 503 555 0100",
17
+ email: "hello@wiltonhouse.com",
18
+ hours: [
19
+ { days: ["Mo", "Tu", "We", "Th"], opens: "16:00", closes: "24:00" },
20
+ { days: ["Fr", "Sa"], opens: "16:00", closes: "02:00" },
21
+ { days: ["Xx"], opens: "16:00", closes: "02:00" },
22
+ ],
23
+ priceRange: null,
24
+ serviceArea: "Portland metro",
25
+ links: ["https://instagram.com/wiltonhouse", "not a url"],
26
+ siteIconUrl: "https://cdn.example/icon.png",
27
+ };
28
+
29
+ const EDITOR_BUSINESS_TYPES = {
30
+ Restaurant: "Restaurant",
31
+ "Café": "CafeOrCoffeeShop",
32
+ Bar: "BarOrPub",
33
+ Bakery: "Bakery",
34
+ Shop: "Store",
35
+ "Online store": "OnlineStore",
36
+ Florist: "Florist",
37
+ "Salon or spa": "BeautySalon",
38
+ "Fitness studio": "ExerciseGym",
39
+ "Health clinic": "MedicalClinic",
40
+ "Repair shop": "LocalBusiness",
41
+ "Home services": "HomeAndConstructionBusiness",
42
+ "Professional services": "ProfessionalService",
43
+ Photographer: "ProfessionalService",
44
+ "Event venue": "EventVenue",
45
+ Nonprofit: "NGO",
46
+ };
47
+
48
+ test("every editor business type maps to its schema.org type", () => {
49
+ for (const [type, schemaType] of Object.entries(EDITOR_BUSINESS_TYPES)) {
50
+ assert.equal(businessSchemaType(type), schemaType, type);
51
+ }
52
+ });
53
+
54
+ test("business types match without accents or case, and the extra keys stay", () => {
55
+ assert.equal(businessSchemaType("cafe"), "CafeOrCoffeeShop");
56
+ assert.equal(businessSchemaType(" SALON OR SPA "), "BeautySalon");
57
+ assert.equal(businessSchemaType("online Store"), "OnlineStore");
58
+ assert.deepEqual(businessSchemaType("Music venue"), ["LocalBusiness", "MusicVenue"]);
59
+ assert.equal(businessSchemaType("Service business"), "ProfessionalService");
60
+ });
61
+
62
+ test("a custom or unknown business type keeps the fallback", () => {
63
+ assert.equal(businessSchemaType("Dog groomer"), "Organization");
64
+ assert.equal(businessSchemaType("Dog groomer", { hasLocalDetails: true }), "LocalBusiness");
65
+ assert.equal(businessSchemaType(null, { hasLocalDetails: true }), "LocalBusiness");
66
+ assert.equal(businessSchemaType(null, { fallback: "OnlineStore" }), "OnlineStore");
67
+ });
68
+
69
+ test("the business node carries the owner's facts and no invented price range", () => {
70
+ const node = businessNode({ business: BUSINESS, origin: ORIGIN, scraped: {} });
71
+
72
+ assert.equal(node["@type"], "BarOrPub");
73
+ assert.equal(node["@id"], "https://wiltonhouse.com/#business");
74
+ assert.equal(node.name, "Wilton House");
75
+ assert.equal(node.telephone, "+1 503 555 0100");
76
+ assert.equal(node.email, "hello@wiltonhouse.com");
77
+ assert.deepEqual(node.address, { "@type": "PostalAddress", streetAddress: "123 Main St, Portland, OR" });
78
+ assert.equal(node.areaServed, "Portland metro");
79
+ assert.deepEqual(node.sameAs, ["https://instagram.com/wiltonhouse"]);
80
+ assert.equal(node.logo, "https://cdn.example/icon.png");
81
+ assert.deepEqual(node.openingHoursSpecification, [
82
+ { "@type": "OpeningHoursSpecification", dayOfWeek: ["Monday", "Tuesday", "Wednesday", "Thursday"], opens: "16:00", closes: "00:00" },
83
+ { "@type": "OpeningHoursSpecification", dayOfWeek: ["Friday", "Saturday"], opens: "16:00", closes: "02:00" },
84
+ ]);
85
+ assert.equal("priceRange" in node, false);
86
+ assert.equal("geo" in node, false);
87
+ });
88
+
89
+ test("a chosen price range is kept, an invalid one is dropped", () => {
90
+ assert.equal(businessNode({ business: { ...BUSINESS, priceRange: "$$$" }, origin: ORIGIN }).priceRange, "$$$");
91
+ assert.equal("priceRange" in businessNode({ business: { ...BUSINESS, priceRange: "cheap" }, origin: ORIGIN }), false);
92
+ });
93
+
94
+ test("a trusted geocoder match adds the structured parts and coordinates to the owner's street", () => {
95
+ const node = businessNode({
96
+ business: BUSINESS,
97
+ origin: ORIGIN,
98
+ geocoded: {
99
+ streetNumber: "123",
100
+ locality: "Portland",
101
+ region: "OR",
102
+ regionName: "Oregon",
103
+ postCode: "97204",
104
+ country: "US",
105
+ countryName: "United States",
106
+ partialMatch: false,
107
+ precise: true,
108
+ latitude: 45.5,
109
+ longitude: -122.6,
110
+ },
111
+ });
112
+
113
+ assert.deepEqual(node.address, {
114
+ "@type": "PostalAddress",
115
+ streetAddress: "123 Main St",
116
+ addressLocality: "Portland",
117
+ addressRegion: "OR",
118
+ postalCode: "97204",
119
+ addressCountry: "US",
120
+ });
121
+ assert.deepEqual(node.geo, { "@type": "GeoCoordinates", latitude: 45.5, longitude: -122.6 });
122
+ });
123
+
124
+ test("a geocoder match on another street never replaces the owner's address", () => {
125
+ const node = businessNode({
126
+ business: { ...BUSINESS, address: "1 QA Test Street, Portland, OR 97201" },
127
+ origin: ORIGIN,
128
+ geocoded: {
129
+ streetNumber: "116",
130
+ locality: "Portland",
131
+ region: "OR",
132
+ regionName: "Oregon",
133
+ postCode: "97201",
134
+ country: "US",
135
+ countryName: "United States",
136
+ partialMatch: true,
137
+ precise: true,
138
+ latitude: 45.51,
139
+ longitude: -122.68,
140
+ },
141
+ });
142
+
143
+ assert.deepEqual(node.address, {
144
+ "@type": "PostalAddress",
145
+ streetAddress: "1 QA Test Street",
146
+ addressLocality: "Portland",
147
+ addressRegion: "OR",
148
+ postalCode: "97201",
149
+ });
150
+ assert.equal("geo" in node, false);
151
+ });
152
+
153
+ test("scraped phone and address fill fields the owner left empty", () => {
154
+ const scraped = {
155
+ telephone: "+1 503 555 0199",
156
+ address: { "@type": "PostalAddress", streetAddress: "9 Side St", addressLocality: undefined },
157
+ geo: { "@type": "GeoCoordinates", latitude: 1, longitude: 2 },
158
+ areaServed: [{ "@type": "City", name: "Portland" }],
159
+ };
160
+ const node = businessNode({ business: { name: "Wilton House" }, origin: ORIGIN, scraped });
161
+
162
+ assert.equal(node["@type"], "Organization");
163
+ assert.equal(node.telephone, "+1 503 555 0199");
164
+ assert.deepEqual(node.address, { "@type": "PostalAddress", streetAddress: "9 Side St" });
165
+ assert.deepEqual(node.geo, { "@type": "GeoCoordinates", latitude: 1, longitude: 2 });
166
+ assert.equal(node.areaServed, "Portland");
167
+ });
168
+
169
+ test("a scraped address without a street line keeps its confirmed locality, region and postcode", () => {
170
+ const scraped = {
171
+ address: { "@type": "PostalAddress", streetAddress: undefined, addressLocality: "Plano", addressRegion: "TX", postalCode: "75074" },
172
+ geo: { "@type": "GeoCoordinates", latitude: undefined, longitude: undefined },
173
+ };
174
+ const node = businessNode({ business: { name: "Xpress Oil & Auto" }, origin: ORIGIN, scraped });
175
+
176
+ assert.deepEqual(node.address, { "@type": "PostalAddress", addressLocality: "Plano", addressRegion: "TX", postalCode: "75074" });
177
+ assert.equal("geo" in node, false);
178
+ assert.equal("address" in businessNode({ business: { name: "Xpress Oil & Auto" }, origin: ORIGIN, scraped: { address: { "@type": "PostalAddress" } } }), false);
179
+ });
180
+
181
+ test("all-default business settings are no business facts", () => {
182
+ assert.equal(hasBusinessFacts({ name: null, type: null, hours: [], links: [], yours: {} }), false);
183
+ assert.equal(hasBusinessFacts({ hours: [{ days: ["Mo"], opens: "09:00", closes: "17:00" }] }), true);
184
+ assert.equal(hasBusinessFacts({ hours: [], hoursNote: "Closed on public holidays" }), true);
185
+ assert.equal(hasBusinessFacts(undefined), false);
186
+ });
187
+
188
+ test("a page graph links the page to the business and adds questions when present", () => {
189
+ const business = businessNode({ business: BUSINESS, origin: ORIGIN });
190
+ const questions = [{ question: "Do you take reservations?", answer: "Yes, for six or more." }];
191
+ const ld = businessPageLdJson({ page: { name: "About", description: "Our story" }, business, questions, pageUrl: `${ORIGIN}/about` });
192
+
193
+ assert.equal(ld["@context"], "https://schema.org");
194
+ assert.deepEqual(ld["@graph"][0], {
195
+ "@type": "WebPage",
196
+ "@id": "https://wiltonhouse.com/about#webpage",
197
+ url: "https://wiltonhouse.com/about",
198
+ name: "About",
199
+ description: "Our story",
200
+ about: { "@id": "https://wiltonhouse.com/#business" },
201
+ });
202
+ assert.equal(ld["@graph"][1], business);
203
+ assert.equal(ld["@graph"][2]["@type"], "FAQPage");
204
+ assert.equal(ld["@graph"][2].mainEntity[0].acceptedAnswer.text, "Yes, for six or more.");
205
+ assert.doesNotMatch(JSON.stringify(ld), /"\$\$"/);
206
+ });
207
+
208
+ test("without questions the fallback page object is left exactly as it was", () => {
209
+ const page = { "@context": "https://schema.org", "@type": "WebPage", name: "Home" };
210
+
211
+ assert.equal(withFaq(page, [], ORIGIN), page);
212
+ assert.deepEqual(withFaq(page, [{ question: "Q?", answer: "A a a" }], ORIGIN)["@graph"][0], { "@type": "WebPage", name: "Home" });
213
+ });