diff --git a/README.md b/README.md
index 71da33e1..047feb87 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 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.
+
+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/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/remarkSnackLink.js b/plugins/snack/remarkSnackLink.js
new file mode 100644
index 00000000..a64c3d84
--- /dev/null
+++ b/plugins/snack/remarkSnackLink.js
@@ -0,0 +1,83 @@
+const {snackUrl} = require('./snackUrl');
+
+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};
+}
+
+/**
+ * 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 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;
+
+ 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
new file mode 100644
index 00000000..c81e74cd
--- /dev/null
+++ b/plugins/snack/snackUrl.js
@@ -0,0 +1,171 @@
+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',
+ // 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']);
+
+// 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 }}>
+ {}} />
+ ${Root}>
+ );
+}
+`;
+}
+
+/**
+ * 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};