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 }}> + {}} /> + + ); +} +`; +} + +/** + * 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};