ssrwire 0.2.0 → 0.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.
- package/CHANGELOG.md +24 -1
- package/CONTRIBUTING.md +1 -0
- package/PUBLISHING.md +15 -15
- package/README.md +70 -23
- package/dist/analyze.d.ts.map +1 -1
- package/dist/analyze.js +162 -6
- package/dist/analyze.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +3 -1
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +4 -0
- package/dist/config.js.map +1 -1
- package/dist/http-probe.d.ts.map +1 -1
- package/dist/http-probe.js +4 -0
- package/dist/http-probe.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/redact.d.ts.map +1 -1
- package/dist/redact.js +5 -0
- package/dist/redact.js.map +1 -1
- package/dist/reporters.d.ts.map +1 -1
- package/dist/reporters.js +27 -0
- package/dist/reporters.js.map +1 -1
- package/dist/social.d.ts +14 -0
- package/dist/social.d.ts.map +1 -0
- package/dist/social.js +88 -0
- package/dist/social.js.map +1 -0
- package/dist/stability.d.ts.map +1 -1
- package/dist/stability.js +84 -4
- package/dist/stability.js.map +1 -1
- package/dist/stream-parser.d.ts.map +1 -1
- package/dist/stream-parser.js +21 -1
- package/dist/stream-parser.js.map +1 -1
- package/dist/types.d.ts +10 -0
- package/dist/types.d.ts.map +1 -1
- package/examples/github-actions.yml +2 -2
- package/examples/ssrwire.config.yml +4 -0
- package/package.json +5 -2
- package/src/analyze.ts +208 -7
- package/src/cli.ts +3 -1
- package/src/config.ts +4 -0
- package/src/http-probe.ts +4 -0
- package/src/index.ts +2 -0
- package/src/redact.ts +5 -0
- package/src/reporters.ts +46 -0
- package/src/social.ts +116 -0
- package/src/stability.ts +123 -4
- package/src/stream-parser.ts +31 -1
- package/src/types.ts +21 -0
package/src/analyze.ts
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
import {
|
|
2
|
+
effectiveTwitterCardSignal,
|
|
3
|
+
firstSocialSignal,
|
|
4
|
+
isAbsoluteHttpSocialUrl,
|
|
5
|
+
normalizeSocialValue,
|
|
6
|
+
OPEN_GRAPH_PROPERTIES,
|
|
7
|
+
OPEN_GRAPH_REQUIRED_PROPERTIES,
|
|
8
|
+
socialSignals,
|
|
9
|
+
TWITTER_CARD_REQUIRED_FIELDS,
|
|
10
|
+
TWITTER_PROPERTIES,
|
|
11
|
+
} from "./social.js";
|
|
1
12
|
import type {
|
|
2
13
|
AuditSummary,
|
|
3
14
|
AuditTarget,
|
|
@@ -7,6 +18,8 @@ import type {
|
|
|
7
18
|
RobotsAudience,
|
|
8
19
|
RobotsSignal,
|
|
9
20
|
Severity,
|
|
21
|
+
SocialMetadataProperty,
|
|
22
|
+
SocialMetadataSignal,
|
|
10
23
|
TargetAuditResult,
|
|
11
24
|
} from "./types.js";
|
|
12
25
|
|
|
@@ -264,6 +277,146 @@ function checkRepeatedRobots(findings: Finding[], targetUrl: string, probe: Prob
|
|
|
264
277
|
}
|
|
265
278
|
}
|
|
266
279
|
|
|
280
|
+
function auditedSocialProperties(
|
|
281
|
+
target: AuditTarget,
|
|
282
|
+
probe: ProbeResult,
|
|
283
|
+
): readonly SocialMetadataProperty[] {
|
|
284
|
+
const properties = new Set<SocialMetadataProperty>();
|
|
285
|
+
if (target.expectations.requireOpenGraph === true) {
|
|
286
|
+
for (const property of OPEN_GRAPH_PROPERTIES) properties.add(property);
|
|
287
|
+
}
|
|
288
|
+
if (target.expectations.requireTwitterCard === true) {
|
|
289
|
+
for (const property of TWITTER_PROPERTIES) properties.add(property);
|
|
290
|
+
if (firstSocialSignal(probe.signals, "twitter:title") === undefined) {
|
|
291
|
+
properties.add("og:title");
|
|
292
|
+
}
|
|
293
|
+
if (firstSocialSignal(probe.signals, "twitter:description") === undefined) {
|
|
294
|
+
properties.add("og:description");
|
|
295
|
+
}
|
|
296
|
+
if (firstSocialSignal(probe.signals, "twitter:image") === undefined) {
|
|
297
|
+
properties.add("og:image");
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return [...properties];
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function normalizedSocialValues(
|
|
304
|
+
property: SocialMetadataProperty,
|
|
305
|
+
signals: readonly SocialMetadataSignal[],
|
|
306
|
+
baseUrl: string,
|
|
307
|
+
): readonly string[] {
|
|
308
|
+
return signals
|
|
309
|
+
.map((signal) => normalizeSocialValue(property, signal.value, baseUrl))
|
|
310
|
+
.filter((value) => value.length > 0);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function checkRepeatedSocialMetadata(
|
|
314
|
+
findings: Finding[],
|
|
315
|
+
target: AuditTarget,
|
|
316
|
+
probe: ProbeResult,
|
|
317
|
+
): void {
|
|
318
|
+
for (const property of auditedSocialProperties(target, probe)) {
|
|
319
|
+
// Open Graph explicitly permits multiple images and gives the first one precedence.
|
|
320
|
+
if (property === "og:image") continue;
|
|
321
|
+
const values = normalizedSocialValues(
|
|
322
|
+
property,
|
|
323
|
+
socialSignals(probe.signals, property),
|
|
324
|
+
probe.finalUrl,
|
|
325
|
+
);
|
|
326
|
+
if (values.length < 2) continue;
|
|
327
|
+
|
|
328
|
+
const distinct = new Set(values);
|
|
329
|
+
const conflicting = distinct.size > 1;
|
|
330
|
+
addFinding(findings, target.url, {
|
|
331
|
+
code: conflicting ? "conflicting-social-metadata" : "duplicate-social-metadata",
|
|
332
|
+
severity: "warning",
|
|
333
|
+
message: conflicting
|
|
334
|
+
? `${probe.agent.label} received conflicting ${property} values.`
|
|
335
|
+
: `${probe.agent.label} received duplicate ${property} values.`,
|
|
336
|
+
agent: probe.agent.key,
|
|
337
|
+
evidence: {
|
|
338
|
+
property,
|
|
339
|
+
count: values.length,
|
|
340
|
+
distinctValues: distinct.size,
|
|
341
|
+
values: [...distinct].join(" | "),
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function checkSocialUrls(findings: Finding[], target: AuditTarget, probe: ProbeResult): void {
|
|
348
|
+
const candidates = new Set<SocialMetadataSignal>();
|
|
349
|
+
if (target.expectations.requireOpenGraph === true) {
|
|
350
|
+
for (const signal of socialSignals(probe.signals, "og:url")) candidates.add(signal);
|
|
351
|
+
for (const signal of socialSignals(probe.signals, "og:image")) candidates.add(signal);
|
|
352
|
+
}
|
|
353
|
+
if (target.expectations.requireTwitterCard === true) {
|
|
354
|
+
const image = effectiveTwitterCardSignal(probe.signals, "image");
|
|
355
|
+
if (image !== undefined) candidates.add(image);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
for (const signal of candidates) {
|
|
359
|
+
if (signal.value.trim().length === 0 || isAbsoluteHttpSocialUrl(signal.value)) continue;
|
|
360
|
+
addFinding(findings, target.url, {
|
|
361
|
+
code: "invalid-social-metadata-url",
|
|
362
|
+
severity: "warning",
|
|
363
|
+
message: `${probe.agent.label} received a non-absolute HTTP(S) ${signal.property} URL.`,
|
|
364
|
+
agent: probe.agent.key,
|
|
365
|
+
evidence: { property: signal.property, value: signal.value },
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function checkRequiredSocialMetadata(
|
|
371
|
+
findings: Finding[],
|
|
372
|
+
target: AuditTarget,
|
|
373
|
+
probe: ProbeResult,
|
|
374
|
+
): void {
|
|
375
|
+
if (probe.completion !== "complete") return;
|
|
376
|
+
|
|
377
|
+
if (target.expectations.requireOpenGraph === true) {
|
|
378
|
+
const missing = OPEN_GRAPH_REQUIRED_PROPERTIES.filter(
|
|
379
|
+
(property) => firstSocialSignal(probe.signals, property) === undefined,
|
|
380
|
+
);
|
|
381
|
+
if (missing.length > 0) {
|
|
382
|
+
addFinding(findings, target.url, {
|
|
383
|
+
code: "missing-open-graph-metadata",
|
|
384
|
+
severity: "warning",
|
|
385
|
+
message: `${probe.agent.label} received an incomplete Open Graph metadata set.`,
|
|
386
|
+
agent: probe.agent.key,
|
|
387
|
+
evidence: { fields: missing.join(", ") },
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (target.expectations.requireTwitterCard === true) {
|
|
393
|
+
const missing = TWITTER_CARD_REQUIRED_FIELDS.filter(
|
|
394
|
+
(field) => effectiveTwitterCardSignal(probe.signals, field) === undefined,
|
|
395
|
+
);
|
|
396
|
+
if (missing.length > 0) {
|
|
397
|
+
addFinding(findings, target.url, {
|
|
398
|
+
code: "missing-twitter-card-metadata",
|
|
399
|
+
severity: "warning",
|
|
400
|
+
message: `${probe.agent.label} received an incomplete Twitter Card metadata set.`,
|
|
401
|
+
agent: probe.agent.key,
|
|
402
|
+
evidence: { fields: missing.join(", ") },
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function checkSocialMetadata(findings: Finding[], target: AuditTarget, probe: ProbeResult): void {
|
|
409
|
+
if (
|
|
410
|
+
target.expectations.requireOpenGraph !== true &&
|
|
411
|
+
target.expectations.requireTwitterCard !== true
|
|
412
|
+
) {
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
checkRequiredSocialMetadata(findings, target, probe);
|
|
416
|
+
checkRepeatedSocialMetadata(findings, target, probe);
|
|
417
|
+
checkSocialUrls(findings, target, probe);
|
|
418
|
+
}
|
|
419
|
+
|
|
267
420
|
function criticalArrivalMs(target: AuditTarget, probe: ProbeResult): number | undefined {
|
|
268
421
|
const marks: number[] = [];
|
|
269
422
|
const { expectations } = target;
|
|
@@ -299,6 +452,20 @@ function criticalArrivalMs(target: AuditTarget, probe: ProbeResult): number | un
|
|
|
299
452
|
) {
|
|
300
453
|
marks.push(probe.signals.firstMainText?.atMs ?? 0);
|
|
301
454
|
}
|
|
455
|
+
if (expectations.requireOpenGraph === true) {
|
|
456
|
+
const signals = OPEN_GRAPH_REQUIRED_PROPERTIES.map((property) =>
|
|
457
|
+
firstSocialSignal(probe.signals, property),
|
|
458
|
+
);
|
|
459
|
+
if (!signals.every((signal) => signal !== undefined)) return undefined;
|
|
460
|
+
marks.push(...signals.map((signal) => signal.atMs));
|
|
461
|
+
}
|
|
462
|
+
if (expectations.requireTwitterCard === true) {
|
|
463
|
+
const signals = TWITTER_CARD_REQUIRED_FIELDS.map((field) =>
|
|
464
|
+
effectiveTwitterCardSignal(probe.signals, field),
|
|
465
|
+
);
|
|
466
|
+
if (!signals.every((signal) => signal !== undefined)) return undefined;
|
|
467
|
+
marks.push(...signals.map((signal) => signal.atMs));
|
|
468
|
+
}
|
|
302
469
|
|
|
303
470
|
return marks.length === 0 ? undefined : Math.max(...marks);
|
|
304
471
|
}
|
|
@@ -365,7 +532,7 @@ function checkRequiredSignals(findings: Finding[], target: AuditTarget, probe: P
|
|
|
365
532
|
}
|
|
366
533
|
}
|
|
367
534
|
|
|
368
|
-
function checkHeadRequirements(findings: Finding[],
|
|
535
|
+
function checkHeadRequirements(findings: Finding[], target: AuditTarget, probe: ProbeResult): void {
|
|
369
536
|
if (!probe.agent.requiresHeadMetadata) {
|
|
370
537
|
return;
|
|
371
538
|
}
|
|
@@ -384,12 +551,17 @@ function checkHeadRequirements(findings: Finding[], targetUrl: string, probe: Pr
|
|
|
384
551
|
if (effectiveRobotsSignals(probe).some((signal) => signal.location === "body")) {
|
|
385
552
|
bodyFields.add("robots");
|
|
386
553
|
}
|
|
554
|
+
for (const property of auditedSocialProperties(target, probe)) {
|
|
555
|
+
if (socialSignals(probe.signals, property).some((signal) => signal.location === "body")) {
|
|
556
|
+
bodyFields.add(property);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
387
559
|
|
|
388
560
|
if (bodyFields.size === 0) {
|
|
389
561
|
return;
|
|
390
562
|
}
|
|
391
563
|
|
|
392
|
-
addFinding(findings,
|
|
564
|
+
addFinding(findings, target.url, {
|
|
393
565
|
code: "head-metadata-in-body",
|
|
394
566
|
severity: "error",
|
|
395
567
|
message: `${probe.agent.label} requires head metadata but received ${[...bodyFields].join(
|
|
@@ -440,9 +612,31 @@ function agentValueSummary(
|
|
|
440
612
|
return probes.map((probe) => `${probe.agent.key}=${valueFor(probe)}`).join("; ");
|
|
441
613
|
}
|
|
442
614
|
|
|
615
|
+
function openGraphValue(probe: ProbeResult): string {
|
|
616
|
+
return OPEN_GRAPH_PROPERTIES.map((property) => {
|
|
617
|
+
const values = [
|
|
618
|
+
...new Set(
|
|
619
|
+
normalizedSocialValues(property, socialSignals(probe.signals, property), probe.finalUrl),
|
|
620
|
+
),
|
|
621
|
+
];
|
|
622
|
+
return `${property}=${values.join(" | ") || "<missing>"}`;
|
|
623
|
+
}).join(", ");
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function twitterCardValue(probe: ProbeResult): string {
|
|
627
|
+
return TWITTER_CARD_REQUIRED_FIELDS.map((field) => {
|
|
628
|
+
const signal = effectiveTwitterCardSignal(probe.signals, field);
|
|
629
|
+
const value =
|
|
630
|
+
signal === undefined
|
|
631
|
+
? "<missing>"
|
|
632
|
+
: normalizeSocialValue(signal.property, signal.value, probe.finalUrl);
|
|
633
|
+
return `${field}=${value || "<missing>"}`;
|
|
634
|
+
}).join(", ");
|
|
635
|
+
}
|
|
636
|
+
|
|
443
637
|
function checkAgentDrift(
|
|
444
638
|
findings: Finding[],
|
|
445
|
-
|
|
639
|
+
target: AuditTarget,
|
|
446
640
|
probes: readonly ProbeResult[],
|
|
447
641
|
): void {
|
|
448
642
|
const complete = probes.filter((probe) => probe.completion === "complete");
|
|
@@ -450,7 +644,7 @@ function checkAgentDrift(
|
|
|
450
644
|
return;
|
|
451
645
|
}
|
|
452
646
|
|
|
453
|
-
const comparisons:
|
|
647
|
+
const comparisons: {
|
|
454
648
|
readonly field: string;
|
|
455
649
|
readonly valueFor: (probe: ProbeResult) => string;
|
|
456
650
|
}[] = [
|
|
@@ -472,6 +666,12 @@ function checkAgentDrift(
|
|
|
472
666
|
valueFor: effectiveRobotsValue,
|
|
473
667
|
},
|
|
474
668
|
];
|
|
669
|
+
if (target.expectations.requireOpenGraph === true) {
|
|
670
|
+
comparisons.push({ field: "open-graph", valueFor: openGraphValue });
|
|
671
|
+
}
|
|
672
|
+
if (target.expectations.requireTwitterCard === true) {
|
|
673
|
+
comparisons.push({ field: "twitter-card", valueFor: twitterCardValue });
|
|
674
|
+
}
|
|
475
675
|
|
|
476
676
|
for (const comparison of comparisons) {
|
|
477
677
|
const values = complete.map(comparison.valueFor);
|
|
@@ -479,7 +679,7 @@ function checkAgentDrift(
|
|
|
479
679
|
continue;
|
|
480
680
|
}
|
|
481
681
|
|
|
482
|
-
addFinding(findings,
|
|
682
|
+
addFinding(findings, target.url, {
|
|
483
683
|
code: `agent-${comparison.field}-drift`,
|
|
484
684
|
severity: "warning",
|
|
485
685
|
message: `Crawler profiles received different ${comparison.field.replace("-", " ")} values.`,
|
|
@@ -556,6 +756,7 @@ export function analyzeTarget(
|
|
|
556
756
|
checkRepeatedMetadata(findings, target.url, probe, "description", probe.signals.descriptions);
|
|
557
757
|
checkRepeatedMetadata(findings, target.url, probe, "canonical", probe.signals.canonicals);
|
|
558
758
|
checkRepeatedRobots(findings, target.url, probe);
|
|
759
|
+
checkSocialMetadata(findings, target, probe);
|
|
559
760
|
|
|
560
761
|
const invalidJsonLd = probe.signals.jsonLd.filter((signal) => signal.valid === false);
|
|
561
762
|
if (probe.completion === "complete" && invalidJsonLd.length > 0) {
|
|
@@ -587,11 +788,11 @@ export function analyzeTarget(
|
|
|
587
788
|
});
|
|
588
789
|
}
|
|
589
790
|
|
|
590
|
-
checkHeadRequirements(findings, target
|
|
791
|
+
checkHeadRequirements(findings, target, probe);
|
|
591
792
|
checkTimings(findings, target, probe);
|
|
592
793
|
}
|
|
593
794
|
|
|
594
|
-
checkAgentDrift(findings, target
|
|
795
|
+
checkAgentDrift(findings, target, probes);
|
|
595
796
|
|
|
596
797
|
return findings;
|
|
597
798
|
}
|
package/src/cli.ts
CHANGED
|
@@ -31,6 +31,8 @@ targets:
|
|
|
31
31
|
canonical: true
|
|
32
32
|
h1: true
|
|
33
33
|
mainText: true
|
|
34
|
+
openGraph: false
|
|
35
|
+
twitterCard: false
|
|
34
36
|
|
|
35
37
|
agents:
|
|
36
38
|
- browser
|
|
@@ -168,7 +170,7 @@ export async function main(argv: readonly string[] = process.argv): Promise<void
|
|
|
168
170
|
const program = new Command();
|
|
169
171
|
program
|
|
170
172
|
.name("ssrwire")
|
|
171
|
-
.description("Inspect streamed SSR HTML and
|
|
173
|
+
.description("Inspect streamed SSR HTML, SEO, and social metadata delivery.")
|
|
172
174
|
.version(VERSION)
|
|
173
175
|
.exitOverride()
|
|
174
176
|
.showHelpAfterError();
|
package/src/config.ts
CHANGED
|
@@ -23,6 +23,8 @@ const requireSchema = z
|
|
|
23
23
|
canonical: z.boolean().optional(),
|
|
24
24
|
h1: z.boolean().optional(),
|
|
25
25
|
mainText: z.boolean().optional(),
|
|
26
|
+
openGraph: z.boolean().optional(),
|
|
27
|
+
twitterCard: z.boolean().optional(),
|
|
26
28
|
})
|
|
27
29
|
.strict();
|
|
28
30
|
|
|
@@ -129,6 +131,8 @@ function normalizeTarget(value: string | z.infer<typeof targetObjectSchema>): Au
|
|
|
129
131
|
requireCanonical: required?.canonical ?? true,
|
|
130
132
|
requireH1: required?.h1 ?? true,
|
|
131
133
|
requireMainText: required?.mainText ?? true,
|
|
134
|
+
requireOpenGraph: required?.openGraph ?? false,
|
|
135
|
+
requireTwitterCard: required?.twitterCard ?? false,
|
|
132
136
|
...(item.maxFirstByteMs === undefined ? {} : { maxFirstByteMs: item.maxFirstByteMs }),
|
|
133
137
|
...(item.maxCriticalMs === undefined ? {} : { maxCriticalMs: item.maxCriticalMs }),
|
|
134
138
|
};
|
package/src/http-probe.ts
CHANGED
|
@@ -55,6 +55,7 @@ function emptySignals(): DocumentSignals {
|
|
|
55
55
|
descriptions: [],
|
|
56
56
|
canonicals: [],
|
|
57
57
|
robots: [],
|
|
58
|
+
socialMetadata: [],
|
|
58
59
|
h1s: [],
|
|
59
60
|
jsonLd: [],
|
|
60
61
|
};
|
|
@@ -91,6 +92,9 @@ function redactSignals(signals: DocumentSignals, redaction: RedactionPlan): Docu
|
|
|
91
92
|
descriptions: signals.descriptions.map(element),
|
|
92
93
|
canonicals: signals.canonicals.map(element),
|
|
93
94
|
robots: signals.robots.map(element),
|
|
95
|
+
...(signals.socialMetadata === undefined
|
|
96
|
+
? {}
|
|
97
|
+
: { socialMetadata: signals.socialMetadata.map(element) }),
|
|
94
98
|
h1s: signals.h1s.map(element),
|
|
95
99
|
...(signals.firstMainText === undefined
|
|
96
100
|
? {}
|
package/src/index.ts
CHANGED
package/src/redact.ts
CHANGED
|
@@ -90,6 +90,11 @@ function redactSignals(signals: DocumentSignals, plan: RedactionPlan): DocumentS
|
|
|
90
90
|
descriptions: signals.descriptions.map((signal) => redactElement(signal, plan)),
|
|
91
91
|
canonicals: signals.canonicals.map((signal) => redactElement(signal, plan)),
|
|
92
92
|
robots: signals.robots.map((signal) => redactElement(signal, plan)),
|
|
93
|
+
...(signals.socialMetadata === undefined
|
|
94
|
+
? {}
|
|
95
|
+
: {
|
|
96
|
+
socialMetadata: signals.socialMetadata.map((signal) => redactElement(signal, plan)),
|
|
97
|
+
}),
|
|
93
98
|
h1s: signals.h1s.map((signal) => redactElement(signal, plan)),
|
|
94
99
|
...(signals.firstMainText === undefined
|
|
95
100
|
? {}
|
package/src/reporters.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import {
|
|
2
|
+
effectiveTwitterCardSignal,
|
|
3
|
+
firstSocialSignal,
|
|
4
|
+
OPEN_GRAPH_REQUIRED_PROPERTIES,
|
|
5
|
+
TWITTER_CARD_REQUIRED_FIELDS,
|
|
6
|
+
} from "./social.js";
|
|
1
7
|
import type {
|
|
2
8
|
AgentStability,
|
|
3
9
|
AuditResult,
|
|
@@ -86,6 +92,31 @@ function probeRow(probe: ProbeResult, showSample: boolean): readonly string[] {
|
|
|
86
92
|
];
|
|
87
93
|
}
|
|
88
94
|
|
|
95
|
+
function formatSignalSet(signals: readonly (ElementSignal | undefined)[]): string {
|
|
96
|
+
const present = signals.filter((signal): signal is ElementSignal => signal !== undefined);
|
|
97
|
+
if (present.length < signals.length) return `${present.length}/${signals.length}`;
|
|
98
|
+
const arrivalMs = Math.max(...present.map((signal) => signal.atMs));
|
|
99
|
+
const location = present.some((signal) => signal.location === "body")
|
|
100
|
+
? "body"
|
|
101
|
+
: present.some((signal) => signal.location === "document")
|
|
102
|
+
? "document"
|
|
103
|
+
: "head";
|
|
104
|
+
return `${Math.round(arrivalMs)} ms/${location}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function socialRow(probe: ProbeResult, showSample: boolean): readonly string[] {
|
|
108
|
+
return [
|
|
109
|
+
...(showSample ? [String(probe.sample ?? "—")] : []),
|
|
110
|
+
truncate(probe.agent.label, 24),
|
|
111
|
+
formatSignalSet(
|
|
112
|
+
OPEN_GRAPH_REQUIRED_PROPERTIES.map((property) => firstSocialSignal(probe.signals, property)),
|
|
113
|
+
),
|
|
114
|
+
formatSignalSet(
|
|
115
|
+
TWITTER_CARD_REQUIRED_FIELDS.map((field) => effectiveTwitterCardSignal(probe.signals, field)),
|
|
116
|
+
),
|
|
117
|
+
];
|
|
118
|
+
}
|
|
119
|
+
|
|
89
120
|
function stabilityRows(stability: readonly AgentStability[]): readonly (readonly string[])[] {
|
|
90
121
|
const labels: Readonly<Record<keyof AgentStability["timings"], string>> = {
|
|
91
122
|
headers: "Headers",
|
|
@@ -171,6 +202,21 @@ export function renderTerminal(audit: AuditResult, options: ReporterOptions = {}
|
|
|
171
202
|
),
|
|
172
203
|
);
|
|
173
204
|
|
|
205
|
+
const showSocial =
|
|
206
|
+
result.target.expectations.requireOpenGraph === true ||
|
|
207
|
+
result.target.expectations.requireTwitterCard === true ||
|
|
208
|
+
result.probes.some((probe) => (probe.signals.socialMetadata?.length ?? 0) > 0);
|
|
209
|
+
if (showSocial) {
|
|
210
|
+
lines.push(
|
|
211
|
+
"",
|
|
212
|
+
"Social preview readiness",
|
|
213
|
+
renderTable(
|
|
214
|
+
[...(showSample ? ["Sample"] : []), "Agent", "Open Graph", "Twitter Card"],
|
|
215
|
+
result.probes.map((probe) => socialRow(probe, showSample)),
|
|
216
|
+
),
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
174
220
|
if (result.stability !== undefined) {
|
|
175
221
|
const rows = stabilityRows(result.stability);
|
|
176
222
|
if (rows.length > 0) {
|
package/src/social.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { DocumentSignals, SocialMetadataProperty, SocialMetadataSignal } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export const SOCIAL_METADATA_PROPERTIES = [
|
|
4
|
+
"og:title",
|
|
5
|
+
"og:type",
|
|
6
|
+
"og:url",
|
|
7
|
+
"og:image",
|
|
8
|
+
"og:description",
|
|
9
|
+
"twitter:card",
|
|
10
|
+
"twitter:title",
|
|
11
|
+
"twitter:description",
|
|
12
|
+
"twitter:image",
|
|
13
|
+
] as const satisfies readonly SocialMetadataProperty[];
|
|
14
|
+
|
|
15
|
+
export const OPEN_GRAPH_PROPERTIES = [
|
|
16
|
+
"og:title",
|
|
17
|
+
"og:type",
|
|
18
|
+
"og:url",
|
|
19
|
+
"og:image",
|
|
20
|
+
"og:description",
|
|
21
|
+
] as const satisfies readonly SocialMetadataProperty[];
|
|
22
|
+
|
|
23
|
+
export const OPEN_GRAPH_REQUIRED_PROPERTIES = [
|
|
24
|
+
"og:title",
|
|
25
|
+
"og:type",
|
|
26
|
+
"og:url",
|
|
27
|
+
"og:image",
|
|
28
|
+
] as const satisfies readonly SocialMetadataProperty[];
|
|
29
|
+
|
|
30
|
+
export const TWITTER_PROPERTIES = [
|
|
31
|
+
"twitter:card",
|
|
32
|
+
"twitter:title",
|
|
33
|
+
"twitter:description",
|
|
34
|
+
"twitter:image",
|
|
35
|
+
] as const satisfies readonly SocialMetadataProperty[];
|
|
36
|
+
|
|
37
|
+
export const TWITTER_CARD_REQUIRED_FIELDS = ["card", "title", "description", "image"] as const;
|
|
38
|
+
export type TwitterCardField = (typeof TWITTER_CARD_REQUIRED_FIELDS)[number];
|
|
39
|
+
|
|
40
|
+
const SOCIAL_METADATA_PROPERTY_SET = new Set<string>(SOCIAL_METADATA_PROPERTIES);
|
|
41
|
+
const SOCIAL_URL_PROPERTY_SET = new Set<SocialMetadataProperty>([
|
|
42
|
+
"og:url",
|
|
43
|
+
"og:image",
|
|
44
|
+
"twitter:image",
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
export function isSocialMetadataProperty(
|
|
48
|
+
value: string | undefined,
|
|
49
|
+
): value is SocialMetadataProperty {
|
|
50
|
+
return value !== undefined && SOCIAL_METADATA_PROPERTY_SET.has(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function socialSignals(
|
|
54
|
+
signals: DocumentSignals,
|
|
55
|
+
property: SocialMetadataProperty,
|
|
56
|
+
): readonly SocialMetadataSignal[] {
|
|
57
|
+
return (signals.socialMetadata ?? []).filter((signal) => signal.property === property);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function firstSocialSignal(
|
|
61
|
+
signals: DocumentSignals,
|
|
62
|
+
property: SocialMetadataProperty,
|
|
63
|
+
): SocialMetadataSignal | undefined {
|
|
64
|
+
return socialSignals(signals, property).find((signal) => signal.value.trim().length > 0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function effectiveTwitterCardSignal(
|
|
68
|
+
signals: DocumentSignals,
|
|
69
|
+
field: TwitterCardField,
|
|
70
|
+
): SocialMetadataSignal | undefined {
|
|
71
|
+
if (field === "card") return firstSocialSignal(signals, "twitter:card");
|
|
72
|
+
if (field === "title") {
|
|
73
|
+
return firstSocialSignal(signals, "twitter:title") ?? firstSocialSignal(signals, "og:title");
|
|
74
|
+
}
|
|
75
|
+
if (field === "description") {
|
|
76
|
+
return (
|
|
77
|
+
firstSocialSignal(signals, "twitter:description") ??
|
|
78
|
+
firstSocialSignal(signals, "og:description")
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
return firstSocialSignal(signals, "twitter:image") ?? firstSocialSignal(signals, "og:image");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function normalizeSocialValue(
|
|
85
|
+
property: SocialMetadataProperty,
|
|
86
|
+
value: string,
|
|
87
|
+
baseUrl: string,
|
|
88
|
+
): string {
|
|
89
|
+
const normalized = value.trim().replace(/\s+/gu, " ");
|
|
90
|
+
if (normalized.length === 0) return "";
|
|
91
|
+
if (SOCIAL_URL_PROPERTY_SET.has(property)) {
|
|
92
|
+
try {
|
|
93
|
+
const url = new URL(normalized, baseUrl);
|
|
94
|
+
url.hash = "";
|
|
95
|
+
return url.href;
|
|
96
|
+
} catch {
|
|
97
|
+
return normalized;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return property === "og:type" || property === "twitter:card"
|
|
101
|
+
? normalized.toLowerCase()
|
|
102
|
+
: normalized;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function isAbsoluteHttpSocialUrl(value: string): boolean {
|
|
106
|
+
try {
|
|
107
|
+
const url = new URL(value.trim());
|
|
108
|
+
return (
|
|
109
|
+
(url.protocol === "http:" || url.protocol === "https:") &&
|
|
110
|
+
url.username.length === 0 &&
|
|
111
|
+
url.password.length === 0
|
|
112
|
+
);
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|