@brandlab-digital/conformance 3.7.0 → 3.9.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/README.md +26 -0
- package/dist/current-release.js +5 -5
- package/dist/current-release.js.map +1 -1
- package/dist/engineering.js +4 -2
- package/dist/engineering.js.map +1 -1
- package/dist/public-copy.d.ts +7 -0
- package/dist/public-copy.d.ts.map +1 -1
- package/dist/public-copy.js +8 -3
- package/dist/public-copy.js.map +1 -1
- package/dist/ready-cli.d.ts +38 -0
- package/dist/ready-cli.d.ts.map +1 -0
- package/dist/ready-cli.js +327 -0
- package/dist/ready-cli.js.map +1 -0
- package/dist/site-system/checks.d.ts +389 -0
- package/dist/site-system/checks.d.ts.map +1 -0
- package/dist/site-system/checks.js +96 -0
- package/dist/site-system/checks.js.map +1 -0
- package/dist/site-system/config.d.ts +40 -0
- package/dist/site-system/config.d.ts.map +1 -0
- package/dist/site-system/config.js +214 -0
- package/dist/site-system/config.js.map +1 -0
- package/dist/site-system/index.d.ts +14 -0
- package/dist/site-system/index.d.ts.map +1 -0
- package/dist/site-system/index.js +14 -0
- package/dist/site-system/index.js.map +1 -0
- package/dist/site-system/journeys/access.d.ts +7 -0
- package/dist/site-system/journeys/access.d.ts.map +1 -0
- package/dist/site-system/journeys/access.js +263 -0
- package/dist/site-system/journeys/access.js.map +1 -0
- package/dist/site-system/journeys/browser.d.ts +79 -0
- package/dist/site-system/journeys/browser.d.ts.map +1 -0
- package/dist/site-system/journeys/browser.js +218 -0
- package/dist/site-system/journeys/browser.js.map +1 -0
- package/dist/site-system/journeys/conversion.d.ts +3 -0
- package/dist/site-system/journeys/conversion.d.ts.map +1 -0
- package/dist/site-system/journeys/conversion.js +152 -0
- package/dist/site-system/journeys/conversion.js.map +1 -0
- package/dist/site-system/journeys/menu.d.ts +11 -0
- package/dist/site-system/journeys/menu.d.ts.map +1 -0
- package/dist/site-system/journeys/menu.js +395 -0
- package/dist/site-system/journeys/menu.js.map +1 -0
- package/dist/site-system/journeys/page-helpers.d.ts +146 -0
- package/dist/site-system/journeys/page-helpers.d.ts.map +1 -0
- package/dist/site-system/journeys/page-helpers.js +426 -0
- package/dist/site-system/journeys/page-helpers.js.map +1 -0
- package/dist/site-system/journeys/plan.d.ts +82 -0
- package/dist/site-system/journeys/plan.d.ts.map +1 -0
- package/dist/site-system/journeys/plan.js +112 -0
- package/dist/site-system/journeys/plan.js.map +1 -0
- package/dist/site-system/journeys/shell.d.ts +24 -0
- package/dist/site-system/journeys/shell.d.ts.map +1 -0
- package/dist/site-system/journeys/shell.js +269 -0
- package/dist/site-system/journeys/shell.js.map +1 -0
- package/dist/site-system/playwright-report.d.ts +22 -0
- package/dist/site-system/playwright-report.d.ts.map +1 -0
- package/dist/site-system/playwright-report.js +131 -0
- package/dist/site-system/playwright-report.js.map +1 -0
- package/dist/site-system/records.d.ts +30 -0
- package/dist/site-system/records.d.ts.map +1 -0
- package/dist/site-system/records.js +98 -0
- package/dist/site-system/records.js.map +1 -0
- package/dist/site-system/report.d.ts +116 -0
- package/dist/site-system/report.d.ts.map +1 -0
- package/dist/site-system/report.js +366 -0
- package/dist/site-system/report.js.map +1 -0
- package/dist/site-system/rule-modules.d.ts +8 -0
- package/dist/site-system/rule-modules.d.ts.map +1 -0
- package/dist/site-system/rule-modules.js +33 -0
- package/dist/site-system/rule-modules.js.map +1 -0
- package/dist/site-system/rules/design/d4-distinctness.d.ts +36 -0
- package/dist/site-system/rules/design/d4-distinctness.d.ts.map +1 -0
- package/dist/site-system/rules/design/d4-distinctness.js +207 -0
- package/dist/site-system/rules/design/d4-distinctness.js.map +1 -0
- package/dist/site-system/rules/design/index.d.ts +17 -0
- package/dist/site-system/rules/design/index.d.ts.map +1 -0
- package/dist/site-system/rules/design/index.js +7 -0
- package/dist/site-system/rules/design/index.js.map +1 -0
- package/dist/site-system/rules/enquiry/c2-quote-form.d.ts +31 -0
- package/dist/site-system/rules/enquiry/c2-quote-form.d.ts.map +1 -0
- package/dist/site-system/rules/enquiry/c2-quote-form.js +211 -0
- package/dist/site-system/rules/enquiry/c2-quote-form.js.map +1 -0
- package/dist/site-system/rules/enquiry/common.d.ts +37 -0
- package/dist/site-system/rules/enquiry/common.d.ts.map +1 -0
- package/dist/site-system/rules/enquiry/common.js +59 -0
- package/dist/site-system/rules/enquiry/common.js.map +1 -0
- package/dist/site-system/rules/enquiry/e1-recorded-titles.d.ts +8 -0
- package/dist/site-system/rules/enquiry/e1-recorded-titles.d.ts.map +1 -0
- package/dist/site-system/rules/enquiry/e1-recorded-titles.js +59 -0
- package/dist/site-system/rules/enquiry/e1-recorded-titles.js.map +1 -0
- package/dist/site-system/rules/enquiry/e9-cost-guides.d.ts +22 -0
- package/dist/site-system/rules/enquiry/e9-cost-guides.d.ts.map +1 -0
- package/dist/site-system/rules/enquiry/e9-cost-guides.js +55 -0
- package/dist/site-system/rules/enquiry/e9-cost-guides.js.map +1 -0
- package/dist/site-system/rules/enquiry/html.d.ts +53 -0
- package/dist/site-system/rules/enquiry/html.d.ts.map +1 -0
- package/dist/site-system/rules/enquiry/html.js +294 -0
- package/dist/site-system/rules/enquiry/html.js.map +1 -0
- package/dist/site-system/rules/enquiry/index.d.ts +17 -0
- package/dist/site-system/rules/enquiry/index.d.ts.map +1 -0
- package/dist/site-system/rules/enquiry/index.js +26 -0
- package/dist/site-system/rules/enquiry/index.js.map +1 -0
- package/dist/site-system/rules/enquiry/k8-sections.d.ts +17 -0
- package/dist/site-system/rules/enquiry/k8-sections.d.ts.map +1 -0
- package/dist/site-system/rules/enquiry/k8-sections.js +76 -0
- package/dist/site-system/rules/enquiry/k8-sections.js.map +1 -0
- package/dist/site-system/rules/enquiry/literal.d.ts +29 -0
- package/dist/site-system/rules/enquiry/literal.d.ts.map +1 -0
- package/dist/site-system/rules/enquiry/literal.js +189 -0
- package/dist/site-system/rules/enquiry/literal.js.map +1 -0
- package/dist/site-system/rules/enquiry/t11-licence-classes.d.ts +73 -0
- package/dist/site-system/rules/enquiry/t11-licence-classes.d.ts.map +1 -0
- package/dist/site-system/rules/enquiry/t11-licence-classes.js +275 -0
- package/dist/site-system/rules/enquiry/t11-licence-classes.js.map +1 -0
- package/dist/site-system/rules/enquiry/t5-reviews.d.ts +23 -0
- package/dist/site-system/rules/enquiry/t5-reviews.d.ts.map +1 -0
- package/dist/site-system/rules/enquiry/t5-reviews.js +91 -0
- package/dist/site-system/rules/enquiry/t5-reviews.js.map +1 -0
- package/dist/site-system/rules/form/c11-contact-route.d.ts +5 -0
- package/dist/site-system/rules/form/c11-contact-route.d.ts.map +1 -0
- package/dist/site-system/rules/form/c11-contact-route.js +132 -0
- package/dist/site-system/rules/form/c11-contact-route.js.map +1 -0
- package/dist/site-system/rules/form/c7-thank-you.d.ts +20 -0
- package/dist/site-system/rules/form/c7-thank-you.d.ts.map +1 -0
- package/dist/site-system/rules/form/c7-thank-you.js +67 -0
- package/dist/site-system/rules/form/c7-thank-you.js.map +1 -0
- package/dist/site-system/rules/form/c8-forgiving-form.d.ts +19 -0
- package/dist/site-system/rules/form/c8-forgiving-form.d.ts.map +1 -0
- package/dist/site-system/rules/form/c8-forgiving-form.js +128 -0
- package/dist/site-system/rules/form/c8-forgiving-form.js.map +1 -0
- package/dist/site-system/rules/form/index.d.ts +14 -0
- package/dist/site-system/rules/form/index.d.ts.map +1 -0
- package/dist/site-system/rules/form/index.js +13 -0
- package/dist/site-system/rules/form/index.js.map +1 -0
- package/dist/site-system/rules/library/common.d.ts +36 -0
- package/dist/site-system/rules/library/common.d.ts.map +1 -0
- package/dist/site-system/rules/library/common.js +70 -0
- package/dist/site-system/rules/library/common.js.map +1 -0
- package/dist/site-system/rules/library/index.d.ts +16 -0
- package/dist/site-system/rules/library/index.d.ts.map +1 -0
- package/dist/site-system/rules/library/index.js +19 -0
- package/dist/site-system/rules/library/index.js.map +1 -0
- package/dist/site-system/rules/library/k1-shared-components.d.ts +29 -0
- package/dist/site-system/rules/library/k1-shared-components.d.ts.map +1 -0
- package/dist/site-system/rules/library/k1-shared-components.js +97 -0
- package/dist/site-system/rules/library/k1-shared-components.js.map +1 -0
- package/dist/site-system/rules/navigation/extract.d.ts +50 -0
- package/dist/site-system/rules/navigation/extract.d.ts.map +1 -0
- package/dist/site-system/rules/navigation/extract.js +220 -0
- package/dist/site-system/rules/navigation/extract.js.map +1 -0
- package/dist/site-system/rules/navigation/html.d.ts +23 -0
- package/dist/site-system/rules/navigation/html.d.ts.map +1 -0
- package/dist/site-system/rules/navigation/html.js +219 -0
- package/dist/site-system/rules/navigation/html.js.map +1 -0
- package/dist/site-system/rules/navigation/index.d.ts +37 -0
- package/dist/site-system/rules/navigation/index.d.ts.map +1 -0
- package/dist/site-system/rules/navigation/index.js +163 -0
- package/dist/site-system/rules/navigation/index.js.map +1 -0
- package/dist/site-system/rules/navigation/record.d.ts +8 -0
- package/dist/site-system/rules/navigation/record.d.ts.map +1 -0
- package/dist/site-system/rules/navigation/record.js +39 -0
- package/dist/site-system/rules/navigation/record.js.map +1 -0
- package/dist/site-system/rules/sections/exif.d.ts +22 -0
- package/dist/site-system/rules/sections/exif.d.ts.map +1 -0
- package/dist/site-system/rules/sections/exif.js +218 -0
- package/dist/site-system/rules/sections/exif.js.map +1 -0
- package/dist/site-system/rules/sections/images.d.ts +31 -0
- package/dist/site-system/rules/sections/images.d.ts.map +1 -0
- package/dist/site-system/rules/sections/images.js +96 -0
- package/dist/site-system/rules/sections/images.js.map +1 -0
- package/dist/site-system/rules/sections/index.d.ts +20 -0
- package/dist/site-system/rules/sections/index.d.ts.map +1 -0
- package/dist/site-system/rules/sections/index.js +34 -0
- package/dist/site-system/rules/sections/index.js.map +1 -0
- package/dist/site-system/rules/sections/k11-density.d.ts +23 -0
- package/dist/site-system/rules/sections/k11-density.d.ts.map +1 -0
- package/dist/site-system/rules/sections/k11-density.js +88 -0
- package/dist/site-system/rules/sections/k11-density.js.map +1 -0
- package/dist/site-system/rules/sections/k12-contact.d.ts +13 -0
- package/dist/site-system/rules/sections/k12-contact.d.ts.map +1 -0
- package/dist/site-system/rules/sections/k12-contact.js +70 -0
- package/dist/site-system/rules/sections/k12-contact.js.map +1 -0
- package/dist/site-system/rules/sections/k2-skin-coverage.d.ts +11 -0
- package/dist/site-system/rules/sections/k2-skin-coverage.d.ts.map +1 -0
- package/dist/site-system/rules/sections/k2-skin-coverage.js +126 -0
- package/dist/site-system/rules/sections/k2-skin-coverage.js.map +1 -0
- package/dist/site-system/rules/sections/n9-footer.d.ts +40 -0
- package/dist/site-system/rules/sections/n9-footer.d.ts.map +1 -0
- package/dist/site-system/rules/sections/n9-footer.js +186 -0
- package/dist/site-system/rules/sections/n9-footer.js.map +1 -0
- package/dist/site-system/rules/sections/t6-media-entries.d.ts +14 -0
- package/dist/site-system/rules/sections/t6-media-entries.d.ts.map +1 -0
- package/dist/site-system/rules/sections/t6-media-entries.js +96 -0
- package/dist/site-system/rules/sections/t6-media-entries.js.map +1 -0
- package/dist/site-system/rules/sections/t7-main-images.d.ts +13 -0
- package/dist/site-system/rules/sections/t7-main-images.d.ts.map +1 -0
- package/dist/site-system/rules/sections/t7-main-images.js +41 -0
- package/dist/site-system/rules/sections/t7-main-images.js.map +1 -0
- package/dist/site-system/rules/sections/t8-prices.d.ts +13 -0
- package/dist/site-system/rules/sections/t8-prices.d.ts.map +1 -0
- package/dist/site-system/rules/sections/t8-prices.js +92 -0
- package/dist/site-system/rules/sections/t8-prices.js.map +1 -0
- package/dist/site-system/rules/sections/t9-location-metadata.d.ts +3 -0
- package/dist/site-system/rules/sections/t9-location-metadata.d.ts.map +1 -0
- package/dist/site-system/rules/sections/t9-location-metadata.js +54 -0
- package/dist/site-system/rules/sections/t9-location-metadata.js.map +1 -0
- package/dist/site-system/rules/shell/banned-wording.d.ts +24 -0
- package/dist/site-system/rules/shell/banned-wording.d.ts.map +1 -0
- package/dist/site-system/rules/shell/banned-wording.js +212 -0
- package/dist/site-system/rules/shell/banned-wording.js.map +1 -0
- package/dist/site-system/rules/shell/budgets.d.ts +43 -0
- package/dist/site-system/rules/shell/budgets.d.ts.map +1 -0
- package/dist/site-system/rules/shell/budgets.js +297 -0
- package/dist/site-system/rules/shell/budgets.js.map +1 -0
- package/dist/site-system/rules/shell/cta-labels.d.ts +17 -0
- package/dist/site-system/rules/shell/cta-labels.d.ts.map +1 -0
- package/dist/site-system/rules/shell/cta-labels.js +86 -0
- package/dist/site-system/rules/shell/cta-labels.js.map +1 -0
- package/dist/site-system/rules/shell/facts.d.ts +33 -0
- package/dist/site-system/rules/shell/facts.d.ts.map +1 -0
- package/dist/site-system/rules/shell/facts.js +217 -0
- package/dist/site-system/rules/shell/facts.js.map +1 -0
- package/dist/site-system/rules/shell/headings.d.ts +5 -0
- package/dist/site-system/rules/shell/headings.d.ts.map +1 -0
- package/dist/site-system/rules/shell/headings.js +23 -0
- package/dist/site-system/rules/shell/headings.js.map +1 -0
- package/dist/site-system/rules/shell/hero-location.d.ts +4 -0
- package/dist/site-system/rules/shell/hero-location.d.ts.map +1 -0
- package/dist/site-system/rules/shell/hero-location.js +45 -0
- package/dist/site-system/rules/shell/hero-location.js.map +1 -0
- package/dist/site-system/rules/shell/html.d.ts +53 -0
- package/dist/site-system/rules/shell/html.d.ts.map +1 -0
- package/dist/site-system/rules/shell/html.js +341 -0
- package/dist/site-system/rules/shell/html.js.map +1 -0
- package/dist/site-system/rules/shell/index.d.ts +18 -0
- package/dist/site-system/rules/shell/index.d.ts.map +1 -0
- package/dist/site-system/rules/shell/index.js +65 -0
- package/dist/site-system/rules/shell/index.js.map +1 -0
- package/dist/site-system/rules/shell/licence.d.ts +4 -0
- package/dist/site-system/rules/shell/licence.d.ts.map +1 -0
- package/dist/site-system/rules/shell/licence.js +60 -0
- package/dist/site-system/rules/shell/licence.js.map +1 -0
- package/dist/site-system/rules/shell/location-wording.d.ts +13 -0
- package/dist/site-system/rules/shell/location-wording.d.ts.map +1 -0
- package/dist/site-system/rules/shell/location-wording.js +15 -0
- package/dist/site-system/rules/shell/location-wording.js.map +1 -0
- package/dist/site-system/rules/shell/navigation.d.ts +5 -0
- package/dist/site-system/rules/shell/navigation.d.ts.map +1 -0
- package/dist/site-system/rules/shell/navigation.js +272 -0
- package/dist/site-system/rules/shell/navigation.js.map +1 -0
- package/dist/site-system/rules/shell/overlays.d.ts +4 -0
- package/dist/site-system/rules/shell/overlays.d.ts.map +1 -0
- package/dist/site-system/rules/shell/overlays.js +93 -0
- package/dist/site-system/rules/shell/overlays.js.map +1 -0
- package/dist/site-system/rules/shell/page.d.ts +51 -0
- package/dist/site-system/rules/shell/page.d.ts.map +1 -0
- package/dist/site-system/rules/shell/page.js +256 -0
- package/dist/site-system/rules/shell/page.js.map +1 -0
- package/dist/site-system/rules/shell/phone.d.ts +13 -0
- package/dist/site-system/rules/shell/phone.d.ts.map +1 -0
- package/dist/site-system/rules/shell/phone.js +99 -0
- package/dist/site-system/rules/shell/phone.js.map +1 -0
- package/dist/site-system/rules/shell/proof-numbers.d.ts +7 -0
- package/dist/site-system/rules/shell/proof-numbers.d.ts.map +1 -0
- package/dist/site-system/rules/shell/proof-numbers.js +81 -0
- package/dist/site-system/rules/shell/proof-numbers.js.map +1 -0
- package/dist/site-system/rules/shell/types.d.ts +80 -0
- package/dist/site-system/rules/shell/types.d.ts.map +1 -0
- package/dist/site-system/rules/shell/types.js +17 -0
- package/dist/site-system/rules/shell/types.js.map +1 -0
- package/dist/site-system/runners.d.ts +37 -0
- package/dist/site-system/runners.d.ts.map +1 -0
- package/dist/site-system/runners.js +48 -0
- package/dist/site-system/runners.js.map +1 -0
- package/dist/site-system/samples.d.ts +43 -0
- package/dist/site-system/samples.d.ts.map +1 -0
- package/dist/site-system/samples.js +99 -0
- package/dist/site-system/samples.js.map +1 -0
- package/dist/site-system/steps.d.ts +66 -0
- package/dist/site-system/steps.d.ts.map +1 -0
- package/dist/site-system/steps.js +45 -0
- package/dist/site-system/steps.js.map +1 -0
- package/dist/site-system/streamed-html.d.ts +9 -0
- package/dist/site-system/streamed-html.d.ts.map +1 -0
- package/dist/site-system/streamed-html.js +43 -0
- package/dist/site-system/streamed-html.js.map +1 -0
- package/dist/site-system-e2e.d.ts +6 -0
- package/dist/site-system-e2e.d.ts.map +1 -0
- package/dist/site-system-e2e.js +54 -0
- package/dist/site-system-e2e.js.map +1 -0
- package/package.json +22 -5
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads location metadata out of an image file without a dependency (T9). A photo straight off a phone carries the GPS
|
|
3
|
+
* position of the job site, and often the street and suburb; none of it may ship in `public/`.
|
|
4
|
+
*
|
|
5
|
+
* What counts as location metadata:
|
|
6
|
+
* - an EXIF GPS block with any tag beyond the version (latitude, longitude, altitude, a timestamp, a processing method);
|
|
7
|
+
* - XMP `exif:GPS*` properties and `photoshop:City`, `photoshop:State` and `Iptc4xmpCore:Location` (sublocation);
|
|
8
|
+
* - IPTC-IIM city (2:90), sublocation (2:92) and province or state (2:95) records.
|
|
9
|
+
*
|
|
10
|
+
* Formats: JPEG (APP1 EXIF and XMP, APP13 IPTC), PNG (`eXIf`, and `iTXt`/`tEXt` XMP), WebP (`EXIF` and `XMP ` chunks)
|
|
11
|
+
* and HEIC/HEIF/AVIF (the `Exif` item is found by its TIFF header, and XMP by its packet; this is a scan, not a full
|
|
12
|
+
* ISO box parser). A file in none of those formats, or one the reader cannot walk, reports nothing.
|
|
13
|
+
*/
|
|
14
|
+
const GPS_IFD_POINTER = 0x8825;
|
|
15
|
+
const GPS_VERSION_TAG = 0x0000;
|
|
16
|
+
const GPS_TAG_NAMES = {
|
|
17
|
+
1: "latitude", 2: "latitude", 3: "longitude", 4: "longitude", 5: "altitude", 6: "altitude", 7: "time", 29: "date", 27: "processing method", 28: "area name",
|
|
18
|
+
};
|
|
19
|
+
const XMP_LOCATION = /<exif:GPS(?:Latitude|Longitude|Altitude|Position)\b|\bexif:GPS(?:Latitude|Longitude|Altitude)\s*=|<photoshop:(?:City|State)\b|\bphotoshop:(?:City|State)\s*=|<Iptc4xmpCore:Location\b|\bIptc4xmpCore:Location\s*=/;
|
|
20
|
+
const IPTC_LOCATION_RECORDS = { 90: "city", 92: "sublocation", 95: "state" };
|
|
21
|
+
function ascii(bytes, start, length) {
|
|
22
|
+
let text = "";
|
|
23
|
+
for (let index = start; index < Math.min(bytes.length, start + length); index += 1)
|
|
24
|
+
text += String.fromCharCode(bytes[index]);
|
|
25
|
+
return text;
|
|
26
|
+
}
|
|
27
|
+
/** Reads a TIFF block (EXIF) and returns the names of the GPS tags it carries. */
|
|
28
|
+
export function gpsTagsInTiff(bytes, start) {
|
|
29
|
+
const header = ascii(bytes, start, 2);
|
|
30
|
+
if (header !== "II" && header !== "MM")
|
|
31
|
+
return [];
|
|
32
|
+
const little = header === "II";
|
|
33
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
34
|
+
const u16 = (offset) => (offset + 2 <= bytes.length ? view.getUint16(offset, little) : 0);
|
|
35
|
+
const u32 = (offset) => (offset + 4 <= bytes.length ? view.getUint32(offset, little) : 0);
|
|
36
|
+
if (u16(start + 2) !== 42)
|
|
37
|
+
return [];
|
|
38
|
+
const entries = (ifdOffset) => {
|
|
39
|
+
const at = start + ifdOffset;
|
|
40
|
+
if (ifdOffset === 0 || at + 2 > bytes.length)
|
|
41
|
+
return [];
|
|
42
|
+
const count = Math.min(u16(at), 512);
|
|
43
|
+
const found = [];
|
|
44
|
+
for (let index = 0; index < count; index += 1) {
|
|
45
|
+
const entry = at + 2 + index * 12;
|
|
46
|
+
if (entry + 12 > bytes.length)
|
|
47
|
+
break;
|
|
48
|
+
found.push({ tag: u16(entry), valueOffset: entry + 8 });
|
|
49
|
+
}
|
|
50
|
+
return found;
|
|
51
|
+
};
|
|
52
|
+
const names = new Set();
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
let ifd = u32(start + 4);
|
|
55
|
+
for (let hop = 0; hop < 4 && ifd !== 0 && !seen.has(ifd); hop += 1) {
|
|
56
|
+
seen.add(ifd);
|
|
57
|
+
const list = entries(ifd);
|
|
58
|
+
const pointer = list.find((entry) => entry.tag === GPS_IFD_POINTER);
|
|
59
|
+
if (pointer) {
|
|
60
|
+
for (const gps of entries(u32(pointer.valueOffset))) {
|
|
61
|
+
if (gps.tag === GPS_VERSION_TAG)
|
|
62
|
+
continue;
|
|
63
|
+
names.add(GPS_TAG_NAMES[gps.tag] ?? "position data");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const next = start + ifd + 2 + Math.min(u16(start + ifd), 512) * 12;
|
|
67
|
+
ifd = next + 4 <= bytes.length ? u32(next) : 0;
|
|
68
|
+
}
|
|
69
|
+
return [...names];
|
|
70
|
+
}
|
|
71
|
+
function xmpFound(text) {
|
|
72
|
+
return XMP_LOCATION.test(text);
|
|
73
|
+
}
|
|
74
|
+
/** IPTC-IIM records inside a Photoshop APP13 resource block: the names of the location records with text. */
|
|
75
|
+
function iptcLocations(bytes, start, end) {
|
|
76
|
+
const names = new Set();
|
|
77
|
+
// The IIM stream sits in the "8BIM" resource 0x0404; scan from there for record 2 datasets (0x1C 0x02 <id> <len>).
|
|
78
|
+
let from = -1;
|
|
79
|
+
for (let index = start; index + 6 < end; index += 1) {
|
|
80
|
+
if (ascii(bytes, index, 4) === "8BIM" && bytes[index + 4] === 0x04 && bytes[index + 5] === 0x04) {
|
|
81
|
+
from = index + 6;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (from === -1)
|
|
86
|
+
return [];
|
|
87
|
+
for (let index = from; index + 5 < end; index += 1) {
|
|
88
|
+
if (bytes[index] !== 0x1c || bytes[index + 1] !== 2)
|
|
89
|
+
continue;
|
|
90
|
+
const dataset = bytes[index + 2];
|
|
91
|
+
const length = (bytes[index + 3] << 8) | bytes[index + 4];
|
|
92
|
+
const name = IPTC_LOCATION_RECORDS[dataset];
|
|
93
|
+
if (name && length > 0 && length < 256 && index + 5 + length <= end)
|
|
94
|
+
names.add(name);
|
|
95
|
+
}
|
|
96
|
+
return [...names];
|
|
97
|
+
}
|
|
98
|
+
function describe(tiffGps, xmp, iptc) {
|
|
99
|
+
const found = [];
|
|
100
|
+
if (tiffGps.length > 0)
|
|
101
|
+
found.push(`EXIF GPS (${[...new Set(tiffGps)].join(", ")})`);
|
|
102
|
+
if (xmp)
|
|
103
|
+
found.push("XMP location");
|
|
104
|
+
if (iptc.length > 0)
|
|
105
|
+
found.push(`IPTC ${iptc.join(", ")}`);
|
|
106
|
+
return found;
|
|
107
|
+
}
|
|
108
|
+
function jpeg(bytes) {
|
|
109
|
+
const tiff = [];
|
|
110
|
+
let xmp = false;
|
|
111
|
+
const iptc = [];
|
|
112
|
+
let offset = 2;
|
|
113
|
+
while (offset + 4 <= bytes.length && bytes[offset] === 0xff) {
|
|
114
|
+
const marker = bytes[offset + 1];
|
|
115
|
+
if (marker === 0xff) {
|
|
116
|
+
offset += 1;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (marker === 0xda || marker === 0xd9)
|
|
120
|
+
break;
|
|
121
|
+
if (marker >= 0xd0 && marker <= 0xd7) {
|
|
122
|
+
offset += 2;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const length = (bytes[offset + 2] << 8) | bytes[offset + 3];
|
|
126
|
+
const body = offset + 4;
|
|
127
|
+
const end = Math.min(bytes.length, offset + 2 + length);
|
|
128
|
+
if (marker === 0xe1) {
|
|
129
|
+
if (ascii(bytes, body, 6) === "Exif\0\0")
|
|
130
|
+
tiff.push(...gpsTagsInTiff(bytes, body + 6));
|
|
131
|
+
else if (ascii(bytes, body, 29).startsWith("http://ns.adobe.com/xap/1.0/"))
|
|
132
|
+
xmp ||= xmpFound(ascii(bytes, body, end - body));
|
|
133
|
+
}
|
|
134
|
+
else if (marker === 0xed && ascii(bytes, body, 14) === "Photoshop 3.0\0") {
|
|
135
|
+
iptc.push(...iptcLocations(bytes, body + 14, end));
|
|
136
|
+
}
|
|
137
|
+
offset = end;
|
|
138
|
+
if (length < 2)
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
return { found: describe(tiff, xmp, iptc) };
|
|
142
|
+
}
|
|
143
|
+
function png(bytes) {
|
|
144
|
+
const tiff = [];
|
|
145
|
+
let xmp = false;
|
|
146
|
+
let offset = 8;
|
|
147
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
148
|
+
while (offset + 12 <= bytes.length) {
|
|
149
|
+
const length = view.getUint32(offset);
|
|
150
|
+
const type = ascii(bytes, offset + 4, 4);
|
|
151
|
+
const body = offset + 8;
|
|
152
|
+
if (type === "eXIf")
|
|
153
|
+
tiff.push(...gpsTagsInTiff(bytes, body));
|
|
154
|
+
else if ((type === "iTXt" || type === "tEXt") && ascii(bytes, body, 17).startsWith("XML:com.adobe.xmp"))
|
|
155
|
+
xmp ||= xmpFound(ascii(bytes, body, Math.min(length, 2_000_000)));
|
|
156
|
+
if (type === "IEND")
|
|
157
|
+
break;
|
|
158
|
+
offset = body + length + 4;
|
|
159
|
+
}
|
|
160
|
+
return { found: describe(tiff, xmp, []) };
|
|
161
|
+
}
|
|
162
|
+
function webp(bytes) {
|
|
163
|
+
const tiff = [];
|
|
164
|
+
let xmp = false;
|
|
165
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
166
|
+
let offset = 12;
|
|
167
|
+
while (offset + 8 <= bytes.length) {
|
|
168
|
+
const type = ascii(bytes, offset, 4);
|
|
169
|
+
const length = view.getUint32(offset + 4, true);
|
|
170
|
+
const body = offset + 8;
|
|
171
|
+
if (type === "EXIF") {
|
|
172
|
+
const start = ascii(bytes, body, 6) === "Exif\0\0" ? body + 6 : body;
|
|
173
|
+
tiff.push(...gpsTagsInTiff(bytes, start));
|
|
174
|
+
}
|
|
175
|
+
else if (type === "XMP ") {
|
|
176
|
+
xmp ||= xmpFound(ascii(bytes, body, Math.min(length, 2_000_000)));
|
|
177
|
+
}
|
|
178
|
+
offset = body + length + (length % 2);
|
|
179
|
+
}
|
|
180
|
+
return { found: describe(tiff, xmp, []) };
|
|
181
|
+
}
|
|
182
|
+
/** HEIC, HEIF and AVIF keep EXIF in an item and XMP in another; find each by its signature. */
|
|
183
|
+
function isobmff(bytes) {
|
|
184
|
+
const tiff = [];
|
|
185
|
+
const limit = Math.min(bytes.length, 8_000_000);
|
|
186
|
+
for (let index = 0; index + 12 < limit; index += 1) {
|
|
187
|
+
if (bytes[index] === 0x45 && bytes[index + 1] === 0x78 && bytes[index + 2] === 0x69 && bytes[index + 3] === 0x66) {
|
|
188
|
+
// "Exif": a 4-byte offset to the TIFF header follows the item's tag, then the TIFF header "II*\0" or "MM\0*".
|
|
189
|
+
for (let start = index + 4; start < index + 16 && start + 4 < limit; start += 1) {
|
|
190
|
+
const marker = ascii(bytes, start, 2);
|
|
191
|
+
if ((marker === "II" && bytes[start + 2] === 42 && bytes[start + 3] === 0) || (marker === "MM" && bytes[start + 2] === 0 && bytes[start + 3] === 42)) {
|
|
192
|
+
tiff.push(...gpsTagsInTiff(bytes, start));
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const text = ascii(bytes, 0, limit);
|
|
199
|
+
return { found: describe(tiff, text.includes("<x:xmpmeta") && xmpFound(text), []) };
|
|
200
|
+
}
|
|
201
|
+
/** The location metadata an image file carries; an empty `found` for a clean file or a format this reader skips. */
|
|
202
|
+
export function locationMetadata(bytes) {
|
|
203
|
+
try {
|
|
204
|
+
if (bytes.length > 4 && bytes[0] === 0xff && bytes[1] === 0xd8)
|
|
205
|
+
return jpeg(bytes);
|
|
206
|
+
if (bytes.length > 8 && ascii(bytes, 1, 3) === "PNG")
|
|
207
|
+
return png(bytes);
|
|
208
|
+
if (bytes.length > 12 && ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 4) === "WEBP")
|
|
209
|
+
return webp(bytes);
|
|
210
|
+
if (bytes.length > 12 && ascii(bytes, 4, 4) === "ftyp")
|
|
211
|
+
return isobmff(bytes);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
// an unreadable file reports nothing
|
|
215
|
+
}
|
|
216
|
+
return { found: [] };
|
|
217
|
+
}
|
|
218
|
+
//# sourceMappingURL=exif.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"exif.js","sourceRoot":"","sources":["../../../../src/site-system/rules/sections/exif.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAOH,MAAM,eAAe,GAAG,MAAM,CAAC;AAC/B,MAAM,eAAe,GAAG,MAAM,CAAC;AAC/B,MAAM,aAAa,GAAqC;IACtD,CAAC,EAAE,UAAU,EAAE,CAAC,EAAE,UAAU,EAAE,CAAC,EAAE,WAAW,EAAE,CAAC,EAAE,WAAW,EAAE,CAAC,EAAE,UAAU,EAAE,CAAC,EAAE,UAAU,EAAE,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,mBAAmB,EAAE,EAAE,EAAE,WAAW;CAC5J,CAAC;AAEF,MAAM,YAAY,GAAG,mNAAmN,CAAC;AACzO,MAAM,qBAAqB,GAAqC,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,aAAa,EAAE,EAAE,EAAE,OAAO,EAAE,CAAC;AAE/G,SAAS,KAAK,CAAC,KAAiB,EAAE,KAAa,EAAE,MAAc;IAC7D,IAAI,IAAI,GAAG,EAAE,CAAC;IACd,KAAK,IAAI,KAAK,GAAG,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,EAAE,KAAK,GAAG,MAAM,CAAC,EAAE,KAAK,IAAI,CAAC;QAAE,IAAI,IAAI,MAAM,CAAC,YAAY,CAAC,KAAK,CAAC,KAAK,CAAE,CAAC,CAAC;IAC/H,OAAO,IAAI,CAAC;AACd,CAAC;AAED,kFAAkF;AAClF,MAAM,UAAU,aAAa,CAAC,KAAiB,EAAE,KAAa;IAC5D,MAAM,MAAM,GAAG,KAAK,CAAC,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC;IACtC,IAAI,MAAM,KAAK,IAAI,IAAI,MAAM,KAAK,IAAI;QAAE,OAAO,EAAE,CAAC;IAClD,MAAM,MAAM,GAAG,MAAM,KAAK,IAAI,CAAC;IAC/B,MAAM,IAAI,GAAG,IAAI,QAAQ,CAAC,KAAK,CAAC,MAAM,EAAE,KAAK,CAAC,UAAU,EAAE,KAAK,CAAC,UAAU,CAAC,CAAC;IAC5E,MAAM,GAAG,GAAG,CAAC,MAAc,EAAE,EAAE,CAAC,CAAC,MAAM,GAAG,CAAC,IAAI,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAClG,MAAM,GAAG,GAAG,CAAC,MAAc,EAAE,EAAE,CAAC,CAAC,MAAM,GAAG,CAAC,IAAI,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAClG,IAAI,GAAG,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK,EAAE;QAAE,OAAO,EAAE,CAAC;IAErC,MAAM,OAAO,GAAG,CAAC,SAAiB,EAA0C,EAAE;QAC5E,MAAM,EAAE,GAAG,KAAK,GAAG,SAAS,CAAC;QAC7B,IAAI,SAAS,KAAK,CAAC,IAAI,EAAE,GAAG,CAAC,GAAG,KAAK,CAAC,MAAM;YAAE,OAAO,EAAE,CAAC;QACxD,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,GAAG,CAAC,CAAC;QACrC,MAAM,KAAK,GAA2C,EAAE,CAAC;QACzD,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,KAAK,EAAE,KAAK,IAAI,CAAC,EAAE,CAAC;YAC9C,MAAM,KAAK,GAAG,EAAE,GAAG,CAAC,GAAG,KAAK,GAAG,EAAE,CAAC;YAClC,IAAI,KAAK,GAAG,EAAE,GAAG,KAAK,CAAC,MAAM;gBAAE,MAAM;YACrC,KAAK,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,GAAG,CAAC,KAAK,CAAC,EAAE,WAAW,EAAE,KAAK,GAAG,CAAC,EAAE,CAAC,CAAC;QAC1D,CAAC;QACD,OAAO,KAAK,CAAC;IACf,CAAC,CAAC;IAEF,MAAM,KAAK,GAAG,IAAI,GAAG,EAAU,CAAC;IAChC,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAC;IAC/B,IAAI,GAAG,GAAG,GAAG,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC;IACzB,KAAK,IAAI,GAAG,GAAG,CAAC,EAAE,GAAG,GAAG,CAAC,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,GAAG,IAAI,CAAC,EAAE,CAAC;QACnE,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QACd,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC;QAC1B,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,KAAK,eAAe,CAAC,CAAC;QACpE,IAAI,OAAO,EAAE,CAAC;YACZ,KAAK,MAAM,GAAG,IAAI,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC,EAAE,CAAC;gBACpD,IAAI,GAAG,CAAC,GAAG,KAAK,eAAe;oBAAE,SAAS;gBAC1C,KAAK,CAAC,GAAG,CAAC,aAAa,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,eAAe,CAAC,CAAC;YACvD,CAAC;QACH,CAAC;QACD,MAAM,IAAI,GAAG,KAAK,GAAG,GAAG,GAAG,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,KAAK,GAAG,GAAG,CAAC,EAAE,GAAG,CAAC,GAAG,EAAE,CAAC;QACpE,GAAG,GAAG,IAAI,GAAG,CAAC,IAAI,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IACjD,CAAC;IACD,OAAO,CAAC,GAAG,KAAK,CAAC,CAAC;AACpB,CAAC;AAED,SAAS,QAAQ,CAAC,IAAY;IAC5B,OAAO,YAAY,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACjC,CAAC;AAED,6GAA6G;AAC7G,SAAS,aAAa,CAAC,KAAiB,EAAE,KAAa,EAAE,GAAW;IAClE,MAAM,KAAK,GAAG,IAAI,GAAG,EAAU,CAAC;IAChC,mHAAmH;IACnH,IAAI,IAAI,GAAG,CAAC,CAAC,CAAC;IACd,KAAK,IAAI,KAAK,GAAG,KAAK,EAAE,KAAK,GAAG,CAAC,GAAG,GAAG,EAAE,KAAK,IAAI,CAAC,EAAE,CAAC;QACpD,IAAI,KAAK,CAAC,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC,KAAK,MAAM,IAAI,KAAK,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK,IAAI,IAAI,KAAK,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK,IAAI,EAAE,CAAC;YAChG,IAAI,GAAG,KAAK,GAAG,CAAC,CAAC;YACjB,MAAM;QACR,CAAC;IACH,CAAC;IACD,IAAI,IAAI,KAAK,CAAC,CAAC;QAAE,OAAO,EAAE,CAAC;IAC3B,KAAK,IAAI,KAAK,GAAG,IAAI,EAAE,KAAK,GAAG,CAAC,GAAG,GAAG,EAAE,KAAK,IAAI,CAAC,EAAE,CAAC;QACnD,IAAI,KAAK,CAAC,KAAK,CAAC,KAAK,IAAI,IAAI,KAAK,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC;YAAE,SAAS;QAC9D,MAAM,OAAO,GAAG,KAAK,CAAC,KAAK,GAAG,CAAC,CAAE,CAAC;QAClC,MAAM,MAAM,GAAG,CAAC,KAAK,CAAC,KAAK,GAAG,CAAC,CAAE,IAAI,CAAC,CAAC,GAAG,KAAK,CAAC,KAAK,GAAG,CAAC,CAAE,CAAC;QAC5D,MAAM,IAAI,GAAG,qBAAqB,CAAC,OAAO,CAAC,CAAC;QAC5C,IAAI,IAAI,IAAI,MAAM,GAAG,CAAC,IAAI,MAAM,GAAG,GAAG,IAAI,KAAK,GAAG,CAAC,GAAG,MAAM,IAAI,GAAG;YAAE,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IACvF,CAAC;IACD,OAAO,CAAC,GAAG,KAAK,CAAC,CAAC;AACpB,CAAC;AAED,SAAS,QAAQ,CAAC,OAA0B,EAAE,GAAY,EAAE,IAAuB;IACjF,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,IAAI,OAAO,CAAC,MAAM,GAAG,CAAC;QAAE,KAAK,CAAC,IAAI,CAAC,aAAa,CAAC,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IACrF,IAAI,GAAG;QAAE,KAAK,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;IACpC,IAAI,IAAI,CAAC,MAAM,GAAG,CAAC;QAAE,KAAK,CAAC,IAAI,CAAC,QAAQ,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IAC3D,OAAO,KAAK,CAAC;AACf,CAAC;AAED,SAAS,IAAI,CAAC,KAAiB;IAC7B,MAAM,IAAI,GAAa,EAAE,CAAC;IAC1B,IAAI,GAAG,GAAG,KAAK,CAAC;IAChB,MAAM,IAAI,GAAa,EAAE,CAAC;IAC1B,IAAI,MAAM,GAAG,CAAC,CAAC;IACf,OAAO,MAAM,GAAG,CAAC,IAAI,KAAK,CAAC,MAAM,IAAI,KAAK,CAAC,MAAM,CAAC,KAAK,IAAI,EAAE,CAAC;QAC5D,MAAM,MAAM,GAAG,KAAK,CAAC,MAAM,GAAG,CAAC,CAAE,CAAC;QAClC,IAAI,MAAM,KAAK,IAAI,EAAE,CAAC;YACpB,MAAM,IAAI,CAAC,CAAC;YACZ,SAAS;QACX,CAAC;QACD,IAAI,MAAM,KAAK,IAAI,IAAI,MAAM,KAAK,IAAI;YAAE,MAAM;QAC9C,IAAI,MAAM,IAAI,IAAI,IAAI,MAAM,IAAI,IAAI,EAAE,CAAC;YACrC,MAAM,IAAI,CAAC,CAAC;YACZ,SAAS;QACX,CAAC;QACD,MAAM,MAAM,GAAG,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAE,IAAI,CAAC,CAAC,GAAG,KAAK,CAAC,MAAM,GAAG,CAAC,CAAE,CAAC;QAC9D,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,CAAC;QACxB,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,EAAE,MAAM,GAAG,CAAC,GAAG,MAAM,CAAC,CAAC;QACxD,IAAI,MAAM,KAAK,IAAI,EAAE,CAAC;YACpB,IAAI,KAAK,CAAC,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,KAAK,UAAU;gBAAE,IAAI,CAAC,IAAI,CAAC,GAAG,aAAa,CAAC,KAAK,EAAE,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC;iBAClF,IAAI,KAAK,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE,CAAC,CAAC,UAAU,CAAC,8BAA8B,CAAC;gBAAE,GAAG,KAAK,QAAQ,CAAC,KAAK,CAAC,KAAK,EAAE,IAAI,EAAE,GAAG,GAAG,IAAI,CAAC,CAAC,CAAC;QAC/H,CAAC;aAAM,IAAI,MAAM,KAAK,IAAI,IAAI,KAAK,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE,CAAC,KAAK,iBAAiB,EAAE,CAAC;YAC3E,IAAI,CAAC,IAAI,CAAC,GAAG,aAAa,CAAC,KAAK,EAAE,IAAI,GAAG,EAAE,EAAE,GAAG,CAAC,CAAC,CAAC;QACrD,CAAC;QACD,MAAM,GAAG,GAAG,CAAC;QACb,IAAI,MAAM,GAAG,CAAC;YAAE,MAAM;IACxB,CAAC;IACD,OAAO,EAAE,KAAK,EAAE,QAAQ,CAAC,IAAI,EAAE,GAAG,EAAE,IAAI,CAAC,EAAE,CAAC;AAC9C,CAAC;AAED,SAAS,GAAG,CAAC,KAAiB;IAC5B,MAAM,IAAI,GAAa,EAAE,CAAC;IAC1B,IAAI,GAAG,GAAG,KAAK,CAAC;IAChB,IAAI,MAAM,GAAG,CAAC,CAAC;IACf,MAAM,IAAI,GAAG,IAAI,QAAQ,CAAC,KAAK,CAAC,MAAM,EAAE,KAAK,CAAC,UAAU,EAAE,KAAK,CAAC,UAAU,CAAC,CAAC;IAC5E,OAAO,MAAM,GAAG,EAAE,IAAI,KAAK,CAAC,MAAM,EAAE,CAAC;QACnC,MAAM,MAAM,GAAG,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC;QACtC,MAAM,IAAI,GAAG,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;QACzC,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,CAAC;QACxB,IAAI,IAAI,KAAK,MAAM;YAAE,IAAI,CAAC,IAAI,CAAC,GAAG,aAAa,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC,CAAC;aACzD,IAAI,CAAC,IAAI,KAAK,MAAM,IAAI,IAAI,KAAK,MAAM,CAAC,IAAI,KAAK,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE,CAAC,CAAC,UAAU,CAAC,mBAAmB,CAAC;YAAE,GAAG,KAAK,QAAQ,CAAC,KAAK,CAAC,KAAK,EAAE,IAAI,EAAE,IAAI,CAAC,GAAG,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC,CAAC;QAC3K,IAAI,IAAI,KAAK,MAAM;YAAE,MAAM;QAC3B,MAAM,GAAG,IAAI,GAAG,MAAM,GAAG,CAAC,CAAC;IAC7B,CAAC;IACD,OAAO,EAAE,KAAK,EAAE,QAAQ,CAAC,IAAI,EAAE,GAAG,EAAE,EAAE,CAAC,EAAE,CAAC;AAC5C,CAAC;AAED,SAAS,IAAI,CAAC,KAAiB;IAC7B,MAAM,IAAI,GAAa,EAAE,CAAC;IAC1B,IAAI,GAAG,GAAG,KAAK,CAAC;IAChB,MAAM,IAAI,GAAG,IAAI,QAAQ,CAAC,KAAK,CAAC,MAAM,EAAE,KAAK,CAAC,UAAU,EAAE,KAAK,CAAC,UAAU,CAAC,CAAC;IAC5E,IAAI,MAAM,GAAG,EAAE,CAAC;IAChB,OAAO,MAAM,GAAG,CAAC,IAAI,KAAK,CAAC,MAAM,EAAE,CAAC;QAClC,MAAM,IAAI,GAAG,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC;QACrC,MAAM,MAAM,GAAG,IAAI,CAAC,SAAS,CAAC,MAAM,GAAG,CAAC,EAAE,IAAI,CAAC,CAAC;QAChD,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,CAAC;QACxB,IAAI,IAAI,KAAK,MAAM,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,KAAK,UAAU,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;YACrE,IAAI,CAAC,IAAI,CAAC,GAAG,aAAa,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC;QAC5C,CAAC;aAAM,IAAI,IAAI,KAAK,MAAM,EAAE,CAAC;YAC3B,GAAG,KAAK,QAAQ,CAAC,KAAK,CAAC,KAAK,EAAE,IAAI,EAAE,IAAI,CAAC,GAAG,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC,CAAC;QACpE,CAAC;QACD,MAAM,GAAG,IAAI,GAAG,MAAM,GAAG,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;IACxC,CAAC;IACD,OAAO,EAAE,KAAK,EAAE,QAAQ,CAAC,IAAI,EAAE,GAAG,EAAE,EAAE,CAAC,EAAE,CAAC;AAC5C,CAAC;AAED,+FAA+F;AAC/F,SAAS,OAAO,CAAC,KAAiB;IAChC,MAAM,IAAI,GAAa,EAAE,CAAC;IAC1B,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;IAChD,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,EAAE,GAAG,KAAK,EAAE,KAAK,IAAI,CAAC,EAAE,CAAC;QACnD,IAAI,KAAK,CAAC,KAAK,CAAC,KAAK,IAAI,IAAI,KAAK,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK,IAAI,IAAI,KAAK,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK,IAAI,IAAI,KAAK,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK,IAAI,EAAE,CAAC;YACjH,8GAA8G;YAC9G,KAAK,IAAI,KAAK,GAAG,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,KAAK,GAAG,EAAE,IAAI,KAAK,GAAG,CAAC,GAAG,KAAK,EAAE,KAAK,IAAI,CAAC,EAAE,CAAC;gBAChF,MAAM,MAAM,GAAG,KAAK,CAAC,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC;gBACtC,IAAI,CAAC,MAAM,KAAK,IAAI,IAAI,KAAK,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK,EAAE,IAAI,KAAK,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,MAAM,KAAK,IAAI,IAAI,KAAK,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC,IAAI,KAAK,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK,EAAE,CAAC,EAAE,CAAC;oBACrJ,IAAI,CAAC,IAAI,CAAC,GAAG,aAAa,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC;oBAC1C,MAAM;gBACR,CAAC;YACH,CAAC;QACH,CAAC;IACH,CAAC;IACD,MAAM,IAAI,GAAG,KAAK,CAAC,KAAK,EAAE,CAAC,EAAE,KAAK,CAAC,CAAC;IACpC,OAAO,EAAE,KAAK,EAAE,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC,QAAQ,CAAC,YAAY,CAAC,IAAI,QAAQ,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC,EAAE,CAAC;AACtF,CAAC;AAED,oHAAoH;AACpH,MAAM,UAAU,gBAAgB,CAAC,KAAiB;IAChD,IAAI,CAAC;QACH,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,IAAI,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,IAAI;YAAE,OAAO,IAAI,CAAC,KAAK,CAAC,CAAC;QACnF,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,IAAI,KAAK,CAAC,KAAK,EAAE,CAAC,EAAE,CAAC,CAAC,KAAK,KAAK;YAAE,OAAO,GAAG,CAAC,KAAK,CAAC,CAAC;QACxE,IAAI,KAAK,CAAC,MAAM,GAAG,EAAE,IAAI,KAAK,CAAC,KAAK,EAAE,CAAC,EAAE,CAAC,CAAC,KAAK,MAAM,IAAI,KAAK,CAAC,KAAK,EAAE,CAAC,EAAE,CAAC,CAAC,KAAK,MAAM;YAAE,OAAO,IAAI,CAAC,KAAK,CAAC,CAAC;QAC5G,IAAI,KAAK,CAAC,MAAM,GAAG,EAAE,IAAI,KAAK,CAAC,KAAK,EAAE,CAAC,EAAE,CAAC,CAAC,KAAK,MAAM;YAAE,OAAO,OAAO,CAAC,KAAK,CAAC,CAAC;IAChF,CAAC;IAAC,MAAM,CAAC;QACP,qCAAqC;IACvC,CAAC;IACD,OAAO,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC;AACvB,CAAC","sourcesContent":["/**\n * Reads location metadata out of an image file without a dependency (T9). A photo straight off a phone carries the GPS\n * position of the job site, and often the street and suburb; none of it may ship in `public/`.\n *\n * What counts as location metadata:\n * - an EXIF GPS block with any tag beyond the version (latitude, longitude, altitude, a timestamp, a processing method);\n * - XMP `exif:GPS*` properties and `photoshop:City`, `photoshop:State` and `Iptc4xmpCore:Location` (sublocation);\n * - IPTC-IIM city (2:90), sublocation (2:92) and province or state (2:95) records.\n *\n * Formats: JPEG (APP1 EXIF and XMP, APP13 IPTC), PNG (`eXIf`, and `iTXt`/`tEXt` XMP), WebP (`EXIF` and `XMP ` chunks)\n * and HEIC/HEIF/AVIF (the `Exif` item is found by its TIFF header, and XMP by its packet; this is a scan, not a full\n * ISO box parser). A file in none of those formats, or one the reader cannot walk, reports nothing.\n */\n\nexport interface LocationMetadata {\n /** Short names of what was found, for example \"EXIF GPS (latitude, longitude)\". */\n readonly found: readonly string[];\n}\n\nconst GPS_IFD_POINTER = 0x8825;\nconst GPS_VERSION_TAG = 0x0000;\nconst GPS_TAG_NAMES: Readonly<Record<number, string>> = {\n 1: \"latitude\", 2: \"latitude\", 3: \"longitude\", 4: \"longitude\", 5: \"altitude\", 6: \"altitude\", 7: \"time\", 29: \"date\", 27: \"processing method\", 28: \"area name\",\n};\n\nconst XMP_LOCATION = /<exif:GPS(?:Latitude|Longitude|Altitude|Position)\\b|\\bexif:GPS(?:Latitude|Longitude|Altitude)\\s*=|<photoshop:(?:City|State)\\b|\\bphotoshop:(?:City|State)\\s*=|<Iptc4xmpCore:Location\\b|\\bIptc4xmpCore:Location\\s*=/;\nconst IPTC_LOCATION_RECORDS: Readonly<Record<number, string>> = { 90: \"city\", 92: \"sublocation\", 95: \"state\" };\n\nfunction ascii(bytes: Uint8Array, start: number, length: number): string {\n let text = \"\";\n for (let index = start; index < Math.min(bytes.length, start + length); index += 1) text += String.fromCharCode(bytes[index]!);\n return text;\n}\n\n/** Reads a TIFF block (EXIF) and returns the names of the GPS tags it carries. */\nexport function gpsTagsInTiff(bytes: Uint8Array, start: number): string[] {\n const header = ascii(bytes, start, 2);\n if (header !== \"II\" && header !== \"MM\") return [];\n const little = header === \"II\";\n const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);\n const u16 = (offset: number) => (offset + 2 <= bytes.length ? view.getUint16(offset, little) : 0);\n const u32 = (offset: number) => (offset + 4 <= bytes.length ? view.getUint32(offset, little) : 0);\n if (u16(start + 2) !== 42) return [];\n\n const entries = (ifdOffset: number): { tag: number; valueOffset: number }[] => {\n const at = start + ifdOffset;\n if (ifdOffset === 0 || at + 2 > bytes.length) return [];\n const count = Math.min(u16(at), 512);\n const found: { tag: number; valueOffset: number }[] = [];\n for (let index = 0; index < count; index += 1) {\n const entry = at + 2 + index * 12;\n if (entry + 12 > bytes.length) break;\n found.push({ tag: u16(entry), valueOffset: entry + 8 });\n }\n return found;\n };\n\n const names = new Set<string>();\n const seen = new Set<number>();\n let ifd = u32(start + 4);\n for (let hop = 0; hop < 4 && ifd !== 0 && !seen.has(ifd); hop += 1) {\n seen.add(ifd);\n const list = entries(ifd);\n const pointer = list.find((entry) => entry.tag === GPS_IFD_POINTER);\n if (pointer) {\n for (const gps of entries(u32(pointer.valueOffset))) {\n if (gps.tag === GPS_VERSION_TAG) continue;\n names.add(GPS_TAG_NAMES[gps.tag] ?? \"position data\");\n }\n }\n const next = start + ifd + 2 + Math.min(u16(start + ifd), 512) * 12;\n ifd = next + 4 <= bytes.length ? u32(next) : 0;\n }\n return [...names];\n}\n\nfunction xmpFound(text: string): boolean {\n return XMP_LOCATION.test(text);\n}\n\n/** IPTC-IIM records inside a Photoshop APP13 resource block: the names of the location records with text. */\nfunction iptcLocations(bytes: Uint8Array, start: number, end: number): string[] {\n const names = new Set<string>();\n // The IIM stream sits in the \"8BIM\" resource 0x0404; scan from there for record 2 datasets (0x1C 0x02 <id> <len>).\n let from = -1;\n for (let index = start; index + 6 < end; index += 1) {\n if (ascii(bytes, index, 4) === \"8BIM\" && bytes[index + 4] === 0x04 && bytes[index + 5] === 0x04) {\n from = index + 6;\n break;\n }\n }\n if (from === -1) return [];\n for (let index = from; index + 5 < end; index += 1) {\n if (bytes[index] !== 0x1c || bytes[index + 1] !== 2) continue;\n const dataset = bytes[index + 2]!;\n const length = (bytes[index + 3]! << 8) | bytes[index + 4]!;\n const name = IPTC_LOCATION_RECORDS[dataset];\n if (name && length > 0 && length < 256 && index + 5 + length <= end) names.add(name);\n }\n return [...names];\n}\n\nfunction describe(tiffGps: readonly string[], xmp: boolean, iptc: readonly string[]): string[] {\n const found: string[] = [];\n if (tiffGps.length > 0) found.push(`EXIF GPS (${[...new Set(tiffGps)].join(\", \")})`);\n if (xmp) found.push(\"XMP location\");\n if (iptc.length > 0) found.push(`IPTC ${iptc.join(\", \")}`);\n return found;\n}\n\nfunction jpeg(bytes: Uint8Array): LocationMetadata {\n const tiff: string[] = [];\n let xmp = false;\n const iptc: string[] = [];\n let offset = 2;\n while (offset + 4 <= bytes.length && bytes[offset] === 0xff) {\n const marker = bytes[offset + 1]!;\n if (marker === 0xff) {\n offset += 1;\n continue;\n }\n if (marker === 0xda || marker === 0xd9) break;\n if (marker >= 0xd0 && marker <= 0xd7) {\n offset += 2;\n continue;\n }\n const length = (bytes[offset + 2]! << 8) | bytes[offset + 3]!;\n const body = offset + 4;\n const end = Math.min(bytes.length, offset + 2 + length);\n if (marker === 0xe1) {\n if (ascii(bytes, body, 6) === \"Exif\\0\\0\") tiff.push(...gpsTagsInTiff(bytes, body + 6));\n else if (ascii(bytes, body, 29).startsWith(\"http://ns.adobe.com/xap/1.0/\")) xmp ||= xmpFound(ascii(bytes, body, end - body));\n } else if (marker === 0xed && ascii(bytes, body, 14) === \"Photoshop 3.0\\0\") {\n iptc.push(...iptcLocations(bytes, body + 14, end));\n }\n offset = end;\n if (length < 2) break;\n }\n return { found: describe(tiff, xmp, iptc) };\n}\n\nfunction png(bytes: Uint8Array): LocationMetadata {\n const tiff: string[] = [];\n let xmp = false;\n let offset = 8;\n const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);\n while (offset + 12 <= bytes.length) {\n const length = view.getUint32(offset);\n const type = ascii(bytes, offset + 4, 4);\n const body = offset + 8;\n if (type === \"eXIf\") tiff.push(...gpsTagsInTiff(bytes, body));\n else if ((type === \"iTXt\" || type === \"tEXt\") && ascii(bytes, body, 17).startsWith(\"XML:com.adobe.xmp\")) xmp ||= xmpFound(ascii(bytes, body, Math.min(length, 2_000_000)));\n if (type === \"IEND\") break;\n offset = body + length + 4;\n }\n return { found: describe(tiff, xmp, []) };\n}\n\nfunction webp(bytes: Uint8Array): LocationMetadata {\n const tiff: string[] = [];\n let xmp = false;\n const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);\n let offset = 12;\n while (offset + 8 <= bytes.length) {\n const type = ascii(bytes, offset, 4);\n const length = view.getUint32(offset + 4, true);\n const body = offset + 8;\n if (type === \"EXIF\") {\n const start = ascii(bytes, body, 6) === \"Exif\\0\\0\" ? body + 6 : body;\n tiff.push(...gpsTagsInTiff(bytes, start));\n } else if (type === \"XMP \") {\n xmp ||= xmpFound(ascii(bytes, body, Math.min(length, 2_000_000)));\n }\n offset = body + length + (length % 2);\n }\n return { found: describe(tiff, xmp, []) };\n}\n\n/** HEIC, HEIF and AVIF keep EXIF in an item and XMP in another; find each by its signature. */\nfunction isobmff(bytes: Uint8Array): LocationMetadata {\n const tiff: string[] = [];\n const limit = Math.min(bytes.length, 8_000_000);\n for (let index = 0; index + 12 < limit; index += 1) {\n if (bytes[index] === 0x45 && bytes[index + 1] === 0x78 && bytes[index + 2] === 0x69 && bytes[index + 3] === 0x66) {\n // \"Exif\": a 4-byte offset to the TIFF header follows the item's tag, then the TIFF header \"II*\\0\" or \"MM\\0*\".\n for (let start = index + 4; start < index + 16 && start + 4 < limit; start += 1) {\n const marker = ascii(bytes, start, 2);\n if ((marker === \"II\" && bytes[start + 2] === 42 && bytes[start + 3] === 0) || (marker === \"MM\" && bytes[start + 2] === 0 && bytes[start + 3] === 42)) {\n tiff.push(...gpsTagsInTiff(bytes, start));\n break;\n }\n }\n }\n }\n const text = ascii(bytes, 0, limit);\n return { found: describe(tiff, text.includes(\"<x:xmpmeta\") && xmpFound(text), []) };\n}\n\n/** The location metadata an image file carries; an empty `found` for a clean file or a format this reader skips. */\nexport function locationMetadata(bytes: Uint8Array): LocationMetadata {\n try {\n if (bytes.length > 4 && bytes[0] === 0xff && bytes[1] === 0xd8) return jpeg(bytes);\n if (bytes.length > 8 && ascii(bytes, 1, 3) === \"PNG\") return png(bytes);\n if (bytes.length > 12 && ascii(bytes, 0, 4) === \"RIFF\" && ascii(bytes, 8, 4) === \"WEBP\") return webp(bytes);\n if (bytes.length > 12 && ascii(bytes, 4, 4) === \"ftyp\") return isobmff(bytes);\n } catch {\n // an unreadable file reports nothing\n }\n return { found: [] };\n}\n"]}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type HtmlElement } from "../enquiry/html.js";
|
|
2
|
+
export declare const IMAGE_EXTENSIONS: readonly [".jpg", ".jpeg", ".png", ".webp", ".avif", ".gif", ".heic", ".heif", ".svg"];
|
|
3
|
+
/** Files that are the business's mark or the browser's icon, not a photograph: they need no provenance. */
|
|
4
|
+
export declare const MEDIA_EXEMPT_NAME: RegExp;
|
|
5
|
+
/** The file behind an `<img>` source, or null for an inline `data:` image or an empty source. */
|
|
6
|
+
export declare function imageFile(source: string): string | null;
|
|
7
|
+
/** An image the page shows, with the element it came from. */
|
|
8
|
+
export interface PageImage {
|
|
9
|
+
readonly file: string;
|
|
10
|
+
readonly element: HtmlElement;
|
|
11
|
+
}
|
|
12
|
+
export declare function isAvatar(element: HtmlElement): boolean;
|
|
13
|
+
/** The image an `<img>` element shows: its `src`, or the first `srcset` candidate. Null for any other element. */
|
|
14
|
+
export declare function imageOf(element: HtmlElement): PageImage | null;
|
|
15
|
+
/** The images inside `scope`, in document order. */
|
|
16
|
+
export declare function imagesIn(scope: HtmlElement): PageImage[];
|
|
17
|
+
/** Whether the image needs a media.ts entry: not a logo, icon or SVG, and not a third-party profile photo. */
|
|
18
|
+
export declare function needsMediaEntry(image: PageImage): boolean;
|
|
19
|
+
export interface PublicImage {
|
|
20
|
+
/** The URL path the file is served at: `/images/jobs/a.jpg`. */
|
|
21
|
+
readonly web: string;
|
|
22
|
+
readonly file: string;
|
|
23
|
+
}
|
|
24
|
+
/** Every image file under the project's `public/` folder, and how many directories could not be read. */
|
|
25
|
+
export declare function scanPublicImages(projectRoot: string): {
|
|
26
|
+
images: PublicImage[];
|
|
27
|
+
unreadableDirectories: number;
|
|
28
|
+
};
|
|
29
|
+
/** Every image file under the project's `public/` folder (none when it has no such folder). */
|
|
30
|
+
export declare function publicImages(projectRoot: string): PublicImage[];
|
|
31
|
+
//# sourceMappingURL=images.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"images.d.ts","sourceRoot":"","sources":["../../../../src/site-system/rules/sections/images.ts"],"names":[],"mappings":"AAMA,OAAO,EAAqB,KAAK,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAEzE,eAAO,MAAM,gBAAgB,wFAAyF,CAAC;AAEvH,2GAA2G;AAC3G,eAAO,MAAM,iBAAiB,QAAqG,CAAC;AAEpI,iGAAiG;AACjG,wBAAgB,SAAS,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAiBvD;AAED,8DAA8D;AAC9D,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC;CAC/B;AAMD,wBAAgB,QAAQ,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAKtD;AAED,kHAAkH;AAClH,wBAAgB,OAAO,CAAC,OAAO,EAAE,WAAW,GAAG,SAAS,GAAG,IAAI,CAK9D;AAED,oDAAoD;AACpD,wBAAgB,QAAQ,CAAC,KAAK,EAAE,WAAW,GAAG,SAAS,EAAE,CAExD;AAED,8GAA8G;AAC9G,wBAAgB,eAAe,CAAC,KAAK,EAAE,SAAS,GAAG,OAAO,CAGzD;AAED,MAAM,WAAW,WAAW;IAC1B,gEAAgE;IAChE,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED,yGAAyG;AACzG,wBAAgB,gBAAgB,CAAC,WAAW,EAAE,MAAM,GAAG;IAAE,MAAM,EAAE,WAAW,EAAE,CAAC;IAAC,qBAAqB,EAAE,MAAM,CAAA;CAAE,CAwB9G;AAED,+FAA+F;AAC/F,wBAAgB,YAAY,CAAC,WAAW,EAAE,MAAM,GAAG,WAAW,EAAE,CAE/D"}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the media rules (T6 and T7) read an image from the rendered HTML: the file it comes from, whatever `next/image`
|
|
3
|
+
* did to the URL. `next/image` writes `/_next/image?url=%2Fimages%2Fa.jpg&w=640&q=75`; the file is the `url` value.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
6
|
+
import { extname, join } from "node:path";
|
|
7
|
+
import { attr, descendants } from "../enquiry/html.js";
|
|
8
|
+
export const IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".avif", ".gif", ".heic", ".heif", ".svg"];
|
|
9
|
+
/** Files that are the business's mark or the browser's icon, not a photograph: they need no provenance. */
|
|
10
|
+
export const MEDIA_EXEMPT_NAME = /(?:^|[\/._-])(?:logo|favicon|icon|apple-touch-icon|android-chrome|mstile|sprite)(?:[\/._\d-]|$)/i;
|
|
11
|
+
/** The file behind an `<img>` source, or null for an inline `data:` image or an empty source. */
|
|
12
|
+
export function imageFile(source) {
|
|
13
|
+
let value = source.trim();
|
|
14
|
+
if (!value || value.startsWith("data:") || value.startsWith("blob:"))
|
|
15
|
+
return null;
|
|
16
|
+
if (/^(?:https?:)?\/\/[^/]+\/_next\/image\?/i.test(value) || value.startsWith("/_next/image?")) {
|
|
17
|
+
const query = value.slice(value.indexOf("?") + 1);
|
|
18
|
+
const inner = new URLSearchParams(query).get("url");
|
|
19
|
+
if (!inner)
|
|
20
|
+
return null;
|
|
21
|
+
value = inner;
|
|
22
|
+
}
|
|
23
|
+
const cut = value.search(/[?#]/);
|
|
24
|
+
if (cut !== -1)
|
|
25
|
+
value = value.slice(0, cut);
|
|
26
|
+
try {
|
|
27
|
+
value = decodeURI(value);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// keep the source as written
|
|
31
|
+
}
|
|
32
|
+
return value || null;
|
|
33
|
+
}
|
|
34
|
+
function firstSrcsetUrl(srcset) {
|
|
35
|
+
return srcset?.split(",")[0]?.trim().split(/\s+/)[0];
|
|
36
|
+
}
|
|
37
|
+
export function isAvatar(element) {
|
|
38
|
+
for (let current = element; current && current.tag !== "#root"; current = current.parent) {
|
|
39
|
+
if (attr(current, "data-slot") === "avatar")
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
/** The image an `<img>` element shows: its `src`, or the first `srcset` candidate. Null for any other element. */
|
|
45
|
+
export function imageOf(element) {
|
|
46
|
+
if (element.tag !== "img")
|
|
47
|
+
return null;
|
|
48
|
+
const source = attr(element, "src") ?? firstSrcsetUrl(attr(element, "srcset"));
|
|
49
|
+
const file = source ? imageFile(source) : null;
|
|
50
|
+
return file ? { file, element } : null;
|
|
51
|
+
}
|
|
52
|
+
/** The images inside `scope`, in document order. */
|
|
53
|
+
export function imagesIn(scope) {
|
|
54
|
+
return descendants(scope).flatMap((element) => imageOf(element) ?? []);
|
|
55
|
+
}
|
|
56
|
+
/** Whether the image needs a media.ts entry: not a logo, icon or SVG, and not a third-party profile photo. */
|
|
57
|
+
export function needsMediaEntry(image) {
|
|
58
|
+
if (/\.svg$/i.test(image.file) || MEDIA_EXEMPT_NAME.test(image.file) || isAvatar(image.element))
|
|
59
|
+
return false;
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
/** Every image file under the project's `public/` folder, and how many directories could not be read. */
|
|
63
|
+
export function scanPublicImages(projectRoot) {
|
|
64
|
+
const root = join(projectRoot, "public");
|
|
65
|
+
const images = [];
|
|
66
|
+
let unreadableDirectories = 0;
|
|
67
|
+
if (!existsSync(root))
|
|
68
|
+
return { images, unreadableDirectories };
|
|
69
|
+
const visit = (directory, web) => {
|
|
70
|
+
let entries;
|
|
71
|
+
try {
|
|
72
|
+
entries = readdirSync(directory, { withFileTypes: true });
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
unreadableDirectories += 1;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
79
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules")
|
|
80
|
+
continue;
|
|
81
|
+
const path = join(directory, entry.name);
|
|
82
|
+
if (entry.isDirectory())
|
|
83
|
+
visit(path, `${web}/${entry.name}`);
|
|
84
|
+
else if (entry.isFile() && IMAGE_EXTENSIONS.includes(extname(entry.name).toLowerCase())) {
|
|
85
|
+
images.push({ web: `${web}/${entry.name}`, file: path });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
visit(root, "");
|
|
90
|
+
return { images, unreadableDirectories };
|
|
91
|
+
}
|
|
92
|
+
/** Every image file under the project's `public/` folder (none when it has no such folder). */
|
|
93
|
+
export function publicImages(projectRoot) {
|
|
94
|
+
return scanPublicImages(projectRoot).images;
|
|
95
|
+
}
|
|
96
|
+
//# sourceMappingURL=images.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"images.js","sourceRoot":"","sources":["../../../../src/site-system/rules/sections/images.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * How the media rules (T6 and T7) read an image from the rendered HTML: the file it comes from, whatever `next/image`\n * did to the URL. `next/image` writes `/_next/image?url=%2Fimages%2Fa.jpg&w=640&q=75`; the file is the `url` value.\n */\nimport { existsSync, readdirSync, type Dirent } from \"node:fs\";\nimport { extname, join } from \"node:path\";\nimport { attr, descendants, type HtmlElement } from \"../enquiry/html.js\";\n\nexport const IMAGE_EXTENSIONS = [\".jpg\", \".jpeg\", \".png\", \".webp\", \".avif\", \".gif\", \".heic\", \".heif\", \".svg\"] as const;\n\n/** Files that are the business's mark or the browser's icon, not a photograph: they need no provenance. */\nexport const MEDIA_EXEMPT_NAME = /(?:^|[\\/._-])(?:logo|favicon|icon|apple-touch-icon|android-chrome|mstile|sprite)(?:[\\/._\\d-]|$)/i;\n\n/** The file behind an `<img>` source, or null for an inline `data:` image or an empty source. */\nexport function imageFile(source: string): string | null {\n let value = source.trim();\n if (!value || value.startsWith(\"data:\") || value.startsWith(\"blob:\")) return null;\n if (/^(?:https?:)?\\/\\/[^/]+\\/_next\\/image\\?/i.test(value) || value.startsWith(\"/_next/image?\")) {\n const query = value.slice(value.indexOf(\"?\") + 1);\n const inner = new URLSearchParams(query).get(\"url\");\n if (!inner) return null;\n value = inner;\n }\n const cut = value.search(/[?#]/);\n if (cut !== -1) value = value.slice(0, cut);\n try {\n value = decodeURI(value);\n } catch {\n // keep the source as written\n }\n return value || null;\n}\n\n/** An image the page shows, with the element it came from. */\nexport interface PageImage {\n readonly file: string;\n readonly element: HtmlElement;\n}\n\nfunction firstSrcsetUrl(srcset: string | undefined): string | undefined {\n return srcset?.split(\",\")[0]?.trim().split(/\\s+/)[0];\n}\n\nexport function isAvatar(element: HtmlElement): boolean {\n for (let current: HtmlElement | null = element; current && current.tag !== \"#root\"; current = current.parent) {\n if (attr(current, \"data-slot\") === \"avatar\") return true;\n }\n return false;\n}\n\n/** The image an `<img>` element shows: its `src`, or the first `srcset` candidate. Null for any other element. */\nexport function imageOf(element: HtmlElement): PageImage | null {\n if (element.tag !== \"img\") return null;\n const source = attr(element, \"src\") ?? firstSrcsetUrl(attr(element, \"srcset\"));\n const file = source ? imageFile(source) : null;\n return file ? { file, element } : null;\n}\n\n/** The images inside `scope`, in document order. */\nexport function imagesIn(scope: HtmlElement): PageImage[] {\n return descendants(scope).flatMap((element) => imageOf(element) ?? []);\n}\n\n/** Whether the image needs a media.ts entry: not a logo, icon or SVG, and not a third-party profile photo. */\nexport function needsMediaEntry(image: PageImage): boolean {\n if (/\\.svg$/i.test(image.file) || MEDIA_EXEMPT_NAME.test(image.file) || isAvatar(image.element)) return false;\n return true;\n}\n\nexport interface PublicImage {\n /** The URL path the file is served at: `/images/jobs/a.jpg`. */\n readonly web: string;\n readonly file: string;\n}\n\n/** Every image file under the project's `public/` folder, and how many directories could not be read. */\nexport function scanPublicImages(projectRoot: string): { images: PublicImage[]; unreadableDirectories: number } {\n const root = join(projectRoot, \"public\");\n const images: PublicImage[] = [];\n let unreadableDirectories = 0;\n if (!existsSync(root)) return { images, unreadableDirectories };\n const visit = (directory: string, web: string) => {\n let entries: Dirent[];\n try {\n entries = readdirSync(directory, { withFileTypes: true });\n } catch {\n unreadableDirectories += 1;\n return;\n }\n for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {\n if (entry.name.startsWith(\".\") || entry.name === \"node_modules\") continue;\n const path = join(directory, entry.name);\n if (entry.isDirectory()) visit(path, `${web}/${entry.name}`);\n else if (entry.isFile() && (IMAGE_EXTENSIONS as readonly string[]).includes(extname(entry.name).toLowerCase())) {\n images.push({ web: `${web}/${entry.name}`, file: path });\n }\n }\n };\n visit(root, \"\");\n return { images, unreadableDirectories };\n}\n\n/** Every image file under the project's `public/` folder (none when it has no such folder). */\nexport function publicImages(projectRoot: string): PublicImage[] {\n return scanPublicImages(projectRoot).images;\n}\n"]}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sections rule module (release 3.11.0): N9 footer content, K2 skin coverage, K11 density, K12-contact, T6 media
|
|
3
|
+
* entries, T7 main images, T8 prices and T9 location metadata. The rendered rules read the sampled pages' server HTML;
|
|
4
|
+
* T6 and T9 also read the project's `src/data/media.ts` and `public/` images.
|
|
5
|
+
*/
|
|
6
|
+
import type { SiteSystemFinding } from "../../checks.js";
|
|
7
|
+
import { type EnquiryRuleInput } from "../enquiry/common.js";
|
|
8
|
+
export { footerColumns, footerOf, footerProblems, checkN9 } from "./n9-footer.js";
|
|
9
|
+
export { K2_REFERENCE_CLASS, K2_SKINNED_HOOKS, K2_SKINNED_SECTIONS, checkK2, hooksStyledBySite } from "./k2-skin-coverage.js";
|
|
10
|
+
export { K11_MAX_CALLS_TO_ACTION, K11_MAX_CARDS, callsToActionIn, cardsIn, checkK11 } from "./k11-density.js";
|
|
11
|
+
export { K12_CONTACT_ORDER, checkK12Contact } from "./k12-contact.js";
|
|
12
|
+
export { T6_MEDIA_PATH, T6_PROVENANCES, checkT6, readMediaManifest } from "./t6-media-entries.js";
|
|
13
|
+
export { checkT7, mainImageOf } from "./t7-main-images.js";
|
|
14
|
+
export { checkT8 } from "./t8-prices.js";
|
|
15
|
+
export { checkT9 } from "./t9-location-metadata.js";
|
|
16
|
+
export { gpsTagsInTiff, locationMetadata } from "./exif.js";
|
|
17
|
+
export { imageFile, imagesIn, publicImages } from "./images.js";
|
|
18
|
+
/** Runs N9, K2, K11, K12-contact, T6, T7, T8 and T9. */
|
|
19
|
+
export declare function runSectionRules(input: EnquiryRuleInput): Promise<readonly SiteSystemFinding[]>;
|
|
20
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/site-system/rules/sections/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACzD,OAAO,EAAc,KAAK,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAUzE,OAAO,EAAE,aAAa,EAAE,QAAQ,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AAClF,OAAO,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,OAAO,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAC9H,OAAO,EAAE,uBAAuB,EAAE,aAAa,EAAE,eAAe,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC9G,OAAO,EAAE,iBAAiB,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACtE,OAAO,EAAE,aAAa,EAAE,cAAc,EAAE,OAAO,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAClG,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAC3D,OAAO,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AACzC,OAAO,EAAE,OAAO,EAAE,MAAM,2BAA2B,CAAC;AACpD,OAAO,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAC5D,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEhE,wDAAwD;AACxD,wBAAsB,eAAe,CAAC,KAAK,EAAE,gBAAgB,GAAG,OAAO,CAAC,SAAS,iBAAiB,EAAE,CAAC,CAYpG"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { parsePages } from "../enquiry/common.js";
|
|
2
|
+
import { checkK11 } from "./k11-density.js";
|
|
3
|
+
import { checkK12Contact } from "./k12-contact.js";
|
|
4
|
+
import { checkK2 } from "./k2-skin-coverage.js";
|
|
5
|
+
import { checkN9 } from "./n9-footer.js";
|
|
6
|
+
import { checkT6 } from "./t6-media-entries.js";
|
|
7
|
+
import { checkT7 } from "./t7-main-images.js";
|
|
8
|
+
import { checkT8 } from "./t8-prices.js";
|
|
9
|
+
import { checkT9 } from "./t9-location-metadata.js";
|
|
10
|
+
export { footerColumns, footerOf, footerProblems, checkN9 } from "./n9-footer.js";
|
|
11
|
+
export { K2_REFERENCE_CLASS, K2_SKINNED_HOOKS, K2_SKINNED_SECTIONS, checkK2, hooksStyledBySite } from "./k2-skin-coverage.js";
|
|
12
|
+
export { K11_MAX_CALLS_TO_ACTION, K11_MAX_CARDS, callsToActionIn, cardsIn, checkK11 } from "./k11-density.js";
|
|
13
|
+
export { K12_CONTACT_ORDER, checkK12Contact } from "./k12-contact.js";
|
|
14
|
+
export { T6_MEDIA_PATH, T6_PROVENANCES, checkT6, readMediaManifest } from "./t6-media-entries.js";
|
|
15
|
+
export { checkT7, mainImageOf } from "./t7-main-images.js";
|
|
16
|
+
export { checkT8 } from "./t8-prices.js";
|
|
17
|
+
export { checkT9 } from "./t9-location-metadata.js";
|
|
18
|
+
export { gpsTagsInTiff, locationMetadata } from "./exif.js";
|
|
19
|
+
export { imageFile, imagesIn, publicImages } from "./images.js";
|
|
20
|
+
/** Runs N9, K2, K11, K12-contact, T6, T7, T8 and T9. */
|
|
21
|
+
export async function runSectionRules(input) {
|
|
22
|
+
const pages = parsePages(input.pages);
|
|
23
|
+
return [
|
|
24
|
+
...checkN9(pages, input.projectRoot),
|
|
25
|
+
...checkK2(pages, input.projectRoot),
|
|
26
|
+
...checkK11(pages),
|
|
27
|
+
...checkK12Contact(pages),
|
|
28
|
+
...checkT6(pages, input.projectRoot),
|
|
29
|
+
...checkT7(pages),
|
|
30
|
+
...checkT8(pages),
|
|
31
|
+
...checkT9(input.projectRoot),
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../src/site-system/rules/sections/index.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,UAAU,EAAyB,MAAM,sBAAsB,CAAC;AACzE,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC5C,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACnD,OAAO,EAAE,OAAO,EAAE,MAAM,uBAAuB,CAAC;AAChD,OAAO,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AACzC,OAAO,EAAE,OAAO,EAAE,MAAM,uBAAuB,CAAC;AAChD,OAAO,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AAC9C,OAAO,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AACzC,OAAO,EAAE,OAAO,EAAE,MAAM,2BAA2B,CAAC;AAEpD,OAAO,EAAE,aAAa,EAAE,QAAQ,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AAClF,OAAO,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,OAAO,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAC9H,OAAO,EAAE,uBAAuB,EAAE,aAAa,EAAE,eAAe,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC9G,OAAO,EAAE,iBAAiB,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACtE,OAAO,EAAE,aAAa,EAAE,cAAc,EAAE,OAAO,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAClG,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAC3D,OAAO,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AACzC,OAAO,EAAE,OAAO,EAAE,MAAM,2BAA2B,CAAC;AACpD,OAAO,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAC5D,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEhE,wDAAwD;AACxD,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,KAAuB;IAC3D,MAAM,KAAK,GAAG,UAAU,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;IACtC,OAAO;QACL,GAAG,OAAO,CAAC,KAAK,EAAE,KAAK,CAAC,WAAW,CAAC;QACpC,GAAG,OAAO,CAAC,KAAK,EAAE,KAAK,CAAC,WAAW,CAAC;QACpC,GAAG,QAAQ,CAAC,KAAK,CAAC;QAClB,GAAG,eAAe,CAAC,KAAK,CAAC;QACzB,GAAG,OAAO,CAAC,KAAK,EAAE,KAAK,CAAC,WAAW,CAAC;QACpC,GAAG,OAAO,CAAC,KAAK,CAAC;QACjB,GAAG,OAAO,CAAC,KAAK,CAAC;QACjB,GAAG,OAAO,CAAC,KAAK,CAAC,WAAW,CAAC;KAC9B,CAAC;AACJ,CAAC","sourcesContent":["/**\n * The sections rule module (release 3.11.0): N9 footer content, K2 skin coverage, K11 density, K12-contact, T6 media\n * entries, T7 main images, T8 prices and T9 location metadata. The rendered rules read the sampled pages' server HTML;\n * T6 and T9 also read the project's `src/data/media.ts` and `public/` images.\n */\nimport type { SiteSystemFinding } from \"../../checks.js\";\nimport { parsePages, type EnquiryRuleInput } from \"../enquiry/common.js\";\nimport { checkK11 } from \"./k11-density.js\";\nimport { checkK12Contact } from \"./k12-contact.js\";\nimport { checkK2 } from \"./k2-skin-coverage.js\";\nimport { checkN9 } from \"./n9-footer.js\";\nimport { checkT6 } from \"./t6-media-entries.js\";\nimport { checkT7 } from \"./t7-main-images.js\";\nimport { checkT8 } from \"./t8-prices.js\";\nimport { checkT9 } from \"./t9-location-metadata.js\";\n\nexport { footerColumns, footerOf, footerProblems, checkN9 } from \"./n9-footer.js\";\nexport { K2_REFERENCE_CLASS, K2_SKINNED_HOOKS, K2_SKINNED_SECTIONS, checkK2, hooksStyledBySite } from \"./k2-skin-coverage.js\";\nexport { K11_MAX_CALLS_TO_ACTION, K11_MAX_CARDS, callsToActionIn, cardsIn, checkK11 } from \"./k11-density.js\";\nexport { K12_CONTACT_ORDER, checkK12Contact } from \"./k12-contact.js\";\nexport { T6_MEDIA_PATH, T6_PROVENANCES, checkT6, readMediaManifest } from \"./t6-media-entries.js\";\nexport { checkT7, mainImageOf } from \"./t7-main-images.js\";\nexport { checkT8 } from \"./t8-prices.js\";\nexport { checkT9 } from \"./t9-location-metadata.js\";\nexport { gpsTagsInTiff, locationMetadata } from \"./exif.js\";\nexport { imageFile, imagesIn, publicImages } from \"./images.js\";\n\n/** Runs N9, K2, K11, K12-contact, T6, T7, T8 and T9. */\nexport async function runSectionRules(input: EnquiryRuleInput): Promise<readonly SiteSystemFinding[]> {\n const pages = parsePages(input.pages);\n return [\n ...checkN9(pages, input.projectRoot),\n ...checkK2(pages, input.projectRoot),\n ...checkK11(pages),\n ...checkK12Contact(pages),\n ...checkT6(pages, input.projectRoot),\n ...checkT7(pages),\n ...checkT8(pages),\n ...checkT9(input.projectRoot),\n ];\n}\n"]}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* K11: section density (Site System v2 §13.2). A section does one thing: one H2, at most 8 cards and at most 2 calls to
|
|
3
|
+
* action. The rule measures what the server HTML shows. The spec also asks for body text no wider than about 75
|
|
4
|
+
* characters a line and section padding of at least the token value; those are CSS, which the HTML cannot show, so
|
|
5
|
+
* they stay with the contrast and spacing journeys and this rule says so rather than guessing.
|
|
6
|
+
*
|
|
7
|
+
* A card is one repeated unit with its own heading, image or quotation: an element with a card slot (`card`, `review`,
|
|
8
|
+
* `project`, `service`, `tile`, `post`, `teaser`), an `<article>`, a card class, or a list item that holds a heading, an
|
|
9
|
+
* image, a `<blockquote>` or a link with a sentence of its own. A call to action is a quote button, a call button, or the
|
|
10
|
+
* submit button of a form; an inline text phone link is a contact detail, not a call to action. A hero that holds the
|
|
11
|
+
* form also carries a quote button that scrolls to it (so the quote action is in the first screen on a phone); that
|
|
12
|
+
* button and the form's submit button are one action.
|
|
13
|
+
*/
|
|
14
|
+
import type { SiteSystemFinding } from "../../checks.js";
|
|
15
|
+
import { type ParsedPage } from "../enquiry/common.js";
|
|
16
|
+
import { type HtmlElement } from "../enquiry/html.js";
|
|
17
|
+
export declare const K11_MAX_CARDS = 8;
|
|
18
|
+
export declare const K11_MAX_CALLS_TO_ACTION = 2;
|
|
19
|
+
/** The cards in a section: the outermost card-like elements, each counted once. */
|
|
20
|
+
export declare function cardsIn(section: HtmlElement): HtmlElement[];
|
|
21
|
+
export declare function callsToActionIn(section: HtmlElement): HtmlElement[];
|
|
22
|
+
export declare function checkK11(pages: readonly ParsedPage[]): SiteSystemFinding[];
|
|
23
|
+
//# sourceMappingURL=k11-density.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"k11-density.d.ts","sourceRoot":"","sources":["../../../../src/site-system/rules/sections/k11-density.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACzD,OAAO,EAA4D,KAAK,UAAU,EAAE,MAAM,sBAAsB,CAAC;AACjH,OAAO,EAA6B,KAAK,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAGjF,eAAO,MAAM,aAAa,IAAI,CAAC;AAC/B,eAAO,MAAM,uBAAuB,IAAI,CAAC;AAmBzC,mFAAmF;AACnF,wBAAgB,OAAO,CAAC,OAAO,EAAE,WAAW,GAAG,WAAW,EAAE,CAW3D;AAED,wBAAgB,eAAe,CAAC,OAAO,EAAE,WAAW,GAAG,WAAW,EAAE,CAgBnE;AAED,wBAAgB,QAAQ,CAAC,KAAK,EAAE,SAAS,UAAU,EAAE,GAAG,iBAAiB,EAAE,CAoB1E"}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { notApplicable, pass, plural, sectionId, topLevelSections } from "../enquiry/common.js";
|
|
2
|
+
import { attr, descendants, textOf } from "../enquiry/html.js";
|
|
3
|
+
import { PageProblems } from "../library/common.js";
|
|
4
|
+
export const K11_MAX_CARDS = 8;
|
|
5
|
+
export const K11_MAX_CALLS_TO_ACTION = 2;
|
|
6
|
+
const CARD_SLOTS = new Set(["card", "review", "project", "service", "tile", "post", "teaser", "testimonial"]);
|
|
7
|
+
const SENTENCE_WORDS = 8;
|
|
8
|
+
function words(element) {
|
|
9
|
+
return textOf(element).split(/\s+/).filter(Boolean).length;
|
|
10
|
+
}
|
|
11
|
+
function isCardLike(element) {
|
|
12
|
+
if (element.tag === "article")
|
|
13
|
+
return true;
|
|
14
|
+
if (CARD_SLOTS.has(attr(element, "data-slot") ?? ""))
|
|
15
|
+
return true;
|
|
16
|
+
if (/(?:^|[\s_-])card(?:[\s_-]|$)/i.test(attr(element, "class") ?? ""))
|
|
17
|
+
return true;
|
|
18
|
+
if (element.tag !== "li")
|
|
19
|
+
return false;
|
|
20
|
+
const inside = descendants(element);
|
|
21
|
+
if (inside.some((child) => /^h[2-6]$/.test(child.tag) || child.tag === "img" || child.tag === "blockquote" || attr(child, "data-slot") === "quote"))
|
|
22
|
+
return true;
|
|
23
|
+
return inside.some((child) => child.tag === "a") && words(element) >= SENTENCE_WORDS;
|
|
24
|
+
}
|
|
25
|
+
/** The cards in a section: the outermost card-like elements, each counted once. */
|
|
26
|
+
export function cardsIn(section) {
|
|
27
|
+
const cards = [];
|
|
28
|
+
const visit = (node) => {
|
|
29
|
+
for (const child of node.children) {
|
|
30
|
+
if (child.type !== "element")
|
|
31
|
+
continue;
|
|
32
|
+
if (isCardLike(child))
|
|
33
|
+
cards.push(child);
|
|
34
|
+
else
|
|
35
|
+
visit(child);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
visit(section);
|
|
39
|
+
return cards;
|
|
40
|
+
}
|
|
41
|
+
export function callsToActionIn(section) {
|
|
42
|
+
const inside = descendants(section);
|
|
43
|
+
const submits = inside.filter((element) => element.tag === "button" && (attr(element, "type") ?? "submit").toLowerCase() === "submit");
|
|
44
|
+
const calls = new Set();
|
|
45
|
+
for (const element of inside) {
|
|
46
|
+
const hook = attr(element, "data-bl");
|
|
47
|
+
const quote = hook === "quote-button" || attr(element, "data-bl-cta") !== undefined;
|
|
48
|
+
// A quote button that jumps to the form beside it is the same action as the form's submit button, not a third.
|
|
49
|
+
if (quote) {
|
|
50
|
+
if (submits.length === 0 || !(attr(element, "href") ?? "").includes("#"))
|
|
51
|
+
calls.add(element);
|
|
52
|
+
}
|
|
53
|
+
else if (hook === "phone-link" && !["text", "inline"].includes(attr(element, "data-variant") ?? "")) {
|
|
54
|
+
calls.add(element);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
for (const submit of submits)
|
|
58
|
+
calls.add(submit);
|
|
59
|
+
return [...calls];
|
|
60
|
+
}
|
|
61
|
+
export function checkK11(pages) {
|
|
62
|
+
const problems = new PageProblems();
|
|
63
|
+
let measured = 0;
|
|
64
|
+
for (const page of pages) {
|
|
65
|
+
const sections = topLevelSections(page.root);
|
|
66
|
+
if (sections.length === 0)
|
|
67
|
+
continue;
|
|
68
|
+
measured += 1;
|
|
69
|
+
for (const section of sections) {
|
|
70
|
+
const id = sectionId(section);
|
|
71
|
+
const h2 = descendants(section).filter((element) => element.tag === "h2").length;
|
|
72
|
+
if (h2 > 1)
|
|
73
|
+
problems.add(page.path, `The "${id}" section has ${h2} H2 headings; a section has one`);
|
|
74
|
+
const cards = cardsIn(section).length;
|
|
75
|
+
if (cards > K11_MAX_CARDS)
|
|
76
|
+
problems.add(page.path, `The "${id}" section shows ${cards} cards; a section shows at most ${K11_MAX_CARDS}`);
|
|
77
|
+
const calls = callsToActionIn(section).length;
|
|
78
|
+
if (calls > K11_MAX_CALLS_TO_ACTION)
|
|
79
|
+
problems.add(page.path, `The "${id}" section has ${calls} calls to action; a section has at most ${K11_MAX_CALLS_TO_ACTION}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (measured === 0)
|
|
83
|
+
return [notApplicable("K11", "None of the sampled pages has a data-bl-section.")];
|
|
84
|
+
if (problems.size > 0)
|
|
85
|
+
return problems.findings("K11");
|
|
86
|
+
return [pass("K11", `Checked the sections of ${plural(measured, "page")}: none has more than one H2, ${K11_MAX_CARDS} cards or ${K11_MAX_CALLS_TO_ACTION} calls to action (line length and section padding are CSS, checked in the browser).`)];
|
|
87
|
+
}
|
|
88
|
+
//# sourceMappingURL=k11-density.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"k11-density.js","sourceRoot":"","sources":["../../../../src/site-system/rules/sections/k11-density.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * K11: section density (Site System v2 §13.2). A section does one thing: one H2, at most 8 cards and at most 2 calls to\n * action. The rule measures what the server HTML shows. The spec also asks for body text no wider than about 75\n * characters a line and section padding of at least the token value; those are CSS, which the HTML cannot show, so\n * they stay with the contrast and spacing journeys and this rule says so rather than guessing.\n *\n * A card is one repeated unit with its own heading, image or quotation: an element with a card slot (`card`, `review`,\n * `project`, `service`, `tile`, `post`, `teaser`), an `<article>`, a card class, or a list item that holds a heading, an\n * image, a `<blockquote>` or a link with a sentence of its own. A call to action is a quote button, a call button, or the\n * submit button of a form; an inline text phone link is a contact detail, not a call to action. A hero that holds the\n * form also carries a quote button that scrolls to it (so the quote action is in the first screen on a phone); that\n * button and the form's submit button are one action.\n */\nimport type { SiteSystemFinding } from \"../../checks.js\";\nimport { notApplicable, pass, plural, sectionId, topLevelSections, type ParsedPage } from \"../enquiry/common.js\";\nimport { attr, descendants, textOf, type HtmlElement } from \"../enquiry/html.js\";\nimport { PageProblems } from \"../library/common.js\";\n\nexport const K11_MAX_CARDS = 8;\nexport const K11_MAX_CALLS_TO_ACTION = 2;\n\nconst CARD_SLOTS = new Set([\"card\", \"review\", \"project\", \"service\", \"tile\", \"post\", \"teaser\", \"testimonial\"]);\nconst SENTENCE_WORDS = 8;\n\nfunction words(element: HtmlElement): number {\n return textOf(element).split(/\\s+/).filter(Boolean).length;\n}\n\nfunction isCardLike(element: HtmlElement): boolean {\n if (element.tag === \"article\") return true;\n if (CARD_SLOTS.has(attr(element, \"data-slot\") ?? \"\")) return true;\n if (/(?:^|[\\s_-])card(?:[\\s_-]|$)/i.test(attr(element, \"class\") ?? \"\")) return true;\n if (element.tag !== \"li\") return false;\n const inside = descendants(element);\n if (inside.some((child) => /^h[2-6]$/.test(child.tag) || child.tag === \"img\" || child.tag === \"blockquote\" || attr(child, \"data-slot\") === \"quote\")) return true;\n return inside.some((child) => child.tag === \"a\") && words(element) >= SENTENCE_WORDS;\n}\n\n/** The cards in a section: the outermost card-like elements, each counted once. */\nexport function cardsIn(section: HtmlElement): HtmlElement[] {\n const cards: HtmlElement[] = [];\n const visit = (node: HtmlElement) => {\n for (const child of node.children) {\n if (child.type !== \"element\") continue;\n if (isCardLike(child)) cards.push(child);\n else visit(child);\n }\n };\n visit(section);\n return cards;\n}\n\nexport function callsToActionIn(section: HtmlElement): HtmlElement[] {\n const inside = descendants(section);\n const submits = inside.filter((element) => element.tag === \"button\" && (attr(element, \"type\") ?? \"submit\").toLowerCase() === \"submit\");\n const calls = new Set<HtmlElement>();\n for (const element of inside) {\n const hook = attr(element, \"data-bl\");\n const quote = hook === \"quote-button\" || attr(element, \"data-bl-cta\") !== undefined;\n // A quote button that jumps to the form beside it is the same action as the form's submit button, not a third.\n if (quote) {\n if (submits.length === 0 || !(attr(element, \"href\") ?? \"\").includes(\"#\")) calls.add(element);\n } else if (hook === \"phone-link\" && ![\"text\", \"inline\"].includes(attr(element, \"data-variant\") ?? \"\")) {\n calls.add(element);\n }\n }\n for (const submit of submits) calls.add(submit);\n return [...calls];\n}\n\nexport function checkK11(pages: readonly ParsedPage[]): SiteSystemFinding[] {\n const problems = new PageProblems();\n let measured = 0;\n for (const page of pages) {\n const sections = topLevelSections(page.root);\n if (sections.length === 0) continue;\n measured += 1;\n for (const section of sections) {\n const id = sectionId(section);\n const h2 = descendants(section).filter((element) => element.tag === \"h2\").length;\n if (h2 > 1) problems.add(page.path, `The \"${id}\" section has ${h2} H2 headings; a section has one`);\n const cards = cardsIn(section).length;\n if (cards > K11_MAX_CARDS) problems.add(page.path, `The \"${id}\" section shows ${cards} cards; a section shows at most ${K11_MAX_CARDS}`);\n const calls = callsToActionIn(section).length;\n if (calls > K11_MAX_CALLS_TO_ACTION) problems.add(page.path, `The \"${id}\" section has ${calls} calls to action; a section has at most ${K11_MAX_CALLS_TO_ACTION}`);\n }\n }\n if (measured === 0) return [notApplicable(\"K11\", \"None of the sampled pages has a data-bl-section.\")];\n if (problems.size > 0) return problems.findings(\"K11\");\n return [pass(\"K11\", `Checked the sections of ${plural(measured, \"page\")}: none has more than one H2, ${K11_MAX_CARDS} cards or ${K11_MAX_CALLS_TO_ACTION} calls to action (line length and section padding are CSS, checked in the browser).`)];\n}\n"]}
|