diff --git a/.pnpmfile.cjs b/.pnpmfile.cjs new file mode 100644 index 000000000..03baabbd9 --- /dev/null +++ b/.pnpmfile.cjs @@ -0,0 +1,26 @@ +function readPackage(pkg) { + const versions = { + react: '19.0.0-rc.0', + 'react-dom': '19.0.0-rc.0', + '@types/react': '18.3.3', + '@types/react-dom': '18.3.0', + 'react-server-dom-webpack': '19.0.0-rc.0', + typescript: '5.4.5', + graphql: '16.8.1', + waku: '0.21.0-alpha.2', + }; + for (const type of ['dependencies', 'devDependencies', 'peerDependencies']) { + for (const [name, version] of Object.entries(versions)) { + if (pkg[type] && Object.keys(pkg[type]).includes(name)) { + pkg[type][name] = version; + } + } + } + return pkg; +} + +module.exports = { + hooks: { + readPackage, + }, +}; diff --git a/apps/cms/config/sync/views.view.ssg_pages.yml b/apps/cms/config/sync/views.view.ssg_pages.yml new file mode 100644 index 000000000..8114f41bf --- /dev/null +++ b/apps/cms/config/sync/views.view.ssg_pages.yml @@ -0,0 +1,215 @@ +uuid: e82d71cc-9fd3-4548-acbb-14da815b76fb +langcode: en +status: true +dependencies: + module: + - node + - user +id: ssg_pages +label: 'SSG: Pages' +module: views +description: 'Pages to be created during static site generation.' +tag: '' +base_table: node_field_data +base_field: nid +display: + default: + id: default + display_title: Default + display_plugin: default + position: 0 + display_options: + fields: + title: + id: title + table: node_field_data + field: title + relationship: none + group_type: group + admin_label: '' + entity_type: node + entity_field: title + plugin_id: field + label: '' + exclude: false + alter: + alter_text: false + make_link: false + absolute: false + word_boundary: false + ellipsis: false + strip_tags: false + trim: false + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + click_sort_column: value + type: string + settings: + link_to_entity: true + group_column: value + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false + pager: + type: mini + options: + offset: 0 + items_per_page: 10 + total_pages: null + id: 0 + tags: + next: ›› + previous: ‹‹ + expose: + items_per_page: false + items_per_page_label: 'Items per page' + items_per_page_options: '5, 10, 25, 50' + items_per_page_options_all: false + items_per_page_options_all_label: '- All -' + offset: false + offset_label: Offset + exposed_form: + type: basic + options: + submit_button: Apply + reset_button: false + reset_button_label: Reset + exposed_sorts_label: 'Sort by' + expose_sort_order: true + sort_asc_label: Asc + sort_desc_label: Desc + access: + type: perm + options: + perm: 'access content' + cache: + type: tag + options: { } + empty: { } + sorts: + created: + id: created + table: node_field_data + field: created + relationship: none + group_type: group + admin_label: '' + entity_type: node + entity_field: created + plugin_id: date + order: DESC + expose: + label: '' + field_identifier: '' + exposed: false + granularity: second + arguments: { } + filters: + status: + id: status + table: node_field_data + field: status + entity_type: node + entity_field: status + plugin_id: boolean + value: '1' + group: 1 + expose: + operator: '' + type: + id: type + table: node_field_data + field: type + relationship: none + group_type: group + admin_label: '' + entity_type: node + entity_field: type + plugin_id: bundle + operator: in + value: { } + group: 1 + exposed: true + expose: + operator_id: type_op + label: 'Content type' + description: '' + use_operator: false + operator: type_op + operator_limit_selection: false + operator_list: { } + identifier: type + required: false + remember: false + multiple: false + remember_roles: + authenticated: authenticated + anonymous: '0' + super_admin: '0' + administrator: '0' + gatsby_build: '0' + editor: '0' + reduce: false + is_grouped: false + group_info: + label: '' + description: '' + identifier: '' + optional: true + widget: select + multiple: false + remember: false + default_group: All + default_group_multiple: { } + group_items: { } + style: + type: default + options: + grouping: { } + row_class: '' + default_row_class: true + uses_fields: false + row: + type: 'entity:node' + options: + relationship: none + view_mode: default + query: + type: views_query + options: + query_comment: '' + disable_sql_rewrite: false + distinct: false + replica: false + query_tags: { } + relationships: { } + header: { } + footer: { } + display_extenders: { } + cache_metadata: + max-age: -1 + contexts: + - 'languages:language_content' + - 'languages:language_interface' + - url + - url.query_args + - 'user.node_grants:view' + - user.permissions + tags: { } diff --git a/apps/cms/gatsby-config.mjs b/apps/cms/gatsby-config.mjs deleted file mode 100644 index 316fb9147..000000000 --- a/apps/cms/gatsby-config.mjs +++ /dev/null @@ -1,36 +0,0 @@ -import autoload from '@custom/schema/gatsby-autoload'; - -process.env.GATSBY_DRUPAL_URL = - process.env.DRUPAL_EXTERNAL_URL || 'http://127.0.0.1:8888'; - -/** - * @type {import('gatsby').GatsbyConfig['plugins']} - */ -export const plugins = [ - { - resolve: '@amazeelabs/gatsby-source-silverback', - options: { - schema_configuration: './graphqlrc.yml', - directives: autoload, - drupal_url: process.env.DRUPAL_INTERNAL_URL || 'http://127.0.0.1:8888', - drupal_external_url: - // File requests are proxied through netlify. - process.env.NETLIFY_URL || 'http://127.0.0.1:8000', - - graphql_path: '/graphql', - auth_key: 'cfdb0555111c0f8924cecab028b53474', - type_prefix: '', - }, - }, -]; - -/** - * @type {import('gatsby').GatsbyConfig} - */ -export default { - proxy: { - prefix: '/sites/default/files', - url: process.env.DRUPAL_EXTERNAL_URL || 'http://127.0.0.1:8888', - }, - plugins, -}; diff --git a/apps/cms/gatsby-node.mjs b/apps/cms/gatsby-node.mjs deleted file mode 100644 index 02e0027ff..000000000 --- a/apps/cms/gatsby-node.mjs +++ /dev/null @@ -1,58 +0,0 @@ -import { Locale } from '@custom/schema'; -import { resolve } from 'path'; - -/** - * - * @type {import('gatsby').GatsbyNode['createPages']} - */ -export const createPages = async ({ actions }) => { - // Rewrite file requests to Drupal. - actions.createRedirect({ - fromPath: '/sites/default/files/*', - toPath: `${process.env.GATSBY_DRUPAL_URL}/sites/default/files/:splat`, - statusCode: 200, - }); - - // Proxy Drupal GraphQL queries. - actions.createRedirect({ - fromPath: '/graphql', - toPath: `${process.env.GATSBY_DRUPAL_URL}/graphql`, - statusCode: 200, - }); - - // Create the content hub page in each language. - Object.values(Locale).forEach((locale) => { - actions.createPage({ - path: `/${locale}/content-hub`, - component: resolve(`./src/templates/content-hub.tsx`), - }); - }); - - // Broken Gatsby links will attempt to load page-data.json files, which don't exist - // and also should not be piped into the strangler function. Thats why they - // are caught right here. - actions.createRedirect({ - fromPath: '/page-data/*', - toPath: '/404', - statusCode: 404, - }); - - // Proxy Drupal webforms. - Object.values(Locale).forEach((locale) => { - actions.createRedirect({ - fromPath: `/${locale}/form/*`, - toPath: `${process.env.GATSBY_DRUPAL_URL}/${locale}/form/:splat`, - statusCode: 200, - }); - }); - - // Additionally proxy themes and modules as they can have additional - // non-aggregated assets. - ['themes', 'modules', 'core/assets'].forEach((path) => { - actions.createRedirect({ - fromPath: `/${path}/*`, - toPath: `${process.env.GATSBY_DRUPAL_URL}/${path}/:splat`, - statusCode: 200, - }); - }); -}; diff --git a/apps/decap/gatsby-config.js b/apps/decap/gatsby-config.js deleted file mode 100644 index a257352d7..000000000 --- a/apps/decap/gatsby-config.js +++ /dev/null @@ -1,32 +0,0 @@ -import autoload from '@custom/schema/gatsby-autoload'; -import { dirname, resolve } from 'path'; -import { fileURLToPath } from 'url'; - -import * as sources from './build/index.js'; - -const dir = resolve(dirname(fileURLToPath(import.meta.url))); - -/** - * @type {import('gatsby').GatsbyConfig['plugins']} - */ -export const plugins = [ - { - resolve: '@amazeelabs/gatsby-source-silverback', - options: { - schema_configuration: './graphqlrc.yml', - directives: autoload, - sources, - }, - }, - { - resolve: '@amazeelabs/gatsby-plugin-static-dirs', - options: { - directories: { - [`${dir}/dist`]: '/admin', - [`${dir}/media`]: '/media', - }, - }, - }, -]; - -export default { plugins }; diff --git a/apps/preview/src/App.tsx b/apps/preview/src/App.tsx index 981244d51..3f9e259ec 100644 --- a/apps/preview/src/App.tsx +++ b/apps/preview/src/App.tsx @@ -1,4 +1,4 @@ -import { OperationExecutor } from '@custom/schema'; +import { OperationExecutorsProvider } from '@custom/schema'; import { Frame } from '@custom/ui/routes/Frame'; import { Preview, usePreviewRefresh } from '@custom/ui/routes/Preview'; import { useEffect } from 'react'; @@ -24,17 +24,17 @@ const updates$ = webSocket({ function App() { const refresh = usePreviewRefresh(); useEffect(() => { - const sub = updates$.subscribe(refresh); + const sub = updates$.subscribe(() => refresh({})); return sub.unsubscribe; }, [refresh]); return ( - - + ); } diff --git a/apps/website/.eslintignore b/apps/website/.eslintignore new file mode 100644 index 000000000..772a489cf --- /dev/null +++ b/apps/website/.eslintignore @@ -0,0 +1,7 @@ +# Local Netlify folder +.netlify +.cache +.turbo +dist +styles.css +persisted-store diff --git a/apps/website/.gitignore b/apps/website/.gitignore index 395b0379a..772a489cf 100644 --- a/apps/website/.gitignore +++ b/apps/website/.gitignore @@ -2,6 +2,6 @@ .netlify .cache .turbo -public +dist styles.css persisted-store diff --git a/apps/website/gatsby-browser.ts b/apps/website/gatsby-browser.ts deleted file mode 100644 index 971084011..000000000 --- a/apps/website/gatsby-browser.ts +++ /dev/null @@ -1,16 +0,0 @@ -import '@custom/ui/styles.css'; - -import { GatsbyBrowser } from 'gatsby'; - -export const shouldUpdateScroll: GatsbyBrowser['shouldUpdateScroll'] = ( - args, -) => { - // Tell Gatsby to only update scroll position if the pathname or hash has changed. - // If only the search has changed (e.g. when a search form is submitted), - // the scroll position should remain the same. - const current = args.routerProps.location; - const previous = args.prevRouterProps?.location; - return ( - current.pathname !== previous?.pathname || current.hash !== previous?.hash - ); -}; diff --git a/apps/website/gatsby-config.mjs b/apps/website/gatsby-config.mjs deleted file mode 100644 index 842076dff..000000000 --- a/apps/website/gatsby-config.mjs +++ /dev/null @@ -1,81 +0,0 @@ -// Please keep this file as JavaScript. -// Gatsby supports both JS and TS config files, but the TS support is poor and -// can lead to crazy errors. -// If it's really needed to use TS (e.g. to import code from other TS files), -// use rollup to compile it to JS first. Please keep in mind that the original -// TS file name should be different from gastby-config.ts, otherwise Gatsby will -// pick it up instead of the JS file. - -import { existsSync } from 'fs'; - -process.env.NETLIFY_URL = process.env.NETLIFY_URL || 'http://127.0.0.1:8000'; - -process.env.CLOUDINARY_API_KEY = process.env.CLOUDINARY_API_KEY || 'test'; -process.env.CLOUDINARY_API_SECRET = process.env.CLOUDINARY_API_SECRET || 'test'; -process.env.CLOUDINARY_CLOUDNAME = process.env.CLOUDINARY_CLOUDNAME || 'demo'; - -/** - * - * @type {import('gatsby').GatsbyConfig['plugins']} - */ -const plugins = [ - 'gatsby-plugin-uninline-styles', - 'gatsby-plugin-pnpm', - 'gatsby-plugin-layout', - 'gatsby-plugin-sharp', - { - resolve: '@amazeelabs/gatsby-plugin-static-dirs', - options: { - directories: { - 'node_modules/@custom/ui/build/styles.css': '/styles.css', - 'node_modules/@custom/ui/build/iframe.css': '/iframe.css', - 'node_modules/@custom/ui/static/public': '/', - }, - }, - }, - { - resolve: '@amazeelabs/gatsby-plugin-operations', - options: { - operations: './node_modules/@custom/schema/build/operations.json', - }, - }, - { - resolve: 'gatsby-plugin-netlify', - options: { - // To avoid "X-Frame-Options: DENY" in Drupal iframes. - mergeSecurityHeaders: false, - }, - }, - { - resolve: 'gatsby-plugin-sitemap', - }, - { - resolve: 'gatsby-plugin-robots-txt', - options: { - policy: [{ userAgent: '*', allow: '/', disallow: [] }], - }, - }, - { - resolve: '@amazeelabs/gatsby-source-silverback', - options: { - schema_configuration: './graphqlrc.yml', - }, - }, - '@custom/cms', - '@custom/decap', -]; - -/** - * @type {import('gatsby').GatsbyConfig} - */ -export default { - trailingSlash: 'ignore', - flags: { - PARTIAL_HYDRATION: false, - }, - siteMetadata: { - // For gatsby-plugin-sitemap and gatsby-plugin-robots-txt. - siteUrl: process.env.NETLIFY_URL, - }, - plugins, -}; diff --git a/apps/website/gatsby-node.mjs b/apps/website/gatsby-node.mjs deleted file mode 100644 index ffce8e751..000000000 --- a/apps/website/gatsby-node.mjs +++ /dev/null @@ -1,117 +0,0 @@ -import { graphqlQuery } from '@amazeelabs/gatsby-plugin-operations'; -import { - HomePageQuery, - ListPagesQuery, - Locale, - NotFoundPageQuery, -} from '@custom/schema'; -import { resolve } from 'path'; - -/** - * @type {import('gatsby').GatsbyNode['onCreateWebpackConfig']} - */ -export const onCreateWebpackConfig = ({ actions }) => { - actions.setWebpackConfig({ - resolve: { - alias: { - '@amazeelabs/bridge': '@amazeelabs/bridge-gatsby', - }, - }, - }); -}; - -/** - * @template T extends any - * @param {T | undefined | null} val - * @returns {val is T} - */ -function isDefined(val) { - return Boolean(val); -} - -/** - * - * @type {import('gatsby').GatsbyNode['createPages']} - */ -export const createPages = async ({ actions }) => { - // Grab Home- and 404 pages. - const homePages = - ( - await graphqlQuery(HomePageQuery) - ).data.websiteSettings?.homePage?.translations?.filter(isDefined) || []; - const notFoundPages = - ( - await graphqlQuery(NotFoundPageQuery) - ).data.websiteSettings?.notFoundPage?.translations?.filter(isDefined) || []; - - // Create pages and root-redirects for home-pages. - homePages.forEach((page) => { - actions.createPage({ - path: `/${page.locale}`, - component: resolve('./src/templates/home.tsx'), - }); - // If a menu link points to the drupal-path of a home page, - // it should redirect to the root path with the language prefix. - actions.createRedirect({ - fromPath: page.path, - toPath: `/${page.locale}`, - statusCode: 301, - }); - }); - - // Create a list of paths that we don't want to render regularly. - // 404 and homepages are dealt with differently. - const skipPaths = [ - ...(homePages.map((page) => page.path) || []), - ...(notFoundPages.map((page) => page.path) || []), - ]; - - // Run the query that lists all pages, both Decap and Drupal. - const pages = await graphqlQuery(ListPagesQuery); - - // Create a gatsby page for each of these pages. - pages.data?.allPages - ?.filter(isDefined) - .filter((page) => !skipPaths.includes(page.path)) - .forEach(({ path }) => { - actions.createPage({ - path: path, - component: resolve(`./src/templates/page.tsx`), - context: { pathname: path }, - }); - }); - - // Create the content hub page in each language. - Object.values(Locale).forEach((locale) => { - actions.createPage({ - path: `/${locale}/content-hub`, - component: resolve(`./src/templates/content-hub.tsx`), - }); - }); - - // Create a inquiry page in each language. - Object.values(Locale).forEach((locale) => { - actions.createPage({ - path: `/${locale}/inquiry`, - component: resolve(`./src/templates/inquiry.tsx`), - }); - }); - - // Broken Gatsby links will attempt to load page-data.json files, which don't exist - // and also should not be piped into the strangler function. Thats why they - // are caught right here. - actions.createRedirect({ - fromPath: '/page-data/*', - toPath: '/404', - statusCode: 404, - }); - - // Any unhandled requests are handed to strangler, which will try to pass - // them to all registered legacy systems and return 404 if none of them - // respond. - actions.createRedirect({ - fromPath: '/*', - toPath: `/.netlify/functions/strangler`, - statusCode: 200, - }); -}; diff --git a/apps/website/gatsby-ssr.ts b/apps/website/gatsby-ssr.ts deleted file mode 100644 index 208aaf44f..000000000 --- a/apps/website/gatsby-ssr.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Locale } from '@custom/schema'; -import { GatsbySSR } from 'gatsby'; - -export const onRenderBody: GatsbySSR['onRenderBody'] = ({ - setHtmlAttributes, - pathname, -}) => { - const locales = Object.values(Locale); - if (locales.length === 1) { - // Single-language project. - setHtmlAttributes({ - lang: locales[0], - }); - } else { - // Multi-language project. - const prefix = pathname.split('/')[1]; - if (locales.includes(prefix as Locale)) { - setHtmlAttributes({ - lang: prefix, - }); - } else { - // We don't know the language. - } - } -}; diff --git a/apps/website/graphqlrc.yml b/apps/website/graphqlrc.yml deleted file mode 100644 index 56f7b3fa5..000000000 --- a/apps/website/graphqlrc.yml +++ /dev/null @@ -1,2 +0,0 @@ -schema: - - node_modules/@custom/schema/build/schema.graphql diff --git a/apps/website/has-drupal.mjs b/apps/website/has-drupal.mjs index a448969c5..fbb942e83 100644 --- a/apps/website/has-drupal.mjs +++ b/apps/website/has-drupal.mjs @@ -1,6 +1,6 @@ -import config from './gatsby-config.mjs'; +import pkgJson from './package.json' assert { type: 'json' }; -if (config.plugins?.filter((plugin) => plugin === '@custom/cms').length) { +if (pkgJson.dependencies['@custom/cms']) { process.exit(0); } process.exit(1); diff --git a/apps/website/package.json b/apps/website/package.json index 8ba4d3ae7..f38a7f208 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -1,60 +1,43 @@ { "name": "@custom/website", "private": true, + "type": "module", "dependencies": { - "@amazeelabs/bridge-gatsby": "^1.2.7", - "@amazeelabs/cloudinary-responsive-image": "^1.6.15", + "@amazeelabs/bridge-waku": "workspace:*", + "@amazeelabs/codegen-operation-ids": "workspace:*", "@amazeelabs/decap-cms-backend-token-auth": "^1.1.7", - "@amazeelabs/gatsby-plugin-operations": "^1.1.3", - "@amazeelabs/gatsby-plugin-static-dirs": "^1.0.1", - "@amazeelabs/gatsby-source-silverback": "^1.14.0", "@amazeelabs/publisher": "^2.4.30", "@amazeelabs/strangler-netlify": "^1.1.9", "@amazeelabs/token-auth-middleware": "^1.1.1", "@custom/cms": "workspace:*", - "@custom/decap": "workspace:*", "@custom/schema": "workspace:*", "@custom/ui": "workspace:*", - "gatsby": "^5.13.1", - "gatsby-plugin-layout": "^4.13.0", - "gatsby-plugin-manifest": "^5.13.0", - "gatsby-plugin-netlify": "^5.1.1", - "gatsby-plugin-pnpm": "^1.2.10", - "gatsby-plugin-robots-txt": "^1.8.0", - "gatsby-plugin-sharp": "^5.13.0", - "gatsby-plugin-sitemap": "^6.13.0", - "gatsby-plugin-uninline-styles": "^0.2.0", - "gatsby-source-filesystem": "^5.13.0", - "image-size": "^1.1.1", - "mime-types": "^2.1.35", "netlify-cli": "^17.21.1", - "react": "^18.2.0", - "react-dom": "^18.2.0" + "react": "19.0.0-rc.0", + "react-dom": "19.0.0-rc.0", + "react-error-boundary": "^4.0.13", + "react-server-dom-webpack": "19.0.0-rc.0", + "waku": "0.21.0-alpha.2" }, "devDependencies": { "@netlify/edge-functions": "^2.3.1", "@netlify/functions": "^2.6.0", "@testing-library/react": "^14.1.2", + "@types/node": "^20.14.2", "@types/react": "^18.2.46", "@types/react-dom": "^18.2.18", - "@types/serve-static": "^1.15.5", - "happy-dom": "^12.10.3", - "start-server-and-test": "^2.0.3", - "vitest": "^1.1.1" + "start-server-and-test": "^2.0.3" }, "scripts": { "test:static": "tsc --noEmit && eslint '**/*.{ts,tsx,js,jsx}' --ignore-path='./.gitignore'", - "full-rebuild": "pnpm clean && pnpm build:gatsby", + "full-rebuild": "pnpm clean && pnpm build:waku", "start:drupal": "pnpm run --filter @custom/cms start", - "build:drupal": "CLOUDINARY_CLOUDNAME=test pnpm start-test start:drupal 8888 build:gatsby", - "build:gatsby": "gatsby build", - "build": "if node has-drupal.mjs; then pnpm build:drupal; else pnpm build:gatsby; fi", + "build:drupal": "CLOUDINARY_CLOUDNAME=test pnpm start-test start:drupal 8888 build:waku", + "build:waku": "waku build", + "build": "if node has-drupal.mjs; then pnpm build:drupal; else pnpm build:waku; fi", "start": "publisher", - "serve": "netlify dev --cwd=. --dir=public --port=8000", + "serve": "netlify dev --cwd=. --dir=dist/public --port=8000", "dev": "pnpm clean && publisher", - "open": "open http://127.0.0.1:8000/___status/", - "gatsby:develop": "ENABLE_GATSBY_REFRESH_ENDPOINT=true pnpm gatsby develop", - "gatsby:refresh": "curl -X POST http://localhost:8000/__refresh", - "clean": "gatsby clean" + "open": "open http://127.0.0.1:8000/___status/" } } diff --git a/apps/website/publisher.config.ts b/apps/website/publisher.config.ts index 6c5d95797..44ca964d8 100644 --- a/apps/website/publisher.config.ts +++ b/apps/website/publisher.config.ts @@ -8,15 +8,8 @@ export default defineConfig({ commands: { build: { command: isNetlifyEnabled - ? // Bug: The first incremental build rewrites compilation hashes. This - // causes all files to be re-uploaded to Netlify two times: - // - on the initial build - // - on the first incremental build - // The bug cannot be reproduced on a clean Gatsby install, so we - // cannot report it. - // Workaround: Do a double build on the first build. - 'if test -d public; then echo "Single build" && pnpm build:gatsby; else echo "Double build" && pnpm build:gatsby && pnpm build:gatsby; fi' - : 'DRUPAL_EXTERNAL_URL=http://127.0.0.1:8888 pnpm build:gatsby', + ? 'pnpm build:waku' + : 'DRUPAL_EXTERNAL_URL=http://127.0.0.1:8888 pnpm build:waku', outputTimeout: 1000 * 60 * 10, }, clean: 'pnpm clean', diff --git a/apps/website/src/bridge.tsx b/apps/website/src/bridge.tsx new file mode 100644 index 000000000..f3ee94a4c --- /dev/null +++ b/apps/website/src/bridge.tsx @@ -0,0 +1,9 @@ +import { + createLinkComponent, + createUseLocationHook, +} from '@amazeelabs/bridge-waku'; +import { Link as WakuLink, useRouter_UNSTABLE } from 'waku'; +export { LocationProvider } from '@amazeelabs/bridge-waku'; + +export const useLocation = createUseLocationHook(useRouter_UNSTABLE); +export const Link = createLinkComponent(WakuLink); diff --git a/apps/website/src/broken-link-handler.tsx b/apps/website/src/broken-link-handler.tsx new file mode 100644 index 000000000..29635e0fb --- /dev/null +++ b/apps/website/src/broken-link-handler.tsx @@ -0,0 +1,20 @@ +'use client'; +import React, { PropsWithChildren } from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; + +export function BrokenLinkHandler({ children }: PropsWithChildren) { + return ( + { + if ((error as any).statusCode === 404) { + window.location.reload(); + } else { + console.error(error); + } + }} + fallback={

Something went wrong.

} + > + {children} +
+ ); +} diff --git a/apps/website/src/utils/drupal-executor.ts b/apps/website/src/drupal-executor.tsx similarity index 72% rename from apps/website/src/utils/drupal-executor.ts rename to apps/website/src/drupal-executor.tsx index f38a98fc5..9b5d21603 100644 --- a/apps/website/src/utils/drupal-executor.ts +++ b/apps/website/src/drupal-executor.tsx @@ -1,14 +1,19 @@ -import { AnyOperationId, OperationVariables } from '@custom/schema'; +import { + AnyOperationId, + OperationExecutorsProvider, + OperationVariables, +} from '@custom/schema'; +import React, { PropsWithChildren } from 'react'; /** * Create an executor that operates against a Drupal endpoint. */ -export function drupalExecutor(endpoint: string, forward: boolean = true) { +function drupalExecutor(endpoint: string, forward: boolean = true) { return async function ( id: OperationId, variables?: OperationVariables, ) { - const url = new URL(endpoint, window.location.origin); + const url = new URL(endpoint); const isMutation = id.includes('Mutation:'); if (isMutation) { const { data, errors } = await ( @@ -17,7 +22,7 @@ export function drupalExecutor(endpoint: string, forward: boolean = true) { credentials: 'include', body: JSON.stringify({ queryId: id, - variables: variables, + variables: variables || {}, }), headers: forward ? { @@ -37,7 +42,7 @@ export function drupalExecutor(endpoint: string, forward: boolean = true) { return data; } else { url.searchParams.set('queryId', id); - url.searchParams.set('variables', JSON.stringify(variables)); + url.searchParams.set('variables', JSON.stringify(variables || {})); const { data, errors } = await ( await fetch(url, { credentials: 'include', @@ -57,3 +62,15 @@ export function drupalExecutor(endpoint: string, forward: boolean = true) { } }; } + +export function DrupalExecutor({ children }: PropsWithChildren) { + return ( + + {children} + + ); +} diff --git a/apps/website/src/entries.tsx b/apps/website/src/entries.tsx new file mode 100644 index 000000000..111d35856 --- /dev/null +++ b/apps/website/src/entries.tsx @@ -0,0 +1,98 @@ +import '@custom/ui/styles.css'; + +import { + AnyOperationId, + ListPagesQuery, + Locale, + LocationProvider, + OperationResult, + OperationVariables, +} from '@custom/schema'; +import { Frame } from '@custom/ui/routes/Frame'; +import { HomePage } from '@custom/ui/routes/HomePage'; +import { NotFoundPage } from '@custom/ui/routes/NotFoundPage'; +import { ContentHub } from '@custom/ui/routes/ContentHub'; +import { Page } from '@custom/ui/routes/Page'; +import React from 'react'; +import { createPages } from 'waku'; + +import { BrokenLinkHandler } from './broken-link-handler.js'; +import { ExecutorsClient } from './executors-client.js'; +import { ExecutorsServer } from './executors-server.js'; + +async function query( + operation: TOperation, + variables: OperationVariables, +) { + const url = new URL('http://localhost:8888/graphql'); + url.searchParams.set('queryId', operation); + url.searchParams.set('variables', JSON.stringify(variables || {})); + const { data, errors } = await (await fetch(url)).json(); + if (errors) { + throw errors; + } + return data as OperationResult; +} + +export default createPages(async ({ createPage, createLayout }) => { + createLayout({ + render: 'static', + path: '/', + component: ({ children, path }) => ( + + + + + {children} + + + + + ), + }); + + Object.values(Locale).forEach((lang) => { + createPage({ + render: 'static', + path: `/${lang}`, + component: () => , + }); + + createPage({ + render: 'static', + path: `/${lang}/content-hub`, + component: () => , + }); + }); + + createPage({ + render: 'static', + path: '/404', + component: () => , + }); + + // TODO: Paginate properly to not load all nodes in Drupal + const pagePaths = new Set(); + const pages = await query(ListPagesQuery, { args: 'pageSize=0&page=1' }); + pages.ssgPages?.rows.forEach((page) => { + page?.translations?.forEach((translation) => { + if (translation?.path) { + pagePaths.add(translation.path); + } + }); + }); + + createPage({ + render: 'static', + path: '/[...path]', + staticPaths: [...pagePaths].map((path) => path.substring(1).split('/')), + component: Page, + }); +}); diff --git a/apps/website/src/executors-client.tsx b/apps/website/src/executors-client.tsx new file mode 100644 index 000000000..34bfa1c51 --- /dev/null +++ b/apps/website/src/executors-client.tsx @@ -0,0 +1,8 @@ +'use client'; +import React, { PropsWithChildren } from 'react'; + +import { DrupalExecutor } from './drupal-executor.js'; + +export function ExecutorsClient({ children }: PropsWithChildren) { + return {children}; +} diff --git a/apps/website/src/executors-server.tsx b/apps/website/src/executors-server.tsx new file mode 100644 index 000000000..823074919 --- /dev/null +++ b/apps/website/src/executors-server.tsx @@ -0,0 +1,7 @@ +import React, { PropsWithChildren } from 'react'; + +import { DrupalExecutor } from './drupal-executor.js'; + +export function ExecutorsServer({ children }: PropsWithChildren) { + return {children}; +} diff --git a/apps/website/src/layouts/index.tsx b/apps/website/src/layouts/index.tsx deleted file mode 100644 index 6bc8438cf..000000000 --- a/apps/website/src/layouts/index.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { graphql, useStaticQuery } from '@amazeelabs/gatsby-plugin-operations'; -import { FrameQuery, OperationExecutor } from '@custom/schema'; -import { Frame } from '@custom/ui/routes/Frame'; -import React, { PropsWithChildren } from 'react'; - -import { drupalExecutor } from '../utils/drupal-executor'; - -export default function Layout({ - children, -}: PropsWithChildren<{ - locale: string; -}>) { - const data = useStaticQuery(graphql(FrameQuery)); - return ( - - - {children} - - - ); -} diff --git a/apps/website/src/main.tsx b/apps/website/src/main.tsx new file mode 100644 index 000000000..b7304447f --- /dev/null +++ b/apps/website/src/main.tsx @@ -0,0 +1,15 @@ +import React, { StrictMode } from 'react'; +import { createRoot, hydrateRoot } from 'react-dom/client'; +import { Router } from 'waku/router/client'; + +const rootElement = ( + + + +); + +if (document.body.dataset.hydrate) { + hydrateRoot(document.body, rootElement); +} else { + createRoot(document.body).render(rootElement); +} diff --git a/apps/website/src/pages/404.tsx b/apps/website/src/pages/404.tsx deleted file mode 100644 index a7062457c..000000000 --- a/apps/website/src/pages/404.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { graphql } from '@amazeelabs/gatsby-plugin-operations'; -import { NotFoundPageQuery, OperationExecutor } from '@custom/schema'; -import { NotFoundPage } from '@custom/ui/routes/NotFoundPage'; -import { PageProps } from 'gatsby'; -import React from 'react'; - -export const query = graphql(NotFoundPageQuery); - -export default function Index({ data }: PageProps) { - return ( - - - - ); -} diff --git a/apps/website/src/templates/content-hub.tsx b/apps/website/src/templates/content-hub.tsx deleted file mode 100644 index 7bcf2cec2..000000000 --- a/apps/website/src/templates/content-hub.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { ContentHub } from '@custom/ui/routes/ContentHub'; -import React from 'react'; - -export function Head() { - // TODO: Add title once content hub is language aware. - return null; -} - -export default function ContentHubPage() { - return ; -} diff --git a/apps/website/src/templates/home.tsx b/apps/website/src/templates/home.tsx deleted file mode 100644 index 22096d7e6..000000000 --- a/apps/website/src/templates/home.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { graphql } from '@amazeelabs/gatsby-plugin-operations'; -import { HomePageQuery, OperationExecutor, useLocalized } from '@custom/schema'; -import { HomePage } from '@custom/ui/routes/HomePage'; -import { HeadProps, PageProps } from 'gatsby'; -import React from 'react'; - -export const query = graphql(HomePageQuery); - -export function Head({ data }: HeadProps) { - const page = useLocalized(data.websiteSettings?.homePage?.translations); - return page ? ( - <> - {page.title} - {page.metaTags?.map((metaTag, index) => { - if (metaTag?.tag === 'meta') { - return ( - - ); - } else if (metaTag?.tag === 'link') { - return ( - - ); - } - return null; - }) || null} - - ) : null; -} - -export default function Index({ data }: PageProps) { - return ( - - - - ); -} diff --git a/apps/website/src/templates/inquiry.tsx b/apps/website/src/templates/inquiry.tsx deleted file mode 100644 index eb90f5fa2..000000000 --- a/apps/website/src/templates/inquiry.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { Inquiry } from '@custom/ui/routes/Inquiry'; -import React from 'react'; - -export default function InquiryPage() { - return ; -} diff --git a/apps/website/src/templates/page.tsx b/apps/website/src/templates/page.tsx deleted file mode 100644 index f707acbb5..000000000 --- a/apps/website/src/templates/page.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { graphql } from '@amazeelabs/gatsby-plugin-operations'; -import { OperationExecutor, useLocation, ViewPageQuery } from '@custom/schema'; -import { Page } from '@custom/ui/routes/Page'; -import { HeadProps, PageProps } from 'gatsby'; -import React from 'react'; - -export const query = graphql(ViewPageQuery); - -export function Head({ data }: HeadProps) { - return data.page ? ( - <> - {data.page.title} - {data.page.metaTags?.map((metaTag, index) => { - if (metaTag?.tag === 'meta') { - return ( - - ); - } else if (metaTag?.tag === 'link') { - return ( - - ); - } - return null; - }) || null} - - ) : null; -} - -export default function PageTemplate({ data }: PageProps) { - // Retrieve the current location and prefill the - // "ViewPageQuery" with these arguments. - // That makes shure the `useOperation(ViewPageQuery, ...)` with this - // path immediately returns this data. - const [location] = useLocation(); - return ( - - - - ); -} diff --git a/apps/website/src/types/operations.d.ts b/apps/website/src/types/operations.d.ts deleted file mode 100644 index b95b0530b..000000000 --- a/apps/website/src/types/operations.d.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { - AnyOperationId, - OperationResult, - OperationVariables, -} from '@custom/schema'; - -declare module '@amazeelabs/gatsby-plugin-operations' { - export const graphql: ( - id: OperationId, - ) => OperationResult; - - function useStaticQuery(id: Input): Input; - - function graphqlQuery( - id: OperationId, - vars?: OperationVariables, - ): Promise<{ - data: OperationResult; - errors?: Array; - }>; -} diff --git a/apps/website/tsconfig.json b/apps/website/tsconfig.json index 95940b49a..8746d3f4b 100644 --- a/apps/website/tsconfig.json +++ b/apps/website/tsconfig.json @@ -9,12 +9,12 @@ "allowSyntheticDefaultImports": true, "strict": true, "forceConsistentCasingInFileNames": true, - "module": "ESNext", - "moduleResolution": "Node", + "module": "NodeNext", + "moduleResolution": "NodeNext", "resolveJsonModule": true, "isolatedModules": true, "checkJs": true, "jsx": "react" }, - "exclude": ["netlify", "node_modules", "public"] + "exclude": ["netlify", "node_modules", "public", "dist"] } diff --git a/apps/website/vite.config.js b/apps/website/vite.config.js new file mode 100644 index 000000000..49661d25e --- /dev/null +++ b/apps/website/vite.config.js @@ -0,0 +1,9 @@ +import path from 'path'; + +export default { + resolve: { + alias: { + '@amazeelabs/bridge': path.resolve(__dirname, 'src/bridge.tsx'), + }, + }, +}; diff --git a/package.json b/package.json index c86941856..2ad0d76fe 100644 --- a/package.json +++ b/package.json @@ -42,14 +42,5 @@ "turbo": "^1.11.2", "typescript": "^5.3.3", "vitest": "^1.1.1" - }, - "resolutions": { - "gatsby-plugin-sharp": "5.13.1", - "sharp": "0.33.1", - "eslint": "7", - "graphql": "16.8.1" - }, - "pnpm": { - "patchedDependencies": {} } } diff --git a/packages/bridge-waku/.eslintrc b/packages/bridge-waku/.eslintrc new file mode 100644 index 000000000..2b8bf76f4 --- /dev/null +++ b/packages/bridge-waku/.eslintrc @@ -0,0 +1,57 @@ +{ + "$schema": "https://json.schemastore.org/eslintrc.json", + "root": true, + "settings": { + "react": { + "version": "18" + } + }, + "env": { + "browser": true, + "es6": true, + "node": true + }, + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:promise/recommended", + "plugin:react/recommended", + "prettier" + ], + "globals": { + "Atomics": "readonly", + "SharedArrayBuffer": "readonly" + }, + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 2018, + "sourceType": "module", + "ecmaFeatures": { + "jsx": true + } + }, + "plugins": [ + "@typescript-eslint", + "promise", + "simple-import-sort", + "import", + "no-only-tests", + "react", + "react-hooks" + ], + "rules": { + "no-unused-vars": ["off"], + "@typescript-eslint/no-unused-vars": ["error"], + "simple-import-sort/imports": "error", + "sort-imports": "off", + "import/first": "error", + "import/newline-after-import": "error", + "import/no-duplicates": "error", + "no-only-tests/no-only-tests": "error", + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", + "react/prop-types": ["off"], + "react/prefer-stateless-function": ["error"], + "react/react-in-jsx-scope": ["off"] + } +} diff --git a/packages/bridge-waku/.gitignore b/packages/bridge-waku/.gitignore new file mode 100644 index 000000000..378eac25d --- /dev/null +++ b/packages/bridge-waku/.gitignore @@ -0,0 +1 @@ +build diff --git a/packages/bridge-waku/.npmignore b/packages/bridge-waku/.npmignore new file mode 100644 index 000000000..5bab5c7af --- /dev/null +++ b/packages/bridge-waku/.npmignore @@ -0,0 +1,4 @@ +** +!build/* +!CHANGELOG.md +!README.md diff --git a/packages/bridge-waku/.prettierrc b/packages/bridge-waku/.prettierrc new file mode 100644 index 000000000..ae00ae6bf --- /dev/null +++ b/packages/bridge-waku/.prettierrc @@ -0,0 +1 @@ +"@amazeelabs/prettier-config" diff --git a/packages/bridge-waku/package.json b/packages/bridge-waku/package.json new file mode 100644 index 000000000..df24e00d9 --- /dev/null +++ b/packages/bridge-waku/package.json @@ -0,0 +1,49 @@ +{ + "name": "@amazeelabs/bridge-waku", + "version": "1.0.0", + "description": "", + "type": "module", + "main": "build/client.js", + "types": "index.d.ts", + "exports": { + ".": { + "react-server": { + "types": "./build/dts/server.d.ts", + "default": "./build/server.js" + }, + "import": { + "types": "./build/dts/client.d.ts", + "default": "./build/client.js" + } + } + }, + "scripts": { + "prep": "tsc", + "watch": "tsc --watch", + "build": "pnpm prep", + "test:static": "tsc --noEmit && eslint \"**/*.{ts,tsx,js,jsx}\" --ignore-path=\"./.gitignore\" --fix" + }, + "dependencies": { + "react-server-dom-webpack": "19.0.0-rc.0", + "server-only-context": "^0.1.0" + }, + "peerDependencies": { + "react": "19.0.0-rc.0", + "react-dom": "19.0.0-rc.0" + }, + "devDependencies": { + "waku": "0.21.0-alpha.2", + "@amazeelabs/bridge": "^1.5.15", + "@amazeelabs/eslint-config": "1.4.43", + "@amazeelabs/prettier-config": "1.1.3", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "eslint": "8.57.0", + "prettier": "3.2.5", + "rollup": "4.14.3", + "typescript": "5.4.5" + }, + "keywords": [], + "author": "", + "license": "ISC" +} diff --git a/packages/bridge-waku/rollup.config.js b/packages/bridge-waku/rollup.config.js new file mode 100644 index 000000000..f4e235e3b --- /dev/null +++ b/packages/bridge-waku/rollup.config.js @@ -0,0 +1,8 @@ +import { defineConfig } from 'rollup'; +import dts from 'rollup-plugin-dts'; + +export default defineConfig({ + input: 'build/dts/index.d.ts', + output: [{ file: 'build/index.d.ts', format: 'es' }], + plugins: [dts()], +}); diff --git a/packages/bridge-waku/src/client.tsx b/packages/bridge-waku/src/client.tsx new file mode 100644 index 000000000..47e030c71 --- /dev/null +++ b/packages/bridge-waku/src/client.tsx @@ -0,0 +1,33 @@ +'use client'; +import type { LocationProviderType, useLocationType } from '@amazeelabs/bridge'; +import { useEffect, useState } from 'react'; +import { useRouter_UNSTABLE } from 'waku'; + +export { createLinkComponent } from './link.js'; + +export const createUseLocationHook: ( + useRouter: typeof useRouter_UNSTABLE, +) => useLocationType = (useRouter) => () => { + const router = useRouter(); + + // TODO: Use router.hash when https://github.com/dai-shi/waku/pull/746 is merged. + const [hash, setHash] = useState(''); + useEffect(() => { + setHash(window.location.hash); + }, [router]); + + return [ + { + pathname: router.path, + search: router.searchParams?.toString() || '', + // TODO: Remove double wrapping, based on feedback in https://github.com/dai-shi/waku/pull/746. + searchParams: new URLSearchParams(router.searchParams), + hash, + }, + router.push, + ]; +}; + +export const LocationProvider: LocationProviderType = ({ children }) => { + return <>{children}; +}; diff --git a/packages/bridge-waku/src/link.tsx b/packages/bridge-waku/src/link.tsx new file mode 100644 index 000000000..bc428f67d --- /dev/null +++ b/packages/bridge-waku/src/link.tsx @@ -0,0 +1,13 @@ +import { LinkType } from '@amazeelabs/bridge'; +import { Link as WakuLink } from 'waku'; + +export const createLinkComponent: (Link: typeof WakuLink) => LinkType = ( + Link, +) => + function WakuLink({ href, children, ...props }) { + return ( + + {children} + + ); + }; diff --git a/packages/bridge-waku/src/server.tsx b/packages/bridge-waku/src/server.tsx new file mode 100644 index 000000000..7e155bfda --- /dev/null +++ b/packages/bridge-waku/src/server.tsx @@ -0,0 +1,31 @@ +import type { LocationProviderType, useLocationType } from '@amazeelabs/bridge'; +import serverContext from 'server-only-context'; + +// @ts-ignore: Typing issue in server-only-context +const [getPath, setPath] = serverContext('/'); +// @ts-ignore: Typing issue in server-only-context +const [getParams, setParams] = serverContext(new URLSearchParams()); + +export { createLinkComponent } from './link.js'; + +export const createUseLocationHook: () => useLocationType = () => () => { + const params = getParams(); + return [ + { + pathname: getPath(), + search: params.toString(), + searchParams: params, + hash: '', + }, + () => undefined, + ]; +}; + +export const LocationProvider: LocationProviderType = ({ + children, + currentLocation, +}) => { + setPath(currentLocation?.pathname || '/'); + setParams(currentLocation?.searchParams || new URLSearchParams()); + return <>{children}; +}; diff --git a/packages/bridge-waku/tsconfig.json b/packages/bridge-waku/tsconfig.json new file mode 100644 index 000000000..4430ea331 --- /dev/null +++ b/packages/bridge-waku/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ESNext", + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "resolveJsonModule": true, + "isolatedModules": true, + "declaration": true, + "declarationDir": "build/dts", + "outDir": "build", + "jsx": "react-jsx", + }, + "include": ["src"] +} diff --git a/packages/bridge-waku/turbo.json b/packages/bridge-waku/turbo.json new file mode 100644 index 000000000..66be584b4 --- /dev/null +++ b/packages/bridge-waku/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://turborepo.org/schema.json", + "extends": ["//"], + "pipeline": { + "prep": { + "outputs": ["build"] + } + } +} diff --git a/packages/codegen-operation-ids/.eslintrc.cjs b/packages/codegen-operation-ids/.eslintrc.cjs new file mode 100644 index 000000000..071ad2164 --- /dev/null +++ b/packages/codegen-operation-ids/.eslintrc.cjs @@ -0,0 +1,4 @@ +module.exports = { + extends: ['@amazeelabs/eslint-config'], + root: true, +}; diff --git a/packages/codegen-operation-ids/.gitignore b/packages/codegen-operation-ids/.gitignore new file mode 100644 index 000000000..4007c033b --- /dev/null +++ b/packages/codegen-operation-ids/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +test/generated diff --git a/packages/codegen-operation-ids/.npmignore b/packages/codegen-operation-ids/.npmignore new file mode 100644 index 000000000..3e87eb52b --- /dev/null +++ b/packages/codegen-operation-ids/.npmignore @@ -0,0 +1,4 @@ +** +!dist/* +!CHANGELOG.md +!README.md diff --git a/packages/codegen-operation-ids/.prettierignore b/packages/codegen-operation-ids/.prettierignore new file mode 100644 index 000000000..1b763b1ba --- /dev/null +++ b/packages/codegen-operation-ids/.prettierignore @@ -0,0 +1 @@ +CHANGELOG.md diff --git a/packages/codegen-operation-ids/CHANGELOG.md b/packages/codegen-operation-ids/CHANGELOG.md new file mode 100644 index 000000000..eec7700b7 --- /dev/null +++ b/packages/codegen-operation-ids/CHANGELOG.md @@ -0,0 +1,394 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.41](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.40...@amazeelabs/codegen-operation-ids@0.1.41) (2024-05-10) + +**Note:** Version bump only for package @amazeelabs/codegen-operation-ids + + + + + +## [0.1.40](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.39...@amazeelabs/codegen-operation-ids@0.1.40) (2024-05-10) + +**Note:** Version bump only for package @amazeelabs/codegen-operation-ids + + + + + +## [0.1.39](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.38...@amazeelabs/codegen-operation-ids@0.1.39) (2024-05-09) + +**Note:** Version bump only for package @amazeelabs/codegen-operation-ids + + + + + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.38](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.37...@amazeelabs/codegen-operation-ids@0.1.38) (2024-04-16) + +**Note:** Version bump only for package @amazeelabs/codegen-operation-ids + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.37](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.36...@amazeelabs/codegen-operation-ids@0.1.37) (2024-04-08) + +**Note:** Version bump only for package @amazeelabs/codegen-operation-ids + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.36](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.35...@amazeelabs/codegen-operation-ids@0.1.36) (2024-02-29) + +**Note:** Version bump only for package @amazeelabs/codegen-operation-ids + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.35](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.34...@amazeelabs/codegen-operation-ids@0.1.35) (2024-02-20) + +**Note:** Version bump only for package @amazeelabs/codegen-operation-ids + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.34](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.33...@amazeelabs/codegen-operation-ids@0.1.34) (2024-01-12) + +### Bug Fixes + +- wrap fragments while inlining, or unions will break + ([cae1f60](https://github.com/AmazeeLabs/silverback-mono/commit/cae1f60c4ddebcd0bfd950ada463744b1397beec)) + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.33](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.32...@amazeelabs/codegen-operation-ids@0.1.33) (2024-01-12) + +### Bug Fixes + +- append operation suffixes in map keys to align with ids + ([e2bb05e](https://github.com/AmazeeLabs/silverback-mono/commit/e2bb05e14ef4918a99efce5aaf48f43146b374be)) + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.32](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.31...@amazeelabs/codegen-operation-ids@0.1.32) (2024-01-11) + +### Bug Fixes + +- **SLB-202:** clone fragments before inlining + ([32ce924](https://github.com/AmazeeLabs/silverback-mono/commit/32ce9247e0c91fb1a80cf43e60a536b822069e7b)) + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.31](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.30...@amazeelabs/codegen-operation-ids@0.1.31) (2024-01-11) + +**Note:** Version bump only for package @amazeelabs/codegen-operation-ids + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.30](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.29...@amazeelabs/codegen-operation-ids@0.1.30) (2024-01-11) + +**Note:** Version bump only for package @amazeelabs/codegen-operation-ids + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.29](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.28...@amazeelabs/codegen-operation-ids@0.1.29) (2024-01-11) + +**Note:** Version bump only for package @amazeelabs/codegen-operation-ids + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.28](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.27...@amazeelabs/codegen-operation-ids@0.1.28) (2024-01-03) + +**Note:** Version bump only for package @amazeelabs/codegen-operation-ids + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.27](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.26...@amazeelabs/codegen-operation-ids@0.1.27) (2023-11-27) + +**Note:** Version bump only for package @amazeelabs/codegen-operation-ids + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.26](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.25...@amazeelabs/codegen-operation-ids@0.1.26) (2023-11-02) + +**Note:** Version bump only for package @amazeelabs/codegen-operation-ids + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.25](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.24...@amazeelabs/codegen-operation-ids@0.1.25) (2023-10-19) + +**Note:** Version bump only for package @amazeelabs/codegen-operation-ids + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.24](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.23...@amazeelabs/codegen-operation-ids@0.1.24) (2023-10-14) + +**Note:** Version bump only for package @amazeelabs/codegen-operation-ids + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.23](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.22...@amazeelabs/codegen-operation-ids@0.1.23) (2023-10-13) + +**Note:** Version bump only for package @amazeelabs/codegen-operation-ids + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.22](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.21...@amazeelabs/codegen-operation-ids@0.1.22) (2023-06-29) + +**Note:** Version bump only for package @amazeelabs/codegen-operation-ids + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.21](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.20...@amazeelabs/codegen-operation-ids@0.1.21) (2023-06-15) + +**Note:** Version bump only for package @amazeelabs/codegen-operation-ids + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.20](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.19...@amazeelabs/codegen-operation-ids@0.1.20) (2023-04-28) + +**Note:** Version bump only for package @amazeelabs/codegen-operation-ids + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.19](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.18...@amazeelabs/codegen-operation-ids@0.1.19) (2023-04-26) + +**Note:** Version bump only for package @amazeelabs/codegen-operation-ids + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.18](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.17...@amazeelabs/codegen-operation-ids@0.1.18) (2023-03-29) + +**Note:** Version bump only for package @amazeelabs/codegen-operation-ids + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.17](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.16...@amazeelabs/codegen-operation-ids@0.1.17) (2023-03-23) + +**Note:** Version bump only for package @amazeelabs/codegen-operation-ids + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.16](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.15...@amazeelabs/codegen-operation-ids@0.1.16) (2023-03-23) + +**Note:** Version bump only for package @amazeelabs/codegen-operation-ids + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.15](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.14...@amazeelabs/codegen-operation-ids@0.1.15) (2023-03-18) + +**Note:** Version bump only for package @amazeelabs/codegen-operation-ids + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.14](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.13...@amazeelabs/codegen-operation-ids@0.1.14) (2023-03-18) + +**Note:** Version bump only for package @amazeelabs/codegen-operation-ids + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.13](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.12...@amazeelabs/codegen-operation-ids@0.1.13) (2023-03-17) + +**Note:** Version bump only for package @amazeelabs/codegen-operation-ids + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.12](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.11...@amazeelabs/codegen-operation-ids@0.1.12) (2023-02-04) + +**Note:** Version bump only for package @amazeelabs/codegen-operation-ids + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.11](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.10...@amazeelabs/codegen-operation-ids@0.1.11) (2023-02-04) + +**Note:** Version bump only for package @amazeelabs/codegen-operation-ids + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.10](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.9...@amazeelabs/codegen-operation-ids@0.1.10) (2023-01-30) + +**Note:** Version bump only for package @amazeelabs/codegen-operation-ids + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.9](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.8...@amazeelabs/codegen-operation-ids@0.1.9) (2023-01-12) + +**Note:** Version bump only for package @amazeelabs/codegen-operation-ids + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.8](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.7...@amazeelabs/codegen-operation-ids@0.1.8) (2023-01-12) + +**Note:** Version bump only for package @amazeelabs/codegen-operation-ids + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.7](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.6...@amazeelabs/codegen-operation-ids@0.1.7) (2023-01-05) + +**Note:** Version bump only for package @amazeelabs/codegen-operation-ids + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.6](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.5...@amazeelabs/codegen-operation-ids@0.1.6) (2022-12-29) + +**Note:** Version bump only for package @amazeelabs/codegen-operation-ids + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.5](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.4...@amazeelabs/codegen-operation-ids@0.1.5) (2022-12-29) + +**Note:** Version bump only for package @amazeelabs/codegen-operation-ids + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.4](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.3...@amazeelabs/codegen-operation-ids@0.1.4) (2022-11-03) + +**Note:** Version bump only for package @amazeelabs/codegen-operation-ids + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.3](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.2...@amazeelabs/codegen-operation-ids@0.1.3) (2022-11-03) + +**Note:** Version bump only for package @amazeelabs/codegen-operation-ids + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.2](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.1...@amazeelabs/codegen-operation-ids@0.1.2) (2022-10-25) + +**Note:** Version bump only for package @amazeelabs/codegen-operation-ids + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.1](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/codegen-operation-ids@0.1.0...@amazeelabs/codegen-operation-ids@0.1.1) (2022-10-14) + +### Bug Fixes + +- **gatsby:** missing npmignore in operation ids plugin + ([f72c721](https://github.com/AmazeeLabs/silverback-mono/commit/f72c7212aca370b5a91e88db53e6609e243f8d73)) + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# 0.1.0 (2022-10-13) + +### Features + +- **gatsby:** codegen plugin for persisted query maps and typed ids + ([f452541](https://github.com/AmazeeLabs/silverback-mono/commit/f452541ca9131592e6d88be6dd9a3e27014c2ca6)) diff --git a/packages/codegen-operation-ids/README.md b/packages/codegen-operation-ids/README.md new file mode 100644 index 000000000..392dda186 --- /dev/null +++ b/packages/codegen-operation-ids/README.md @@ -0,0 +1,61 @@ +# GraphQL operation id generator + +A [GraphQL Codegen] plugin to generate operation ids for your queries and +mutations. Can be used with any request framework, be it [react-query], [urql] +or plain fetch. + +[graphql codegen]: https://www.the-guild.dev/graphql/codegen +[react-query]: https://tanstack.com/query/v4 +[urql]: https://formidable.com/open-source/urql/ + +## Usage + +Install the plugin and add it to your `codegen.yml` configuration file. When +used on its own to generate a `.json` file, it will produce a query map that can +be used by a GraphQL service to execute queries by id. + +```yaml +generated/map.json: + documents: + - ./graphql-files/**/*.gql + plugins: + - '@amazeelabs/codegen-operation-ids' +``` + +If it is applied in combination with `typescript` and `typescript-operations` to +generate a `.ts` file, it will append utility types and an exported variable for +each operation found. The variable itself is just the query id, but it is +annotated with type information about input and output of the operation. This +can be used to type the resulting requests based on the provided operation id. + +```yaml +generated/schema.ts: + documents: + - ./graphql-files/**/*.gql + plugins: + - typescript + - typescript-operations + - '@amazeelabs/codegen-operation-ids' +``` + +```ts +import { + MyQuery, + OperationResults, + OperationVariables, + AnyOperationId, +} from './generated/schema'; + +function graphqlFetch( + id: T, + variables: OperationVariables, +): Promise> { + return fetch('/graphql', { + method: 'POST', + body: JSON.stringify({ + id, + variables, + }), + }).then((response) => response.json()); +} +``` diff --git a/packages/codegen-operation-ids/codegen.yml b/packages/codegen-operation-ids/codegen.yml new file mode 100644 index 000000000..186a43c35 --- /dev/null +++ b/packages/codegen-operation-ids/codegen.yml @@ -0,0 +1,17 @@ +overwrite: true +schema: ./test/schema.graphqls + +generates: + test/generated/schema.ts: + documents: + - ./test/graphql/**/*.gql + plugins: + - typescript + - typescript-operations + - ./dist/index.cjs + + test/generated/map.json: + documents: + - ./test/graphql/**/*.gql + plugins: + - ./dist/index.cjs diff --git a/packages/codegen-operation-ids/jest.config.js b/packages/codegen-operation-ids/jest.config.js new file mode 100644 index 000000000..5ff818bf7 --- /dev/null +++ b/packages/codegen-operation-ids/jest.config.js @@ -0,0 +1,3 @@ +module.exports = { + preset: '@amazeelabs/jest-preset', +}; diff --git a/packages/codegen-operation-ids/package.json b/packages/codegen-operation-ids/package.json new file mode 100644 index 000000000..1cb157938 --- /dev/null +++ b/packages/codegen-operation-ids/package.json @@ -0,0 +1,41 @@ +{ + "name": "@amazeelabs/codegen-operation-ids", + "private": false, + "version": "0.1.41", + "type": "module", + "types": "./src/types.d.ts", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "publishConfig": { + "access": "public" + }, + "license": "MIT", + "scripts": { + "dev": "vite", + "test:watch": "vitest", + "preview": "vite preview", + "build": "pnpm prep", + "prep": "vite build && pnpm graphql-codegen", + "test:static": "tsc --noEmit && eslint \"**/*.{ts,tsx,js,jsx}\" --ignore-path=\"./.gitignore\" --fix", + "test:unit": "vitest run --passWithNoTests" + }, + "dependencies": { + "@graphql-codegen/plugin-helpers": "^5.0.3", + "@graphql-codegen/visitor-plugin-common": "^5.1.0" + }, + "peerDependencies": { + "graphql": "> 14" + }, + "devDependencies": { + "@amazeelabs/eslint-config": "1.4.43", + "@graphql-codegen/cli": "5.0.2", + "@graphql-codegen/typescript": "4.0.6", + "@graphql-codegen/typescript-operations": "4.2.0", + "change-case-all": "2.1.0", + "eslint": "8.57.0", + "graphql": "16.8.1", + "typescript": "5.4.5", + "vite": "5.2.11", + "vitest": "1.6.0" + } +} diff --git a/packages/codegen-operation-ids/src/index.test.ts b/packages/codegen-operation-ids/src/index.test.ts new file mode 100644 index 000000000..51871070b --- /dev/null +++ b/packages/codegen-operation-ids/src/index.test.ts @@ -0,0 +1,345 @@ +import { Types } from '@graphql-codegen/plugin-helpers'; +import { buildSchema, parse } from 'graphql'; +import { describe, expect, it } from 'vitest'; + +import { plugin } from './'; + +const schema = buildSchema(` + type Query { + loadPage(path: String!): Page + } + type Mutation { + login(username: String!, password: String!): Boolean! + } + type Page { + title: String! + path: String! + related: [Page!]! + } + `); + +describe('mode: map', () => { + function runPlugin(documents: Array) { + return plugin(schema, documents, {}, { outputFile: 'map.json' }); + } + + it('creates a map entry for a single operation', async () => { + const result = await runPlugin([ + { + location: 'queries.gql', + document: parse(`query Home { loadPage(path: "/") { title } }`), + schema, + }, + ]); + expect(JSON.parse(result)).toMatchInlineSnapshot(` + { + "HomeQuery:37b18153e5d5ac538e6f4b371203b73e0b273d9ea2cd26c8b8eeed655c229db6": "query Home { + loadPage(path: "/") { + title + } + }", + } + `); + }); + + it('creates map entries for multiple operations in one document', async () => { + const result = await runPlugin([ + { + location: 'queries.gql', + document: parse(` + query Home { loadPage(path: "/") { title } } + query Sitemap { loadPage(path: "/sitemap") { title } } + `), + schema, + }, + ]); + expect(JSON.parse(result)).toMatchInlineSnapshot(` + { + "HomeQuery:37b18153e5d5ac538e6f4b371203b73e0b273d9ea2cd26c8b8eeed655c229db6": "query Home { + loadPage(path: "/") { + title + } + }", + "SitemapQuery:58d3e46159d03193c571a8d6d2101a93456902d63a9b9d8c925f7d4cb8c69b0a": "query Sitemap { + loadPage(path: "/sitemap") { + title + } + }", + } + `); + }); + + it('creates map entries for multiple operations in multiple documents', async () => { + const result = await runPlugin([ + { + location: 'a.gql', + document: parse(` + query Home { loadPage(path: "/") { title } } + `), + schema, + }, + { + location: 'b.gql', + document: parse(` + query Sitemap { loadPage(path: "/sitemap") { title } } + `), + schema, + }, + ]); + expect(JSON.parse(result)).toMatchInlineSnapshot(` + { + "HomeQuery:37b18153e5d5ac538e6f4b371203b73e0b273d9ea2cd26c8b8eeed655c229db6": "query Home { + loadPage(path: "/") { + title + } + }", + "SitemapQuery:58d3e46159d03193c571a8d6d2101a93456902d63a9b9d8c925f7d4cb8c69b0a": "query Sitemap { + loadPage(path: "/sitemap") { + title + } + }", + } + `); + }); + + it('ignores unused fragments', async () => { + const result = await runPlugin([ + { + location: 'a.gql', + document: parse(` + fragment Page on Page { title } + query Home { loadPage(path: "/") { title } } + `), + }, + ]); + expect(JSON.parse(result)).toMatchInlineSnapshot(` + { + "HomeQuery:37b18153e5d5ac538e6f4b371203b73e0b273d9ea2cd26c8b8eeed655c229db6": "query Home { + loadPage(path: "/") { + title + } + }", + } + `); + }); + + it('adds used fragments', async () => { + const result = await runPlugin([ + { + location: 'a.gql', + document: parse(` + fragment Page on Page { title } + query Home { loadPage(path: "/") { ...Page } } + `), + }, + ]); + expect(JSON.parse(result)).toMatchInlineSnapshot(` + { + "HomeQuery:37d40553a898c4026ba372c8f42af3df9c3451953b65695b823a8e1e7b5fd90d": "query Home { + loadPage(path: "/") { + ... on Page { + title + } + } + }", + } + `); + }); + it('inlines multiple invocations', async () => { + const result = await runPlugin([ + { + location: 'a.gql', + document: parse(` + fragment Page on Page { title, related { path } } + fragment Teaser on Page { path } + query Home { + loadPage(path: "/") { + ...Page, + related { + ...Page + ...Teaser + } + } + } + `), + }, + ]); + expect(JSON.parse(result)).toMatchInlineSnapshot(` + { + "HomeQuery:e8b5953fe0f339244ebb14102eddc5d0e23259606de6f697574f69bfe468ac53": "query Home { + loadPage(path: "/") { + ... on Page { + title + related { + path + } + } + related { + ... on Page { + title + related { + path + } + } + ... on Page { + path + } + } + } + }", + } + `); + }); + it('adds nested fragments', async () => { + const result = await runPlugin([ + { + location: 'a.gql', + document: parse(` + fragment RelatedPage on Page { title } + fragment Page on Page { title, related { ...RelatedPage } } + query Home { + loadPage(path: "/") { + ...Page, + } + } + `), + }, + ]); + expect(JSON.parse(result)).toMatchInlineSnapshot(` + { + "HomeQuery:37d40553a898c4026ba372c8f42af3df9c3451953b65695b823a8e1e7b5fd90d": "query Home { + loadPage(path: "/") { + ... on Page { + title + related { + ... on Page { + title + } + } + } + } + }", + } + `); + }); + + it('adds fragments from different documents', async () => { + const result = await runPlugin([ + { + location: 'a.gql', + document: parse(` + query Home { + loadPage(path: "/") { + ...Page, + } + } + `), + }, + { + location: 'b.gql', + document: parse(` + fragment Page on Page { title } + `), + }, + ]); + expect(JSON.parse(result)).toMatchInlineSnapshot(` + { + "HomeQuery:37d40553a898c4026ba372c8f42af3df9c3451953b65695b823a8e1e7b5fd90d": "query Home { + loadPage(path: "/") { + ... on Page { + title + } + } + }", + } + `); + }); + + it('adds nested fragments from different documents', async () => { + const result = await runPlugin([ + { + location: 'a.gql', + document: parse(` + query Home { + loadPage(path: "/") { + ...Page, + } + } + `), + }, + { + location: 'b.gql', + document: parse(` + fragment Page on Page { title, related { ...RelatedPage } } + `), + }, + { + location: 'c.gql', + document: parse(` + fragment RelatedPage on Page { title } + `), + }, + ]); + expect(JSON.parse(result)).toMatchInlineSnapshot(` + { + "HomeQuery:37d40553a898c4026ba372c8f42af3df9c3451953b65695b823a8e1e7b5fd90d": "query Home { + loadPage(path: "/") { + ... on Page { + title + related { + ... on Page { + title + } + } + } + } + }", + } + `); + }); +}); + +describe('mode: ids', () => { + function runPlugin(documents: Array) { + return plugin(schema, documents, {}, { outputFile: 'output.ts' }); + } + it("adds utility types for working with operation id's", async () => { + const result = await runPlugin([]); + expect(result).toMatchInlineSnapshot(`"import type { OperationId } from '@amazeelabs/codegen-operation-ids';"`); + }); + + it('creates a typed id for each query', async () => { + const result = await runPlugin([ + { + location: 'queries.gql', + document: parse(` + fragment Page on Page { title } + query Home { loadPage(path: "/") { ... Page } } + `), + schema, + }, + ]); + expect(result).toMatchInlineSnapshot(` + "import type { OperationId } from '@amazeelabs/codegen-operation-ids'; + export const HomeQuery = "HomeQuery:37d40553a898c4026ba372c8f42af3df9c3451953b65695b823a8e1e7b5fd90d" as OperationId;" + `); + }); + + it('creates a typed id for each mutation', async () => { + const result = await runPlugin([ + { + location: 'mutations.gql', + document: parse(` + mutation Login ($username: String!, $password: String!) { + login(username: $username, password: $password) + } + `), + schema, + }, + ]); + expect(result).toMatchInlineSnapshot(` + "import type { OperationId } from '@amazeelabs/codegen-operation-ids'; + export const LoginMutation = "LoginMutation:10f1c5ac787ce93e9fe860ec9bb4a552967778d3873fbec2ce15fad2164da315" as OperationId;" + `); + }); +}); diff --git a/packages/codegen-operation-ids/src/index.ts b/packages/codegen-operation-ids/src/index.ts new file mode 100644 index 000000000..4e1db38e8 --- /dev/null +++ b/packages/codegen-operation-ids/src/index.ts @@ -0,0 +1,99 @@ +import { oldVisit, PluginFunction } from '@graphql-codegen/plugin-helpers'; +import { ClientSideBaseVisitor } from '@graphql-codegen/visitor-plugin-common'; +import { pascalCase } from 'change-case-all'; +import crypto from 'crypto'; +import { + concatAST, + FragmentDefinitionNode, + OperationDefinitionNode, + print, + visit, +} from 'graphql'; + +import { inlineFragments } from './inline'; + +class OperationIdVisitor extends ClientSideBaseVisitor { + _extractFragments() { + return []; + } + OperationDefinition(node: OperationDefinitionNode) { + this._collectedOperations.push(node); + const operationType = pascalCase(node.operation); + const operationTypeSuffix = this.getOperationSuffix(node, operationType); + const operationResultType = this.convertName(node, { + suffix: operationTypeSuffix + this._parsedConfig.operationResultSuffix, + }); + const operationVariablesTypes = this.convertName(node, { + suffix: operationTypeSuffix + 'Variables', + }); + const hasRequiredVariables = this.checkVariablesRequirements(node); + + return `export const ${operationResultType} = "${queryId( + node, + )}" as OperationId<${operationResultType},${operationVariablesTypes}${ + hasRequiredVariables ? '' : ' | undefined' + }>;`; + } +} + +function queryId(node: OperationDefinitionNode) { + return `${node.name?.value ?? 'anonymous'}${pascalCase( + node.operation, + )}:${crypto.createHash('sha256').update(print(node)).digest('hex')}`; +} + +export const plugin: PluginFunction = async ( + schema, + documents, + config, + info, +) => { + const outputMap = info?.outputFile?.match(/\.json$/); + + function isNotEmpty(obj: T | undefined): obj is T { + return obj !== undefined; + } + + const allAst = concatAST( + documents.map(({ document }) => document).filter(isNotEmpty), + ); + + const fragmentMap = new Map(); + visit(allAst, { + FragmentDefinition(node) { + fragmentMap.set(node.name.value, node); + }, + }); + + const operationMap = new Map(); + const idMap = new Map(); + visit(allAst, { + OperationDefinition(node) { + const query = [print(inlineFragments(node, fragmentMap))]; + const id = queryId(node); + operationMap.set(id, query.join('\n')); + if (node.name) { + idMap.set(node.name.value, id); + } + }, + }); + + if (outputMap) { + return JSON.stringify(Object.fromEntries(operationMap)); + } + + const document = [ + `import type { OperationId } from '@amazeelabs/codegen-operation-ids';`, + ]; + + const visitor = new OperationIdVisitor(schema, [], config, {}, documents); + const visitorResult = oldVisit(allAst, { + // TODO: Remove @ts-ignore once the issue is fixed. + // @ts-ignore Looks like graphql v16 is not fully supported yet: https://github.com/dotansimha/graphql-code-generator/issues/7519 + leave: visitor, + }); + return [ + ...document, + ...visitorResult.definitions.filter((def: any) => typeof def === 'string'), + ].join('\n'); +}; diff --git a/packages/codegen-operation-ids/src/inline.test.ts b/packages/codegen-operation-ids/src/inline.test.ts new file mode 100644 index 000000000..07d268d8a --- /dev/null +++ b/packages/codegen-operation-ids/src/inline.test.ts @@ -0,0 +1,101 @@ +import { + DefinitionNode, + FragmentDefinitionNode, + Kind, + OperationDefinitionNode, + parse, + print, +} from 'graphql'; +import { describe, expect, it } from 'vitest'; + +import { inlineFragments } from './inline'; + +function isFragmentDefinitionNode( + def: DefinitionNode, +): def is FragmentDefinitionNode { + return def.kind === Kind.FRAGMENT_DEFINITION; +} + +function isOperationDefinitionNode( + def: DefinitionNode, +): def is OperationDefinitionNode { + return def.kind === Kind.OPERATION_DEFINITION; +} + +describe('inlineFragments', () => { + it('inlines a fragment into a query', () => { + const doc = parse(` + query { + ...A + } + fragment A on Query { + myprop + } + `); + const [query] = doc.definitions.filter(isOperationDefinitionNode); + const [A] = doc.definitions.filter(isFragmentDefinitionNode); + const inlined = inlineFragments(query, new Map(Object.entries({ A }))); + expect(print(inlined)).toEqual(`{ + ... on Query { + myprop + } +}`); + }); + + it('inlines a fragment into a field', () => { + const doc = parse(` + query { + a { + ...A + } + } + fragment A on A { + myprop + } + `); + const [query] = doc.definitions.filter(isOperationDefinitionNode); + const [A] = doc.definitions.filter(isFragmentDefinitionNode); + const inlined = inlineFragments(query, new Map(Object.entries({ A }))); + expect(print(inlined)).toEqual(`{ + a { + ... on A { + myprop + } + } +}`); + }); + + it('inlines nested fragments', () => { + const doc = parse(`query { + a { + propA + ...A + } +} +fragment A on A { + propA + propB { + ...B + } +} +fragment B on B { + propC +}`); + const [query] = doc.definitions.filter(isOperationDefinitionNode); + const [A, B] = doc.definitions.filter(isFragmentDefinitionNode); + const inlined = inlineFragments(query, new Map(Object.entries({ A, B }))); + expect(print(inlined)).toEqual(`{ + a { + propA + ... on A { + propA + propB { + ... on B { + propC + } + } + } + } +}`); + }); +}); diff --git a/packages/codegen-operation-ids/src/inline.ts b/packages/codegen-operation-ids/src/inline.ts new file mode 100644 index 000000000..930614b59 --- /dev/null +++ b/packages/codegen-operation-ids/src/inline.ts @@ -0,0 +1,43 @@ +import { + ExecutableDefinitionNode, + FieldNode, + FragmentDefinitionNode, + Kind, + SelectionNode, +} from 'graphql'; + +export function inlineFragments< + TNode extends ExecutableDefinitionNode | FieldNode, +>(node: TNode, fragments: Map): TNode { + const selections: Array = []; + const target = structuredClone(node); + target.selectionSet?.selections.forEach((sel) => { + if (sel.kind === Kind.FRAGMENT_SPREAD) { + const fragment = fragments.get(sel.name.value); + if (fragment) { + const fragmentSelections: Array = []; + inlineFragments(fragment, fragments).selectionSet.selections.forEach( + (sel) => { + fragmentSelections.push(sel); + }, + ); + selections.push({ + kind: Kind.INLINE_FRAGMENT, + typeCondition: fragment.typeCondition, + selectionSet: { + kind: Kind.SELECTION_SET, + selections: fragmentSelections, + }, + }); + } + } else if (sel.kind === Kind.FIELD && sel.selectionSet) { + selections.push(inlineFragments(sel, fragments)); + } else { + selections.push(sel); + } + }); + if (target.selectionSet) { + target.selectionSet.selections = selections; + } + return target; +} diff --git a/packages/codegen-operation-ids/src/types.d.ts b/packages/codegen-operation-ids/src/types.d.ts new file mode 100644 index 000000000..463a86541 --- /dev/null +++ b/packages/codegen-operation-ids/src/types.d.ts @@ -0,0 +1,16 @@ +export type OperationId< + TQueryResult extends any, + TQueryVariables extends any, +> = string & { + ___query_result: TQueryResult; + ___query_variables: TQueryVariables; +}; + +export type AnyOperationId = OperationId; +// export type UnknownOperationId = OperationId; + +export type OperationResult> = + TQueryID['___query_result']; + +export type OperationVariables> = + TQueryID['___query_variables']; diff --git a/packages/codegen-operation-ids/test/app.ts b/packages/codegen-operation-ids/test/app.ts new file mode 100644 index 000000000..0e35c7afd --- /dev/null +++ b/packages/codegen-operation-ids/test/app.ts @@ -0,0 +1,36 @@ +import { + AnyOperationId, + OperationResult, + OperationVariables, +} from '../src/types'; +import { + ListPagesQuery, + LoadPageQuery, + LoginMutation, +} from './generated/schema'; + +declare function graphqlFetch( + operationId: T, + variables: OperationVariables, +): Promise>; + +async function app() { + // Input and output of queries is strictly typed. + const data = await graphqlFetch(LoadPageQuery, { path: '/' }); + console.log(data.loadPage?.title); + console.log(data.loadPage?.weight); + + await graphqlFetch(LoginMutation, { user: 'admin', pass: 'admin' }); + + // Fully optional inputs can be undefined. + await graphqlFetch(ListPagesQuery, undefined); + + // Missing input will throw a type error. + // @ts-expect-error + await graphqlFetch(LoadPageQuery); + + // Invalid input will throw a type error. + await graphqlFetch(LoadPageQuery, { path: undefined }); +} + +app().then(console.log).catch(console.error); diff --git a/packages/codegen-operation-ids/test/graphql/fragments/PageFull.gql b/packages/codegen-operation-ids/test/graphql/fragments/PageFull.gql new file mode 100644 index 000000000..c5a23465b --- /dev/null +++ b/packages/codegen-operation-ids/test/graphql/fragments/PageFull.gql @@ -0,0 +1,4 @@ +fragment PageFull on Page { + title + content +} diff --git a/packages/codegen-operation-ids/test/graphql/fragments/PageTeaser.gql b/packages/codegen-operation-ids/test/graphql/fragments/PageTeaser.gql new file mode 100644 index 000000000..12aa59a98 --- /dev/null +++ b/packages/codegen-operation-ids/test/graphql/fragments/PageTeaser.gql @@ -0,0 +1,4 @@ +fragment PageTeaser on Page { + title + path +} diff --git a/packages/codegen-operation-ids/test/graphql/operations/ListPages.gql b/packages/codegen-operation-ids/test/graphql/operations/ListPages.gql new file mode 100644 index 000000000..ba6ac7aba --- /dev/null +++ b/packages/codegen-operation-ids/test/graphql/operations/ListPages.gql @@ -0,0 +1,5 @@ +query ListPages($limit: Int, $offset: Int) { + listPages(limit: $limit, offset: $offset) { + ...PageTeaser + } +} diff --git a/packages/codegen-operation-ids/test/graphql/operations/LoadPage.gql b/packages/codegen-operation-ids/test/graphql/operations/LoadPage.gql new file mode 100644 index 000000000..822dd764f --- /dev/null +++ b/packages/codegen-operation-ids/test/graphql/operations/LoadPage.gql @@ -0,0 +1,5 @@ +query LoadPage($path: String!) { + loadPage(path: $path) { + ...PageFull + } +} diff --git a/packages/codegen-operation-ids/test/graphql/operations/Login.gql b/packages/codegen-operation-ids/test/graphql/operations/Login.gql new file mode 100644 index 000000000..3373ddf0d --- /dev/null +++ b/packages/codegen-operation-ids/test/graphql/operations/Login.gql @@ -0,0 +1,3 @@ +mutation Login($user: String!, $pass: String!) { + login(user: $user, pass: $pass) +} diff --git a/packages/codegen-operation-ids/test/schema.graphqls b/packages/codegen-operation-ids/test/schema.graphqls new file mode 100644 index 000000000..1fa0cdb06 --- /dev/null +++ b/packages/codegen-operation-ids/test/schema.graphqls @@ -0,0 +1,15 @@ +type Query { + loadPage(path: String!): Page + listPages(limit: Int, offset: Int): [Page!]! +} + +type Mutation { + login(user: String!, pass: String!): Boolean +} + +type Page { + path: String! + title: String! + content: String! + relatedPages: [Page!] +} diff --git a/packages/codegen-operation-ids/tsconfig.json b/packages/codegen-operation-ids/tsconfig.json new file mode 100644 index 000000000..796dc5691 --- /dev/null +++ b/packages/codegen-operation-ids/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "noEmit": true, + "target": "ES2019", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": false, + "jsx": "react" + }, + "include": ["src"], + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Recommended" +} diff --git a/packages/codegen-operation-ids/vite.config.ts b/packages/codegen-operation-ids/vite.config.ts new file mode 100644 index 000000000..8377eee64 --- /dev/null +++ b/packages/codegen-operation-ids/vite.config.ts @@ -0,0 +1,32 @@ +/// + +import { builtinModules } from 'module'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + test: { + deps: { + inline: + // TODO: Replace with true once https://github.com/vitest-dev/vitest/issues/2806 is fixed. + [/^(?!.*vitest).*$/], + }, + }, + define: { + 'import.meta.vitest': 'undefined', + }, + build: { + rollupOptions: { + external: [ + ...builtinModules, + 'graphql', + '@graphql-codegen/plugin-helpers', + '@graphql-codegen/visitor-plugin-common', + ], + }, + lib: { + entry: `src/index.ts`, + fileName: 'index', + formats: ['cjs', 'es'], + }, + }, +}); diff --git a/packages/drupal/custom/custom.services.yml b/packages/drupal/custom/custom.services.yml index 5adbb9068..3140f5751 100644 --- a/packages/drupal/custom/custom.services.yml +++ b/packages/drupal/custom/custom.services.yml @@ -10,3 +10,7 @@ services: custom.menus: class: Drupal\custom\Menus + + custom.translatables: + class: Drupal\custom\Translatables + arguments: ['@language_manager', '@locale.storage', '@database'] diff --git a/packages/drupal/custom/src/Translatables.php b/packages/drupal/custom/src/Translatables.php new file mode 100644 index 000000000..337449bd8 --- /dev/null +++ b/packages/drupal/custom/src/Translatables.php @@ -0,0 +1,63 @@ +context->addCacheTags(['locale']); + $languages = $this->languageManager->getLanguages(); + $query = $this->connection->select('locales_source', 's') + ->fields('s'); + if (!empty($args->args['context'])) { + $query->condition('s.context', $this->connection->escapeLike($args->args['context']) . '%', 'LIKE'); + } + $result = $query->execute()->fetchAll(); + + /** @var array{source: $string string, language: string, translation: string}[] */ + $strings = []; + foreach ($result as $item) { + $sourceString = new SourceString($item); + foreach ($languages as $language) { + $translations = $this->localeStorage->getTranslations([ + 'lid' => $sourceString->getId(), + 'language' => $language->getId(), + ]); + if (!empty($translations)) { + $translatedString = reset($translations); + if ($translatedString->isTranslation()) { + $strings[] = [ + 'source' => $sourceString->getString(), + 'language' => $language->getId(), + 'translation' => $translatedString->getString(), + ]; + } + } + } + } + return $strings; + } + +} diff --git a/packages/executors/.eslintrc b/packages/executors/.eslintrc new file mode 100644 index 000000000..2b8bf76f4 --- /dev/null +++ b/packages/executors/.eslintrc @@ -0,0 +1,57 @@ +{ + "$schema": "https://json.schemastore.org/eslintrc.json", + "root": true, + "settings": { + "react": { + "version": "18" + } + }, + "env": { + "browser": true, + "es6": true, + "node": true + }, + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:promise/recommended", + "plugin:react/recommended", + "prettier" + ], + "globals": { + "Atomics": "readonly", + "SharedArrayBuffer": "readonly" + }, + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 2018, + "sourceType": "module", + "ecmaFeatures": { + "jsx": true + } + }, + "plugins": [ + "@typescript-eslint", + "promise", + "simple-import-sort", + "import", + "no-only-tests", + "react", + "react-hooks" + ], + "rules": { + "no-unused-vars": ["off"], + "@typescript-eslint/no-unused-vars": ["error"], + "simple-import-sort/imports": "error", + "sort-imports": "off", + "import/first": "error", + "import/newline-after-import": "error", + "import/no-duplicates": "error", + "no-only-tests/no-only-tests": "error", + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", + "react/prop-types": ["off"], + "react/prefer-stateless-function": ["error"], + "react/react-in-jsx-scope": ["off"] + } +} diff --git a/packages/executors/.gitignore b/packages/executors/.gitignore new file mode 100644 index 000000000..bf969b4af --- /dev/null +++ b/packages/executors/.gitignore @@ -0,0 +1,7 @@ +build +dist +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/packages/executors/.npmignore b/packages/executors/.npmignore new file mode 100644 index 000000000..5bab5c7af --- /dev/null +++ b/packages/executors/.npmignore @@ -0,0 +1,4 @@ +** +!build/* +!CHANGELOG.md +!README.md diff --git a/packages/executors/.prettierrc b/packages/executors/.prettierrc new file mode 100644 index 000000000..ae00ae6bf --- /dev/null +++ b/packages/executors/.prettierrc @@ -0,0 +1 @@ +"@amazeelabs/prettier-config" diff --git a/packages/executors/CHANGELOG.md b/packages/executors/CHANGELOG.md new file mode 100644 index 000000000..5b6b23e6c --- /dev/null +++ b/packages/executors/CHANGELOG.md @@ -0,0 +1,112 @@ +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [2.0.4](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/executors@2.0.3...@amazeelabs/executors@2.0.4) (2024-04-16) + +**Note:** Version bump only for package @amazeelabs/executors + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [2.0.3](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/executors@2.0.2...@amazeelabs/executors@2.0.3) (2024-04-16) + +**Note:** Version bump only for package @amazeelabs/executors + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [2.0.2](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/executors@2.0.1...@amazeelabs/executors@2.0.2) (2024-04-09) + +### Bug Fixes + +- remove state from executors context and deduplicate entries + ([4c5d2a7](https://github.com/AmazeeLabs/silverback-mono/commit/4c5d2a785505bd6a6b9b7cc12aea1c50c2e49936)) + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [2.0.1](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/executors@2.0.0...@amazeelabs/executors@2.0.1) (2024-04-03) + +### Bug Fixes + +- make executors accept arguments directly + ([6df6102](https://github.com/AmazeeLabs/silverback-mono/commit/6df61029cc0836f8e5be74ff7ebb7aa8108ad5a4)) + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [2.0.0](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/executors@1.1.5...@amazeelabs/executors@2.0.0) (2024-04-02) + +- refactor!: turn executors into react contexts + ([c421391](https://github.com/AmazeeLabs/silverback-mono/commit/c42139120b2bdfd5cb550790fc02d710f06ed43d)) + +### BREAKING CHANGES + +- executors are now based on context and have to be used that way + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [1.1.5](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/executors@1.1.4...@amazeelabs/executors@1.1.5) (2024-03-13) + +**Note:** Version bump only for package @amazeelabs/executors + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [1.1.4](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/executors@1.1.3...@amazeelabs/executors@1.1.4) (2024-02-29) + +**Note:** Version bump only for package @amazeelabs/executors + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [1.1.3](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/executors@1.1.2...@amazeelabs/executors@1.1.3) (2024-02-20) + +**Note:** Version bump only for package @amazeelabs/executors + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [1.1.2](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/executors@1.1.1...@amazeelabs/executors@1.1.2) (2024-01-11) + +**Note:** Version bump only for package @amazeelabs/executors + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [1.1.1](https://github.com/AmazeeLabs/silverback-mono/compare/@amazeelabs/executors@1.1.0...@amazeelabs/executors@1.1.1) (2024-01-11) + +**Note:** Version bump only for package @amazeelabs/executors + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# 1.1.0 (2024-01-03) + +### Features + +- **SLB-203:** executors package + ([7274e0c](https://github.com/AmazeeLabs/silverback-mono/commit/7274e0cbb5f107005e52151a95290c38cabf5fe3)) diff --git a/packages/executors/package.json b/packages/executors/package.json new file mode 100644 index 000000000..05074b966 --- /dev/null +++ b/packages/executors/package.json @@ -0,0 +1,67 @@ +{ + "name": "@amazeelabs/executors", + "version": "2.0.4", + "description": "", + "types": "build/client.d.ts", + "private": false, + "sideEffects": false, + "publishConfig": { + "access": "public" + }, + "type": "module", + "scripts": { + "prep": "tsup src/client.tsx src/server.tsx --dts --format esm --out-dir build", + "watch": "pnpm prep --watch", + "build": "pnpm prep", + "test:unit": "vitest run", + "test:static": "tsc --noEmit && eslint \"**/*.{ts,tsx,js,jsx}\" --ignore-path=\"./.gitignore\" --fix", + "test:integration": "playwright test", + "test:cmd": "cd test && ../node_modules/waku/cli.js", + "test:dev": "pnpm test:cmd dev", + "test:start": "pnpm test:cmd build && pnpm test:cmd start", + "test:all": "pnpm test:unit && pnpm test:static && pnpm test:integration" + }, + "exports": { + ".": { + "react-server": "./build/server.js", + "default": "./build/client.js" + } + }, + "keywords": [], + "author": "Amazee Labs ", + "license": "ISC", + "dependencies": { + "@amazeelabs/codegen-operation-ids": "workspace:*", + "lodash-es": "^4.17.21", + "react-server-dom-webpack": "19.0.0-rc.0", + "server-only-context": "^0.1.0", + "swr": "^2.2.4", + "tsup": "^8.0.1", + "valtio": "^1.13.2", + "zustand": "^4.4.7" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + }, + "devDependencies": { + "@amazeelabs/eslint-config": "1.4.43", + "@amazeelabs/prettier-config": "1.1.3", + "@playwright/test": "^1.44.1", + "@testing-library/react": "14.3.1", + "@types/lodash-es": "4.17.12", + "@types/node": "^20.14.5", + "@types/react": "18.3.3", + "@types/react-dom": "18.3.0", + "eslint": "8.57.0", + "jsdom": "^24.1.0", + "playwright": "^1.44.1", + "prettier": "3.2.5", + "react": "^19.0.0-rc.0", + "react-dom": "^19.0.0-rc.0", + "ts-expect": "^1.3.0", + "typescript": "5.4.5", + "vitest": "1.5.0", + "waku": "0.21.0-alpha.2" + } +} diff --git a/packages/executors/playwright.config.ts b/packages/executors/playwright.config.ts new file mode 100644 index 000000000..6840a1bfd --- /dev/null +++ b/packages/executors/playwright.config.ts @@ -0,0 +1,39 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './test', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'list', + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'pnpm test:start', + url: 'http://127.0.0.1:8080/static', + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/packages/executors/rollup.config.js b/packages/executors/rollup.config.js new file mode 100644 index 000000000..f4e235e3b --- /dev/null +++ b/packages/executors/rollup.config.js @@ -0,0 +1,8 @@ +import { defineConfig } from 'rollup'; +import dts from 'rollup-plugin-dts'; + +export default defineConfig({ + input: 'build/dts/index.d.ts', + output: [{ file: 'build/index.d.ts', format: 'es' }], + plugins: [dts()], +}); diff --git a/packages/executors/src/client.tsx b/packages/executors/src/client.tsx new file mode 100644 index 000000000..d230df574 --- /dev/null +++ b/packages/executors/src/client.tsx @@ -0,0 +1,132 @@ +'use client'; +import { + AnyOperationId, + OperationResult, + OperationVariables, +} from '@amazeelabs/codegen-operation-ids'; +import { createContext, useContext, useEffect, useState } from 'react'; + +import type { + Operation as ComponentType, + OperationExecutorsProvider as ProviderType, + useOperationExecutor as HookType, +} from './interface.js'; +import { findExecutor, mergeExecutors } from './lib.js'; +import type { + ExecutorFunction, + OperationProps, + RegistryEntry, +} from './types.js'; + +const ExecutorsContext = createContext<{ + executors: RegistryEntry[]; +}>({ + executors: [], +}); + +export const OperationExecutorsProvider: ProviderType = ({ + children, + executors, +}) => { + const upstream = useContext(ExecutorsContext).executors; + const merged = mergeExecutors(upstream, executors); + return ( + + {children} + + ); +}; + +export const useOperationExecutor: HookType = < + TOperation extends AnyOperationId, +>( + id: TOperation, + variables?: OperationVariables, +) => { + const { executors } = useContext(ExecutorsContext); + const op = findExecutor(executors, id, variables); + if (typeof op.executor === 'function') { + return (vars?: OperationVariables) => op.executor(id, vars); + } + return op.executor; +}; + +function StaticOperation({ + children, + result, +}: Pick, 'children'> & { + result: OperationResult; +}) { + return children({ state: 'success', data: result }); +} + +function DynamicOperation({ + variables, + children, + executor, +}: OperationProps & { + executor: ExecutorFunction; +}) { + try { + const res = executor(variables); + if (res instanceof Promise) { + return {children}; + } else { + return children({ state: 'success', data: res }); + } + } catch (error) { + return children({ state: 'error', error }); + } +} + +function DelayedOperation({ + children, + promise, +}: Pick, 'children'> & { + promise: Promise>; +}) { + const [state, setState] = + useState['children']>[0]['state']>( + 'loading', + ); + + const [data, setData] = useState | undefined>( + undefined, + ); + + const [error, setError] = useState(); + + useEffect(() => { + promise + .then((result) => { + setData(result); + setState('success'); + return; + }) + .catch((error) => { + setError(error); + setState('error'); + }); + }, [promise]); + + return children({ state, error, data }); +} + +export const Operation: ComponentType = ({ + id, + variables, + children, +}: OperationProps) => { + const executor = useOperationExecutor(id, variables); + return executor instanceof Function ? ( + + {children} + + ) : ( + {children} + ); +}; diff --git a/packages/executors/src/interface.ts b/packages/executors/src/interface.ts new file mode 100644 index 000000000..a43d3255d --- /dev/null +++ b/packages/executors/src/interface.ts @@ -0,0 +1,24 @@ +import type { + AnyOperationId, + OperationVariables, +} from '@amazeelabs/codegen-operation-ids'; +import type { PropsWithChildren, ReactNode } from 'react'; + +import type { ExecutorFunction, OperationProps, RegistryEntry } from './types'; + +export type Operation = ( + props: undefined extends OperationVariables + ? Omit, 'variables'> + : OperationProps & { + variables: OperationVariables; + }, +) => ReactNode; + +export type useOperationExecutor = ( + id: TOperation, + variables?: OperationVariables, +) => ExecutorFunction; + +export type OperationExecutorsProvider = ( + props: PropsWithChildren<{ executors: Array> }>, +) => ReactNode; diff --git a/packages/executors/src/lib.test.tsx b/packages/executors/src/lib.test.tsx new file mode 100644 index 000000000..2aa0e87e4 --- /dev/null +++ b/packages/executors/src/lib.test.tsx @@ -0,0 +1,276 @@ +// @vitest-environment jsdom +import { AnyOperationId, OperationId } from '@amazeelabs/codegen-operation-ids'; +import { + act, + cleanup, + fireEvent, + render, + screen, +} from '@testing-library/react'; +import { PropsWithChildren, useState } from 'react'; +import { beforeEach, expect, test, vi } from 'vitest'; + +import { OperationExecutorsProvider, useOperationExecutor } from './client'; + +beforeEach(cleanup); + +function Consumer({ + id, + variables, +}: { + id: string; + variables?: Record; +}) { + const executor = useOperationExecutor(id as AnyOperationId, variables); + return ( +

{typeof executor === 'function' ? executor(variables) : executor}

+ ); +} + +test('no operator', () => { + expect(() => render()).toThrow(); +}); + +test('global default operator', () => { + expect(() => + render( + 'default' }]}> + + , + ), + ).not.toThrow(); + expect(screen.getByText('default')).toBeDefined(); +}); + +test('global default operator with arguments', () => { + expect(() => + render( + vars.foo }]} + > + + , + ), + ).not.toThrow(); + expect(screen.getByText('bar')).toBeDefined(); +}); + +test('operation default operator', () => { + expect(() => + render( + , + executor: () => 'operation a', + }, + ]} + > + + , + ), + ).not.toThrow(); + + expect(screen.getByText('operation a')).toBeDefined(); +}); + +test('operation default operator with arguments', () => { + expect(() => + render( + , + executor: (_: any, vars: any) => vars.foo, + }, + ]} + > + + , + ), + ).not.toThrow(); + expect(screen.getByText('bar')).toBeDefined(); +}); + +test('structural argument matching', () => { + const id = 'x' as AnyOperationId; + const a = vi.fn(); + const b = vi.fn(); + const c = vi.fn(); + + expect(() => + render( + + + + + , + ), + ).not.toThrow(); + + expect(a).toHaveBeenCalledOnce(); + expect(a).toHaveBeenCalledWith(id, { y: 1 }); + expect(b).toHaveBeenCalledOnce(); + expect(b).toHaveBeenCalledWith(id, { y: 2 }); + expect(c).toHaveBeenCalledOnce(); + expect(c).toHaveBeenCalledWith(id, { y: 1, z: 1 }); +}); + +test('functional argument matching', () => { + const id = 'x' as AnyOperationId; + const a = vi.fn(); + const b = vi.fn(); + const c = vi.fn(); + + expect(() => + render( + false }, + { id, executor: b, variables: () => false }, + { id, executor: c, variables: () => true }, + ]} + > + + + + , + ), + ).not.toThrow(); + + expect(a).not.toHaveBeenCalled(); + expect(b).not.toHaveBeenCalled(); + expect(c).toHaveBeenCalledTimes(3); + expect(c).toHaveBeenCalledWith(id, { y: 1, z: 1 }); + expect(c).toHaveBeenCalledWith(id, { y: 1 }); + expect(c).toHaveBeenCalledWith(id, { y: 2 }); +}); + +test('structural argument mismatch', () => { + const id = 'x' as AnyOperationId; + const a = vi.fn(); + const b = vi.fn(); + const c = vi.fn(); + + expect(() => + render( + + + , + ), + ).toThrow( + 'No executor found for: x:{"y":3} Candidates: x:{"y":1} x:{"y":2} x:{"y":1,"z":1}', + ); +}); + +test('static data resolution', () => { + const id = 'x' as AnyOperationId; + + expect(() => + render( + + + , + ), + ).not.toThrow(); + expect(screen.getByText('static data')).toBeDefined(); +}); + +test('static data resolution with arguments', () => { + const id = 'x' as AnyOperationId; + + expect(() => + render( + + + , + ), + ).not.toThrow(); + expect(screen.getByText('static data')).toBeDefined(); +}); + +test('static data updates', () => { + const id = 'x' as AnyOperationId; + + function Executor({ children }: PropsWithChildren) { + const [count, setCount] = useState(0); + return ( + <> + + + {children} + + + ); + } + + expect(() => + render( + + + , + ), + ).not.toThrow(); + + expect(screen.getByText(0)).toBeDefined(); + + act(() => { + fireEvent.click(screen.getByText(/Up/)); + }); + + expect(screen.getByText(1)).toBeDefined(); +}); + +test('fallback to functional', () => { + const id = 'x' as AnyOperationId; + + expect(() => + render( + 'global' }, + { id, executor: () => 'functional', variables: () => true }, + { id, executor: () => 'structural', variables: { foo: 'bar' } }, + ]} + > + + , + ), + ).not.toThrow(); + expect(screen.getByText('functional')).toBeDefined(); +}); + +test('fallback to global', () => { + const id = 'x' as AnyOperationId; + + expect(() => + render( + 'global' }, + { id, executor: () => 'functional', variables: () => false }, + { id, executor: () => 'structural', variables: { foo: 'bar' } }, + ]} + > + + , + ), + ).not.toThrow(); + expect(screen.getByText('global')).toBeDefined(); +}); diff --git a/packages/executors/src/lib.tsx b/packages/executors/src/lib.tsx new file mode 100644 index 000000000..5d3c192c2 --- /dev/null +++ b/packages/executors/src/lib.tsx @@ -0,0 +1,87 @@ +import { isFunction, isMatch } from 'lodash-es'; + +import { RegistryEntry } from './types.js'; + +type VariablesMatcher = + | Record + | ((vars: Record) => boolean); + +function executorMap(executors: RegistryEntry[]) { + return Object.fromEntries( + executors.map((ex) => { + const varkey = isFunction(ex.variables) + ? ex.variables.name + : JSON.stringify(ex.variables); + return [`${ex.id}:${varkey}`, ex]; + }), + ); +} + +export function mergeExecutors( + oldExecutors: RegistryEntry[], + newExecutors: RegistryEntry[], +): RegistryEntry[] { + return Object.values( + Object.assign({}, executorMap(oldExecutors), executorMap(newExecutors)), + ); +} + +export function findExecutor( + executors: RegistryEntry[], + id: string, + variables: any, +) { + const op = getCandidates(id, executors) + .filter((entry) => matchVariables(entry.variables, variables)) + .pop(); + if (!op) { + throw new ExecutorRegistryError(executors, id, variables); + } + return op; +} + +export function matchVariables( + matcher: VariablesMatcher | undefined, + variables: any, +) { + if (typeof matcher === 'undefined') { + return true; + } + if (typeof matcher === 'function') { + return matcher(variables); + } + return isMatch(variables, matcher); +} + +export function getCandidates(id: string, registry: RegistryEntry[]) { + return (registry as Array).filter( + (entry) => id === entry.id || entry.id === undefined, + ); +} + +function formatEntry(id: string | undefined, variables?: unknown) { + return `${id ? id : '*'}:${variables ? JSON.stringify(variables) : '*'}`; +} + +export class ExecutorRegistryError extends Error { + constructor(registry: RegistryEntry[], id: string, variables?: unknown) { + const candidates = getCandidates(id, registry); + const candidatesMessage = + candidates.length > 0 + ? [ + 'Candidates:', + ...candidates.map(({ id, variables }) => + formatEntry(id, variables), + ), + ] + : []; + super( + [ + 'No executor found for:', + formatEntry(id, variables), + ...candidatesMessage, + ].join(' '), + ); + this.name = 'ExecutorRegistryError'; + } +} diff --git a/packages/executors/src/server.tsx b/packages/executors/src/server.tsx new file mode 100644 index 000000000..1e7e648cb --- /dev/null +++ b/packages/executors/src/server.tsx @@ -0,0 +1,87 @@ +import { + AnyOperationId, + OperationVariables, +} from '@amazeelabs/codegen-operation-ids'; +import { cache } from 'react'; + +import type { + Operation as ComponentType, + OperationExecutorsProvider as ProviderType, + useOperationExecutor as HookType, +} from './interface.js'; +import { ExecutorRegistryError, findExecutor, mergeExecutors } from './lib.js'; +import type { OperationProps, RegistryEntry } from './types.js'; + +function serverContext(defaultValue: T): [() => T, (v: T) => void] { + const getRef = cache(() => ({ current: defaultValue })); + + const getValue = (): T => getRef().current; + + const setValue = (value: T) => { + getRef().current = value; + }; + + return [getValue, setValue]; +} + +const [getRegistry, setRegistry] = serverContext< + RegistryEntry[] +>([]); + +export const OperationExecutorsProvider: ProviderType = ({ + children, + executors, +}) => { + const registry = getRegistry(); + if (registry.length) { + throw new Error( + 'OperationExecutor can only be used once in a server context.', + ); + } + setRegistry(mergeExecutors(registry, executors)); + return children; +}; + +export const useOperationExecutor: HookType = < + TOperation extends AnyOperationId, +>( + id: TOperation, + variables: OperationVariables, +) => { + const op = findExecutor(getRegistry(), id, variables); + if (op) { + if (typeof op.executor === 'function') { + return (vars) => op.executor(id, vars); + } + return op.executor; + } + throw new ExecutorRegistryError(getRegistry(), id, variables); +}; + +type Promisify any> = ( + ...args: Parameters +) => Promise>; + +type ServerComponentType = Promisify; + +export const Operation: ComponentType = (async < + TOperation extends AnyOperationId, +>({ + id, + variables, + children, +}: OperationProps) => { + try { + const executor = useOperationExecutor(id, variables); + if (executor instanceof Function) { + const result = executor(variables); + if (result instanceof Promise) { + return children({ state: 'success', data: await result }); + } + return children({ state: 'success', data: result }); + } + return children({ state: 'success', data: executor }); + } catch (error) { + return children({ state: 'error', error }); + } +}) satisfies ServerComponentType as unknown as ComponentType; diff --git a/packages/executors/src/types.ts b/packages/executors/src/types.ts new file mode 100644 index 000000000..cb048c330 --- /dev/null +++ b/packages/executors/src/types.ts @@ -0,0 +1,94 @@ +import type { + AnyOperationId, + OperationId, + OperationResult, + OperationVariables, +} from '@amazeelabs/codegen-operation-ids'; +import type { ReactNode } from 'react'; +import { expectType } from 'ts-expect'; + +type TestWithVariables = OperationId<{ hasVariables: true }, { a: string }>; +type TestWithoutVariables = OperationId< + { hasVariables: false }, + { [key: string]: never } | undefined +>; + +export type ExecutorFunction = ( + variables: OperationVariables, +) => OperationResult | Promise>; + +export type Executor = + | OperationResult + | (( + id: TOperation, + variables: OperationVariables, + ) => OperationResult | Promise>); + +type ExecutorWithVariables = Executor; +type ExecutorWithoutVariables = Executor; + +expectType({ hasVariables: true }); +expectType( + (id: TestWithVariables, variables: { a: string }) => + new Promise(() => ({ hasVariables: true, id, variables })), +); + +expectType({ hasVariables: false }); +expectType( + (id: TestWithoutVariables) => + new Promise(() => ({ hasVariables: false, id })), +); + +type VariablesMatcher = + | OperationVariables + | ((vars: OperationVariables) => boolean); + +expectType>({ a: 'string' }); +expectType>( + (vars: { a: string }) => !!vars, +); + +expectType>({}); + +export type RegistryEntry = + { + executor: Executor; + id?: TOperation; + variables?: VariablesMatcher; + }; + +type RegistryEntryWithVariables = RegistryEntry; +type RegistryEntryWithoutVariables = RegistryEntry; +expectType({ + id: '' as TestWithVariables, + executor: { hasVariables: true }, + variables: { a: 'string' }, +}); + +expectType({ + id: '' as TestWithoutVariables, + executor: { hasVariables: false }, +}); + +type OperationChildProps = + | { + state: 'loading'; + } + | { + state: 'error'; + error: unknown; + } + | { + state: 'updating'; + data: OperationResult; + } + | { + state: 'success'; + data: OperationResult; + }; + +export type OperationProps = { + id: TOperation; + children: (props: OperationChildProps) => ReactNode; + variables?: OperationVariables; +}; diff --git a/packages/executors/test/executors.spec.ts b/packages/executors/test/executors.spec.ts new file mode 100644 index 000000000..a97f37baf --- /dev/null +++ b/packages/executors/test/executors.spec.ts @@ -0,0 +1,16 @@ +import { expect, test } from '@playwright/test'; + +['static', 'dynamic', 'client'].forEach((render) => { + const cases = { + Hardcoded: 'Hardcoded: 1 + 2 = 3', + Immediate: 'Immediate: 1 + 1 = 2', + Delayed: 'Delayed: 2 + 3 = 5', + Error: 'Error: I dont like zeros!', + }; + Object.entries(cases).forEach(([label, expected]) => { + test(`${render}: ${label}`, async ({ page }) => { + await page.goto(`http://localhost:8080/${render}`); + await expect(page.getByTestId(label)).toHaveText(expected); + }); + }); +}); diff --git a/packages/executors/test/src/add-client.tsx b/packages/executors/test/src/add-client.tsx new file mode 100644 index 000000000..a3a9e0b6b --- /dev/null +++ b/packages/executors/test/src/add-client.tsx @@ -0,0 +1,11 @@ +'use client'; +import { Operation, OperationExecutorsProvider } from '../../src/client.js'; +import { TestComponent } from './add.js'; + +export const Add = () => ( + +); diff --git a/packages/executors/test/src/add-server.tsx b/packages/executors/test/src/add-server.tsx new file mode 100644 index 000000000..6b37fac47 --- /dev/null +++ b/packages/executors/test/src/add-server.tsx @@ -0,0 +1,10 @@ +import { Operation, OperationExecutorsProvider } from '../../src/server.js'; +import { TestComponent } from './add.js'; + +export const Add = ({ label }: { label: string }) => ( + +); diff --git a/packages/executors/test/src/add.tsx b/packages/executors/test/src/add.tsx new file mode 100644 index 000000000..a75eb5719 --- /dev/null +++ b/packages/executors/test/src/add.tsx @@ -0,0 +1,97 @@ +import type { OperationId } from '@amazeelabs/codegen-operation-ids'; + +import type { + Operation as ComponentType, + OperationExecutorsProvider as ProviderType, +} from '../../src/interface.js'; +import type { RegistryEntry } from '../../src/types.js'; + +export const AddOperation = 'add_two_numbers' as OperationId< + { result: number }, + { a: number; b: number } +>; + +export const HardcodedAdd: RegistryEntry = { + id: AddOperation, + executor: { result: 3 }, + variables: { a: 1, b: 2 }, +}; + +export const ImmediateAdd: RegistryEntry = { + id: AddOperation, + executor: (_, { a, b }) => { + return { result: a + b }; + }, + variables: { a: 1, b: 1 }, +}; + +export const ErrorAdd: RegistryEntry = { + id: AddOperation, + executor: () => { + throw 'I dont like zeros!'; + }, + variables: function Zero({ a, b }) { + return a === 0 && b === 0; + }, +}; + +export const DelayedAdd: RegistryEntry = { + executor: (_, { a, b }) => + new Promise((resolve) => { + setTimeout(() => { + resolve({ result: a + b }); + }, 1000); + }), +}; + +export function Calc({ + label, + a, + b, + Operation, +}: { + label: string; + a: number; + b: number; + Operation: ComponentType; +}) { + return ( + + {(props) => { + if (props.state === 'loading') { + return

Loading...

; + } + if (props.state === 'error') { + return

Error: {`${props.error}`}

; + } + return ( +

+ {label}: {a} + {b} = {props.data.result} +

+ ); + }} +
+ ); +} + +export function TestComponent({ + label, + OperationExecutorsProvider, + Operation, +}: { + label: string; + OperationExecutorsProvider: ProviderType; + Operation: ComponentType; +}) { + return ( + +

{label}

+ + + + +
+ ); +} diff --git a/packages/executors/test/src/pages/client.tsx b/packages/executors/test/src/pages/client.tsx new file mode 100644 index 000000000..1e800a1c8 --- /dev/null +++ b/packages/executors/test/src/pages/client.tsx @@ -0,0 +1,11 @@ +import { Add } from '../add-client.js'; + +export default function Static() { + return ; +} + +export const getConfig = async () => { + return { + render: 'static', + }; +}; diff --git a/packages/executors/test/src/pages/dynamic.tsx b/packages/executors/test/src/pages/dynamic.tsx new file mode 100644 index 000000000..01ddef538 --- /dev/null +++ b/packages/executors/test/src/pages/dynamic.tsx @@ -0,0 +1,5 @@ +import { Add } from '../add-server.js'; + +export default function Dynamic() { + return ; +} diff --git a/packages/executors/test/src/pages/static.tsx b/packages/executors/test/src/pages/static.tsx new file mode 100644 index 000000000..94ca7d09e --- /dev/null +++ b/packages/executors/test/src/pages/static.tsx @@ -0,0 +1,11 @@ +import { Add } from '../add-server.js'; + +export default function Static() { + return ; +} + +export const getConfig = async () => { + return { + render: 'static', + }; +}; diff --git a/packages/executors/tsconfig.json b/packages/executors/tsconfig.json new file mode 100644 index 000000000..b315a6be0 --- /dev/null +++ b/packages/executors/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ESNext", + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "declaration": true, + "declarationDir": "build", + "outDir": "build", + "types": ["react/experimental"], + "jsx": "react-jsx" + }, + "include": ["src", "test"] +} diff --git a/packages/executors/turbo.json b/packages/executors/turbo.json new file mode 100644 index 000000000..66be584b4 --- /dev/null +++ b/packages/executors/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://turborepo.org/schema.json", + "extends": ["//"], + "pipeline": { + "prep": { + "outputs": ["build"] + } + } +} diff --git a/packages/executors/vitest.config.ts b/packages/executors/vitest.config.ts new file mode 100644 index 000000000..2f9fd6254 --- /dev/null +++ b/packages/executors/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.tsx'], + }, +}); diff --git a/packages/react-intl/client.js b/packages/react-intl/client.js new file mode 100644 index 000000000..4d2021998 --- /dev/null +++ b/packages/react-intl/client.js @@ -0,0 +1,2 @@ +'use client'; +export { IntlProvider, useIntl } from 'react-intl'; diff --git a/packages/react-intl/index.d.ts b/packages/react-intl/index.d.ts new file mode 100644 index 000000000..999586887 --- /dev/null +++ b/packages/react-intl/index.d.ts @@ -0,0 +1 @@ +export { IntlProvider, useIntl } from 'react-intl'; diff --git a/packages/react-intl/package.json b/packages/react-intl/package.json new file mode 100644 index 000000000..5e9f6209b --- /dev/null +++ b/packages/react-intl/package.json @@ -0,0 +1,28 @@ +{ + "name": "@amazeelabs/react-intl", + "version": "1.0.0", + "description": "Minimal RSC-compatible wrapper around react-intl", + "main": "client.js", + "types": "index.d.ts", + "private": false, + "sideEffects": false, + "type": "module", + "keywords": [], + "author": "Amazee Labs ", + "license": "ISC", + "dependencies": { + "react-intl": "^6.6.5", + "react": "19.0.0-rc.0", + "react-server-dom-webpack": "19.0.0-rc.0" + }, + "exports": { + ".": { + "react-server": "./server.js", + "default": "./client.js" + } + }, + "peerDependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + } +} diff --git a/packages/react-intl/server.js b/packages/react-intl/server.js new file mode 100644 index 000000000..50c6eecbd --- /dev/null +++ b/packages/react-intl/server.js @@ -0,0 +1,15 @@ +import { createElement } from 'react'; +import { createIntl } from 'react-intl/src/components/createIntl'; + +import { IntlProvider as ClientIntlProvider } from './client.js'; + +let intl = null; + +export function IntlProvider(props) { + intl = createIntl(props); + return createElement(ClientIntlProvider, props); +} + +export function useIntl() { + return intl; +} diff --git a/packages/schema/package.json b/packages/schema/package.json index bcbdd2da1..b8d0bbf78 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -46,7 +46,7 @@ }, "devDependencies": { "@amazeelabs/codegen-autoloader": "^1.1.3", - "@amazeelabs/codegen-operation-ids": "^0.1.34", + "@amazeelabs/codegen-operation-ids": "workspace:*", "@graphql-codegen/cli": "^5.0.0", "@graphql-codegen/schema-ast": "^4.0.0", "@graphql-codegen/typescript": "^4.0.1", @@ -62,7 +62,7 @@ "typescript": "^5.3.3" }, "dependencies": { - "@amazeelabs/executors": "^2.0.2", + "@amazeelabs/executors": "workspace:*", "@amazeelabs/gatsby-silverback-cloudinary": "^1.2.7", "@amazeelabs/gatsby-source-silverback": "^1.14.0", "@amazeelabs/scalars": "^1.6.13", diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index 42559e8d3..35efe8b34 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -1,48 +1,8 @@ -import { - OperationExecutor as UntypedOperationExecutor, - useExecutor as untypedUseExecutor, -} from '@amazeelabs/executors'; -import { PropsWithChildren } from 'react'; - -import type { +export * from './generated/index.js'; +export * from '@amazeelabs/scalars'; +export * from '@amazeelabs/executors'; +export type { AnyOperationId, OperationResult, OperationVariables, -} from './generated/index.js'; - -export * from './generated/index.js'; -export * from '@amazeelabs/scalars'; - -export * from './locale.js'; - -type Executor = - | OperationResult - | (( - id: OperationId, - variables: OperationVariables, - ) => OperationResult | Promise>); - -type VariablesMatcher = - | Partial> - | ((vars: OperationVariables) => boolean); - -export function OperationExecutor( - props: PropsWithChildren<{ - id?: OperationId; - variables?: VariablesMatcher>; - executor: Executor; - }>, -) { - return UntypedOperationExecutor(props); -} - -export function useExecutor( - id: OperationId, - variables?: OperationVariables, -): - | OperationResult - | (( - variables?: OperationVariables, - ) => Promise>) { - return untypedUseExecutor(id, variables); -} +} from '@amazeelabs/codegen-operation-ids'; diff --git a/packages/schema/src/operations/ListPages.gql b/packages/schema/src/operations/ListPages.gql index c296e4a39..d4024d185 100644 --- a/packages/schema/src/operations/ListPages.gql +++ b/packages/schema/src/operations/ListPages.gql @@ -1,5 +1,10 @@ -query ListPages { - allPages { - path +query ListPages($args: String!) { + ssgPages(args: $args) { + rows { + translations { + path + } + } + total } } diff --git a/packages/schema/src/schema.graphql b/packages/schema/src/schema.graphql index 4c0150a42..bbeb749b3 100644 --- a/packages/schema/src/schema.graphql +++ b/packages/schema/src/schema.graphql @@ -8,6 +8,11 @@ implementation(drupal): custom.menus::getMenuTranslations """ directive @menuTranslations(menu_id: String!) on FIELD_DEFINITION +""" +implementation(drupal): custom.translatables::all +""" +directive @translatables(context: String!) on FIELD_DEFINITION + """ implementation(drupal): custom.webform::url """ @@ -65,7 +70,7 @@ directive @decapPageTranslations on FIELD_DEFINITION """ Parse a given Url. -For Drupal, this is implicitly implemented in the "grraphql_directives" module. +For Drupal, this is implicitly implemented in the "graphql_directives" module. implementation(gatsby): ./page.js#route """ @@ -328,24 +333,27 @@ type Query { previewDrupalPage(id: ID!, rid: ID, locale: String!): DrupalPage @fetchEntity(type: "node", id: "$id", rid: "$rid", language: "$locale") - mainNavigations: [MainNavigation] - @gatsbyNodes(type: "MainNavigation") - @menuTranslations(menu_id: "main") + mainNavigations: [MainNavigation] @menuTranslations(menu_id: "main") + + footerNavigations: [FooterNavigation] @menuTranslations(menu_id: "footer") - footerNavigations: [FooterNavigation] - @gatsbyNodes(type: "FooterNavigation") - @menuTranslations(menu_id: "footer") + ssgPages(args: String): SSGPagesResult + @drupalView(id: "ssg_pages:default", args: "$args") + + websiteSettings: WebsiteSettings @loadEntity(type: "config_pages", id: "1") - allPages: [Page] @gatsbyNodes(type: "Page") - websiteSettings: WebsiteSettings viewPage(path: String!): Page @route(path: "$path") @loadEntity contentHub(locale: Locale!, args: String): ContentHubResult! @lang(code: "$locale") @drupalView(id: "content_hub:default", args: "$args") - stringTranslations: [TranslatableString!] - @gatsbyNodes(type: "TranslatableString") + stringTranslations: [TranslatableString!] @translatables(context: "gatsby") +} + +type SSGPagesResult { + total: Int! + rows: [Page]! } type Mutation { @@ -392,9 +400,8 @@ type DemoBlock { """ The type provided by translations source (e.g. Decap or Drupal). -Ingested by @mergeTranslatableStrings which handles priorities. """ -interface TranslatableString @default @value { +interface TranslatableString @value(string: "drupal") @default @value { """ The default message, used in the UI. """ @@ -409,15 +416,14 @@ interface TranslatableString @default @value { translation: String } -type DecapTranslatableString implements TranslatableString - @sourceFrom(fn: "getTranslatables") { +type DecapTranslatableString implements TranslatableString @type(id: "decap") { source: String! language: Locale! translation: String } type DrupalTranslatableString implements TranslatableString - @translatableString(contextPrefix: "gatsby") { + @type(id: "drupal") { source: String! language: Locale! translation: String diff --git a/packages/ui/.storybook/preview.tsx b/packages/ui/.storybook/preview.tsx index 26d96ce3e..5a1c88e87 100644 --- a/packages/ui/.storybook/preview.tsx +++ b/packages/ui/.storybook/preview.tsx @@ -3,7 +3,7 @@ import '../src/tailwind.css'; import { LocationProvider } from '@custom/schema'; import { Decorator } from '@storybook/react'; import React from 'react'; -import { IntlProvider } from 'react-intl'; +import { IntlProvider } from '@amazeelabs/react-intl'; import { SWRConfig, useSWRConfig } from 'swr'; // Every story is wrapped in an IntlProvider by default. diff --git a/packages/ui/package.json b/packages/ui/package.json index 1dc3c6245..150a527c5 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -38,6 +38,7 @@ "report": "mkdir -p coverage/storybook && nyc report --reporter=lcov -t coverage/storybook --report-dir coverage/storybook" }, "dependencies": { + "@amazeelabs/react-intl": "workspace:*", "@amazeelabs/silverback-iframe": "^1.3.0", "@custom/schema": "workspace:*", "@headlessui/react": "^2.0.3", @@ -49,7 +50,7 @@ "hast-util-select": "^5.0.5", "query-string": "^9.0.0", "react-hook-form": "^7.49.2", - "react-intl": "^6.6.2", + "react-server-dom-webpack": "19.0.0-rc.0", "swr": "^2.2.4", "unified": "^10.1.2", "zod": "^3.22.4", diff --git a/packages/ui/src/components/Client/MobileMenu.tsx b/packages/ui/src/components/Client/MobileMenu.tsx index 022402639..4a3bf3abb 100644 --- a/packages/ui/src/components/Client/MobileMenu.tsx +++ b/packages/ui/src/components/Client/MobileMenu.tsx @@ -1,4 +1,5 @@ 'use client'; +import { useIntl } from '@amazeelabs/react-intl'; import { Dialog, DialogPanel, @@ -9,7 +10,6 @@ import { import { ChevronDownIcon } from '@heroicons/react/20/solid'; import clsx from 'clsx'; import React, { createContext, PropsWithChildren } from 'react'; -import { useIntl } from 'react-intl'; const MobileMenuContext = createContext({ isOpen: false, diff --git a/packages/ui/src/components/Molecules/Breadcrumbs.stories.tsx b/packages/ui/src/components/Molecules/Breadcrumbs.stories.tsx index 9a7c6d0e5..998796615 100644 --- a/packages/ui/src/components/Molecules/Breadcrumbs.stories.tsx +++ b/packages/ui/src/components/Molecules/Breadcrumbs.stories.tsx @@ -1,4 +1,4 @@ -import { FrameQuery, OperationExecutor } from '@custom/schema'; +import { FrameQuery, OperationExecutorsProvider } from '@custom/schema'; import { Meta } from '@storybook/react'; import React from 'react'; @@ -16,9 +16,11 @@ export default { export const Default = { render: () => { return ( - + - + ); }, }; diff --git a/packages/ui/src/components/Molecules/InquiryForm.stories.tsx b/packages/ui/src/components/Molecules/InquiryForm.stories.tsx index 853793c16..c1576a302 100644 --- a/packages/ui/src/components/Molecules/InquiryForm.stories.tsx +++ b/packages/ui/src/components/Molecules/InquiryForm.stories.tsx @@ -1,6 +1,6 @@ import { CreateSubmissionMutation, - OperationExecutor, + OperationExecutorsProvider, OperationResult, } from '@custom/schema'; import { Meta, StoryObj } from '@storybook/react'; @@ -17,9 +17,11 @@ export default { title: 'Components/Molecules/InquiryForm', render: (args) => { return ( - + - + ); }, } satisfies Meta<{ exec: InquiryFormExecutor }>; diff --git a/packages/ui/src/components/Molecules/InquiryForm.tsx b/packages/ui/src/components/Molecules/InquiryForm.tsx index c58db3309..1ae6c5d39 100644 --- a/packages/ui/src/components/Molecules/InquiryForm.tsx +++ b/packages/ui/src/components/Molecules/InquiryForm.tsx @@ -1,7 +1,7 @@ +import { useIntl } from '@amazeelabs/react-intl'; import { CreateSubmissionMutation } from '@custom/schema'; import React from 'react'; import { useForm } from 'react-hook-form'; -import { useIntl } from 'react-intl'; import { z } from 'zod'; import { useMutation } from '../../utils/operation'; diff --git a/packages/ui/src/components/Molecules/LanguageSwitcher.stories.tsx b/packages/ui/src/components/Molecules/LanguageSwitcher.stories.tsx index e31a6bc01..7efbef9b3 100644 --- a/packages/ui/src/components/Molecules/LanguageSwitcher.stories.tsx +++ b/packages/ui/src/components/Molecules/LanguageSwitcher.stories.tsx @@ -1,33 +1,40 @@ -import { FrameQuery, OperationExecutor, Url } from '@custom/schema'; +import { FrameQuery, OperationExecutorsProvider, Url } from '@custom/schema'; import { Decorator, Meta, StoryObj } from '@storybook/react'; import React from 'react'; -import { Translations, TranslationsProvider } from '../../utils/translations'; +import { + TranslationPaths, + TranslationsProvider, +} from '../../utils/translations'; import { Default } from '../Routes/Frame.stories'; import { LanguageSwitcher } from './LanguageSwitcher'; const TranslationsDecorator = ((Story, ctx) => { return ( - - + ); -}) as Decorator; +}) as Decorator; export default { component: LanguageSwitcher, @@ -35,9 +42,9 @@ export default { parameters: { location: new URL('local:/en/english-version'), }, -} satisfies Meta; +} satisfies Meta; -type Story = StoryObj; +type Story = StoryObj; export const Empty = {} satisfies Story; diff --git a/packages/ui/src/components/Molecules/LanguageSwitcher.tsx b/packages/ui/src/components/Molecules/LanguageSwitcher.tsx index c77683543..05550c75a 100644 --- a/packages/ui/src/components/Molecules/LanguageSwitcher.tsx +++ b/packages/ui/src/components/Molecules/LanguageSwitcher.tsx @@ -1,3 +1,4 @@ +'use client'; import { Link, Locale, useLocation } from '@custom/schema'; import { Menu, Transition } from '@headlessui/react'; import { ChevronDownIcon } from '@heroicons/react/20/solid'; diff --git a/packages/ui/src/components/Molecules/PageTransition.tsx b/packages/ui/src/components/Molecules/PageTransition.tsx index 72ca7569b..180103c7a 100644 --- a/packages/ui/src/components/Molecules/PageTransition.tsx +++ b/packages/ui/src/components/Molecules/PageTransition.tsx @@ -1,8 +1,23 @@ -import { motion, useReducedMotion } from 'framer-motion'; +'use client'; +import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'; import React, { PropsWithChildren, useEffect } from 'react'; import { Messages, readMessages } from './Messages'; +export function PageTransitionWrapper({ children }: PropsWithChildren) { + return ( +
+ {useReducedMotion() ? ( + <>{children} + ) : ( + + {children} + + )} +
+ ); +} + export function PageTransition({ children }: PropsWithChildren) { const [messages, setMessages] = React.useState>([]); useEffect(() => { diff --git a/packages/ui/src/components/Molecules/Pagination.tsx b/packages/ui/src/components/Molecules/Pagination.tsx index d85beb270..f26b76880 100644 --- a/packages/ui/src/components/Molecules/Pagination.tsx +++ b/packages/ui/src/components/Molecules/Pagination.tsx @@ -1,3 +1,4 @@ +import { useIntl } from '@amazeelabs/react-intl'; import { Link, useLocation } from '@custom/schema'; import { ArrowLongLeftIcon, @@ -5,7 +6,6 @@ import { } from '@heroicons/react/20/solid'; import clsx from 'clsx'; import React from 'react'; -import { useIntl } from 'react-intl'; import { z } from 'zod'; export const paginationParamsSchema = z.object({ diff --git a/packages/ui/src/components/Molecules/SearchForm.tsx b/packages/ui/src/components/Molecules/SearchForm.tsx index e043a6fcd..989a49fe3 100644 --- a/packages/ui/src/components/Molecules/SearchForm.tsx +++ b/packages/ui/src/components/Molecules/SearchForm.tsx @@ -1,8 +1,8 @@ +import { useIntl } from '@amazeelabs/react-intl'; import { useLocation } from '@custom/schema'; import { zodResolver } from '@hookform/resolvers/zod'; import React from 'react'; import { useForm } from 'react-hook-form'; -import { useIntl } from 'react-intl'; import { z } from 'zod'; const formValueSchema = z.object({ diff --git a/packages/ui/src/components/Organisms/ContentHub.stories.tsx b/packages/ui/src/components/Organisms/ContentHub.stories.tsx index 864d92352..ee15a86c4 100644 --- a/packages/ui/src/components/Organisms/ContentHub.stories.tsx +++ b/packages/ui/src/components/Organisms/ContentHub.stories.tsx @@ -1,7 +1,7 @@ import { ContentHubQuery, ContentHubResultItemFragment, - OperationExecutor, + OperationExecutorsProvider, OperationResult, OperationVariables, Url, @@ -26,9 +26,11 @@ export default { title: 'Components/Organisms/ContentHub', render: (args) => { return ( - + - + ); }, } satisfies Meta<{ exec: ContentHubExecutor }>; @@ -58,7 +60,7 @@ export const Error = { }, } satisfies ContentHubStory; -export const WithResults = { +export const WithResults: ContentHubStory = { args: { exec: async (_, vars) => { const items = [...Array(82).keys()].map( @@ -93,18 +95,18 @@ export const WithResults = { }; }, }, -} satisfies ContentHubStory; +}; -export const Filtered = { +export const Filtered: ContentHubStory = { ...WithResults, parameters: { location: new URL('local:/content-hub?keyword=Article'), }, -} satisfies ContentHubStory; +}; -export const Paged = { +export const Paged: ContentHubStory = { ...WithResults, parameters: { location: new URL('local:/content-hub?page=2'), }, -} satisfies ContentHubStory; +}; diff --git a/packages/ui/src/components/Organisms/ContentHub.tsx b/packages/ui/src/components/Organisms/ContentHub.tsx index c6ccd3475..5dface3d5 100644 --- a/packages/ui/src/components/Organisms/ContentHub.tsx +++ b/packages/ui/src/components/Organisms/ContentHub.tsx @@ -1,7 +1,7 @@ +import { useIntl } from '@amazeelabs/react-intl'; import { ContentHubQuery, Image, Link, Locale } from '@custom/schema'; import qs from 'query-string'; import React from 'react'; -import { useIntl } from 'react-intl'; import { isTruthy } from '../../utils/isTruthy'; import { useOperation } from '../../utils/operation'; diff --git a/packages/ui/src/components/Organisms/Footer.stories.tsx b/packages/ui/src/components/Organisms/Footer.stories.tsx index abef3981e..d3bef7003 100644 --- a/packages/ui/src/components/Organisms/Footer.stories.tsx +++ b/packages/ui/src/components/Organisms/Footer.stories.tsx @@ -1,4 +1,9 @@ -import { FrameQuery, Locale, OperationExecutor, Url } from '@custom/schema'; +import { + FrameQuery, + Locale, + OperationExecutorsProvider, + Url, +} from '@custom/schema'; import { Meta, StoryObj } from '@storybook/react'; import React from 'react'; @@ -14,9 +19,11 @@ export default { export const Footer = { render: (args) => { return ( - args}> + - + ); }, args: { diff --git a/packages/ui/src/components/Organisms/Footer.tsx b/packages/ui/src/components/Organisms/Footer.tsx index 73dc34bdf..e66a600da 100644 --- a/packages/ui/src/components/Organisms/Footer.tsx +++ b/packages/ui/src/components/Organisms/Footer.tsx @@ -1,6 +1,7 @@ +'use client'; +import { useIntl } from '@amazeelabs/react-intl'; import { FrameQuery, Link } from '@custom/schema'; import React from 'react'; -import { useIntl } from 'react-intl'; import { isTruthy } from '../../utils/isTruthy'; import { buildNavigationTree } from '../../utils/navigation'; diff --git a/packages/ui/src/components/Organisms/Header.stories.tsx b/packages/ui/src/components/Organisms/Header.stories.tsx index 8450caff8..331342d52 100644 --- a/packages/ui/src/components/Organisms/Header.stories.tsx +++ b/packages/ui/src/components/Organisms/Header.stories.tsx @@ -1,4 +1,9 @@ -import { FrameQuery, Locale, OperationExecutor, Url } from '@custom/schema'; +import { + FrameQuery, + Locale, + OperationExecutorsProvider, + Url, +} from '@custom/schema'; import { Meta, StoryObj } from '@storybook/react'; import { userEvent, within } from '@storybook/test'; import React from 'react'; @@ -16,9 +21,11 @@ export default { export const Idle = { render: (args) => { return ( - args}> +
- + ); }, args: { diff --git a/packages/ui/src/components/Organisms/Header.tsx b/packages/ui/src/components/Organisms/Header.tsx index b132e4ceb..d9ef03f49 100644 --- a/packages/ui/src/components/Organisms/Header.tsx +++ b/packages/ui/src/components/Organisms/Header.tsx @@ -1,7 +1,8 @@ +'use client'; +import { useIntl } from '@amazeelabs/react-intl'; import { FrameQuery, Link, Url } from '@custom/schema'; import clsx from 'clsx'; import React from 'react'; -import { useIntl } from 'react-intl'; import { isTruthy } from '../../utils/isTruthy'; import { buildNavigationTree } from '../../utils/navigation'; diff --git a/packages/ui/src/components/Organisms/PageDisplay.tsx b/packages/ui/src/components/Organisms/PageDisplay.tsx index 438d43893..c5999faf9 100644 --- a/packages/ui/src/components/Organisms/PageDisplay.tsx +++ b/packages/ui/src/components/Organisms/PageDisplay.tsx @@ -1,3 +1,4 @@ +'use client'; import { PageFragment } from '@custom/schema'; import React from 'react'; diff --git a/packages/ui/src/components/Routes/ContentHub.tsx b/packages/ui/src/components/Routes/ContentHub.tsx index c9b001b80..1cf661b62 100644 --- a/packages/ui/src/components/Routes/ContentHub.tsx +++ b/packages/ui/src/components/Routes/ContentHub.tsx @@ -1,20 +1,24 @@ +'use client'; import { Locale } from '@custom/schema'; import React from 'react'; -import { useTranslations } from '../../utils/translations'; +import { Translations } from '../../utils/translations'; import { PageTransition } from '../Molecules/PageTransition'; import { ContentHub as ContentHubOrganism } from '../Organisms/ContentHub'; export function ContentHub(props: { pageSize: number }) { - // Initialize the content hub in each language. - useTranslations( - Object.fromEntries( - Object.values(Locale).map((locale) => [locale, `/${locale}/content-hub`]), - ), - ); return ( - + [ + locale, + `/${locale}/content-hub`, + ]), + )} + > + + ); } diff --git a/packages/ui/src/components/Routes/Frame.stories.tsx b/packages/ui/src/components/Routes/Frame.stories.tsx index a100b1016..efaca21e6 100644 --- a/packages/ui/src/components/Routes/Frame.stories.tsx +++ b/packages/ui/src/components/Routes/Frame.stories.tsx @@ -1,4 +1,4 @@ -import { FrameQuery, OperationExecutor } from '@custom/schema'; +import { FrameQuery, OperationExecutorsProvider } from '@custom/schema'; import { Meta, StoryObj } from '@storybook/react'; import React from 'react'; @@ -16,9 +16,11 @@ export default { export const Default = { render: (args) => { return ( - args} id={FrameQuery}> + - + ); }, args: { diff --git a/packages/ui/src/components/Routes/Frame.tsx b/packages/ui/src/components/Routes/Frame.tsx index 6efe5c1c9..2ab1ec148 100644 --- a/packages/ui/src/components/Routes/Frame.tsx +++ b/packages/ui/src/components/Routes/Frame.tsx @@ -1,11 +1,11 @@ -import { FrameQuery, Locale, useLocale } from '@custom/schema'; -import { AnimatePresence, useReducedMotion } from 'framer-motion'; +import { IntlProvider } from '@amazeelabs/react-intl'; +import { FrameQuery, Locale, Operation } from '@custom/schema'; import React, { PropsWithChildren } from 'react'; -import { IntlProvider } from 'react-intl'; import translationSources from '../../../build/translatables.json'; -import { useOperation } from '../../utils/operation'; +import { useLocale } from '../../utils/locale'; import { TranslationsProvider } from '../../utils/translations'; +import { PageTransitionWrapper } from '../Molecules/PageTransition'; import { Footer } from '../Organisms/Footer'; import { Header } from '../Organisms/Header'; @@ -30,44 +30,43 @@ function translationsMap(translatables: FrameQuery['stringTranslations']) { ); } -function useTranslations() { +export function Frame({ children }: PropsWithChildren) { const locale = useLocale(); - const translations = useOperation(FrameQuery).data?.stringTranslations; - return { - ...translationsMap(translations?.filter(filterByLocale('en')) || []), - ...translationsMap(translations?.filter(filterByLocale(locale)) || []), - }; -} - -export function Frame(props: PropsWithChildren<{}>) { - const locale = useLocale(); - const translations = useTranslations(); - const messages = Object.fromEntries( - Object.keys(translationSources).map((key) => [ - key, - translations[ - translationSources[key as keyof typeof translationSources] - .defaultMessage - ] || - translationSources[key as keyof typeof translationSources] - .defaultMessage, - ]), - ); return ( - - -
-
- {useReducedMotion() ? ( - <>{props.children} - ) : ( - - {props.children} - - )} -
-