The poster credited Mapbox for tiles OpenFreeMap had served
The footer's attribution is a licence statement, so it has to be true rather than merely present. posterDefaults ships "(c) Mapbox (c) OpenStreetMap" and any caller that did not override it printed exactly that -- including country-posters, whose maps run on the fleet's MapLibre default, where Mapbox serves nothing at all and OpenFreeMap, OpenMapTiles and OpenStreetMap are the three parties whose licences actually apply. research-map had a hand-written pair for the two providers, which was right for research-map and helped nobody else. The obvious approach is to read the style's sources. Checked against the live style rather than assumed, and it does not work: 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, which the engine fetches and merges into the SOURCE OBJECT once it loads. The style is the request and the source is what came back, and only the second one knows. map-attribution.js reads the live source first and falls back to any style-declared value. The TileJSON value is HTML with anchors and a © entity, and a poster footer is text, so it is flattened rather than printed raw. Repeated credits collapse. When nothing can be read -- no map, a style that throws before it has loaded -- it returns empty and the caller keeps its own string, because a blank licence line is the one outcome that is never acceptable. The check uses the real TileJSON string as its fixture. A tidy invented style would have passed while the real map printed the wrong credit, and its first negative test is exactly that: reading the style alone returns nothing for OpenFreeMap. Cut directly on main rather than on the branch this began life in, so it does not wait behind a poster-layout change it has nothing to do with. A wrong tile credit is a licence problem; the layout question it was sitting behind is not.
This commit is contained in:
parent
fa14fedfdd
commit
63276767cf
5 changed files with 194 additions and 2 deletions
122
internal/check-poster-attribution.mjs
Normal file
122
internal/check-poster-attribution.mjs
Normal file
|
|
@ -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 =
|
||||||
|
'<a href="https://openfreemap.org" target="_blank">OpenFreeMap</a> ' +
|
||||||
|
'<a href="https://www.openmaptiles.org/" target="_blank">© OpenMapTiles</a> ' +
|
||||||
|
'Data from <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>'
|
||||||
|
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: '<a href="https://www.mapbox.com/about/maps/">© Mapbox</a> <a href="http://www.openstreetmap.org/copyright">© OpenStreetMap</a>' } },
|
||||||
|
})
|
||||||
|
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('<a href="x">© OpenStreetMap</a>'), '© 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.')
|
||||||
|
|
@ -34,6 +34,7 @@
|
||||||
import { ref, readonly } from 'vue';
|
import { ref, readonly } from 'vue';
|
||||||
import { equalHeightLatBands } from '../../maps/mercator-slice.js';
|
import { equalHeightLatBands } from '../../maps/mercator-slice.js';
|
||||||
import { computeLayout } from './usePosterLayout.js';
|
import { computeLayout } from './usePosterLayout.js';
|
||||||
|
import { attributionTextFromMap } from '../../maps/map-attribution.js';
|
||||||
import { engineFor } from '../useMapProvider.js';
|
import { engineFor } from '../useMapProvider.js';
|
||||||
|
|
||||||
const MAX_SINGLE_CANVAS = 4096; // WebGL safe ceiling for most hardware
|
const MAX_SINGLE_CANVAS = 4096; // WebGL safe ceiling for most hardware
|
||||||
|
|
@ -126,7 +127,17 @@ export function useMapPoster(getSourceMap) {
|
||||||
: await _renderTileStitch(sourceMap, px, signal);
|
: await _renderTileStitch(sourceMap, px, signal);
|
||||||
|
|
||||||
_setProgress('compose', 0, 1, 'compositing overlay');
|
_setProgress('compose', 0, 1, 'compositing overlay');
|
||||||
const composed = await _composeOverlay(baseRaster, 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(baseRaster, specForOverlay, px);
|
||||||
|
|
||||||
_setProgress('encode', 0, 1, `encoding ${format.toUpperCase()}`);
|
_setProgress('encode', 0, 1, `encoding ${format.toUpperCase()}`);
|
||||||
const blob = (format === 'pdf')
|
const blob = (format === 'pdf')
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,11 @@ export const posterDefaults = {
|
||||||
fontSizePt: 10,
|
fontSizePt: 10,
|
||||||
color: '#222',
|
color: '#222',
|
||||||
// Caller may set left/right text; attribution is enforced by FooterSlot.
|
// 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',
|
attribution: '© Mapbox © OpenStreetMap',
|
||||||
backgroundColor: 'rgba(255,255,255,0.92)',
|
backgroundColor: 'rgba(255,255,255,0.92)',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
54
library/maps/map-attribution.js
Normal file
54
library/maps/map-attribution.js
Normal file
|
|
@ -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(' ');
|
||||||
|
}
|
||||||
|
|
@ -296,7 +296,7 @@
|
||||||
"fix:sri": "node internal/fix-sri.mjs",
|
"fix:sri": "node internal/fix-sri.mjs",
|
||||||
"gallery": "node internal/generate-gallery.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:instance-config-keys check:frozen-labels check:plural-forms",
|
"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:instance-config-keys check:frozen-labels check:plural-forms",
|
||||||
"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:cdn-cache check:plural-forms check:select-zone-race check:script-registry 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:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution",
|
||||||
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
|
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
|
||||||
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
|
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
|
||||||
"library-index": "node internal/generate-library-index.mjs",
|
"library-index": "node internal/generate-library-index.mjs",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue