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.
Files changed (51) hide show
  1. package/CHANGELOG.md +24 -1
  2. package/CONTRIBUTING.md +1 -0
  3. package/PUBLISHING.md +15 -15
  4. package/README.md +70 -23
  5. package/dist/analyze.d.ts.map +1 -1
  6. package/dist/analyze.js +162 -6
  7. package/dist/analyze.js.map +1 -1
  8. package/dist/cli.d.ts.map +1 -1
  9. package/dist/cli.js +3 -1
  10. package/dist/cli.js.map +1 -1
  11. package/dist/config.d.ts.map +1 -1
  12. package/dist/config.js +4 -0
  13. package/dist/config.js.map +1 -1
  14. package/dist/http-probe.d.ts.map +1 -1
  15. package/dist/http-probe.js +4 -0
  16. package/dist/http-probe.js.map +1 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js.map +1 -1
  20. package/dist/redact.d.ts.map +1 -1
  21. package/dist/redact.js +5 -0
  22. package/dist/redact.js.map +1 -1
  23. package/dist/reporters.d.ts.map +1 -1
  24. package/dist/reporters.js +27 -0
  25. package/dist/reporters.js.map +1 -1
  26. package/dist/social.d.ts +14 -0
  27. package/dist/social.d.ts.map +1 -0
  28. package/dist/social.js +88 -0
  29. package/dist/social.js.map +1 -0
  30. package/dist/stability.d.ts.map +1 -1
  31. package/dist/stability.js +84 -4
  32. package/dist/stability.js.map +1 -1
  33. package/dist/stream-parser.d.ts.map +1 -1
  34. package/dist/stream-parser.js +21 -1
  35. package/dist/stream-parser.js.map +1 -1
  36. package/dist/types.d.ts +10 -0
  37. package/dist/types.d.ts.map +1 -1
  38. package/examples/github-actions.yml +2 -2
  39. package/examples/ssrwire.config.yml +4 -0
  40. package/package.json +5 -2
  41. package/src/analyze.ts +208 -7
  42. package/src/cli.ts +3 -1
  43. package/src/config.ts +4 -0
  44. package/src/http-probe.ts +4 -0
  45. package/src/index.ts +2 -0
  46. package/src/redact.ts +5 -0
  47. package/src/reporters.ts +46 -0
  48. package/src/social.ts +116 -0
  49. package/src/stability.ts +123 -4
  50. package/src/stream-parser.ts +31 -1
  51. package/src/types.ts +21 -0
package/src/stability.ts CHANGED
@@ -1,3 +1,12 @@
1
+ import {
2
+ effectiveTwitterCardSignal,
3
+ firstSocialSignal,
4
+ normalizeSocialValue,
5
+ OPEN_GRAPH_PROPERTIES,
6
+ OPEN_GRAPH_REQUIRED_PROPERTIES,
7
+ socialSignals,
8
+ TWITTER_CARD_REQUIRED_FIELDS,
9
+ } from "./social.js";
1
10
  import type {
2
11
  AgentProfile,
3
12
  AgentStability,
@@ -7,6 +16,7 @@ import type {
7
16
  ProbeResult,
8
17
  RobotsAudience,
9
18
  RobotsSignal,
19
+ SocialMetadataProperty,
10
20
  TimingStats,
11
21
  } from "./types.js";
12
22
 
@@ -25,6 +35,11 @@ export interface StabilityAnalysis {
25
35
  readonly findings: readonly Finding[];
26
36
  }
27
37
 
38
+ interface SocialSignature {
39
+ openGraph?: unknown;
40
+ twitterCard?: unknown;
41
+ }
42
+
28
43
  function normalizeText(value: string): string {
29
44
  return value.trim().replace(/\s+/g, " ");
30
45
  }
@@ -117,8 +132,97 @@ function robotsValueLocations(probe: ProbeResult): readonly (readonly [string, s
117
132
  );
118
133
  }
119
134
 
120
- function metadataValueSignature(probe: ProbeResult): string {
135
+ function socialValuesForSignature(
136
+ probe: ProbeResult,
137
+ property: SocialMetadataProperty,
138
+ ): readonly string[] {
139
+ const values = [
140
+ ...new Set(
141
+ socialSignals(probe.signals, property)
142
+ .map((signal) => normalizeSocialValue(property, signal.value, probe.finalUrl))
143
+ .filter((value) => value.length > 0),
144
+ ),
145
+ ];
146
+ return property === "og:image" ? values : values.sort();
147
+ }
148
+
149
+ function socialLocationsForSignature(
150
+ probe: ProbeResult,
151
+ property: SocialMetadataProperty,
152
+ ): readonly (readonly [string, string])[] {
153
+ if (property !== "og:image") {
154
+ return signalValueLocations(socialSignals(probe.signals, property), (value) =>
155
+ normalizeSocialValue(property, value, probe.finalUrl),
156
+ );
157
+ }
158
+ return socialSignals(probe.signals, property)
159
+ .map(
160
+ (signal) =>
161
+ [normalizeSocialValue(property, signal.value, probe.finalUrl), signal.location] as const,
162
+ )
163
+ .filter(([value]) => value.length > 0);
164
+ }
165
+
166
+ function socialValueSignature(target: AuditTarget, probe: ProbeResult): unknown {
167
+ const signature: SocialSignature = {};
168
+ if (target.expectations.requireOpenGraph === true) {
169
+ signature.openGraph = Object.fromEntries(
170
+ OPEN_GRAPH_PROPERTIES.map((property) => [
171
+ property,
172
+ socialValuesForSignature(probe, property),
173
+ ]),
174
+ );
175
+ }
176
+ if (target.expectations.requireTwitterCard === true) {
177
+ signature.twitterCard = Object.fromEntries(
178
+ TWITTER_CARD_REQUIRED_FIELDS.map((field) => {
179
+ const signal = effectiveTwitterCardSignal(probe.signals, field);
180
+ return [
181
+ field,
182
+ signal === undefined
183
+ ? "<missing>"
184
+ : normalizeSocialValue(signal.property, signal.value, probe.finalUrl) || "<missing>",
185
+ ];
186
+ }),
187
+ );
188
+ }
189
+ return Object.keys(signature).length === 0 ? undefined : signature;
190
+ }
191
+
192
+ function socialLocationSignature(target: AuditTarget, probe: ProbeResult): unknown {
193
+ const signature: SocialSignature = {};
194
+ if (target.expectations.requireOpenGraph === true) {
195
+ signature.openGraph = Object.fromEntries(
196
+ OPEN_GRAPH_PROPERTIES.map((property) => [
197
+ property,
198
+ socialLocationsForSignature(probe, property),
199
+ ]),
200
+ );
201
+ }
202
+ if (target.expectations.requireTwitterCard === true) {
203
+ signature.twitterCard = Object.fromEntries(
204
+ TWITTER_CARD_REQUIRED_FIELDS.map((field) => {
205
+ const signal = effectiveTwitterCardSignal(probe.signals, field);
206
+ return [
207
+ field,
208
+ signal === undefined
209
+ ? []
210
+ : [
211
+ [
212
+ normalizeSocialValue(signal.property, signal.value, probe.finalUrl),
213
+ signal.location,
214
+ ],
215
+ ],
216
+ ];
217
+ }),
218
+ );
219
+ }
220
+ return Object.keys(signature).length === 0 ? undefined : signature;
221
+ }
222
+
223
+ function metadataValueSignature(target: AuditTarget, probe: ProbeResult): string {
121
224
  const titles = probe.signals.titles ?? (probe.signals.title ? [probe.signals.title] : []);
225
+ const social = socialValueSignature(target, probe);
122
226
  return JSON.stringify({
123
227
  title: normalizedSignalValues(titles),
124
228
  description: normalizedSignalValues(probe.signals.descriptions),
@@ -126,11 +230,13 @@ function metadataValueSignature(probe: ProbeResult): string {
126
230
  normalizeCanonical(value, probe.finalUrl),
127
231
  ),
128
232
  robots: effectiveRobotsValue(probe),
233
+ ...(social === undefined ? {} : { social }),
129
234
  });
130
235
  }
131
236
 
132
- function metadataLocationSignature(probe: ProbeResult): string {
237
+ function metadataLocationSignature(target: AuditTarget, probe: ProbeResult): string {
133
238
  const titles = probe.signals.titles ?? (probe.signals.title ? [probe.signals.title] : []);
239
+ const social = socialLocationSignature(target, probe);
134
240
  return JSON.stringify({
135
241
  title: signalValueLocations(titles),
136
242
  description: signalValueLocations(probe.signals.descriptions),
@@ -138,6 +244,7 @@ function metadataLocationSignature(probe: ProbeResult): string {
138
244
  normalizeCanonical(value, probe.finalUrl),
139
245
  ),
140
246
  robots: robotsValueLocations(probe),
247
+ ...(social === undefined ? {} : { social }),
141
248
  });
142
249
  }
143
250
 
@@ -213,6 +320,16 @@ export function criticalSignalsArrivalMs(
213
320
  }
214
321
  if (expectations.requireH1 && !addRequired(firstNonEmpty(probe.signals.h1s))) return undefined;
215
322
  if (expectations.requireMainText && !addRequired(probe.signals.firstMainText)) return undefined;
323
+ if (expectations.requireOpenGraph === true) {
324
+ for (const property of OPEN_GRAPH_REQUIRED_PROPERTIES) {
325
+ if (!addRequired(firstSocialSignal(probe.signals, property))) return undefined;
326
+ }
327
+ }
328
+ if (expectations.requireTwitterCard === true) {
329
+ for (const field of TWITTER_CARD_REQUIRED_FIELDS) {
330
+ if (!addRequired(effectiveTwitterCardSignal(probe.signals, field))) return undefined;
331
+ }
332
+ }
216
333
 
217
334
  return required === 0 ? undefined : Math.max(...marks);
218
335
  }
@@ -275,8 +392,10 @@ function summarizeAgent(target: AuditTarget, probes: readonly ProbeResult[]): Ag
275
392
  finalUrl: uniqueCount(finalResponses.map((probe) => normalizeUrl(probe.finalUrl))),
276
393
  redirectChain: uniqueCount(probes.map(redirectChainSignature)),
277
394
  bodySha256: uniqueCount(complete.map((probe) => probe.bodySha256 ?? "<missing>")),
278
- metadataValues: uniqueCount(complete.map(metadataValueSignature)),
279
- metadataLocations: uniqueCount(complete.map(metadataLocationSignature)),
395
+ metadataValues: uniqueCount(complete.map((probe) => metadataValueSignature(target, probe))),
396
+ metadataLocations: uniqueCount(
397
+ complete.map((probe) => metadataLocationSignature(target, probe)),
398
+ ),
280
399
  },
281
400
  };
282
401
  }
@@ -1,5 +1,7 @@
1
1
  import { Parser } from "htmlparser2";
2
2
 
3
+ import { isSocialMetadataProperty } from "./social.js";
4
+
3
5
  import type {
4
6
  DocumentSignals,
5
7
  ElementLocation,
@@ -7,6 +9,8 @@ import type {
7
9
  JsonLdSignal,
8
10
  RobotsAudience,
9
11
  RobotsSignal,
12
+ SocialMetadataProperty,
13
+ SocialMetadataSignal,
10
14
  TimingMark,
11
15
  } from "./types.js";
12
16
 
@@ -93,6 +97,8 @@ class HtmlStreamInspector implements StreamInspector {
93
97
  readonly #descriptions: ElementSignal[] = [];
94
98
  readonly #canonicals: ElementSignal[] = [];
95
99
  readonly #robots: RobotsSignal[] = [];
100
+ readonly #socialMetadata: SocialMetadataSignal[] = [];
101
+ readonly #socialMetadataCounts = new Map<SocialMetadataProperty, number>();
96
102
  readonly #h1s: ElementSignal[] = [];
97
103
  readonly #jsonLd: JsonLdSignal[] = [];
98
104
  readonly #titles: ElementSignal[] = [];
@@ -170,6 +176,7 @@ class HtmlStreamInspector implements StreamInspector {
170
176
  descriptions: [...this.#descriptions],
171
177
  canonicals: [...this.#canonicals],
172
178
  robots: [...this.#robots],
179
+ socialMetadata: [...this.#socialMetadata],
173
180
  h1s: [...this.#h1s],
174
181
  ...(firstMainText === undefined || firstMainText.value.length === 0 ? {} : { firstMainText }),
175
182
  jsonLd: [...this.#jsonLd],
@@ -213,7 +220,14 @@ class HtmlStreamInspector implements StreamInspector {
213
220
  return;
214
221
  }
215
222
 
216
- const { name: metaNameAttribute, content, rel, href, type: scriptType } = attributes;
223
+ const {
224
+ name: metaNameAttribute,
225
+ property: metaPropertyAttribute,
226
+ content,
227
+ rel,
228
+ href,
229
+ type: scriptType,
230
+ } = attributes;
217
231
  if (name === "head") this.#inHead = true;
218
232
  if (name === "body") {
219
233
  if (this.#bodyStarted === undefined) this.#bodyStarted = this.#mark();
@@ -232,6 +246,7 @@ class HtmlStreamInspector implements StreamInspector {
232
246
 
233
247
  if (name === "meta") {
234
248
  const metaName = metaNameAttribute?.trim().toLowerCase();
249
+ const metaProperty = metaPropertyAttribute?.trim().toLowerCase();
235
250
  if (
236
251
  content !== undefined &&
237
252
  metaName === "description" &&
@@ -249,6 +264,21 @@ class HtmlStreamInspector implements StreamInspector {
249
264
  audience: metaName,
250
265
  });
251
266
  }
267
+ const socialProperty = isSocialMetadataProperty(metaProperty)
268
+ ? metaProperty
269
+ : isSocialMetadataProperty(metaName)
270
+ ? metaName
271
+ : undefined;
272
+ if (content !== undefined && socialProperty !== undefined) {
273
+ const count = this.#socialMetadataCounts.get(socialProperty) ?? 0;
274
+ if (count < SIGNAL_LIMIT) {
275
+ this.#socialMetadata.push({
276
+ ...this.#elementSignal(content, location),
277
+ property: socialProperty,
278
+ });
279
+ this.#socialMetadataCounts.set(socialProperty, count + 1);
280
+ }
281
+ }
252
282
  }
253
283
 
254
284
  if (name === "link") {
package/src/types.ts CHANGED
@@ -18,6 +18,21 @@ export interface RobotsSignal extends ElementSignal {
18
18
  readonly audience: RobotsAudience;
19
19
  }
20
20
 
21
+ export type SocialMetadataProperty =
22
+ | "og:title"
23
+ | "og:type"
24
+ | "og:url"
25
+ | "og:image"
26
+ | "og:description"
27
+ | "twitter:card"
28
+ | "twitter:title"
29
+ | "twitter:description"
30
+ | "twitter:image";
31
+
32
+ export interface SocialMetadataSignal extends ElementSignal {
33
+ readonly property: SocialMetadataProperty;
34
+ }
35
+
21
36
  export interface JsonLdSignal extends TimingMark {
22
37
  readonly location: ElementLocation;
23
38
  readonly valid?: boolean;
@@ -33,6 +48,8 @@ export interface DocumentSignals {
33
48
  readonly descriptions: readonly ElementSignal[];
34
49
  readonly canonicals: readonly ElementSignal[];
35
50
  readonly robots: readonly RobotsSignal[];
51
+ /** Present on probes produced by SSRWire 0.3.0 and newer. */
52
+ readonly socialMetadata?: readonly SocialMetadataSignal[];
36
53
  readonly h1s: readonly ElementSignal[];
37
54
  readonly firstMainText?: ElementSignal;
38
55
  readonly jsonLd: readonly JsonLdSignal[];
@@ -109,6 +126,10 @@ export interface TargetExpectations {
109
126
  readonly requireCanonical: boolean;
110
127
  readonly requireH1: boolean;
111
128
  readonly requireMainText: boolean;
129
+ /** Require the four Open Graph protocol basic metadata properties. Defaults to false. */
130
+ readonly requireOpenGraph?: boolean;
131
+ /** Require SSRWire's Twitter Card readiness contract. Defaults to false. */
132
+ readonly requireTwitterCard?: boolean;
112
133
  readonly maxFirstByteMs?: number;
113
134
  readonly maxCriticalMs?: number;
114
135
  }