diff --git a/.gitignore b/.gitignore index 786e6cc75..c08230023 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,9 @@ src/components/icon/generated-icons.ts # Widget screenshots from `pnpm screenshot-widgets` /widget-screenshots/ +# Unreleased widget build from `pnpm generate-widget-next` +/public/widget/*/next/ + # Widget build used by `pnpm size:widget` /.widget-size/ diff --git a/README.md b/README.md index abbf3a883..36203f238 100644 --- a/README.md +++ b/README.md @@ -95,10 +95,35 @@ Check that NEXT_PUBLIC_PLANNER_ORG_ID is set in your .env.local file, and run pnpm generate-widget ``` -This will place asset inside `public/widget/` which will be reachable -through `http://localhost:3000/widget` or -`http://localhost:3000/widget/` (omitting the version will default to -the most recent one). +This will place assets inside `public/widget//`, using the version +from `package.json`, and add the version to the manifest in +`src/page-modules/widget/available-widgets`. The widget is then reachable +through `http://localhost:3000/widget`, or in fullscreen through +`http://localhost:3000/widget/`. + +`/widget` shows the most recent version, unless the unreleased widget has been +built (see below). + +### Testing unreleased widget changes + +New widget versions are only generated on the release PR. Until then, the latest +widget version does not include widget changes merged to `main`, so they can't +be tested on staging. Staging therefore also has an unreleased widget, built +from the current code on every deploy: + +- `/widget` shows the unreleased widget, with a link to switch to the latest + released version. +- `/widget/next` shows it in fullscreen. + +It is built as the version `next` into `public/widget//next`. This folder +is not checked in, not listed in the manifests and not built for production, so +`next` should never be used in integrations. + +To get it locally, run this and restart `pnpm dev`: + +``` +pnpm generate-widget-next +``` ### Releases and Widget Development diff --git a/next.config.js b/next.config.js index 9c2e698c8..4aa356c52 100644 --- a/next.config.js +++ b/next.config.js @@ -1,8 +1,28 @@ /** @type {import('next').NextConfig} */ const { version } = require('./package.json'); +const { existsSync } = require('fs'); +const { join } = require('path'); +const { compressToEncodedURIComponent } = require('lz-string'); + const orgId = process.env.NEXT_PUBLIC_PLANNER_ORG_ID; +/** + * Whether the unreleased `next` widget (scripts/build-widget-next.sh) was built + * before this build. Read at build time, as it is never built for production. + */ +function hasNextWidget() { + if (!orgId) return false; + return existsSync( + join( + __dirname, + 'public/widget', + compressToEncodedURIComponent(orgId), + 'next/planner-web.umd.js', + ), + ); +} + /** Sites allowed to show /embed pages in an iframe. */ function getEmbedFrameAncestors() { const orgData = orgId ? require(`./orgs/${orgId}.json`) : undefined; @@ -19,7 +39,10 @@ function getEmbedFrameAncestors() { */ const nextConfig = { output: 'standalone', - env: { PLANNER_WEB_VERSION: version }, + env: { + PLANNER_WEB_VERSION: version, + WIDGET_NEXT_AVAILABLE: hasNextWidget() ? 'true' : '', + }, transpilePackages: ['@atb-as/mapbox-shared', '@mrfylke/contact-form'], // Emit source maps for the server build so production stack traces resolve to original TypeScript experimental: { serverSourceMaps: true }, @@ -37,6 +60,12 @@ const nextConfig = { }, ], }, + // The `next` widget is rebuilt on every deploy under the same URL, so it + // must not be cached. Has to come after the rule above to override it. + { + source: '/widget/:org/next/:path*', + headers: [{ key: 'Cache-Control', value: 'no-cache' }], + }, { source: '/embed/:path+', headers: [ diff --git a/package.json b/package.json index b57e93da9..99c2a7139 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "prestart": "pnpm generate", "predev": "pnpm generate", "generate-widget": "dotenv -e .env.local -- sh ./scripts/build-widget.sh", + "generate-widget-next": "dotenv -e .env.local -- sh ./scripts/build-widget-next.sh", "generate-all-widgets": "sh ./scripts/build-all-widgets.sh", "screenshot-widgets": "sh ./scripts/screenshot-widgets.sh", "size:widget": "sh ./scripts/build-widget-size.sh && size-limit", diff --git a/scripts/build-docker.sh b/scripts/build-docker.sh index 38b18a88e..32a55ab94 100644 --- a/scripts/build-docker.sh +++ b/scripts/build-docker.sh @@ -13,6 +13,11 @@ mkdir -p dist mkdir -p dist/$NEXT_PUBLIC_PLANNER_ORG_ID echo "Running pnpm setup && pnpm build for $NEXT_PUBLIC_PLANNER_ORG_ID" pnpm setup + +if [ "$NEXT_PUBLIC_ENVIRONMENT" != "prod" ]; then + sh ./scripts/build-widget-next.sh +fi + pnpm build echo "Moving the output into the dist directory" diff --git a/scripts/build-widget-next.sh b/scripts/build-widget-next.sh new file mode 100644 index 000000000..1ae96e09a --- /dev/null +++ b/scripts/build-widget-next.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +# Builds the unreleased `next` widget for the current org from the current +# source, into public/widget//next. It is not checked in and not added to +# the manifests. Used for non-production builds (see build-docker.sh), so widget +# changes can be tested before they are released. + +set -e + +if [ -z "$NEXT_PUBLIC_PLANNER_ORG_ID" ]; then + echo "No NEXT_PUBLIC_PLANNER_ORG_ID found in environment." + exit 1 +fi + +echo "Building next widget for $NEXT_PUBLIC_PLANNER_ORG_ID" +WIDGET_VERSION=next pnpm build:widget + +ORG_DIR=$(node -p "require('lz-string').compressToEncodedURIComponent(process.env.NEXT_PUBLIC_PLANNER_ORG_ID)") +CSS_FILE="public/widget/$ORG_DIR/next/planner-web.css" + +# Same PostCSS workaround as in build-widget.sh +npx postcss --config . "$CSS_FILE" -o "$CSS_FILE" + +if grep -q 'token(' "$CSS_FILE"; then + echo "Unprocessed token() found in $CSS_FILE" + exit 1 +fi diff --git a/scripts/generate-widget-stat.js b/scripts/generate-widget-stat.js index 0fe10bd38..3899d3244 100644 --- a/scripts/generate-widget-stat.js +++ b/scripts/generate-widget-stat.js @@ -45,7 +45,10 @@ async function getExistingCreated(manifestFile) { async function getWidgetData(existingCreated) { const baseUrl = join(process.cwd(), '/public/widget', compressedOrgId); - const dirs = await readdir(baseUrl); + // Only released versions, not e.g. the unreleased `next` build. + const dirs = (await readdir(baseUrl)).filter((dir) => + /^\d+\.\d+\.\d+$/.test(dir), + ); // return all files from /public/widget based on dirs const allP = dirs.map(async (dir) => { diff --git a/scripts/release-widgets.sh b/scripts/release-widgets.sh index 68ef0dcd4..4aa1dd408 100644 --- a/scripts/release-widgets.sh +++ b/scripts/release-widgets.sh @@ -38,7 +38,9 @@ for ORG_DIR in "$BASE_DIR"/*/; do fi done - PREVIOUS=$(ls "$ORG_DIR" | grep -vxF "$VERSION" | sort -V | tail -n 1) + # Only released versions, not e.g. the unreleased `next` build + PREVIOUS=$(ls "$ORG_DIR" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | + grep -vxF "$VERSION" | sort -V | tail -n 1) if [[ -z "$PREVIOUS" ]]; then echo "$ORG_DIR: no previous version" CHANGED=true diff --git a/src/page-modules/widget/index.ts b/src/page-modules/widget/index.ts index b88fc94b9..d12e02e3e 100644 --- a/src/page-modules/widget/index.ts +++ b/src/page-modules/widget/index.ts @@ -1,2 +1,6 @@ export { CopyMarkup, CopyMarkupLarge } from './copy-markup'; -export type { PlannerWidgetData, PlannerModule } from './server'; +export type { + PlannerWidgetData, + PlannerModule, + UnreleasedPlannerModule, +} from './server'; diff --git a/src/page-modules/widget/server/index.ts b/src/page-modules/widget/server/index.ts index ba7435cec..ff2fae941 100644 --- a/src/page-modules/widget/server/index.ts +++ b/src/page-modules/widget/server/index.ts @@ -4,8 +4,15 @@ import { compressToEncodedURIComponent } from 'lz-string'; export type PlannerWidgetData = { latest: PlannerModule; all: PlannerModule[]; + /** + * Unreleased widget built from the current source. Only in non-production + * builds, see scripts/build-widget-next.sh. + */ + next?: UnreleasedPlannerModule; }; +export type UnreleasedPlannerModule = Pick; + export type PlannerModule = { version: string; created: string; @@ -26,5 +33,17 @@ export async function getWidgetData(): Promise { `../available-widgets/${compressedOrgId}/manifest.json` ); - return data.default as PlannerWidgetData; + const widgetData = data.default as PlannerWidgetData; + if (!process.env.WIDGET_NEXT_AVAILABLE) return widgetData; + + return { ...widgetData, next: nextWidget }; } + +const nextWidget: UnreleasedPlannerModule = { + version: 'next', + urls: { + umd: `/widget/${compressedOrgId}/next/planner-web.umd.js`, + esm: `/widget/${compressedOrgId}/next/planner-web.mjs`, + css: `/widget/${compressedOrgId}/next/planner-web.css`, + }, +}; diff --git a/src/page-modules/widget/widget.module.css b/src/page-modules/widget/widget.module.css index c56b1ef15..0fe16d2c0 100644 --- a/src/page-modules/widget/widget.module.css +++ b/src/page-modules/widget/widget.module.css @@ -15,6 +15,14 @@ margin-bottom: token('spacing.medium'); } +.nextWidgetInfo { + margin-top: token('spacing.medium'); + padding: token('spacing.medium'); + border-radius: token('border.radius.regular'); + background-color: token('color.status.info.secondary.background'); + color: token('color.status.info.secondary.foreground.primary'); +} + .main details summary { cursor: pointer; margin: token('spacing.medium') 0; diff --git a/src/pages/widget/index.tsx b/src/pages/widget/index.tsx index 9d25ca3c2..ccf4cdf61 100644 --- a/src/pages/widget/index.tsx +++ b/src/pages/widget/index.tsx @@ -4,6 +4,7 @@ import { CopyMarkup, CopyMarkupLarge, type PlannerWidgetData, + type UnreleasedPlannerModule, } from '@atb/page-modules/widget'; import type { NextPage } from 'next'; import Head from 'next/head'; @@ -20,6 +21,8 @@ import { withAccessLogging } from '@atb/modules/logging'; type WidgetPagePropsContent = { data: PlannerWidgetData; + /** The widget version shown in the demo. */ + active: UnreleasedPlannerModule; }; export type WidgetPageProps = WithGlobalData; @@ -104,7 +107,7 @@ const outputCodeExample2 = html` `; -const WidgetPage: NextPage = ({ data, ...props }) => { +const WidgetPage: NextPage = ({ data, active, ...props }) => { const [isLoaded, setLoaded] = useState(false); const [html, setHtml] = useState(''); const lib = useRef(null); @@ -119,10 +122,10 @@ const WidgetPage: NextPage = ({ data, ...props }) => { {/* eslint-disable-next-line @next/next/no-css-tags */} - +