From 758a206ebb7155af699eeedb2ba3cffb09f8434b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Tue, 27 Oct 2026 09:09:12 +0100 Subject: [PATCH 1/2] Add Open in Snack buttons to web-capable example pages A remark plugin adds a button above the code of allowlisted examples that opens an unsaved Snack on web with @rnmapbox/maps 10.3.14, loading the example and its helpers from the release tag. --- README.md | 8 +- docusaurus.config.js | 1 + plugins/snack/allowlist.js | 50 ++++++++++ plugins/snack/remarkSnackLink.js | 43 ++++++++ plugins/snack/snackUrl.js | 166 +++++++++++++++++++++++++++++++ 5 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 plugins/snack/allowlist.js create mode 100644 plugins/snack/remarkSnackLink.js create mode 100644 plugins/snack/snackUrl.js diff --git a/README.md b/README.md index 71da33e1..7f3e4381 100644 --- a/README.md +++ b/README.md @@ -58,4 +58,10 @@ yarn detox test --configuration ios.debug --debug-synchronization 500 --loglevel ### Docosaurus update: 1. See https://docusaurus.io/changelog/3.5.0 -2. Update the version \ No newline at end of file +2. Update the version + +### Open in Snack + +Example pages listed in `plugins/snack/allowlist.js` get an "Open in Snack" button (`plugins/snack/remarkSnackLink.js`). It opens an unsaved Snack on the web platform with `@rnmapbox/maps` pinned to `RNMAPBOX_MAPS_VERSION` in `plugins/snack/snackUrl.js`; the example and its helpers are loaded from that release's tag, and the access token is fetched at runtime from rnmapbox/ci-config. + +To add an example, build the site, open its link from the built page and check that the map renders without errors in the Snack web preview, then add its `example_rel_path` to the allowlist. Examples importing binary assets or files not in `SHARED_FILES` can't be linked; the build fails if one is allowlisted. When bumping `RNMAPBOX_MAPS_VERSION`, recheck the allowlisted examples. diff --git a/docusaurus.config.js b/docusaurus.config.js index 683dba65..cbd0d863 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -67,6 +67,7 @@ const config = { docs: { sidebarPath: require.resolve('./sidebars.js'), showLastUpdateTime: true, + remarkPlugins: [require('./plugins/snack/remarkSnackLink')], editUrl: (url) => { if (url.docPath.startsWith('examples/')) { return `https://github.com/rnmapbox/maps/tree/main/example/src/examples/${url.docPath.replace('examples/', '').replace('.md', '.tsx')}` diff --git a/plugins/snack/allowlist.js b/plugins/snack/allowlist.js new file mode 100644 index 00000000..a7e4d2f1 --- /dev/null +++ b/plugins/snack/allowlist.js @@ -0,0 +1,50 @@ +// Examples whose "Open in Snack" link was verified to render the map with tiles on Snack web. +// Paths are front matter `example_rel_path` values; see README.md before adding one. +module.exports = [ + 'Annotations/Heatmap.js', + 'Annotations/MarkerView.tsx', + 'Annotations/MarkerViewPlacement.tsx', + 'Camera/Fit.tsx', + 'Camera/FlyTo.js', + 'Camera/GetCenter.js', + 'Camera/GetZoom.js', + 'Camera/ViewportGestures.tsx', + 'FillRasterLayer/ChoroplethLayerByZoomLevel.js', + 'FillRasterLayer/CustomVectorSource.js', + 'FillRasterLayer/HillshadeSource.tsx', + 'FillRasterLayer/IndoorBuilding.js', + 'FillRasterLayer/QueryAtPoint.js', + 'FillRasterLayer/QuerySourceFeatures.tsx', + 'FillRasterLayer/QueryWithRect.js', + 'FillRasterLayer/RasterSource.js', + 'FillRasterLayer/WatercolorRasterTiles.js', + 'LineLayer/DrawPolyline.tsx', + 'LineLayer/GradientLine.js', + 'LineLayer/ThirdPartyVectorSource.js', + 'Map/CameraGestureObserver.tsx', + 'Map/ChangeLayerColor.js', + 'Map/DynamicUrl.tsx', + 'Map/LocalizeLabels.tsx', + 'Map/MapFps.tsx', + 'Map/MapUnMount.tsx', + 'Map/PointInMapView.js', + 'Map/ScreenCoordinates.tsx', + 'Map/SetLanguage.tsx', + 'Map/ShowAndHideLayer.js', + 'Map/ShowClick.js', + 'Map/ShowMap.tsx', + 'Map/ShowMapLocalStyle.tsx', + 'Map/ShowRegionDidChange.js', + 'Map/SourceLayerVisibility.js', + 'Map/StyleJson.js', + 'Map/SymbolScaleBehavior.tsx', + 'Map/TwoByTwo.js', + 'SymbolCircleLayer/DataDrivenCircleColors.js', + 'SymbolCircleLayer/Earthquakes.tsx', + 'SymbolCircleLayer/ScalableImages.tsx', + 'V10/FeatureState.tsx', + 'V10/GlobeProjection.tsx', + 'V10/MapHandlers.tsx', + 'V10/Markers.tsx', + 'V10/TerrainSkyAtmosphere.tsx', +]; diff --git a/plugins/snack/remarkSnackLink.js b/plugins/snack/remarkSnackLink.js new file mode 100644 index 00000000..4797019f --- /dev/null +++ b/plugins/snack/remarkSnackLink.js @@ -0,0 +1,43 @@ +const {snackUrl} = require('./snackUrl'); +const allowlist = new Set(require('./allowlist')); + +function attribute(name, value) { + return {type: 'mdxJsxAttribute', name, value}; +} + +/** + * Adds an "Open in Snack" button above the code of example pages listed in `allowlist.js`. + */ +module.exports = function remarkSnackLink() { + return (tree, file) => { + const relPath = file.data.frontMatter?.custom_props?.example_rel_path; + if (!allowlist.has(relPath)) return; + + const index = tree.children.findIndex((node) => node.type === 'code'); + if (index < 0) return; + const url = snackUrl(relPath, tree.children[index].value); + if (!url) { + throw new Error(`${relPath} is in the Snack allowlist but imports files the Snack can't load`); + } + + tree.children.splice(index, 0, { + type: 'mdxJsxFlowElement', + name: 'p', + attributes: [], + children: [ + { + type: 'mdxJsxTextElement', + name: 'a', + attributes: [ + attribute('className', 'button button--primary button--sm'), + attribute('href', url), + attribute('target', '_blank'), + attribute('rel', 'noopener noreferrer'), + attribute('title', 'Runs the web version of this example in Expo Snack'), + ], + children: [{type: 'text', value: 'Open in Snack'}], + }, + ], + }); + }; +}; diff --git a/plugins/snack/snackUrl.js b/plugins/snack/snackUrl.js new file mode 100644 index 00000000..d3181afa --- /dev/null +++ b/plugins/snack/snackUrl.js @@ -0,0 +1,166 @@ +const path = require('path'); + +// Snackager caches npm metadata for ~1h, so pin the release the docs were generated from. +// Example sources are loaded from the same tag, so the Snack runs code written for that release. +const RNMAPBOX_MAPS_VERSION = '10.3.14'; + +const EXAMPLE_SRC_URL = `https://raw.githubusercontent.com/rnmapbox/maps/v${RNMAPBOX_MAPS_VERSION}/example/src`; + +const TOKEN_URL = + 'https://raw.githubusercontent.com/rnmapbox/ci-config/main/mapbox-snack-token.txt'; + +// Versions from the example app's package.json; unlisted packages get Snack's default. +const DEPENDENCY_VERSIONS = { + '@rnmapbox/maps': RNMAPBOX_MAPS_VERSION, + '@rneui/base': '4.0.0-rc.8', + '@turf/bbox': '6.5.0', + '@turf/helpers': '6.5.0', + '@turf/length': '6.5.0', + '@mapbox/geo-viewport': '0.5.0', +}; + +const PEER_DEPENDENCIES = { + '@rneui/base': ['@expo/vector-icons'], +}; + +const BUILTIN_MODULES = new Set(['react', 'react-native']); + +// Relative imports of the examples, resolved to the files at the pinned tag. +const SHARED_FILES = { + 'examples/common/Bubble': {file: 'examples/common/Bubble.js', dependencies: ['prop-types']}, + 'examples/common/BaseExamplePropTypes': { + file: 'examples/common/BaseExamplePropTypes.ts', + dependencies: ['prop-types'], + }, + 'examples/common/ExampleMetadata': {file: 'examples/common/ExampleMetadata.ts'}, + 'styles/sheet': {file: 'styles/sheet.js'}, + 'styles/colors': {file: 'styles/colors.js'}, + utils: {file: 'utils/index.js'}, +}; + +const IMPORT_RE = /^import\s+(type\s+)?([\s\S]*?)\s+from\s+'([^']+)';?/gm; + +const REQUIRE_RE = /require\('([^']+)'\)/g; + +function isTypeOnlyClause(clause) { + return /^\{[^}]*\}$/.test(clause) && clause.slice(1, -1).split(',').every((s) => /^\s*(type\s|$)/.test(s)); +} + +function parseImports(code) { + return [ + ...[...code.matchAll(IMPORT_RE)].map(([, typeOnly, clause, source]) => ({ + source, + typeOnly: !!typeOnly || isTypeOnlyClause(clause), + })), + ...[...code.matchAll(REQUIRE_RE)].map(([, source]) => ({source, typeOnly: false})), + ]; +} + +function appCode(exampleModule, safeArea) { + const [Root, rootImport] = safeArea + ? ['SafeAreaProvider', "import { SafeAreaProvider } from 'react-native-safe-area-context';\n"] + : ['View', '']; + return `import React, { useEffect, useState } from 'react'; +import { Platform, Text, View } from 'react-native'; +${rootImport}import Mapbox from '@rnmapbox/maps'; +import mapboxgl from 'mapbox-gl'; + +import Example from './${exampleModule}'; + +// Public token restricted to the Snack web player, rotated in rnmapbox/ci-config. +const TOKEN_URL = '${TOKEN_URL}'; + +if (Platform.OS === 'web') { + const worker = \`importScripts('https://api.mapbox.com/mapbox-gl-js/v\${mapboxgl.version}/mapbox-gl-csp-worker.js');\`; + Mapbox.setWorkerUrl( + URL.createObjectURL(new Blob([worker], { type: 'text/javascript' })), + ); +} + +export default function App() { + const [ready, setReady] = useState(false); + const [error, setError] = useState(null); + useEffect(() => { + (async () => { + try { + const token = atob((await (await fetch(TOKEN_URL)).text()).trim()); + Mapbox.setAccessToken(token); + setReady(true); + } catch (e) { + setError(String(e)); + } + })(); + }, []); + if (error) return {error}; + if (!ready) return Loading access token…; + return ( + <${Root} style={{ flex: 1 }}> + {}} /> + + ); +} +`; +} + +/** + * Builds an unsaved snack.expo.dev URL running the example on web, or returns null when the + * example imports something the Snack can't provide. + * + * The example is referenced by URL rather than inlined: snack.expo.dev rejects URLs over ~15k + * characters with HTTP 431. + * + * @param {string} relPath example path relative to example/src/examples, e.g. `Map/ShowMap.tsx` + * @param {string} code example source as shown in the docs, used to find its imports + */ +function snackUrl(relPath, code) { + const exampleFile = `src/examples/${relPath}`; + const exampleDir = path.posix.dirname(exampleFile); + + const files = { + [exampleFile]: {type: 'CODE', url: `${EXAMPLE_SRC_URL}/examples/${relPath}`}, + }; + const dependencies = new Set(['@rnmapbox/maps', 'mapbox-gl']); + + for (const {source, typeOnly} of parseImports(code)) { + if (typeOnly || BUILTIN_MODULES.has(source)) continue; + if (!source.startsWith('.')) { + dependencies.add(source.startsWith('@') ? source.split('/').slice(0, 2).join('/') : source.split('/')[0]); + continue; + } + const target = path.posix.join(exampleDir, source).replace(/^src\//, ''); + // Snack evaluates binary files given by URL as code, so only JSON assets work. + if (target.startsWith('assets/') && target.endsWith('.json')) { + files[`src/${target}`] = {type: 'CODE', url: `${EXAMPLE_SRC_URL}/${target}`}; + continue; + } + const shared = SHARED_FILES[target]; + if (!shared) return null; + files[`src/${shared.file}`] = {type: 'CODE', url: `${EXAMPLE_SRC_URL}/${shared.file}`}; + (shared.dependencies || []).forEach((d) => dependencies.add(d)); + } + + for (const dependency of [...dependencies]) { + (PEER_DEPENDENCIES[dependency] || []).forEach((d) => dependencies.add(d)); + } + + files['App.js'] = { + type: 'CODE', + contents: appCode( + exampleFile.replace(/\.[jt]sx?$/, ''), + dependencies.has('react-native-safe-area-context'), + ), + }; + + const params = new URLSearchParams({ + platform: 'web', + supportedPlatforms: 'web', + name: `@rnmapbox/maps ${path.posix.basename(relPath).replace(/\.[jt]sx?$/, '')} example`, + dependencies: [...dependencies] + .map((d) => (DEPENDENCY_VERSIONS[d] ? `${d}@${DEPENDENCY_VERSIONS[d]}` : d)) + .join(','), + files: JSON.stringify(files), + }); + return `https://snack.expo.dev/?${params.toString().replace(/\+/g, '%20')}`; +} + +module.exports = {snackUrl}; From 4e3f2351d865fb774c229b97e9e81876b84c61ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Tue, 27 Oct 2026 10:46:08 +0100 Subject: [PATCH 2/2] Show the Snack button from example metadata instead of an allowlist rnmapbox/maps writes the example's snackSupported (defaulting to webSupported) to the snack_supported front matter; pages without it fall back to the "Try it on web" link. The web link becomes a "Run on web" button next to "Edit in Snack". CameraAnimation and QueryTerrainElevation now run in Snack with the turf dependencies Snackager leaves out. --- README.md | 4 +- plugins/snack/allowlist.js | 50 ---------------- plugins/snack/remarkSnackLink.js | 98 ++++++++++++++++++++++---------- plugins/snack/snackUrl.js | 7 ++- 4 files changed, 77 insertions(+), 82 deletions(-) delete mode 100644 plugins/snack/allowlist.js diff --git a/README.md b/README.md index 7f3e4381..047feb87 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,6 @@ yarn detox test --configuration ios.debug --debug-synchronization 500 --loglevel ### Open in Snack -Example pages listed in `plugins/snack/allowlist.js` get an "Open in Snack" button (`plugins/snack/remarkSnackLink.js`). It opens an unsaved Snack on the web platform with `@rnmapbox/maps` pinned to `RNMAPBOX_MAPS_VERSION` in `plugins/snack/snackUrl.js`; the example and its helpers are loaded from that release's tag, and the access token is fetched at runtime from rnmapbox/ci-config. +Example pages get "Run on web" and "Edit in Snack" buttons (`plugins/snack/remarkSnackLink.js`). "Edit in Snack" opens an unsaved Snack on the web platform with `@rnmapbox/maps` pinned to `RNMAPBOX_MAPS_VERSION` in `plugins/snack/snackUrl.js`; the example and its helpers are loaded from that release's tag, and the access token is fetched at runtime from rnmapbox/ci-config. -To add an example, build the site, open its link from the built page and check that the map renders without errors in the Snack web preview, then add its `example_rel_path` to the allowlist. Examples importing binary assets or files not in `SHARED_FILES` can't be linked; the build fails if one is allowlisted. When bumping `RNMAPBOX_MAPS_VERSION`, recheck the allowlisted examples. +Which examples get the Snack button is decided in rnmapbox/maps: the example's `snackSupported` metadata, defaulting to `webSupported`, is written to the `snack_supported` front matter of its page. Pages generated without that field fall back to showing it wherever there is a "Try it on web" link. Examples importing binary assets or files not in `SHARED_FILES` can't be linked; the build fails if one has `snack_supported: true`. When bumping `RNMAPBOX_MAPS_VERSION`, recheck the Snack-enabled examples. diff --git a/plugins/snack/allowlist.js b/plugins/snack/allowlist.js deleted file mode 100644 index a7e4d2f1..00000000 --- a/plugins/snack/allowlist.js +++ /dev/null @@ -1,50 +0,0 @@ -// Examples whose "Open in Snack" link was verified to render the map with tiles on Snack web. -// Paths are front matter `example_rel_path` values; see README.md before adding one. -module.exports = [ - 'Annotations/Heatmap.js', - 'Annotations/MarkerView.tsx', - 'Annotations/MarkerViewPlacement.tsx', - 'Camera/Fit.tsx', - 'Camera/FlyTo.js', - 'Camera/GetCenter.js', - 'Camera/GetZoom.js', - 'Camera/ViewportGestures.tsx', - 'FillRasterLayer/ChoroplethLayerByZoomLevel.js', - 'FillRasterLayer/CustomVectorSource.js', - 'FillRasterLayer/HillshadeSource.tsx', - 'FillRasterLayer/IndoorBuilding.js', - 'FillRasterLayer/QueryAtPoint.js', - 'FillRasterLayer/QuerySourceFeatures.tsx', - 'FillRasterLayer/QueryWithRect.js', - 'FillRasterLayer/RasterSource.js', - 'FillRasterLayer/WatercolorRasterTiles.js', - 'LineLayer/DrawPolyline.tsx', - 'LineLayer/GradientLine.js', - 'LineLayer/ThirdPartyVectorSource.js', - 'Map/CameraGestureObserver.tsx', - 'Map/ChangeLayerColor.js', - 'Map/DynamicUrl.tsx', - 'Map/LocalizeLabels.tsx', - 'Map/MapFps.tsx', - 'Map/MapUnMount.tsx', - 'Map/PointInMapView.js', - 'Map/ScreenCoordinates.tsx', - 'Map/SetLanguage.tsx', - 'Map/ShowAndHideLayer.js', - 'Map/ShowClick.js', - 'Map/ShowMap.tsx', - 'Map/ShowMapLocalStyle.tsx', - 'Map/ShowRegionDidChange.js', - 'Map/SourceLayerVisibility.js', - 'Map/StyleJson.js', - 'Map/SymbolScaleBehavior.tsx', - 'Map/TwoByTwo.js', - 'SymbolCircleLayer/DataDrivenCircleColors.js', - 'SymbolCircleLayer/Earthquakes.tsx', - 'SymbolCircleLayer/ScalableImages.tsx', - 'V10/FeatureState.tsx', - 'V10/GlobeProjection.tsx', - 'V10/MapHandlers.tsx', - 'V10/Markers.tsx', - 'V10/TerrainSkyAtmosphere.tsx', -]; diff --git a/plugins/snack/remarkSnackLink.js b/plugins/snack/remarkSnackLink.js index 4797019f..a64c3d84 100644 --- a/plugins/snack/remarkSnackLink.js +++ b/plugins/snack/remarkSnackLink.js @@ -1,43 +1,83 @@ const {snackUrl} = require('./snackUrl'); -const allowlist = new Set(require('./allowlist')); + +const WEB_LINK_TEXT = 'Try it on web'; function attribute(name, value) { return {type: 'mdxJsxAttribute', name, value}; } +function button(label, href, variant, extraAttributes = []) { + return { + type: 'mdxJsxTextElement', + name: 'a', + attributes: [ + attribute('className', `button button--${variant} button--sm margin-right--sm`), + attribute('href', href), + ...extraAttributes, + ], + children: [{type: 'text', value: label}], + }; +} + +function isWebLink(node) { + return node.type === 'link' && node.children[0]?.value === WEB_LINK_TEXT; +} + +/** Removes the "Try it on web" link, which ends the description paragraph, and returns its URL. */ +function takeWebLink(tree) { + const index = tree.children.findIndex( + (node) => node.type === 'paragraph' && node.children.some(isWebLink), + ); + if (index < 0) return {}; + const paragraph = tree.children[index]; + const url = paragraph.children.find(isWebLink).url; + paragraph.children = paragraph.children.filter((node) => !isWebLink(node)); + const last = paragraph.children[paragraph.children.length - 1]; + if (last?.type === 'text') last.value = last.value.trimEnd(); + if (paragraph.children.every((node) => node.type === 'text' && !node.value)) { + tree.children.splice(index, 1); + return {url, index}; + } + return {url, index: index + 1}; +} + /** - * Adds an "Open in Snack" button above the code of example pages listed in `allowlist.js`. + * Turns the "Try it on web" link of example pages into a "Run on web" button, and adds an + * "Edit in Snack" button next to it when the example's `snack_supported` front matter is true. */ module.exports = function remarkSnackLink() { return (tree, file) => { - const relPath = file.data.frontMatter?.custom_props?.example_rel_path; - if (!allowlist.has(relPath)) return; - - const index = tree.children.findIndex((node) => node.type === 'code'); - if (index < 0) return; - const url = snackUrl(relPath, tree.children[index].value); - if (!url) { - throw new Error(`${relPath} is in the Snack allowlist but imports files the Snack can't load`); + const customProps = file.data.frontMatter?.custom_props || {}; + const relPath = customProps.example_rel_path; + if (!relPath || !tree.children.some((node) => node.type === 'code')) return; + + const {url: webUrl, index: webLinkIndex} = takeWebLink(tree); + // Pages generated by rnmapbox/maps before snack_supported existed fall back to webSupported. + const snackSupported = customProps.snack_supported ?? webUrl != null; + + const codeIndex = tree.children.findIndex((node) => node.type === 'code'); + let snack = null; + if (snackSupported) { + snack = snackUrl(relPath, tree.children[codeIndex].value); + if (!snack && customProps.snack_supported) { + throw new Error(`${relPath} has snack_supported but imports files the Snack can't load`); + } + } + + const buttons = []; + if (webUrl) buttons.push(button('Run on web', webUrl, 'secondary')); + if (snack) { + buttons.push( + button('Edit in Snack', snack, 'primary', [ + attribute('target', '_blank'), + attribute('rel', 'noopener noreferrer'), + attribute('title', 'Runs the web version of this example in Expo Snack'), + ]), + ); } + if (!buttons.length) return; - tree.children.splice(index, 0, { - type: 'mdxJsxFlowElement', - name: 'p', - attributes: [], - children: [ - { - type: 'mdxJsxTextElement', - name: 'a', - attributes: [ - attribute('className', 'button button--primary button--sm'), - attribute('href', url), - attribute('target', '_blank'), - attribute('rel', 'noopener noreferrer'), - attribute('title', 'Runs the web version of this example in Expo Snack'), - ], - children: [{type: 'text', value: 'Open in Snack'}], - }, - ], - }); + const row = {type: 'mdxJsxFlowElement', name: 'p', attributes: [], children: buttons}; + tree.children.splice(webLinkIndex ?? codeIndex, 0, row); }; }; diff --git a/plugins/snack/snackUrl.js b/plugins/snack/snackUrl.js index d3181afa..c81e74cd 100644 --- a/plugins/snack/snackUrl.js +++ b/plugins/snack/snackUrl.js @@ -15,12 +15,17 @@ const DEPENDENCY_VERSIONS = { '@rneui/base': '4.0.0-rc.8', '@turf/bbox': '6.5.0', '@turf/helpers': '6.5.0', - '@turf/length': '6.5.0', + // 6.5.0 needs @babel/runtime, which Snackager can't bundle. + '@turf/length': '6.3.0', + '@turf/meta': '6.5.0', '@mapbox/geo-viewport': '0.5.0', }; +// Dependencies Snackager leaves out of the bundles of these packages. const PEER_DEPENDENCIES = { '@rneui/base': ['@expo/vector-icons'], + '@turf/bbox': ['@turf/helpers', '@turf/meta'], + '@turf/length': ['@turf/meta'], }; const BUILTIN_MODULES = new Set(['react', 'react-native']);