diff --git a/internal/check-poster-attribution.mjs b/internal/check-poster-attribution.mjs new file mode 100644 index 000000000..24a1c9125 --- /dev/null +++ b/internal/check-poster-attribution.mjs @@ -0,0 +1,122 @@ +#!/usr/bin/env node +/** + * check-poster-attribution — the poster footer credits whoever actually served the tiles. + * + * The footer is a LICENCE statement, so it has to be true rather than merely present. + * posterDefaults ships '© Mapbox © OpenStreetMap'; under the fleet's MapLibre default Mapbox serves + * nothing, OpenFreeMap serves everything, and any poster carrying the default credited a company + * with no involvement while omitting the three parties whose licences apply. + * + * ★ THE FIXTURE IS THE REAL THING, and it is the reason this check exists in this shape. Fetched + * 2026-09-28: tiles.openfreemap.org/styles/liberty declares two sources and NEITHER has an + * `attribution` field, so reading the style alone returns nothing. The credit lives in the TileJSON + * at tiles.openfreemap.org/planet, as HTML with anchors and a © entity, which the engine merges + * into the SOURCE object after it loads. A check written against a tidy invented style would have + * passed while the real map printed the wrong credit. + * + * Headless, no network, no browser, no dependencies. Exit 0 pass · 1 fail. + */ +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { readFileSync } from 'node:fs' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const { attributionTextFromMap, attributionToText } = await import(resolve(ROOT, 'library/maps/map-attribution.js')) + +const fails = [] +const eq = (what, got, want) => { if (got !== want) fails.push(`${what}\n got: ${JSON.stringify(got)}\n want: ${JSON.stringify(want)}`) } +const ok = (what, cond, detail) => { if (!cond) fails.push(what + (detail ? `\n ${detail}` : '')) } + +// The exact string tiles.openfreemap.org/planet returns. +const OFM_TILEJSON_HTML = + 'OpenFreeMap ' + + '© OpenMapTiles ' + + 'Data from OpenStreetMap' +const OFM_TEXT = 'OpenFreeMap © OpenMapTiles Data from OpenStreetMap' + +const fakeMap = ({ sources, live }) => ({ + getStyle: () => ({ sources }), + getSource: (id) => (live && Object.prototype.hasOwnProperty.call(live, id) ? live[id] : undefined), +}) + +// 1. the real shape: style declares nothing, the loaded source carries the TileJSON credit +{ + const map = fakeMap({ + sources: { ne2_shaded: { type: 'raster' }, openmaptiles: { type: 'vector', url: 'https://tiles.openfreemap.org/planet' } }, + live: { ne2_shaded: {}, openmaptiles: { attribution: OFM_TILEJSON_HTML } }, + }) + eq('the OpenFreeMap credit is read from the LOADED source, not the style', attributionTextFromMap(map), OFM_TEXT) + ok('and it no longer says Mapbox', !attributionTextFromMap(map).includes('Mapbox')) +} + +// 2. a Mapbox map still credits Mapbox — this must not become "always OpenFreeMap" +{ + const map = fakeMap({ + sources: { composite: { type: 'vector' } }, + live: { composite: { attribution: '© Mapbox © OpenStreetMap' } }, + }) + eq('a Mapbox-served map credits Mapbox', attributionTextFromMap(map), '© Mapbox © OpenStreetMap') +} + +// 3. a style that DOES declare attribution is honoured (the source is preferred, not required) +{ + const map = fakeMap({ sources: { s: { type: 'raster', attribution: 'Tiles by Someone' } }, live: {} }) + eq('a style-declared attribution is used when there is no live source', attributionTextFromMap(map), 'Tiles by Someone') +} +{ + const map = fakeMap({ + sources: { s: { type: 'raster', attribution: 'the stale request' } }, + live: { s: { attribution: 'what actually came back' } }, + }) + eq('the LOADED source wins over the style declaration', attributionTextFromMap(map), 'what actually came back') +} + +// 4. repeated credits collapse; several distinct ones all survive +{ + const map = fakeMap({ + sources: { a: {}, b: {}, c: {} }, + live: { a: { attribution: '© OpenStreetMap' }, b: { attribution: '© OpenStreetMap' }, c: { attribution: 'geoBoundaries' } }, + }) + eq('the same credit twice is printed once', attributionTextFromMap(map), '© OpenStreetMap geoBoundaries') +} + +// 5. it must never invent, and never throw — an empty answer hands the caller back its fallback +{ + eq('no map -> empty', attributionTextFromMap(null), '') + eq('a map with no getStyle -> empty', attributionTextFromMap({}), '') + eq('sources with no attribution -> empty', attributionTextFromMap(fakeMap({ sources: { a: {} }, live: { a: {} } })), '') + const throwing = { getStyle: () => { throw new Error('style not loaded') } } + eq('getStyle throwing before load -> empty, not a crash', attributionTextFromMap(throwing), '') + const badSource = { getStyle: () => ({ sources: { a: {} } }), getSource: () => { throw new Error('nope') } } + eq('getSource throwing -> empty, not a crash', attributionTextFromMap(badSource), '') +} + +// 6. flattening: a footer is text, and entities must not print raw +{ + eq('anchors are removed', attributionToText('© OpenStreetMap'), '© OpenStreetMap') + eq('© becomes ©', attributionToText('© OpenMapTiles'), '© OpenMapTiles') + eq('whitespace collapses', attributionToText(' a \n b '), 'a b') + ok('no markup survives', !/[<>]/.test(attributionToText(OFM_TILEJSON_HTML))) + ok('no undecoded entity survives', !/&[a-z]+;/i.test(attributionToText(OFM_TILEJSON_HTML))) +} + +// 7. the renderer must actually consult it, and must keep the spec's value as a fallback. +{ + const SRC = readFileSync(resolve(ROOT, 'library/composables/poster/useMapPoster.js'), 'utf8') + .replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '') + ok('the renderer imports the helper', /import\s*\{[^}]*attributionTextFromMap[^}]*\}\s*from/.test(SRC)) + ok('and calls it with the live source map', /attributionTextFromMap\s*\(\s*sourceMap\s*\)/.test(SRC)) + // The spread `...(spec.slots?.footer || {})` contains a brace, so a [^}] class stops short of the + // property it is looking for and reports the opposite of the truth. + ok('the derived credit reaches the footer slot', /footer\s*:\s*\{[\s\S]{0,160}?attribution\s*:\s*credit/.test(SRC)) + ok('an empty credit falls back to the spec rather than printing nothing', + /credit\s*\?[\s\S]{0,240}?:\s*spec/.test(SRC), + 'a licence footer may never be blank') +} + +if (fails.length) { + console.error('POSTER-ATTRIBUTION: FAIL') + for (const f of fails) console.error(` ✗ ${f}`) + process.exit(1) +} +console.log('POSTER-ATTRIBUTION: PASS — the credit is read from the LOADED source (where the TileJSON lands) rather than the style or a constant, HTML and entities are flattened for print, duplicates collapse, a Mapbox map still says Mapbox, and an unreadable map falls back instead of printing a blank licence line.') diff --git a/library/composables/poster/useMapPoster.js b/library/composables/poster/useMapPoster.js index 8bbd14d1e..1a3b1f4d8 100644 --- a/library/composables/poster/useMapPoster.js +++ b/library/composables/poster/useMapPoster.js @@ -34,6 +34,7 @@ import { ref, readonly } from 'vue'; import { equalHeightLatBands } from '../../maps/mercator-slice.js'; import { computeLayout } from './usePosterLayout.js'; +import { attributionTextFromMap } from '../../maps/map-attribution.js'; import { engineFor } from '../useMapProvider.js'; const MAX_SINGLE_CANVAS = 4096; // WebGL safe ceiling for most hardware @@ -151,7 +152,17 @@ export function useMapPoster(getSourceMap) { _setProgress('compose', 0, 1, 'compositing overlay'); const sheet = fits ? _placeMapOnSheet(baseRaster, layout, px) : baseRaster; - const composed = await _composeOverlay(sheet, spec, px); + // ★ THE FOOTER CREDITS WHOEVER ACTUALLY SERVED THE TILES. posterDefaults ships + // '© Mapbox © OpenStreetMap', which under the fleet's MapLibre default credits a company that + // served nothing and omits the three parties whose licences do apply. Read from the map rather + // than from a constant; the spec's value stays as the fallback, because an EMPTY attribution is + // the one outcome a licence never allows. The caller's spec is copied, not mutated — it belongs + // to whoever passed it in. + const credit = attributionTextFromMap(sourceMap); + const specForOverlay = credit + ? { ...spec, slots: { ...spec.slots, footer: { ...(spec.slots?.footer || {}), attribution: credit } } } + : spec; + const composed = await _composeOverlay(sheet, specForOverlay, px); _setProgress('encode', 0, 1, `encoding ${format.toUpperCase()}`); const blob = (format === 'pdf') diff --git a/library/constants/posterDefaults.js b/library/constants/posterDefaults.js index bad8faeff..78319c04c 100644 --- a/library/constants/posterDefaults.js +++ b/library/constants/posterDefaults.js @@ -71,6 +71,11 @@ export const posterDefaults = { fontSizePt: 10, color: '#222', // Caller may set left/right text; attribution is enforced by FooterSlot. + // ★ A FALLBACK OF LAST RESORT, NOT THE ANSWER. useMapPoster reads the credit from the map's + // LOADED sources (library/maps/map-attribution.js) and overrides this, because under the + // fleet's MapLibre default Mapbox serves nothing and this line would credit the wrong party. + // It stays non-empty only so a map that cannot be read still prints something: a licence + // footer may never be blank. attribution: '© Mapbox © OpenStreetMap', backgroundColor: 'rgba(255,255,255,0.92)', }, diff --git a/library/maps/map-attribution.js b/library/maps/map-attribution.js new file mode 100644 index 000000000..01e521373 --- /dev/null +++ b/library/maps/map-attribution.js @@ -0,0 +1,54 @@ +/** + * map-attribution — the credit the tiles on THIS map actually ask for, as plain text. + * + * ★ WHY THIS IS READ AND NOT DECLARED. The poster footer defaulted to '© Mapbox © OpenStreetMap', + * which is a licence statement, so it must be true. Under the fleet's MapLibre default Mapbox serves + * nothing and OpenFreeMap serves everything, and any poster carrying that default credited a company + * with no involvement while omitting the three parties whose licences actually apply. + * + * ★ AND IT CANNOT BE READ FROM THE STYLE. Checked against the live style 2026-09-28: + * tiles.openfreemap.org/styles/liberty declares TWO sources and NEITHER carries an `attribution` + * field. The credit lives in the TileJSON the vector source points at + * (tiles.openfreemap.org/planet), which the engine fetches and merges into the SOURCE OBJECT once it + * loads. So `map.getStyle().sources[id].attribution` is null and `map.getSource(id).attribution` is + * the real answer — the style is the request, the source is what came back. This reads both and + * prefers the live source, because a style that DOES declare one should still be honoured. + * + * The TileJSON value is HTML with anchors and entities; a poster footer is text, so it is flattened + * rather than escaped-and-printed. Returns '' when nothing can be read, and the CALLER keeps its own + * fallback — printing an empty attribution is the one outcome that is never acceptable. + */ + +const ENTITIES = { '©': '©', '&': '&', ' ': ' ', '<': '<', '>': '>', '"': '"', ''': "'" }; + +/** Flatten one attribution value (HTML or plain) to trimmed, single-spaced text. */ +export function attributionToText(value) { + if (!value) return ''; + return String(value) + .replace(/<[^>]*>/g, ' ') // anchors carry the link, not the credit + .replace(/&[a-z]+;|&#\d+;/gi, (e) => ENTITIES[e.toLowerCase()] ?? ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * Every distinct credit the map's sources ask for, joined into one line. + * + * @param {Object} map a mapboxgl/maplibregl Map instance + * @returns {string} plain text, or '' when the map cannot be read + */ +export function attributionTextFromMap(map) { + if (!map || typeof map.getStyle !== 'function') return ''; + let style; + try { style = map.getStyle(); } catch (_) { return ''; } // getStyle throws before the style loads + const sources = (style && style.sources) || {}; + const seen = []; + for (const id of Object.keys(sources)) { + // The live source first: it is the one the TileJSON was merged into. + let live = null; + try { live = typeof map.getSource === 'function' ? map.getSource(id) : null; } catch (_) { live = null; } + const text = attributionToText((live && live.attribution) || sources[id].attribution); + if (text && !seen.includes(text)) seen.push(text); + } + return seen.join(' '); +} diff --git a/package.json b/package.json index ba41b3656..e43ed673c 100644 --- a/package.json +++ b/package.json @@ -208,6 +208,7 @@ "check:poster-action-print": "node internal/check-poster-action-print.mjs", "check:poster-layout": "node internal/check-poster-layout.mjs", "check:map-control-overlap": "node internal/check-map-control-overlap.mjs", + "check:poster-attribution": "node internal/check-poster-attribution.mjs", "check:poster-card-fits": "node internal/check-poster-card-fits.mjs", "check:poster-degrades": "node internal/check-poster-degrades-honestly.mjs", "check:poster-print-fallback": "node internal/check-poster-print-fallback.mjs", @@ -291,7 +292,7 @@ "fix:sri": "node internal/fix-sri.mjs", "gallery": "node internal/generate-gallery.mjs", "gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:frozen-labels", - "gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:script-registry check:poster-layout check:map-control-overlap", + "gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:script-registry check:poster-layout check:map-control-overlap check:poster-attribution", "gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs", "internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs", "library-index": "node internal/generate-library-index.mjs", @@ -372,6 +373,7 @@ "check:script-registry": "one bundle script executes ONCE PER PAGE. Every plugin is a self-contained IIFE, so library/utils/embedScript.js is compiled into each one and a module-scoped cache was one cache per BUNDLE: two shells on a page each loaded the same child, which re-executed a whole bundle (a second Vue, i18n and pinia) before its guarded customElements.define silently declined. The check imports the module twice under different URLs — two module instances, one globalThis, the same shape as two IIFEs — and asserts the second caller receives the FIRST ONE'S PROMISE, because counting script tags alone is satisfied by the DOM-adoption path and would not catch a regression. Headless; in gates:pre.", "check:poster-layout": "the EXPORTED poster is a layout, not a map with things painted on it (Driver 2026-09-28: the legend beside the map, never over it, with a proper header and footer). usePosterLayout always computed the right geometry; the renderer never used it, spawning the hidden map at FULL PAGE size and compositing the title, legend and footer on top. One arm checks the geometry on four real sheets, the other checks the renderer CONSUMES it — the link that was missing, and the reason a computeLayout-only test would have passed throughout the bug's life. Headless; in gates:pre.", "check:map-control-overlap": "two controls pinned to the same corner of the research-dashboard map must not land on each other (Driver 2026-09-28: \"some map controls overlap each other\"). The zoom toolbar was pinned at a literal top:34px, tuned when the projection toggle measured 23px tall; a later WCAG 2.5.8 fix gave the toggle min-height:24px, which in a shadow root with no global box-sizing rendered 34px and ran 8px into a toolbar drawn over it at z-index 10. The check COMPUTES the toggle's rendered height from its own declarations, honouring box-sizing, and requires the toolbar to clear it and to DERIVE its offset rather than restate a number. Headless; in gates:pre.", + "check:poster-attribution": "the poster footer is a LICENCE statement, so it must credit whoever actually served the tiles. posterDefaults ships '\u00a9 Mapbox \u00a9 OpenStreetMap', which under the fleet's MapLibre default credits a company that served nothing. Checked live 2026-09-28: the OpenFreeMap STYLE declares no attribution on either source \u2014 the credit is in the TileJSON, merged into the SOURCE object after load \u2014 so it is read from map.getSource(id), flattened from HTML, deduped, and an unreadable map falls back rather than printing a blank line. Headless; in gates:pre.", "check:describe-group": "the prayer packet's generated profile sentence (library/packets/describeGroup.js): each GSEC level states its own fact, and an index-ordered language name (\"Arabic, Moroccan\") is un-inverted by library/utils/languageLabel.js without shredding a comma inside parentheses, the 13 names the old split printed reversed. A catalogue arm renders every GSEC level from the REAL packet-strings.json in en and ar and fails on a blank sentence. Headless; in gates:pre.", "check:descriptions": "every shipped profile must say what it is (reads the BUILT manifest)", "check:done-evidence": "a card may not reach status:done with NO bluf AND NO fields — the Doxa team inherits this board and an evidence-less 'done' cannot be reconciled against the code. 10 pre-existing are baselined by id; the list should only ever SHRINK.",