dh-remixer-sdk 0.0.45-d202e64 → 0.0.45
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/package.json +6 -2
- package/scripts/sdk-rewrite/index.mjs +15 -5
- package/scripts/sdk-update/cmd-utils.mjs +14 -4
- package/scripts/sdk-update/fs-utils.mjs +17 -5
- package/scripts/sdk-update/sdk-utils.mjs +2 -2
- package/scripts/sdk-update/vars.mjs +6 -0
- package/scripts/ssg-helmet/build-sitemap-xml.mjs +12 -16
- package/scripts/ssg-helmet/build-sitemap-xml.test.mjs +22 -0
- package/scripts/ssg-helmet/business-hours.mjs +65 -0
- package/scripts/ssg-helmet/business-hours.test.mjs +50 -0
- package/scripts/ssg-helmet/business-ld-json.mjs +138 -0
- package/scripts/ssg-helmet/business-ld-json.test.mjs +213 -0
- package/scripts/ssg-helmet/extract-routes.mjs +102 -1
- package/scripts/ssg-helmet/geocoded-address.mjs +156 -0
- package/scripts/ssg-helmet/geocoded-address.test.mjs +325 -0
- package/scripts/ssg-helmet/html-text.mjs +210 -0
- package/scripts/ssg-helmet/html-text.test.mjs +93 -0
- package/scripts/ssg-helmet/image-size.mjs +50 -0
- package/scripts/ssg-helmet/image-size.test.mjs +29 -0
- package/scripts/ssg-helmet/index.mjs +21 -14
- package/scripts/ssg-helmet/inject-metadata-head.mjs +86 -23
- package/scripts/ssg-helmet/inject-metadata-head.test.mjs +151 -0
- package/scripts/ssg-helmet/integration-fences.mjs +1 -1
- package/scripts/ssg-helmet/map-address.test.mjs +142 -0
- package/scripts/ssg-helmet/no-settings.test.mjs +112 -0
- package/scripts/ssg-helmet/parse-html-for-contact-info.mjs +79 -72
- package/scripts/ssg-helmet/pipeline.test.mjs +233 -0
- package/scripts/ssg-helmet/prerenderer.mjs +43 -0
- package/scripts/ssg-helmet/private-site.test.mjs +101 -0
- package/scripts/ssg-helmet/review.mjs +709 -0
- package/scripts/ssg-helmet/review.test.mjs +250 -0
- package/scripts/ssg-helmet/robots-txt.mjs +67 -3
- package/scripts/ssg-helmet/robots-txt.test.mjs +64 -0
- package/scripts/ssg-helmet/route-patterns.mjs +51 -0
- package/scripts/ssg-helmet/route-patterns.test.mjs +188 -0
- package/scripts/ssg-helmet/run-clock.mjs +14 -0
- package/scripts/ssg-helmet/screenshots.mjs +71 -0
- package/scripts/ssg-helmet/screenshots.test.mjs +100 -0
- package/scripts/ssg-helmet/seo-only-files.test.mjs +67 -0
- package/scripts/ssg-helmet/seo-settings.mjs +120 -0
- package/scripts/ssg-helmet/seo-settings.test.mjs +94 -0
- package/scripts/ssg-helmet/site-files.mjs +185 -0
- package/scripts/ssg-helmet/site-files.test.mjs +169 -0
- package/scripts/ssg-helmet/site-wide-canonical.test.mjs +91 -0
- package/scripts/ssg-helmet/vars.mjs +2 -1
- package/scripts/ssg-helmet/write-llms.mjs +145 -0
- package/scripts/ssg-helmet/write-llms.test.mjs +143 -0
- package/templates/base/filemap.json +1 -0
- package/templates/base/index.tsx +2 -0
- package/templates/base/package.json +1 -1
- package/templates/base/remixer/auth.ts +18 -0
- package/templates/base/remixer/context.ts +20 -0
- package/templates/base/remixer/core.ts +17 -6
- package/templates/base/remixer/data.ts +6 -0
- package/templates/base/remixer/dpop.ts +2 -1
- package/templates/base/remixer/errors.ts +23 -0
- package/templates/base/remixer/seo.tsx +157 -0
- 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
|
|
3
|
+
"version": "0.0.45",
|
|
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
|
}
|
|
@@ -12,6 +12,7 @@ const SDK_ROOT = process.env.REMIXER_SDK_ROOT
|
|
|
12
12
|
: path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
13
13
|
const PROJECT_ROOT = path.resolve(process.env.INIT_CWD ?? process.cwd());
|
|
14
14
|
const BASE_ROOT = path.join(SDK_ROOT, "templates", "base");
|
|
15
|
+
const SDK_PACKAGE_NAMES = ["dh-remixer-sdk", "@dreamhost/remixer-sdk"];
|
|
15
16
|
|
|
16
17
|
function isObj(x) {
|
|
17
18
|
return x != null && typeof x === "object" && !Array.isArray(x);
|
|
@@ -171,15 +172,24 @@ async function main() {
|
|
|
171
172
|
}),
|
|
172
173
|
);
|
|
173
174
|
|
|
174
|
-
// Re-pin
|
|
175
|
+
// Re-pin the SDK under SDK_ROOT's own package name and version (the templates'
|
|
176
|
+
// source), dropping the other name so a site never carries both
|
|
177
|
+
// dh-remixer-sdk and @dreamhost/remixer-sdk
|
|
175
178
|
const projPkg = JSON.parse(await fs.readFile(projPkgPath, "utf8"));
|
|
179
|
+
const dependencies = { ...(projPkg?.dependencies || {}) };
|
|
180
|
+
const devDependencies = { ...(projPkg?.devDependencies || {}) };
|
|
181
|
+
for (const name of SDK_PACKAGE_NAMES) {
|
|
182
|
+
if (name !== sdkPkg.name) {
|
|
183
|
+
delete dependencies[name];
|
|
184
|
+
delete devDependencies[name];
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
dependencies[sdkPkg.name] = currentVersion;
|
|
176
188
|
const newPkg = JSON.stringify(
|
|
177
189
|
{
|
|
178
190
|
...(projPkg || {}),
|
|
179
|
-
dependencies
|
|
180
|
-
|
|
181
|
-
"dh-remixer-sdk": currentVersion,
|
|
182
|
-
},
|
|
191
|
+
dependencies,
|
|
192
|
+
...(projPkg?.devDependencies ? { devDependencies } : {}),
|
|
183
193
|
},
|
|
184
194
|
null,
|
|
185
195
|
2,
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { spawn } from "child_process";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
SDK_PACKAGE,
|
|
4
|
+
SDK_REGISTRY,
|
|
5
|
+
SDK_REGISTRY_TOKEN,
|
|
6
|
+
TARGET_ENVIRONMENT,
|
|
7
|
+
} from "./vars.mjs";
|
|
3
8
|
|
|
4
9
|
const TAG_BY_ENV = {
|
|
5
10
|
production: "latest",
|
|
@@ -17,13 +22,18 @@ export async function getSdkVersion() {
|
|
|
17
22
|
);
|
|
18
23
|
}
|
|
19
24
|
|
|
20
|
-
const res = await fetch(
|
|
25
|
+
const res = await fetch(
|
|
26
|
+
`${SDK_REGISTRY}/${encodeURIComponent(SDK_PACKAGE)}`,
|
|
27
|
+
SDK_REGISTRY_TOKEN
|
|
28
|
+
? { headers: { Authorization: `Bearer ${SDK_REGISTRY_TOKEN}` } }
|
|
29
|
+
: undefined,
|
|
30
|
+
);
|
|
21
31
|
const data = await res.json();
|
|
22
32
|
const sdkVersion = data["dist-tags"]?.[tag];
|
|
23
33
|
|
|
24
34
|
if (!sdkVersion) {
|
|
25
35
|
throw new Error(
|
|
26
|
-
`[sdk-update] No "${tag}" dist-tag published for
|
|
36
|
+
`[sdk-update] No "${tag}" dist-tag published for ${SDK_PACKAGE} on ${SDK_REGISTRY}`,
|
|
27
37
|
);
|
|
28
38
|
}
|
|
29
39
|
|
|
@@ -46,7 +56,7 @@ export async function pullSdk() {
|
|
|
46
56
|
await runCommand(
|
|
47
57
|
"bun",
|
|
48
58
|
"install",
|
|
49
|
-
|
|
59
|
+
`${SDK_PACKAGE}@${sdkVersion}`,
|
|
50
60
|
"--save-exact",
|
|
51
61
|
);
|
|
52
62
|
}
|
|
@@ -2,7 +2,13 @@ import path from "node:path";
|
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import fssync from "node:fs";
|
|
4
4
|
import { jsonMerge } from "./type-utils.mjs";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
BASE_ROOT,
|
|
7
|
+
LEGACY_SDK_PACKAGE,
|
|
8
|
+
PROJECT_ROOT,
|
|
9
|
+
SDK_PACKAGE,
|
|
10
|
+
SDK_ROOT,
|
|
11
|
+
} from "./vars.mjs";
|
|
6
12
|
import { getSdkVersion } from "./cmd-utils.mjs";
|
|
7
13
|
|
|
8
14
|
export async function getTargetPackageJson() {
|
|
@@ -64,13 +70,19 @@ export async function injectSdkPackage() {
|
|
|
64
70
|
const projPkgPath = path.join(projectRoot, "package.json");
|
|
65
71
|
const projPkg = JSON.parse(await fs.readFile(projPkgPath, "utf8"));
|
|
66
72
|
|
|
73
|
+
const dependencies = { ...(projPkg?.dependencies || {}) };
|
|
74
|
+
const devDependencies = { ...(projPkg?.devDependencies || {}) };
|
|
75
|
+
if (SDK_PACKAGE !== LEGACY_SDK_PACKAGE) {
|
|
76
|
+
delete dependencies[LEGACY_SDK_PACKAGE];
|
|
77
|
+
delete devDependencies[LEGACY_SDK_PACKAGE];
|
|
78
|
+
}
|
|
79
|
+
dependencies[SDK_PACKAGE] = sdkVersion;
|
|
80
|
+
|
|
67
81
|
const newPkg = JSON.stringify(
|
|
68
82
|
{
|
|
69
83
|
...(projPkg || {}),
|
|
70
|
-
dependencies
|
|
71
|
-
|
|
72
|
-
"dh-remixer-sdk": sdkVersion,
|
|
73
|
-
},
|
|
84
|
+
dependencies,
|
|
85
|
+
...(projPkg?.devDependencies ? { devDependencies } : {}),
|
|
74
86
|
},
|
|
75
87
|
null,
|
|
76
88
|
2,
|
|
@@ -10,8 +10,8 @@ function assertTemplateExists(templateType) {
|
|
|
10
10
|
const templateRoot = path.join(SDK_ROOT, "templates", templateType);
|
|
11
11
|
if (!fssync.existsSync(templateRoot)) {
|
|
12
12
|
throw new Error(
|
|
13
|
-
`[sdk-update] Template "${templateType}" not found in installed
|
|
14
|
-
`Run:
|
|
13
|
+
`[sdk-update] Template "${templateType}" not found in installed SDK package (${templateRoot}). ` +
|
|
14
|
+
`Run: bun run remixer-sdk:update`,
|
|
15
15
|
);
|
|
16
16
|
}
|
|
17
17
|
}
|
|
@@ -9,3 +9,9 @@ export const SDK_ROOT = path.resolve(
|
|
|
9
9
|
);
|
|
10
10
|
export const PROJECT_ROOT = path.resolve(process.env.INIT_CWD ?? process.cwd());
|
|
11
11
|
export const BASE_ROOT = path.join(SDK_ROOT, "templates", "base");
|
|
12
|
+
export const LEGACY_SDK_PACKAGE = "dh-remixer-sdk";
|
|
13
|
+
export const SDK_PACKAGE = process.env.REMIXER_SDK_PACKAGE || LEGACY_SDK_PACKAGE;
|
|
14
|
+
export const SDK_REGISTRY = (
|
|
15
|
+
process.env.REMIXER_SDK_REGISTRY || "https://registry.npmjs.org"
|
|
16
|
+
).replace(/\/$/, "");
|
|
17
|
+
export const SDK_REGISTRY_TOKEN = process.env.REMIXER_SDK_REGISTRY_TOKEN;
|
|
@@ -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
|
|
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({
|
|
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({
|
|
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
|
|
38
|
+
const byUrl = new Map();
|
|
43
39
|
for (const e of entries) {
|
|
44
|
-
const prev =
|
|
45
|
-
if (!prev || rank[e.kind] > rank[prev.kind])
|
|
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(
|
|
44
|
+
const ordered = Array.from(byUrl.values()).sort((a, b) => {
|
|
49
45
|
const rk = rank[b.kind] - rank[a.kind];
|
|
50
|
-
return rk || a.
|
|
46
|
+
return rk || a.url.localeCompare(b.url);
|
|
51
47
|
});
|
|
52
48
|
|
|
53
|
-
const node = ({
|
|
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(
|
|
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
|
+
}
|