diff --git a/.lagoon.yml b/.lagoon.yml index 4bd830f15..bcbf4394e 100644 --- a/.lagoon.yml +++ b/.lagoon.yml @@ -87,3 +87,19 @@ environments: schedule: '30 * * * *' command: drush silverback-gatsby:build main service: cli + lagoon-waku: + routes: + - nginx: + - waku.cms.amazeelabs.dev + - build: + - waku.build.amazeelabs.dev + - preview: + - waku.preview.amazeelabs.dev + cronjobs: + - name: drush cron + schedule: '*/15 * * * *' + command: drush cron + - name: Frontend a frontend build + schedule: '30 * * * *' + command: drush silverback-gatsby:build main + service: cli diff --git a/.pnpmfile.cjs b/.pnpmfile.cjs new file mode 100644 index 000000000..304a38b6f --- /dev/null +++ b/.pnpmfile.cjs @@ -0,0 +1,28 @@ +function readPackage(pkg) { + const versions = { + react: '19.0.0-rc-7771d3a7-20240827', + 'react-dom': '19.0.0-rc-7771d3a7-20240827', + '@types/react': '18.3.3', + '@types/react-dom': '18.3.0', + 'react-server-dom-webpack': '19.0.0-rc-7771d3a7-20240827', + typescript: '5.4.5', + graphql: '16.8.1', + waku: '0.21.1', + sharp: '0.33.1', + 'vite-imagetools': '6.2.9', + }; + 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/.gitignore b/apps/cms/.gitignore index 9f48ed0ec..1468de243 100644 --- a/apps/cms/.gitignore +++ b/apps/cms/.gitignore @@ -38,3 +38,6 @@ autoload.json # A workaround to avoid turbo caching locally. turbo-seed.txt + +# Executor typescript build +dist diff --git a/apps/cms/.lagoon.env.lagoon-waku b/apps/cms/.lagoon.env.lagoon-waku new file mode 100644 index 000000000..301fd2e00 --- /dev/null +++ b/apps/cms/.lagoon.env.lagoon-waku @@ -0,0 +1,7 @@ +PROJECT_NAME=example +PUBLISHER_URL="https://waku.build.amazeelabs.dev" +NETLIFY_URL="https://waku.amazeelabs.dev" +PREVIEW_URL="https://waku.preview.amazeelabs.dev" + +# Used to set the original client secret. +PUBLISHER_OAUTH2_CLIENT_SECRET=REPLACE_ME diff --git a/apps/cms/config/sync/core.extension.yml b/apps/cms/config/sync/core.extension.yml index 248a09dfb..70e0cf2cb 100644 --- a/apps/cms/config/sync/core.extension.yml +++ b/apps/cms/config/sync/core.extension.yml @@ -51,6 +51,7 @@ module: menu_link_content: 0 menu_ui: 0 metatag: 0 + metatag_open_graph: 0 mysql: 0 node: 0 options: 0 diff --git a/apps/cms/config/sync/language/de/views.view.redirect.yml b/apps/cms/config/sync/language/de/views.view.redirect.yml index 7aee34148..461904ad3 100644 --- a/apps/cms/config/sync/language/de/views.view.redirect.yml +++ b/apps/cms/config/sync/language/de/views.view.redirect.yml @@ -44,19 +44,19 @@ display: label: Statuscode group_items: 1: - title: '300 Mehrere Auswahlmöglichkeiten' + title: '300 Multiple Choices' 2: - title: '301 Dauerhaft verschoben' + title: '301 Moved Permanently' 3: - title: '302 Gefunden' + title: '302 Found' 4: - title: '303 Siehe andere' + title: '303 See Other' 5: - title: '304 Nicht modifiziert' + title: '304 Not Modified' 6: - title: '305 Proxy verwenden' + title: '305 Use Proxy' 7: - title: '307 Temporäre Weiterleitung' + title: '307 Temporary Redirect' language: expose: label: Originalsprache diff --git a/apps/cms/config/sync/metatag.metatag_defaults.node.yml b/apps/cms/config/sync/metatag.metatag_defaults.node.yml index 9208e71d5..9edcd7545 100644 --- a/apps/cms/config/sync/metatag.metatag_defaults.node.yml +++ b/apps/cms/config/sync/metatag.metatag_defaults.node.yml @@ -7,6 +7,8 @@ _core: id: node label: Content tags: - title: '[node:title] | [site:name]' - description: '[node:summary]' canonical_url: '[node:url]' + description: '[node:summary]' + og_description: '[node:summary]' + og_title: '[node:title] | [site:name]' + title: '[node:title] | [site:name]' diff --git a/apps/cms/config/sync/user.role.anonymous.yml b/apps/cms/config/sync/user.role.anonymous.yml index f9b390ea8..7a0d6b2fb 100644 --- a/apps/cms/config/sync/user.role.anonymous.yml +++ b/apps/cms/config/sync/user.role.anonymous.yml @@ -3,6 +3,7 @@ langcode: en status: true dependencies: module: + - config_pages - graphql - media - system @@ -16,3 +17,4 @@ permissions: - 'access content' - 'execute main persisted graphql requests' - 'view media' + - 'view website_settings config page entity' diff --git a/apps/cms/config/sync/user.role.authenticated.yml b/apps/cms/config/sync/user.role.authenticated.yml index e2ff53635..ff60db55e 100644 --- a/apps/cms/config/sync/user.role.authenticated.yml +++ b/apps/cms/config/sync/user.role.authenticated.yml @@ -5,6 +5,7 @@ dependencies: config: - filter.format.webform_default module: + - config_pages - file - filter - graphql @@ -28,3 +29,4 @@ permissions: - userprotect.mail.edit - userprotect.pass.edit - 'view media' + - 'view website_settings config page entity' diff --git a/apps/cms/config/sync/views.view.campaign_urls.yml b/apps/cms/config/sync/views.view.campaign_urls.yml index baf16cb53..acbc3525a 100644 --- a/apps/cms/config/sync/views.view.campaign_urls.yml +++ b/apps/cms/config/sync/views.view.campaign_urls.yml @@ -740,6 +740,75 @@ display: - url.query_args - user.permissions tags: { } + frontend_redirects: + id: frontend_redirects + display_title: 'Frontend redirects' + display_plugin: embed + position: 3 + display_options: + title: 'Frontend redirects' + pager: + type: full + options: + offset: 0 + items_per_page: 2 + total_pages: null + id: 0 + tags: + next: ›› + previous: ‹‹ + first: '« First' + last: 'Last »' + 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 + quantity: 9 + access: + type: perm + options: + perm: 'access content' + cache: + type: none + options: { } + filters: { } + filter_groups: + operator: AND + groups: { } + style: + type: default + options: + grouping: { } + row_class: '' + default_row_class: true + row: + type: 'entity:campaign_url' + options: + relationship: none + view_mode: default + defaults: + access: false + cache: false + title: false + pager: false + style: false + row: false + filters: false + filter_groups: false + display_description: '' + display_extenders: { } + cache_metadata: + max-age: 0 + contexts: + - 'languages:language_content' + - 'languages:language_interface' + - url.query_args + - user.permissions + tags: { } page_campaign_urls: id: page_campaign_urls display_title: Page 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/cms/package.json b/apps/cms/package.json index 73f8179a7..ee641d888 100644 --- a/apps/cms/package.json +++ b/apps/cms/package.json @@ -1,15 +1,19 @@ { + "$schema": "https://json.schemastore.org/package.json", "name": "@custom/cms", "version": "1.0.0", - "main": "index.js", + "main": "./dist/index.js", "license": "MIT", "private": true, + "types": "./dist/index.d.ts", + "type": "module", "description": "Drupal based content management system.", "sideEffects": false, "scripts": { "prep:composer": "if command -v composer; then COMPOSER_ALLOW_SUPERUSER=1 composer install; else echo 'Skipping composer install.'; fi", "prep:database": "./prep-database.sh", "prep:schema": "pnpm drush silverback-gatsby:schema-export ../../../tests/schema || true", + "prep:scripts": "tsc", "fix-premissions": "chmod +w web/sites/default/files/.htaccess && chmod +w web/sites/default/files/private/.htaccess", "drush": "SB_ENVIRONMENT=1 SIMPLETEST_DB=sqlite://localhost/sites/default/files/.sqlite DRUSH_OPTIONS_URI=http://127.0.0.1:8888 vendor/bin/drush", "silverback": "SB_ENVIRONMENT=1 SIMPLETEST_DB=sqlite://localhost/sites/default/files/.sqlite SB_ADMIN_USER=admin SB_ADMIN_PASS=admin vendor/bin/silverback", @@ -33,9 +37,12 @@ "@custom/custom_heavy": "workspace:*", "@custom/gutenberg_blocks": "workspace:*", "@custom/schema": "workspace:*", - "@custom/test_content": "workspace:*", + "@custom/test_content": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/react": "^18.3.3", "@custom/ui": "workspace:*", - "@custom/preview": "workspace:*", "@custom/search_api_global": "workspace:*" } } diff --git a/apps/cms/src/index.ts b/apps/cms/src/index.ts new file mode 100644 index 000000000..1627056b5 --- /dev/null +++ b/apps/cms/src/index.ts @@ -0,0 +1,62 @@ +import type { AnyOperationId, OperationVariables } from '@custom/schema'; + +export function createDrupalExecutor(host: string, frontendUrl: string) { + return async function ( + id: OperationId, + variables?: OperationVariables, + ) { + const url = new URL(`${host}/graphql`); + const isMutation = id.includes('Mutation:'); + const publicUrl = + typeof window !== 'undefined' + ? new URL(window.location.href) + : new URL(frontendUrl); + const headers = { + 'SLB-Forwarded-Proto': publicUrl.protocol.slice(0, -1), + 'SLB-Forwarded-Host': publicUrl.hostname, + 'SLB-Forwarded-Port': publicUrl.port, + 'X-Forwarded-Proto': publicUrl.protocol.slice(0, -1), + 'X-Forwarded-Host': publicUrl.hostname, + 'X-Forwarded-Port': publicUrl.port, + }; + + const requestInit = ( + isMutation + ? { + method: 'POST', + credentials: 'include', + body: JSON.stringify({ + queryId: id, + variables: variables || {}, + }), + headers: { + ...headers, + 'Content-Type': 'application/json', + }, + } + : { + credentials: 'include', + headers, + } + ) satisfies RequestInit; + + if (!isMutation) { + url.searchParams.set('queryId', id); + url.searchParams.set('variables', JSON.stringify(variables || {})); + } + + try { + const { data, errors } = await (await fetch(url, requestInit)).json(); + if (errors) { + console.error('GraphQL error:', errors); + if (!data) { + throw new Error('GraphQL error: ' + JSON.stringify(errors)); + } + } + return data; + } catch (error) { + console.error('Fetch error:', error); + throw new Error(`Fetch error: ${error}`); + } + }; +} diff --git a/apps/cms/tsconfig.json b/apps/cms/tsconfig.json new file mode 100644 index 000000000..31deef1d2 --- /dev/null +++ b/apps/cms/tsconfig.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "NodeNext", + "declaration": true, + "moduleResolution": "NodeNext", + "resolveJsonModule": true, + "isolatedModules": true, + "checkJs": true, + "outDir": "dist", + "jsx": "react-jsx" + }, + "include": ["src/**/*"] +} diff --git a/apps/cms/turbo.json b/apps/cms/turbo.json index 5980d80a1..0edd528b1 100644 --- a/apps/cms/turbo.json +++ b/apps/cms/turbo.json @@ -2,7 +2,12 @@ "extends": ["//"], "tasks": { "prep": { - "dependsOn": ["prep:schema"] + "dependsOn": ["prep:scripts", "prep:schema"] + }, + "prep:scripts": { + "dependsOn": ["^prep"], + "inputs": ["src/**"], + "outputs": ["dist/**"] }, "prep:schema": { "dependsOn": ["prep:database"] 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/decap/package.json b/apps/decap/package.json index 8df1dc560..968ed7384 100644 --- a/apps/decap/package.json +++ b/apps/decap/package.json @@ -3,6 +3,8 @@ "private": true, "version": "0.0.0", "type": "module", + "main": "build/index.js", + "types": "build/index.d.ts", "scripts": { "dev": "vite --host", "prep:vite": "vite build", @@ -17,13 +19,18 @@ "dependencies": { "@amazeelabs/cloudinary-responsive-image": "^1.6.15", "@amazeelabs/gatsby-plugin-static-dirs": "^1.0.1", - "@amazeelabs/graphql-directives": "^1.3.2", + "@amazeelabs/graphql-directives": "^1.3.7", "@custom/schema": "workspace:*", "@custom/ui": "workspace:*", "decap-cms-app": "^3.0.12", "decap-cms-core": "^3.2.8", + "deepmerge": "^4.3.1", "graphql": "16.8.1", + "image-size": "^1.1.1", + "lodash": "^4.17.21", + "mime-types": "^2.1.35", "nanoid": "^5.0.4", + "object-deep-merge": "^1.0.4", "react": "^18.2.0", "react-dom": "^18.2.0", "rehype-sanitize": "^5.0.1", @@ -36,11 +43,15 @@ }, "devDependencies": { "@amazeelabs/decap-cms-backend-token-auth": "^1.2.1", + "@types/deepmerge": "^2.2.0", + "@types/lodash": "^4.17.6", + "@types/mime-types": "^2.1.4", "@custom/eslint-config": "workspace:*", "@types/node": "^18", "@types/react": "^18.2.46", "@types/react-dom": "^18.2.18", "@vitejs/plugin-react-swc": "^3.5.0", + "esbuild-plugin-raw": "^0.1.8", "netlify-cms-proxy-server": "^1.3.24", "tsup": "^8.0.1", "typescript": "^5.3.3", diff --git a/apps/decap/src/collections/page.test.ts b/apps/decap/src/collections/page.test.ts index c7d2bb4b0..6c65e46b7 100644 --- a/apps/decap/src/collections/page.test.ts +++ b/apps/decap/src/collections/page.test.ts @@ -1,14 +1,54 @@ -import { expect, test, vi } from 'vitest'; +import { ListPagesQuery, ViewPageQuery } from '@custom/schema'; +import { expect, test } from 'vitest'; +import { z } from 'zod'; -import { getPages } from '..'; +import { createExecutor } from '../graphql'; +import { pageResolvers } from './page'; -vi.mock('../helpers/path', () => ({ - path: `${new URL(import.meta.url).pathname - .split('/') - .slice(0, -1) - .join('/')}/../..`, -})); +const exec = createExecutor([pageResolvers('./')]); -test('getPages', () => { - expect(() => getPages()).not.toThrow(); +const listPagesSchema = z.object({ + ssgPages: z.object({ + rows: z.array( + z.object({ translations: z.array(z.object({ path: z.string() })) }), + ), + total: z.number(), + }), +}); + +test('retrieve all pages', async () => { + const result = await exec(ListPagesQuery, { args: '' }); + const parsed = listPagesSchema.safeParse(result); + + expect(parsed.success).toBe(true); +}); + +test('load a page by path', async () => { + const list = listPagesSchema.parse(await exec(ListPagesQuery, { args: '' })); + + const path = list.ssgPages.rows[0].translations[0].path; + + const result = await exec(ViewPageQuery, { pathname: path }); + + const parsed = z + .object({ + page: z.object({ + path: z.string(), + title: z.string(), + locale: z.string(), + translations: z.array( + z.object({ + locale: z.string(), + path: z.string(), + }), + ), + }), + }) + .safeParse(result); + + if (!parsed.success) { + console.error(parsed.error); + } + expect(parsed.success).toBe(true); + expect(parsed.data?.page.path).toEqual(path); }); diff --git a/apps/decap/src/collections/page.ts b/apps/decap/src/collections/page.ts index 9a990aa87..7b7fb1e1c 100644 --- a/apps/decap/src/collections/page.ts +++ b/apps/decap/src/collections/page.ts @@ -1,10 +1,11 @@ -import { SilverbackSource } from '@amazeelabs/gatsby-source-silverback'; +import { Markup } from '@custom/schema'; import { - BlockMarkupSource, - BlockMediaSource, - DecapPageSource, - LocaleSource, - MediaImageSource, + SourceBlockMarkup, + SourceBlockMedia, + SourceLocale, + SourceMediaImage, + SourceResolvers, + SourceResolversTypes, } from '@custom/schema/source'; import type { CmsCollection, CmsField } from 'decap-cms-core'; import fs from 'fs'; @@ -12,7 +13,13 @@ import yaml from 'yaml'; import { z } from 'zod'; import { transformMarkdown } from '../helpers/markdown'; -import { path } from '../helpers/path'; + +export const imagePath = (source: string) => { + // The Decap "media" directory is symlinked to "public/media" in Waku, + // thats why this works. The "Image" component in "@amazeelabs/image" will + // handle relative paths by prepending the `public` path to the URL. + return source.substring(`/apps/decap/`.length); +}; // ============================================================================= // Decap CMS collection definition. @@ -131,6 +138,11 @@ export const PageCollection: CmsCollection = { ], }; +// TODO: generalize this, maybe based on `Image` scalar. +// function decapImageSource(path: string) { +// return JSON.stringify({ src: path.replace('apps/decap', '') }); +// } + // ============================================================================= // Transformation schema definitions. // ============================================================================= @@ -140,10 +152,10 @@ const BlockMarkupSchema = z type: z.literal('text'), text: transformMarkdown, }) - .transform(({ text }): BlockMarkupSource => { + .transform(({ text }): SourceBlockMarkup => { return { __typename: 'BlockMarkup', - markup: text, + markup: text as Markup, }; }); @@ -154,86 +166,124 @@ const BlockMediaImageSchema = z image: z.string(), caption: transformMarkdown, }) - .transform(({ image, alt, caption }): BlockMediaSource => { + .transform(({ image, alt, caption }): SourceBlockMedia => { return { __typename: 'BlockMedia', media: { __typename: 'MediaImage', - source: image, + url: imagePath(image), alt, }, caption: caption, }; }); -export const pageSchema = z.object({ - __typename: z.literal('DecapPage').optional().default('DecapPage'), - id: z.string(), - title: z.string(), - locale: z.string().transform((l) => l as LocaleSource), - path: z.string(), - hero: z.object({ - __typename: z.literal('Hero').optional().default('Hero'), - headline: z.string(), - lead: z.string().optional(), - image: z - .string() - .optional() - .transform((source): MediaImageSource | undefined => - source - ? { - __typename: 'MediaImage', - source, - alt: '', - } - : undefined, - ), - }), - content: z.array(z.union([BlockMarkupSchema, BlockMediaImageSchema])), -}); +type ResolvedPage = Exclude>; -export const getPages: SilverbackSource = () => { +export const pageSchema = z + .object({ + id: z.string(), + title: z.string(), + locale: z.string().transform((l) => l as SourceLocale), + path: z.string(), + hero: z.object({ + __typename: z.literal('Hero').optional().default('Hero'), + headline: z.string(), + lead: z.string().optional(), + image: z + .string() + .optional() + .transform((source): SourceMediaImage | undefined => + source + ? { + __typename: 'MediaImage', + url: imagePath(source), + alt: '', + } + : undefined, + ), + }), + content: z.array(z.union([BlockMarkupSchema, BlockMediaImageSchema])), + }) + .transform((data): ResolvedPage => ({ __typename: 'Page', ...data })); + +const pages: Array = []; + +const getPages = (path: string) => { const dir = `${path}/data/page`; - const pages: Array<[string, DecapPageSource & { _decap_id: string }]> = []; - fs.readdirSync(dir) - .filter((file) => file.endsWith('.yml')) - .forEach((file) => { - const content = yaml.parse(fs.readFileSync(`${dir}/${file}`, 'utf-8')); - const id = Object.values(content) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .map((page: any) => page.id) - .filter((id) => !!id) - .pop(); - Object.keys(content).forEach((lang) => { - if (Object.keys(content[lang]).length < 2) { - return; - } - const input = { - ...content[lang], - id, - locale: lang, - }; - const page = pageSchema.safeParse(input); - if (page.success) { - const editUrl = `${process.env.NETLIFY_URL || 'http://127.0.0.1:8000'}/admin/#/collections/page/entries/${file.replace(/\.yml$/, '')}`; - pages.push([ - `${page.data.id}:${lang}`, - { + if (pages.length === 0) { + fs.readdirSync(dir) + .filter((file) => file.endsWith('.yml')) + .forEach((file) => { + const content = yaml.parse(fs.readFileSync(`${dir}/${file}`, 'utf-8')); + const id = Object.values(content) + .map((page: any) => page.id) + .filter((id) => !!id) + .pop(); + const translations: Array = []; + Object.keys(content).forEach((lang) => { + if (Object.keys(content[lang]).length < 2) { + return; + } + const input = { + ...content[lang], + id, + locale: lang, + }; + const page = pageSchema.safeParse(input); + if (page.success) { + const editUrl = `${process.env.NETLIFY_URL || 'http://127.0.0.1:8000'}/admin/#/collections/page/entries/${file.replace(/\.yml$/, '')}`; + translations.push({ ...page.data, - _decap_id: id, editLink: { __typename: 'EditLink', url: editUrl, type: 'decap', }, - }, - ]); - } else { - console.warn(`Error parsing ${file} (${lang}):`); - console.warn(page.error.message); - console.warn('Input:', content[lang]); - } + }); + } else { + console.warn(`Error parsing ${file} (${lang}):`); + console.warn(page.error.message); + console.warn('Input:', content[lang]); + } + }); + const original = { ...translations[0] }; + original.translations = translations; + pages.push(original); }); - }); + } return pages; }; + +export const pageResolvers = (path: string) => + ({ + SSGPagesResult: { + rows: () => getPages(path), + total: () => 0, + }, + Query: { + ssgPages: () => { + return { + __typename: 'SSGPagesResult', + total: 0, + rows: [], + }; + }, + viewPage: async (_, { path }) => { + const pages = getPages(path); + let result: ResolvedPage | undefined; + for (const page of pages) { + if (page.translations) { + for (const promise of page.translations) { + const translation = await promise; + if (translation?.path === path) { + result = { ...translation }; + result.translations = page.translations; + } + } + } + } + return result; + }, + }, + }) satisfies SourceResolvers; diff --git a/apps/decap/src/collections/translatables.test.ts b/apps/decap/src/collections/translatables.test.ts index 37ec5eb3a..9c5a0c492 100644 --- a/apps/decap/src/collections/translatables.test.ts +++ b/apps/decap/src/collections/translatables.test.ts @@ -1,14 +1,28 @@ -import { expect, test, vi } from 'vitest'; +import { FrameQuery } from '@custom/schema'; +import { expect, test } from 'vitest'; +import { z } from 'zod'; -import { getTranslatables } from './translatables'; +import { createExecutor } from '../graphql'; +import { translatableResolvers } from './translatables'; -vi.mock('../helpers/path', () => ({ - path: `${new URL(import.meta.url).pathname - .split('/') - .slice(0, -1) - .join('/')}/../..`, -})); +const exec = createExecutor([translatableResolvers('./')]); -test('getTranslatables', () => { - expect(() => getTranslatables()).not.toThrow(); +test('retrieve all translatables', async () => { + const result = await exec(FrameQuery, undefined); + const parsed = z + .object({ + stringTranslations: z.array( + z.object({ + language: z.string(), + source: z.string(), + translation: z.string().nullable(), + }), + ), + }) + .safeParse(result); + + if (!parsed.success) { + console.error(parsed.error); + } + expect(parsed.success).toBe(true); }); diff --git a/apps/decap/src/collections/translatables.ts b/apps/decap/src/collections/translatables.ts index cf1c3c289..dc5e4c7ce 100644 --- a/apps/decap/src/collections/translatables.ts +++ b/apps/decap/src/collections/translatables.ts @@ -1,13 +1,14 @@ -import { SilverbackSource } from '@amazeelabs/gatsby-source-silverback'; import { Locale } from '@custom/schema'; -import { DecapTranslatableStringSource } from '@custom/schema/source'; +import { + SourceResolvers, + SourceTranslatableString, +} from '@custom/schema/source'; import { CmsCollection } from 'decap-cms-core'; import fs from 'fs'; import yaml from 'yaml'; import { z } from 'zod'; import rawTranslatables from '../../node_modules/@custom/ui/build/translatables.json'; -import { path } from '../helpers/path'; // Validate that translatables.json contains what we expect. const translationSources = z @@ -45,35 +46,37 @@ export const Translatables: CmsCollection = { ], }; -export const getTranslatables: SilverbackSource< - DecapTranslatableStringSource -> = () => { - const dir = `${path}/data`; - const rawTranslations = yaml.parse( - fs.readFileSync(`${dir}/translatables.yml`, 'utf-8'), - ); - return z - .record(z.record(z.string())) - .transform((data) => { - const translations: Array<[string, DecapTranslatableStringSource]> = []; - Object.keys(data).forEach((langcode) => { - Object.keys(data[langcode]).forEach((key) => { - Object.keys(data).forEach((locale) => { - if (translationSources[key]) { - translations.push([ - `${key}:${locale}`, - { - __typename: 'DecapTranslatableString', - source: translationSources[key], - language: locale as Locale, - translation: data[locale][key], - }, - ]); - } - }); - }); - }); - return translations; - }) - .parse(rawTranslations); -}; +export const translatableResolvers = (path: string) => + ({ + Query: { + stringTranslations: () => { + const dir = `${path}/data`; + const rawTranslations = yaml.parse( + fs.readFileSync(`${dir}/translatables.yml`, 'utf-8'), + ); + + return z + .record(z.record(z.string())) + .transform((data) => { + const translations: Array = []; + + Object.keys(data).forEach((langcode) => { + Object.keys(data[langcode]).forEach((key) => { + Object.keys(data).forEach((locale) => { + if (translationSources[key]) { + translations.push({ + __typename: 'TranslatableString', + source: translationSources[key], + language: locale as Locale, + translation: data[locale][key], + }); + } + }); + }); + }); + return translations; + }) + .parse(rawTranslations); + }, + }, + }) satisfies SourceResolvers; diff --git a/apps/decap/src/graphql.ts b/apps/decap/src/graphql.ts new file mode 100644 index 000000000..486d9a998 --- /dev/null +++ b/apps/decap/src/graphql.ts @@ -0,0 +1,77 @@ +import { + AnyOperationId, + OperationResult, + OperationVariables, +} from '@custom/schema'; +import { SourceResolvers } from '@custom/schema/source'; +import merge from 'deepmerge'; +import { + buildSchema, + graphql, + GraphQLFieldResolver, + GraphQLNonNull, + GraphQLOutputType, + GraphQLScalarType, + GraphQLSchema, +} from 'graphql'; +import { z } from 'zod'; + +import rawOperations from '../node_modules/@custom/schema/build/operations.json?raw'; +import rawSchema from '../node_modules/@custom/schema/build/schema.graphql?raw'; + +function isImageSource(type: GraphQLOutputType) { + if (type instanceof GraphQLNonNull) { + return isImageSource(type.ofType); + } + return type instanceof GraphQLScalarType && type.name === 'ImageSource'; +} + +export function createExecutor( + registries: Array, +): ( + operation: TOperation, + variables: OperationVariables, +) => Promise> { + const registry = merge.all(registries); + const schema: GraphQLSchema = buildSchema(rawSchema); + const operations = z + .record(z.string(), z.string()) + .parse(JSON.parse(rawOperations)); + + return async (operation, variables) => { + const result = await graphql({ + schema, + source: operations[operation as keyof typeof operations], + variableValues: variables, + rootValue: {}, + fieldResolver: async (source, args, context, info) => { + const resolver = registry[ + info.parentType.name as keyof SourceResolvers + ]?.[info.fieldName as keyof SourceResolvers[keyof SourceResolvers]] as + | GraphQLFieldResolver + | undefined; + if (resolver) { + try { + const res = await resolver(source, args, context, info); + return res; + } catch (err) { + console.error(err); + } + } else { + if (isImageSource(info.returnType)) { + return JSON.stringify({ + src: source[info.fieldName].replace('/apps/decap', ''), + }); + } else { + return source[info.fieldName]; + } + } + }, + }); + if (!result.data) { + console.error(result.errors); + throw new Error(`Preview error: ${result.errors}`); + } + return JSON.parse(JSON.stringify(result.data)); + }; +} diff --git a/apps/decap/src/helpers/frame.tsx b/apps/decap/src/helpers/frame.tsx index c181fa330..3c047eccb 100644 --- a/apps/decap/src/helpers/frame.tsx +++ b/apps/decap/src/helpers/frame.tsx @@ -4,7 +4,7 @@ import { OperationExecutorsProvider, Url, } from '@custom/schema'; -import { NavigationItemSource } from '@custom/schema/source'; +import { SourceNavigationItem } from '@custom/schema/source'; import { Frame } from '@custom/ui/routes/Frame'; import { PropsWithChildren } from 'react'; @@ -16,7 +16,7 @@ const menuItems = (amount: number) => __typename: 'NavigationItem', title: `Item ${i + 1}`, target: '/' as Url, - }) satisfies NavigationItemSource, + }) satisfies SourceNavigationItem, ); export function PreviewFrame({ children }: PropsWithChildren) { diff --git a/apps/decap/src/helpers/path.ts b/apps/decap/src/helpers/path.ts deleted file mode 100644 index b959bfc12..000000000 --- a/apps/decap/src/helpers/path.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const path = `${new URL(import.meta.url).pathname - .split('/') - .slice(0, -1) - .join('/')}/..`; diff --git a/apps/decap/src/helpers/preview.tsx b/apps/decap/src/helpers/preview.tsx index 898eaa529..47c9356e1 100644 --- a/apps/decap/src/helpers/preview.tsx +++ b/apps/decap/src/helpers/preview.tsx @@ -6,15 +6,16 @@ import { OperationResult, OperationVariables, } from '@custom/schema'; -import operations from '@custom/schema/operations'; import { PreviewTemplateComponentProps } from 'decap-cms-core'; import { buildSchema, graphql, GraphQLFieldResolver } from 'graphql'; import { useEffect, useState } from 'react'; import { ZodType, ZodTypeDef } from 'zod'; +import rawOperations from '../../node_modules/@custom/schema/build/operations.json?raw'; import rawSchema from '../../node_modules/@custom/schema/build/schema.graphql?raw'; import { PreviewFrame } from './frame.js'; +const operations = JSON.parse(rawOperations); const schema = buildSchema(rawSchema); type DecapContext = { diff --git a/apps/decap/src/index.ts b/apps/decap/src/index.ts index 3e7b1cd90..d0de99352 100644 --- a/apps/decap/src/index.ts +++ b/apps/decap/src/index.ts @@ -1,2 +1,6 @@ -export { getPages } from './collections/page.js'; -export { getTranslatables } from './collections/translatables.js'; +import { pageResolvers } from './collections/page'; +import { translatableResolvers } from './collections/translatables'; +import { createExecutor } from './graphql'; + +export const createDecapExecutor = (path: string) => + createExecutor([translatableResolvers(path), pageResolvers(path)]); diff --git a/apps/decap/tsup.config.ts b/apps/decap/tsup.config.ts index 8f8c2056f..3d9a6be27 100644 --- a/apps/decap/tsup.config.ts +++ b/apps/decap/tsup.config.ts @@ -1,3 +1,4 @@ +import raw from 'esbuild-plugin-raw'; import { defineConfig } from 'tsup'; export default defineConfig({ @@ -7,4 +8,5 @@ export default defineConfig({ treeshake: true, outDir: 'build', clean: true, + esbuildPlugins: [raw()], }); diff --git a/apps/preview/.lagoon.env.lagoon-waku b/apps/preview/.lagoon.env.lagoon-waku new file mode 100644 index 000000000..8f8580d42 --- /dev/null +++ b/apps/preview/.lagoon.env.lagoon-waku @@ -0,0 +1 @@ +DRUPAL_URL="https://waku.cms.amazeelabs.dev" diff --git a/apps/preview/package.json b/apps/preview/package.json index fbca48ef5..fb8597cad 100644 --- a/apps/preview/package.json +++ b/apps/preview/package.json @@ -15,6 +15,7 @@ "@custom/eslint-config": "workspace:*", "@custom/schema": "workspace:*", "@custom/ui": "workspace:*", + "@custom/cms": "workspace:*", "cookie-parser": "^1.4.6", "express": "^4.19.2", "express-basic-auth": "^1.2.1", diff --git a/apps/preview/server/index.ts b/apps/preview/server/index.ts index 2b0bd880a..64aec39cf 100644 --- a/apps/preview/server/index.ts +++ b/apps/preview/server/index.ts @@ -34,9 +34,9 @@ const authMiddleware = getAuthenticationMiddleware(); app.get('/endpoint.js', (_, res) => { res.send( - `window.GRAPHQL_ENDPOINT = "${ - process.env.DRUPAL_URL || 'http://127.0.0.1:8888' - }/graphql";`, + `window.DRUPAL_URL = ${JSON.stringify( + process.env.DRUPAL_URL || 'http://127.0.0.1:8888', + )};`, ); }); diff --git a/apps/preview/src/App.tsx b/apps/preview/src/App.tsx index 1ebef1475..621ed116c 100644 --- a/apps/preview/src/App.tsx +++ b/apps/preview/src/App.tsx @@ -1,3 +1,4 @@ +import { createDrupalExecutor } from '@custom/cms'; import { OperationExecutorsProvider } from '@custom/schema'; import { Frame } from '@custom/ui/routes/Frame'; import { Preview, usePreviewRefresh } from '@custom/ui/routes/Preview'; @@ -5,11 +6,9 @@ import { useEffect } from 'react'; import { retry } from 'rxjs'; import { webSocket } from 'rxjs/webSocket'; -import { drupalExecutor } from './drupal-executor'; - declare global { interface Window { - GRAPHQL_ENDPOINT: string; + DRUPAL_URL: string; } } @@ -31,7 +30,7 @@ function App() { }, [refresh]); return ( diff --git a/apps/preview/src/drupal-executor.ts b/apps/preview/src/drupal-executor.ts deleted file mode 100644 index cfe9a009c..000000000 --- a/apps/preview/src/drupal-executor.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { AnyOperationId, OperationVariables } from '@custom/schema'; - -/** - * Create an executor that operates against a Drupal endpoint. - */ -export function drupalExecutor(endpoint: string, forward: boolean = true) { - return async function ( - id: OperationId, - variables?: OperationVariables, - ) { - const url = new URL(endpoint, window.location.origin); - const isMutation = id.includes('Mutation:'); - if (isMutation) { - const { data, errors } = await ( - await fetch(url, { - method: 'POST', - credentials: 'include', - body: JSON.stringify({ - queryId: id, - variables: variables || {}, - }), - headers: forward - ? { - 'SLB-Forwarded-Proto': window.location.protocol.slice(0, -1), - 'SLB-Forwarded-Host': window.location.hostname, - 'SLB-Forwarded-Port': window.location.port, - 'Content-Type': 'application/json', - } - : { - 'Content-Type': 'application/json', - }, - }) - ).json(); - if (errors) { - throw errors; - } - return data; - } else { - url.searchParams.set('queryId', id); - url.searchParams.set('variables', JSON.stringify(variables || {})); - const { data, errors } = await ( - await fetch(url, { - credentials: 'include', - headers: forward - ? { - 'SLB-Forwarded-Proto': window.location.protocol.slice(0, -1), - 'SLB-Forwarded-Host': window.location.hostname, - 'SLB-Forwarded-Port': window.location.port, - } - : {}, - }) - ).json(); - if (errors) { - throw errors; - } - return data; - } - }; -} diff --git a/apps/preview/turbo.json b/apps/preview/turbo.json index 4fd41579b..30b8206fc 100644 --- a/apps/preview/turbo.json +++ b/apps/preview/turbo.json @@ -14,6 +14,7 @@ "outputs": ["build/**"] }, "test:static": { + "dependsOn": ["^prep"], "inputs": ["src/**", "!src/gatsby-fragments.js"] } } diff --git a/apps/publisher/.lagoon.env b/apps/publisher/.lagoon.env index 1c5898aad..b7236da89 100644 --- a/apps/publisher/.lagoon.env +++ b/apps/publisher/.lagoon.env @@ -1,7 +1,7 @@ PROJECT_NAME=example DRUPAL_INTERNAL_URL="http://nginx:8080" DRUPAL_EXTERNAL_URL="https://nginx.${LAGOON_ENVIRONMENT}.${LAGOON_PROJECT}.ch4.amazee.io" -NETLIFY_URL="https://build.${LAGOON_ENVIRONMENT}.${LAGOON_PROJECT}.ch4.amazee.io" +WAKU_PUBLIC_URL="https://build.${LAGOON_ENVIRONMENT}.${LAGOON_PROJECT}.ch4.amazee.io" # ----------------------------------------------- # Publisher authentication with Drupal (OAuth2). diff --git a/apps/publisher/.lagoon.env.dev b/apps/publisher/.lagoon.env.dev index f53dd6958..f6cdc2474 100644 --- a/apps/publisher/.lagoon.env.dev +++ b/apps/publisher/.lagoon.env.dev @@ -1,5 +1,5 @@ DRUPAL_EXTERNAL_URL="https://${LAGOON_GIT_BRANCH}-${PROJECT_NAME}.cms.amazeelabs.dev" -NETLIFY_URL="https://${LAGOON_GIT_BRANCH}-${PROJECT_NAME}.amazeelabs.dev" +WAKU_PUBLIC_URL="https://${LAGOON_GIT_BRANCH}-${PROJECT_NAME}.amazeelabs.dev" # ----------------------------------------------- # Publisher authentication with Drupal (OAuth2). diff --git a/apps/publisher/.lagoon.env.prod b/apps/publisher/.lagoon.env.prod index b44b6f5ab..90cd884f7 100644 --- a/apps/publisher/.lagoon.env.prod +++ b/apps/publisher/.lagoon.env.prod @@ -1,5 +1,5 @@ DRUPAL_EXTERNAL_URL="https://example.cms.amazeelabs.dev" -NETLIFY_URL="https://example.amazeelabs.dev" +WAKU_PUBLIC_URL="https://example.amazeelabs.dev" # ----------------------------------------------- # Publisher authentication with Drupal (OAuth2). diff --git a/apps/publisher/.lagoon.env.stage b/apps/publisher/.lagoon.env.stage index 96ed313a8..572aae58d 100644 --- a/apps/publisher/.lagoon.env.stage +++ b/apps/publisher/.lagoon.env.stage @@ -1,5 +1,5 @@ DRUPAL_EXTERNAL_URL="https://${LAGOON_GIT_BRANCH}-${PROJECT_NAME}.cms.amazeelabs.dev" -NETLIFY_URL="https://${LAGOON_GIT_BRANCH}-${PROJECT_NAME}.amazeelabs.dev" +WAKU_PUBLIC_URL="https://${LAGOON_GIT_BRANCH}-${PROJECT_NAME}.amazeelabs.dev" # ----------------------------------------------- # Publisher authentication with Drupal (OAuth2). diff --git a/apps/website/.eslintignore b/apps/website/.eslintignore new file mode 100644 index 000000000..e1a7e7e69 --- /dev/null +++ b/apps/website/.eslintignore @@ -0,0 +1,8 @@ +# Local Netlify folder +.netlify +.cache +.turbo +dist +styles.css +persisted-store +public/admin 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/.lagoon.env.lagoon-waku b/apps/website/.lagoon.env.lagoon-waku new file mode 100644 index 000000000..bb49718da --- /dev/null +++ b/apps/website/.lagoon.env.lagoon-waku @@ -0,0 +1,29 @@ +PROJECT_NAME=waku +PUBLIC_DRUPAL_URL="https://waku.cms.amazeelabs.dev" +WAKU_PUBLIC_DRUPAL_URL="https://waku.cms.amazeelabs.dev" +WAKU_PUBLIC_URL="https://waku.amazeelabs.dev" +NETLIFY_SITE_ID="ca4ea730-4703-4a61-b041-0a2a6983ee1d" + +# ----------------------------------------------- +# Publisher authentication with Drupal (OAuth2). +# See main ./README.md for more information. +# ----------------------------------------------- +# Set to true to fully skip authentication. +PUBLISHER_SKIP_AUTHENTICATION=false + +# Secret from the Drupal Publisher Consumer. +PUBLISHER_OAUTH2_CLIENT_SECRET=REPLACE_ME + +# Client id from the Drupal Publisher Consumer. +PUBLISHER_OAUTH2_CLIENT_ID=publisher + +# A random string, used to encrypt the session. +PUBLISHER_OAUTH2_SESSION_SECRET=REPLACE_ME + +# "development" or "production", production will trust first proxy +# and serve secure cookies. +PUBLISHER_OAUTH2_ENVIRONMENT_TYPE=production + +# DRUPAL_EXTERNAL_URL is used by default, but can be overridden +# to match the Drupal production url, without the nginx prefix. +PUBLISHER_OAUTH2_TOKEN_HOST="${DRUPAL_EXTERNAL_URL}" diff --git a/apps/website/eslint.config.mjs b/apps/website/eslint.config.mjs index c1c73a5a0..3b53d48f7 100644 --- a/apps/website/eslint.config.mjs +++ b/apps/website/eslint.config.mjs @@ -4,6 +4,12 @@ import { defineConfig, frontend } from '@custom/eslint-config'; export default defineConfig([ ...frontend, { - ignores: ['**/.netlify/**', '.cache/**', '.turbo/**', 'public/**'], + ignores: [ + '**/.netlify/**', + '.cache/**', + '.turbo/**', + 'public/**', + 'dist/**', + ], }, ]); 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 81457209e..000000000 --- a/apps/website/gatsby-config.mjs +++ /dev/null @@ -1,79 +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. - -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.tsx b/apps/website/gatsby-ssr.tsx deleted file mode 100644 index 8481b8ea3..000000000 --- a/apps/website/gatsby-ssr.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Locale } from '@custom/schema'; -import { GatsbySSR } from 'gatsby'; -import React from 'react'; - -import fonts from './node_modules/@custom/ui/build/preloaded-fonts.json'; - -export const onRenderBody: GatsbySSR['onRenderBody'] = ({ - setHtmlAttributes, - pathname, - setHeadComponents, -}) => { - 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. - } - } - - fonts.forEach((font) => { - setHeadComponents([ - , - ]); - }); -}; 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/netlify.toml b/apps/website/netlify.toml index e14f23bb3..88a154698 100644 --- a/apps/website/netlify.toml +++ b/apps/website/netlify.toml @@ -1,29 +1,30 @@ [dev] - autoLaunch = false +autoLaunch = false [functions] - directory = "netlify/functions" +directory = "netlify/functions" [build] - publish = "public" - edge_functions = "netlify/edge-functions" +publish = "dist/public" +edge_functions = "netlify/edge-functions" +command = "true" [functions.strangler] - included_files = ["public/404.html"] +included_files = ["dist/public/404.html", "dist/public/RSC/404.txt"] [[edge_functions]] - path = "/" - function = "homepage-redirect" +path = "/" +function = "homepage-redirect" [[edge_functions]] - path = "/.netlify/functions/github-proxy" - function = "github-proxy-auth" +path = "/.netlify/functions/github-proxy" +function = "github-proxy-auth" [[edge_functions]] - path = "/.netlify/functions/github-proxy/*" - function = "github-proxy-auth" +path = "/.netlify/functions/github-proxy/*" +function = "github-proxy-auth" [[headers]] - for = "/set-drupal-session" - [headers.values] - Content-Security-Policy = "frame-ancestors *" +for = "/set-drupal-session" +[headers.values] +Content-Security-Policy = "frame-ancestors *" diff --git a/apps/website/netlify/functions/strangler.ts b/apps/website/netlify/functions/strangler.ts index e103c80f6..7f020086b 100644 --- a/apps/website/netlify/functions/strangler.ts +++ b/apps/website/netlify/functions/strangler.ts @@ -1,18 +1,63 @@ import { createStrangler } from '@amazeelabs/strangler-netlify'; import fs from 'fs'; +const drupalUrl = + process.env.DRUPAL_INTERNAL_URL || + process.env.DRUPAL_EXTERNAL_URL || + 'http://127.0.0.1:8888'; + +function encodeRSCUrl(inputUrl: string) { + const url = new URL(inputUrl, 'http://localhost'); + url.pathname = `/RSC${url.pathname}.txt`; + return url; +} + +function decodeRSCUrl(inputUrl: string) { + const url = new URL(inputUrl, 'http://localhost'); + url.pathname = url.pathname.replace(/^\/RSC/, '').replace(/\.txt$/, ''); + return url; +} + +const notFoundRSC = fs.readFileSync('dist/public/RSC/404.txt').toString(); + export const handler = createStrangler( [ + { + url: drupalUrl, + applies: (url) => url.pathname.startsWith('/RSC/'), + preprocess: (event) => { + // Before handling, turn the RSC url into the corresponding Drupal url. + event.rawUrl = decodeRSCUrl(event.rawUrl).toString(); + event.path = decodeRSCUrl(event.path).pathname; + return event; + }, + process: (response) => { + if ([301, 302].includes(response.status)) { + const location = response.headers.get('Location'); + if (location) { + return new Response(response.body, { + status: response.status, + headers: { + ...response.headers, + // After handling, turn the resulting redirect target + // into the corresponding RSC url. + Location: encodeRSCUrl(location).toString(), + }, + }); + } + } + return new Response(notFoundRSC, { + status: 404, + }); + }, + }, { // For Standard drupal redirects, we are interested in any // url (therefore no urlFilter) and only in redirects (301, 302). - url: - process.env.DRUPAL_INTERNAL_URL || - process.env.DRUPAL_EXTERNAL_URL || - 'http://127.0.0.1:8888', + url: drupalUrl, process: (response) => [301, 302].includes(response.status) ? response : undefined, }, ], - fs.readFileSync('public/404.html').toString(), + fs.readFileSync('dist/public/404.html').toString(), ); diff --git a/apps/website/package.json b/apps/website/package.json index b2ace6fbb..765d157ec 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -1,35 +1,29 @@ { "name": "@custom/website", "private": true, + "type": "module", "dependencies": { - "@amazeelabs/bridge-gatsby": "^1.2.7", - "@amazeelabs/cloudinary-responsive-image": "^1.6.15", + "@amazeelabs/bridge-waku": "^1.1.6", "@amazeelabs/decap-cms-backend-token-auth": "^1.2.1", "@amazeelabs/gatsby-plugin-operations": "^1.1.3", "@amazeelabs/gatsby-plugin-static-dirs": "^1.0.1", "@amazeelabs/gatsby-source-silverback": "^1.14.0", - "@amazeelabs/strangler-netlify": "^1.1.9", + "@amazeelabs/strangler-netlify": "^1.2.1", "@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", - "start-server-and-test": "^2.0.3" + "image-dimensions": "^2.3.0", + "netlify-cli": "^17.29.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", + "sharp": "^0.33.4", + "start-server-and-test": "^2.0.3", + "tsx": "^4.7.1", + "waku": "0.21.0-alpha.2" }, "devDependencies": { "@custom/eslint-config": "workspace:*", @@ -45,14 +39,14 @@ }, "scripts": { "test:static": "tsc --noEmit && eslint . --quiet", - "full-rebuild": "pnpm clean && pnpm build:gatsby", + "full-rebuild": "pnpm clean && pnpm build:waku", "start:drupal": "pnpm run --filter @custom/cms start >> /tmp/cms.log 2>&1", - "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", - "serve": "netlify dev --cwd=. --dir=public --port=8000", - "gatsby:develop": "pnpm gatsby develop", - "gatsby:refresh": "curl -X POST http://localhost:8000/__refresh", - "clean": "gatsby clean" + "build:drupal": "CLOUDINARY_CLOUDNAME=test pnpm start-test start:drupal 8888 build:waku", + "build:waku": "waku build && pnpm build:redirects", + "build:redirects": "tsx ./src/build/redirects.ts --dir=dist/public", + "build": "if node has-drupal.mjs; then pnpm build:drupal; else pnpm build:waku; fi", + "serve:proxy": "mitmproxy -p 8000 --mode reverse:http://localhost:8002/", + "serve:proxied": "netlify dev --cwd=. --dir=dist/public --port=8002", + "serve": "netlify dev --cwd=. --dir=dist/public --port=8000" } } diff --git a/apps/website/static/.gitkeep b/apps/website/public/.gitkeep similarity index 100% rename from apps/website/static/.gitkeep rename to apps/website/public/.gitkeep diff --git a/apps/website/public/admin b/apps/website/public/admin new file mode 120000 index 000000000..20a6f52af --- /dev/null +++ b/apps/website/public/admin @@ -0,0 +1 @@ +../../decap/dist \ No newline at end of file diff --git a/apps/website/public/fonts b/apps/website/public/fonts new file mode 120000 index 000000000..fbeac2ffc --- /dev/null +++ b/apps/website/public/fonts @@ -0,0 +1 @@ +../../../packages/ui/static/public/fonts \ No newline at end of file diff --git a/apps/website/public/images b/apps/website/public/images new file mode 120000 index 000000000..b9c26b4e5 --- /dev/null +++ b/apps/website/public/images @@ -0,0 +1 @@ +../../../packages/ui/static/public/images \ No newline at end of file diff --git a/apps/website/public/media b/apps/website/public/media new file mode 120000 index 000000000..d41e98ae0 --- /dev/null +++ b/apps/website/public/media @@ -0,0 +1 @@ +../../decap/media \ No newline at end of file diff --git a/apps/website/src/bridge.tsx b/apps/website/src/bridge.tsx new file mode 100644 index 000000000..b393e6c81 --- /dev/null +++ b/apps/website/src/bridge.tsx @@ -0,0 +1,10 @@ +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..c5446342b --- /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 ('statusCode' in error && error.statusCode === 404) { + window.location.reload(); + } else { + console.error(error); + } + }} + fallback={

Something went wrong.

} + > + {children} +
+ ); +} diff --git a/apps/website/src/build/redirects.ts b/apps/website/src/build/redirects.ts new file mode 100644 index 000000000..d7ee86a5c --- /dev/null +++ b/apps/website/src/build/redirects.ts @@ -0,0 +1,151 @@ +import fs from 'node:fs'; + +import { + CampaignUrlRedirectsQuery, + findExecutor, + HomePageQuery, + Locale, +} from '@custom/schema'; + +import { serverExecutors } from '../executors-server.js'; +import { drupalUrl } from '../utils.js'; + +export type Redirect = { + source: string; + destination: string; + statusCode: number; + force?: boolean; +}; + +type RedirectsOutputConfig = { + outputFile?: string; +}; + +type RedirectsOutputService = 'netlify'; + +const redirectsPool: Map = new Map(); + +function createRedirect(redirect: Redirect) { + redirectsPool.set(redirect.source, redirect); +} + +async function createRedirects() { + createRedirect({ + source: '/sites/default/files/*', + destination: `${drupalUrl}/sites/default/files/:splat`, + statusCode: 200, + }); + + // Proxy Drupal GraphQL queries. + createRedirect({ + source: '/graphql', + destination: `${drupalUrl}/graphql`, + statusCode: 200, + }); + + // Proxy Drupal webforms. + Object.values(Locale).forEach((locale) => { + createRedirect({ + source: `/${locale}/form/*`, + destination: `${drupalUrl}/${locale}/form/:splat`, + statusCode: 200, + }); + }); + + // Additionally proxy themes and modules as they can have additional + // non-aggregated assets. + ['themes', 'modules', 'core/assets'].forEach((path) => { + createRedirect({ + source: `/${path}/*`, + destination: `${drupalUrl}/${path}/:splat`, + statusCode: 200, + }); + }); + + const campaignUrlExec = findExecutor( + serverExecutors, + CampaignUrlRedirectsQuery, + { + args: '', + }, + ); + + // Create redirects for all the CampaignUrl entries from the CMS. + let currentPage = 1; + const pageSize = 100; + let fetchNext = true; + while (fetchNext) { + const redirects = + campaignUrlExec instanceof Function + ? await campaignUrlExec(CampaignUrlRedirectsQuery, { + args: `pageSize=${pageSize}&page=${currentPage}`, + }) + : campaignUrlExec; + if (!redirects.campaignUrlRedirects?.rows?.length) { + fetchNext = false; + } + redirects.campaignUrlRedirects?.rows?.forEach( + (redirect) => redirect && createRedirect(redirect), + ); + currentPage++; + } + + const homepageExec = findExecutor(serverExecutors, HomePageQuery, {}); + + // Redirect from the internal path of the home page to its root path, e.g. + // from /en/home to /en (for all its translations). + const homePages = + homepageExec instanceof Function + ? await homepageExec(HomePageQuery, {}) + : homepageExec; + homePages.websiteSettings?.homePage?.translations?.forEach( + (homePageTranslation) => { + createRedirect({ + source: homePageTranslation?.path as string, + destination: `/${homePageTranslation?.locale}`, + statusCode: 301, + }); + }, + ); + + // 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. + createRedirect({ + source: '/*', + destination: `/.netlify/functions/strangler`, + statusCode: 200, + }); +} + +function writeRedirects( + service: RedirectsOutputService, + config: RedirectsOutputConfig, +) { + switch (service) { + case 'netlify': + default: + writeRedirectsNetlify(config); + } +} + +function writeRedirectsNetlify(config: RedirectsOutputConfig) { + if (!config.outputFile) { + throw new Error('The netlify redirects file is not provided.'); + } + + redirectsPool.forEach((value) => { + let redirectEntry = `\n${value.source} ${value.destination} ${value.statusCode}`; + if (value.force) { + redirectEntry += '!'; + } + if ([301, 302].includes(value.statusCode)) { + redirectEntry += `\n/RSC${value.source}.txt /RSC${value.destination}.txt ${value.statusCode}`; + } + + fs.appendFileSync(`${config.outputFile}`, redirectEntry); + }); +} + +await createRedirects(); +writeRedirects('netlify', { outputFile: './dist/public/_redirects' }); diff --git a/apps/website/src/entries.tsx b/apps/website/src/entries.tsx new file mode 100644 index 000000000..6a59a911d --- /dev/null +++ b/apps/website/src/entries.tsx @@ -0,0 +1,128 @@ +import '@custom/ui/styles.css'; + +import { + AnyOperationId, + findExecutors, + HomePageQuery, + ListPagesQuery, + Locale, + LocationProvider, + OperationVariables, + Url, +} from '@custom/schema'; +import { ContentHub } from '@custom/ui/routes/ContentHub'; +import { Frame } from '@custom/ui/routes/Frame'; +import { HomePage } from '@custom/ui/routes/HomePage'; +import { Inquiry } from '@custom/ui/routes/Inquiry'; +import { NotFoundPage } from '@custom/ui/routes/NotFoundPage'; +import { Page } from '@custom/ui/routes/Page'; +import React from 'react'; +import { createPages } from 'waku'; + +import { BrokenLinkHandler } from './broken-link-handler.js'; +import { ClientExecutors } from './executors-client.js'; +import { ServerExecutors, serverExecutors } from './executors-server.js'; +import { query } from './query.js'; +import { drupalUrl, frontendUrl } from './utils.js'; + +async function queryAll( + operation: TOperation, + variables: OperationVariables, +) { + return Promise.all( + findExecutors(serverExecutors, operation, variables).map((exec) => + exec instanceof Function ? exec(operation, variables) : exec, + ), + ); +} + +export default createPages(async ({ createPage, createLayout }) => { + createLayout({ + render: 'static', + path: '/', + component: ({ children, path }) => ( + + + + + src.replace(frontendUrl, drupalUrl)}> + {children} + + + + + + ), + }); + + Object.values(Locale).forEach((lang) => { + createPage({ + render: 'static', + path: `/${lang}`, + component: () => , + }); + + createPage({ + render: 'static', + path: `/${lang}/content-hub`, + component: () => , + }); + + createPage({ + render: 'static', + path: `/${lang}/inquiry`, + component: () => , + }); + }); + + createPage({ + render: 'static', + path: '/404', + component: () => , + }); + + // Initialise a map for the homepages, since we want to exclude them from + // creating a page for their internal path. + const homePages = await query(HomePageQuery, {}); + const homePageTranslations: Array = []; + homePages.websiteSettings?.homePage?.translations?.forEach( + (homePageTranslation) => { + if (homePageTranslation?.locale) { + homePageTranslations.push(homePageTranslation?.path); + } + }, + ); + + // TODO: Paginate properly to not load all nodes in Drupal + const pagePaths = new Set(); + const pageSources = await queryAll(ListPagesQuery, { + args: 'pageSize=0&page=1', + }); + + for (const source of pageSources) { + source.ssgPages?.rows.forEach((page) => { + page?.translations?.forEach((translation) => { + if ( + translation?.path && + !homePageTranslations.includes(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..f802424dd --- /dev/null +++ b/apps/website/src/executors-client.tsx @@ -0,0 +1,20 @@ +'use client'; +import { createDrupalExecutor } from '@custom/cms'; +import { OperationExecutorsProvider } from '@custom/schema'; +import React, { PropsWithChildren } from 'react'; + +import { frontendUrl } from './utils.js'; + +export function ClientExecutors({ 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..f83e5bde9 --- /dev/null +++ b/apps/website/src/executors-server.tsx @@ -0,0 +1,23 @@ +import { createDrupalExecutor } from '@custom/cms'; +import { createDecapExecutor } from '@custom/decap'; +import { OperationExecutorsProvider } from '@custom/schema'; +import React, { PropsWithChildren } from 'react'; + +import { drupalUrl, frontendUrl } from './utils.js'; + +export const serverExecutors = [ + { + executor: createDecapExecutor('./node_modules/@custom/decap'), + }, + { + executor: createDrupalExecutor(drupalUrl, frontendUrl), + }, +]; + +export function ServerExecutors({ 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 6df5c7e93..000000000 --- a/apps/website/src/layouts/index.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { graphql, useStaticQuery } from '@amazeelabs/gatsby-plugin-operations'; -import { FrameQuery, OperationExecutorsProvider } 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/utils/locale.ts b/apps/website/src/locale.ts similarity index 100% rename from apps/website/src/utils/locale.ts rename to apps/website/src/locale.ts 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 bfe49d2c4..000000000 --- a/apps/website/src/pages/404.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { graphql } from '@amazeelabs/gatsby-plugin-operations'; -import { NotFoundPageQuery, OperationExecutorsProvider } 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/query.ts b/apps/website/src/query.ts new file mode 100644 index 000000000..12cb57a99 --- /dev/null +++ b/apps/website/src/query.ts @@ -0,0 +1,21 @@ +import { + AnyOperationId, + OperationResult, + OperationVariables, +} from '@custom/schema'; + +import { drupalUrl } from './utils.js'; + +export async function query( + operation: TOperation, + variables: OperationVariables, +) { + const url = new URL(`${drupalUrl}/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; +} 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 8c149c00a..000000000 --- a/apps/website/src/templates/home.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { graphql } from '@amazeelabs/gatsby-plugin-operations'; -import { HomePageQuery, OperationExecutorsProvider } from '@custom/schema'; -import { HomePage } from '@custom/ui/routes/HomePage'; -import { HeadProps, PageProps } from 'gatsby'; -import React from 'react'; - -import { useLocalized } from '../utils/locale'; - -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 63510fcdb..000000000 --- a/apps/website/src/templates/page.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { graphql } from '@amazeelabs/gatsby-plugin-operations'; -import { - OperationExecutorsProvider, - 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 c4a6056ed..000000000 --- a/apps/website/src/types/operations.d.ts +++ /dev/null @@ -1,22 +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; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - errors?: Array; - }>; -} diff --git a/apps/website/src/utils.ts b/apps/website/src/utils.ts new file mode 100644 index 000000000..5a237f191 --- /dev/null +++ b/apps/website/src/utils.ts @@ -0,0 +1,5 @@ +export const drupalUrl = + process.env.PUBLIC_DRUPAL_URL || 'http://127.0.0.1:8888'; + +export const frontendUrl = + process.env.WAKU_PUBLIC_URL || 'http://127.0.0.1:8000'; diff --git a/apps/website/src/utils/drupal-executor.ts b/apps/website/src/utils/drupal-executor.ts deleted file mode 100644 index f38a98fc5..000000000 --- a/apps/website/src/utils/drupal-executor.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { AnyOperationId, OperationVariables } from '@custom/schema'; - -/** - * Create an executor that operates against a Drupal endpoint. - */ -export function drupalExecutor(endpoint: string, forward: boolean = true) { - return async function ( - id: OperationId, - variables?: OperationVariables, - ) { - const url = new URL(endpoint, window.location.origin); - const isMutation = id.includes('Mutation:'); - if (isMutation) { - const { data, errors } = await ( - await fetch(url, { - method: 'POST', - credentials: 'include', - body: JSON.stringify({ - queryId: id, - variables: variables, - }), - headers: forward - ? { - 'SLB-Forwarded-Proto': window.location.protocol.slice(0, -1), - 'SLB-Forwarded-Host': window.location.hostname, - 'SLB-Forwarded-Port': window.location.port, - 'Content-Type': 'application/json', - } - : { - 'Content-Type': 'application/json', - }, - }) - ).json(); - if (errors) { - throw errors; - } - return data; - } else { - url.searchParams.set('queryId', id); - url.searchParams.set('variables', JSON.stringify(variables)); - const { data, errors } = await ( - await fetch(url, { - credentials: 'include', - headers: forward - ? { - 'SLB-Forwarded-Proto': window.location.protocol.slice(0, -1), - 'SLB-Forwarded-Host': window.location.hostname, - 'SLB-Forwarded-Port': window.location.port, - } - : {}, - }) - ).json(); - if (errors) { - throw errors; - } - return data; - } - }; -} diff --git a/apps/website/tsconfig.json b/apps/website/tsconfig.json index 95940b49a..f809729e1 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", "eslint.config.mjs"] } diff --git a/apps/website/turbo.json b/apps/website/turbo.json index 7b3520047..e79a794dd 100644 --- a/apps/website/turbo.json +++ b/apps/website/turbo.json @@ -9,6 +9,7 @@ "outputs": ["src/gatsby-fragments.js", "styles.css"] }, "test:static": { + "dependsOn": ["^prep"], "inputs": ["src/**", "!src/gatsby-fragments.js"] }, "build": { diff --git a/apps/website/vite.config.js b/apps/website/vite.config.js new file mode 100644 index 000000000..d19344e7f --- /dev/null +++ b/apps/website/vite.config.js @@ -0,0 +1,16 @@ +import path from 'path'; + +export default { + resolve: { + alias: { + '@amazeelabs/bridge': path.resolve(__dirname, 'src/bridge.tsx'), + fs: 'node:fs', + }, + }, + ssr: { + external: ['sharp', 'image-dimensions'], + }, + optimizeDeps: { + exclude: ['sharp', 'image-dimensions'], + }, +}; diff --git a/package.json b/package.json index 2dcbb5752..1c4173e7f 100644 --- a/package.json +++ b/package.json @@ -17,11 +17,11 @@ "test:format:root": "pnpm prettier --ignore-unknown '**/**' --ignore-path .prettierignore-root", "test:format:workspaces": "pnpm --workspace-concurrency=1 -r exec prettier --ignore-unknown '**/**'", "turbo:local": "if [ -z $CI ]; then echo $(date)$RANDOM > apps/cms/turbo-seed.txt; fi", - "turbo:test": "pnpm turbo:local && pnpm turbo test:unit --output-logs=new-only && pnpm turbo test:integration --output-logs=new-only --concurrency=1", - "turbo:test:force": "pnpm turbo test:unit --output-logs=new-only --force && pnpm turbo test:integration --output-logs=new-only --concurrency=1 --force", - "turbo:test:quick": "pnpm turbo:local && pnpm turbo test:unit --output-logs=new-only", + "turbo:test": "pnpm turbo:local && pnpm tb test:unit --output-logs=new-only && pnpm tb test:integration --output-logs=new-only --concurrency=1", + "turbo:test:force": "pnpm tb test:unit --output-logs=new-only --force && pnpm tb test:integration --output-logs=new-only --concurrency=1 --force", + "turbo:test:quick": "pnpm turbo:local && pnpm tb test:unit --output-logs=new-only", "turbo:prep": "pnpm turbo:local && pnpm turbo prep --output-logs=new-only", - "turbo:prep:force": "rm -f apps/cms/web/sites/default/files/.sqlite && pnpm turbo prep --force", + "turbo:prep:force": "rm -f apps/cms/web/sites/default/files/.sqlite && pnpm tb prep --force", "gutenberg:generate": "pnpm run --filter \"@custom/gutenberg_blocks\" gutenberg:generate" }, "private": true, @@ -30,14 +30,8 @@ "@commitlint/config-conventional": "^18.4.3", "husky": "^8.0.3", "prettier": "^3.2.5", - "turbo": "^2.0.6" - }, - "resolutions": { - "gatsby-plugin-sharp": "5.13.1", - "sharp": "0.33.1", - "graphql": "16.8.1" - }, - "pnpm": { - "patchedDependencies": {} + "turbo": "^2.0.6", + "typescript": "^5.3.3", + "vitest": "^1.1.1" } } diff --git a/packages/drupal/custom/custom.services.yml b/packages/drupal/custom/custom.services.yml index 7424d6f1d..85c02e027 100644 --- a/packages/drupal/custom/custom.services.yml +++ b/packages/drupal/custom/custom.services.yml @@ -11,6 +11,10 @@ services: custom.menus: class: Drupal\custom\Menus + custom.translatables: + class: Drupal\custom\Translatables + arguments: ['@language_manager', '@locale.storage', '@database'] + custom.entity_language_redirect_subscriber: class: Drupal\custom\EventSubscriber\EntityLanguageRedirectSubscriber arguments: ['@language_manager', '@current_route_match'] diff --git a/packages/drupal/custom/src/FocalPoint.php b/packages/drupal/custom/src/FocalPoint.php new file mode 100644 index 000000000..44e123212 --- /dev/null +++ b/packages/drupal/custom/src/FocalPoint.php @@ -0,0 +1,42 @@ +value instanceof File) { + $filePath = $args->value->getFileUri(); + + $crop = Crop::findCrop($filePath, 'focal_point'); + $x = $crop?->x->value; + $y = $crop?->y->value; + if ($x && $y) { + return [ + $x, + $y, + ]; + } + } + } + +} 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/eslint-config/index.mjs b/packages/eslint-config/index.mjs index 52238a784..959d2fb48 100644 --- a/packages/eslint-config/index.mjs +++ b/packages/eslint-config/index.mjs @@ -1,5 +1,3 @@ -// @ts-check - import eslint from '@eslint/js'; import prettier from 'eslint-config-prettier'; import formatjs from 'eslint-plugin-formatjs'; diff --git a/packages/schema/codegen.ts b/packages/schema/codegen.ts index 7a0ea92c9..bc9a5e087 100644 --- a/packages/schema/codegen.ts +++ b/packages/schema/codegen.ts @@ -32,14 +32,6 @@ const config: CodegenConfig = { 'build/operations.json': { plugins: ['@amazeelabs/codegen-operation-ids'], }, - // Directive autoloader for Gatsby. - 'build/gatsby-autoload.mjs': { - plugins: ['@amazeelabs/codegen-autoloader'], - config: { - mode: 'js', - context: ['gatsby'], - }, - }, // Directive autoloader for Drupal. 'build/drupal-autoload.json': { plugins: ['@amazeelabs/codegen-autoloader'], @@ -67,16 +59,16 @@ const config: CodegenConfig = { }, }, // Source type definitions. - // All graphql schema types, suffixed with `Source` and with required + // All graphql schema types and resolvers, prefixed with `Source` and with required // type names. Used for validating incoming data, e.g. from Decap. 'src/generated/source.ts': { - plugins: [`typescript`], + plugins: [`typescript`, `typescript-resolvers`], config: { ...common, scalars: Object.fromEntries( Object.keys(scalars).map((key) => [key, 'string']), ), - typesSuffix: 'Source', + typesPrefix: 'Source', // In source types we always want an enforced __typename, so unions and // interfaces can be resolved automatically. skipTypename: false, diff --git a/packages/schema/package.json b/packages/schema/package.json index ed31b079e..780dba9e7 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -7,29 +7,23 @@ "private": true, "sideEffects": false, "exports": { - ".": [ - "./build/index.js" - ], "./source": [ "./build/generated/source.js" ], + ".": [ + "./build/index.js" + ], "./operations": [ "./build/operations.json" - ], - "./gatsby-autoload": [ - "./build/gatsby-autoload.mjs" ] }, "typesVersions": { "*": { - "": [ - "build/index.d.ts" - ], "/source": [ "build/generated/source.d.ts" ], - "/gatsby-autoload": [ - "src/types/gatsby-autoload.d.ts" + "": [ + "build/index.d.ts" ] } }, @@ -46,13 +40,14 @@ "watcher": "graphql-codegen && tsc --emitDeclarationOnly" }, "devDependencies": { - "@amazeelabs/codegen-autoloader": "^1.1.3", + "@amazeelabs/codegen-autoloader": "^1.2.0", "@amazeelabs/codegen-operation-ids": "^0.1.43", "@custom/eslint-config": "workspace:*", "@graphql-codegen/cli": "^5.0.0", "@graphql-codegen/schema-ast": "^4.0.0", "@graphql-codegen/typescript": "^4.0.1", "@graphql-codegen/typescript-operations": "^4.0.1", + "@graphql-codegen/typescript-resolvers": "^4.2.1", "@swc/cli": "^0.1.62", "@swc/core": "^1.3.67", "@types/image-size": "^0.8.0", @@ -64,15 +59,11 @@ "typescript": "^5.3.3" }, "dependencies": { - "@amazeelabs/executors": "^3.0.3", + "@amazeelabs/executors": "^3.1.0", "@amazeelabs/gatsby-silverback-cloudinary": "^1.2.7", "@amazeelabs/gatsby-source-silverback": "^1.14.0", "@amazeelabs/scalars": "^1.6.13", "@swc/cli": "^0.1.63", - "@swc/core": "^1.3.102", - "gatsby-plugin-sharp": "^5.13.0", - "gatsby-source-filesystem": "^5.13.0", - "image-size": "^1.1.1", - "mime-types": "^2.1.35" + "@swc/core": "^1.3.102" } } diff --git a/packages/schema/src/fragments/Card.gql b/packages/schema/src/fragments/Card.gql index be7c9ee1f..9764d2193 100644 --- a/packages/schema/src/fragments/Card.gql +++ b/packages/schema/src/fragments/Card.gql @@ -8,6 +8,6 @@ fragment CardItem on CardItem { } teaserImage { alt - source(width: 400, height: 300) + url } } diff --git a/packages/schema/src/fragments/Page.gql b/packages/schema/src/fragments/Page.gql index ddaeed564..df7ad4b37 100644 --- a/packages/schema/src/fragments/Page.gql +++ b/packages/schema/src/fragments/Page.gql @@ -14,19 +14,9 @@ fragment Page on Page { headline lead image { - portrait: source(width: 1200, height: 2400) - landscape: source( - width: 2000 - height: 500 - sizes: [ - [800, 800] - [1200, 200] - [1600, 1600] - [2000, 2000] - [2800, 2800] - ] - ) + url alt + focalPoint } ctaText ctaUrl diff --git a/packages/schema/src/fragments/PageContent/BlockImageTeasers.gql b/packages/schema/src/fragments/PageContent/BlockImageTeasers.gql index cfc0f42bf..2d97fb423 100644 --- a/packages/schema/src/fragments/PageContent/BlockImageTeasers.gql +++ b/packages/schema/src/fragments/PageContent/BlockImageTeasers.gql @@ -1,7 +1,7 @@ fragment BlockImageTeasers on BlockImageTeasers { teasers { image { - source(width: 1536, sizes: [[768, 768], [1536, 1536]]) + url alt } title diff --git a/packages/schema/src/fragments/PageContent/BlockImageWithText.gql b/packages/schema/src/fragments/PageContent/BlockImageWithText.gql index 84da846c2..fd7eaeb31 100644 --- a/packages/schema/src/fragments/PageContent/BlockImageWithText.gql +++ b/packages/schema/src/fragments/PageContent/BlockImageWithText.gql @@ -1,6 +1,6 @@ fragment BlockImageWithText on BlockImageWithText { image { - source(width: 1536, height: 1336) + url alt } textContent { diff --git a/packages/schema/src/fragments/PageContent/BlockMedia.gql b/packages/schema/src/fragments/PageContent/BlockMedia.gql index c428428e0..4042fa3b6 100644 --- a/packages/schema/src/fragments/PageContent/BlockMedia.gql +++ b/packages/schema/src/fragments/PageContent/BlockMedia.gql @@ -2,7 +2,7 @@ fragment BlockMedia on BlockMedia { media { __typename ... on MediaImage { - source(width: 1536, sizes: [[768, 768], [1536, 1536]]) + url alt } ... on MediaVideo { diff --git a/packages/schema/src/fragments/PageContent/BlockQuote.gql b/packages/schema/src/fragments/PageContent/BlockQuote.gql index 785efb620..7503c7bd3 100644 --- a/packages/schema/src/fragments/PageContent/BlockQuote.gql +++ b/packages/schema/src/fragments/PageContent/BlockQuote.gql @@ -3,7 +3,7 @@ fragment BlockQuote on BlockQuote { author role image { - source(width: 1536, sizes: [[768, 768], [1536, 1536]]) + url alt } } diff --git a/packages/schema/src/image.ts b/packages/schema/src/image.ts deleted file mode 100644 index edddcdb91..000000000 --- a/packages/schema/src/image.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { readFileSync } from 'fs'; -import { fluid } from 'gatsby-plugin-sharp'; -import { - createFileNodeFromBuffer, - createRemoteFileNode, -} from 'gatsby-source-filesystem'; -import type { GraphQLFieldResolver } from 'graphql'; -import sizeOf from 'image-size'; -import { lookup } from 'mime-types'; - -const AREA_FALLBACK = 'attention'; - -export const imageProps: GraphQLFieldResolver = (source) => { - // If it's a Decap image, process it. - // Otherwise, it comes from Drupal and - // already has all necessary props. - if (source && source.startsWith('/apps/decap')) { - const relativeSource = source.substring(`/apps/decap`.length); - const dimensions = sizeOf(`node_modules/@custom/decap/${relativeSource}`); - const imageSrc = `${ - process.env.GATSBY_PUBLIC_URL || 'node_modules/@custom/decap' - }${relativeSource}`; - - return JSON.stringify({ - src: imageSrc, - originalSrc: imageSrc, - width: dimensions.width || 0, - height: dimensions.height || 0, - mime: lookup(relativeSource) || 'application/octet-stream', - }); - } - // Otherwise, replace NETLIFY_URL with DRUPAL_EXTERNAL_URL. - // - If images are processed in Gatsby, they have to be loaded from Drupal directly. - // - If images are processed in Cloudinary, we don't need two CDN's (Netlify & Cloudinary) - // - TODO: Once we have image processing in Drupal, it has to be handled here. - if (process.env.NETLIFY_URL && process.env.DRUPAL_EXTERNAL_URL) { - try { - const decoded = JSON.parse(source); - if (decoded && typeof decoded === 'object') { - for (const key in decoded) { - if (typeof decoded[key] === 'string') { - decoded[key] = decoded[key].replaceAll( - process.env.NETLIFY_URL, - process.env.DRUPAL_EXTERNAL_URL, - ); - } - } - return JSON.stringify(decoded); - } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (error) { - return source; - } - } - return source; -}; - -function determineCropFocus(filename: string) { - if (filename.includes('.c.')) return 'center'; - if (filename.includes('.nw_.')) return 'northwest'; - if (filename.includes('.w.')) return 'west'; - if (filename.includes('.sw_.')) return 'southwest'; - if (filename.includes('.s.')) return 'south'; - if (filename.includes('.se_.')) return 'southeast'; - if (filename.includes('.e.')) return 'east'; - if (filename.includes('.ne_.')) return 'northeast'; - if (filename.includes('.n.')) return 'north'; - return null; -} - -const calculateCropArea = ( - width: number, - height: number, - x: number, - y: number, -) => { - if ((x === 0 && y === 0) || width === 0 || height === 0) { - return AREA_FALLBACK; - } - // Adjust if Y === height or X === width as - // the division result will produce 1, - // which will not be a valid index - // for the areas array (out of bounds). - const adjustedY = y === height ? y - 1 : y; - const adjustedX = x === width ? x - 1 : x; - - const row = Math.floor((adjustedY / height) * 3); - const col = Math.floor((adjustedX / width) * 3); - const areas = [ - ['northwest', 'north', 'northeast'], - ['west', 'center', 'east'], - ['southwest', 'south', 'southeast'], - ]; - - // If we met an exception in the calculation of the area, - // use the fallback value. - if (!areas[row] || !areas[row][col]) { - return AREA_FALLBACK; - } - - return areas[row][col]; -}; - -function isValidUrl(url: string) { - try { - new URL(url); - return true; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (e) { - return false; - } -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const responsiveImage: GraphQLFieldResolver = async ( - originalImage, - args, - context, -) => { - const responsiveImage = JSON.parse(originalImage); - const { cache, createNode, createNodeId, reporter } = context.api; - try { - const responsiveImageResult = { - ...responsiveImage, - originalSrc: isValidUrl(responsiveImage.src) - ? new URL(responsiveImage.src).pathname - : responsiveImage.src, - }; - - // If no config object is given, or no width is specified, we just return - // the original image url. - if (typeof args === 'undefined' || typeof args.width === 'undefined') { - return JSON.stringify(responsiveImageResult); - } - - const file = isValidUrl(responsiveImage.src) - ? await createRemoteFileNode({ - url: responsiveImage.src, - cache: cache, - createNode: createNode, - createNodeId: createNodeId, - }) - : await createFileNodeFromBuffer({ - buffer: readFileSync(responsiveImage.src), - cache: cache, - createNode: createNode, - createNodeId: createNodeId, - }); - const width = args.width; - const height = args.height || undefined; - const breakpoints = - args.sizes && args.sizes.length > 0 - ? args.sizes.map((item: unknown) => { - // If the sizes array contains tuples, then just return the first item - // to be added to the breakpoints elements. - if (Array.isArray(item)) { - return item[0]; - } - return item; - }) - : undefined; - - const fluidFileResult = await fluid({ - file, - args: { - maxWidth: width, - maxHeight: height, - quality: 90, - srcSetBreakpoints: breakpoints, - cropFocus: - determineCropFocus(responsiveImage.src) || - calculateCropArea( - parseInt(responsiveImage.width), - parseInt(responsiveImage.height), - parseInt(responsiveImage.focalPoint?.x), - parseInt(responsiveImage.focalPoint?.y), - ), - }, - reporter: reporter, - cache: cache, - }); - responsiveImageResult.src = fluidFileResult.src; - responsiveImageResult.width = fluidFileResult.presentationWidth; - responsiveImageResult.height = fluidFileResult.presentationHeight; - responsiveImageResult.sizes = fluidFileResult.sizes; - responsiveImageResult.srcset = fluidFileResult.srcSet; - - return JSON.stringify(responsiveImageResult); - } catch (err) { - console.error(`Error loading image ${responsiveImage.src}`, err); - return JSON.stringify(responsiveImage); - } -}; diff --git a/packages/schema/src/operations/CampaignUrlRedirects.gql b/packages/schema/src/operations/CampaignUrlRedirects.gql new file mode 100644 index 000000000..53ffacdb3 --- /dev/null +++ b/packages/schema/src/operations/CampaignUrlRedirects.gql @@ -0,0 +1,11 @@ +query CampaignUrlRedirects($args: String!) { + campaignUrlRedirects(args: $args) { + rows { + source + destination + statusCode + force + } + total + } +} diff --git a/packages/schema/src/operations/ListPages.gql b/packages/schema/src/operations/ListPages.gql index c296e4a39..a2fa80e0c 100644 --- a/packages/schema/src/operations/ListPages.gql +++ b/packages/schema/src/operations/ListPages.gql @@ -1,5 +1,11 @@ -query ListPages { - allPages { - path +query ListPages($args: String!) { + ssgPages(args: $args) { + rows { + translations { + path + locale + } + } + total } } diff --git a/packages/schema/src/page.ts b/packages/schema/src/page.ts deleted file mode 100644 index be6d46547..000000000 --- a/packages/schema/src/page.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { GraphQLFieldResolver } from 'graphql'; - -// The generated file can be missing during the build process. -// TODO: Can we change the build process to avoid this TS error? -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore -import { DecapPageSource } from './generated/source'; - -// TODO: Generate typing helpers to make this easier. -// TODO: Move these into a shared package that implements Drupals "graphql_directives" for Gatsby. - -const internalTypes = ['Site', 'SiteBuildMetadata', 'SitePage', 'SitePlugin']; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const loadEntity: GraphQLFieldResolver = async ( - url, - _, - { nodeModel }, -) => { - const types = nodeModel.getTypes(); - for (const type of types) { - // Skip Gatsby internal types. - if (internalTypes.includes(type)) { - continue; - } - try { - const result = await nodeModel.findOne({ - type, - query: { filter: { path: { eq: url.pathname } } }, - }); - if (result) { - return result; - } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (e) { - // Ignore: - // Gatsby breaks when trying to query a field that - // does not exist on the current type. - // TODO: Probably a better solution would be to always - // restrict "loadEntity" by type. But then we need - // to make that change in Drupal too. - } - } -}; - -export const route: GraphQLFieldResolver< - undefined, - { nodeModel: unknown }, - { path: string } -> = (_, { path }) => { - try { - return new URL(path, process.env.NETLIFY_URL || 'https://localhost:8000'); - } catch (e) { - console.warn(`Invalid url "${path}".`); - console.warn(e); - return new URL('/', process.env.NETLIFY_URL || 'https://localhost:8000'); - } -}; - -export const decapTranslations: GraphQLFieldResolver< - DecapPageSource & { _decap_id: string }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - { nodeModel: any } -> = async (page, _, { nodeModel }) => { - return ( - await nodeModel.findAll({ - type: 'DecapPage', - query: { filter: { _decap_id: { eq: page._decap_id } } }, - }) - ).entries; -}; diff --git a/packages/schema/src/schema.graphql b/packages/schema/src/schema.graphql index 4682680fa..37785357f 100644 --- a/packages/schema/src/schema.graphql +++ b/packages/schema/src/schema.graphql @@ -1,6 +1,5 @@ scalar Url @default @value(string: "") scalar Markup @default @value(string: "") -scalar ImageSource @default @value(string: "") scalar JSONString @default @value(string: "{}") """ @@ -8,6 +7,16 @@ implementation(drupal): custom.menus::getMenuTranslations """ directive @menuTranslations(menu_id: String!) on FIELD_DEFINITION +""" +implementation(drupal): \Drupal\custom\FocalPoint::getFocalPoint +""" +directive @focalPoint on FIELD_DEFINITION + +""" +implementation(drupal): custom.translatables::all +""" +directive @translatables(context: String!) on FIELD_DEFINITION + """ implementation(drupal): custom.webform::url """ @@ -21,57 +30,6 @@ directive @createWebformSubmission( submittedData: JSONString! ) on FIELD_DEFINITION -""" -Retrieve the properties of an image. -TODO: Move to a shared "image" package. - -implementation(gatsby): ./image.js#imageProps -""" -directive @imageProps repeatable on FIELD_DEFINITION | SCALAR | UNION | ENUM | INTERFACE | OBJECT - -""" -Override the image saving to add focal point info - -implementation(gatsby): ./image.js#responsiveImage -""" -directive @responsiveImage( - height: String - sizes: String - transform: String - width: String -) repeatable on ENUM | FIELD_DEFINITION | INTERFACE | OBJECT | SCALAR | UNION - -""" -Retrieve an entity from the Gatsby datastore. -For Drupal, this is implicitly implemented in the "graphql_directives" module. - -implementation(gatsby): ./page.js#loadEntity -""" -directive @loadEntity( - id: String - operation: String - route: String - type: String - uuid: String -) repeatable on ENUM | FIELD_DEFINITION | INTERFACE | OBJECT | SCALAR | UNION - -""" -Retrieve all translations of a decap page. - -implementation(gatsby): ./page.js#decapTranslations -""" -directive @decapPageTranslations on FIELD_DEFINITION - -""" -Parse a given Url. -For Drupal, this is implicitly implemented in the "graphql_directives" module. - -implementation(gatsby): ./page.js#route -""" -directive @route( - path: String! -) repeatable on FIELD_DEFINITION | SCALAR | UNION | ENUM | INTERFACE | OBJECT - enum Locale @default @value(string: "en") { en de @@ -141,37 +99,10 @@ type FooterNavigation implements Navigation @menu(menu_id: "footer") { items: [NavigationItem]! @lang @resolveMenuItems } -interface Page implements CardItem & Editable @resolveEntityBundle { - id: ID! - translations: [Page] - locale: Locale! - path: Url! - editLink: EditLink - title: String! - teaserImage: MediaImage - hero: Hero - content: [PageContent] - metaTags: [MetaTag] -} - -type DecapPage implements Page & Editable & CardItem - @sourceFrom(fn: "getPages") { - id: ID! - translations: [Page] @decapPageTranslations - locale: Locale! - path: Url! - editLink: EditLink - title: String! - teaserImage: MediaImage - hero: Hero - content: [PageContent] - metaTags: [MetaTag] -} - """ A generic page. """ -type DrupalPage implements Page & Editable & CardItem +type Page implements CardItem @entity(type: "node", bundle: "page") @type(id: "page") { id: ID! @resolveEntityUuid @@ -203,6 +134,13 @@ type DrupalPage implements Page & Editable & CardItem metaTags: [MetaTag] @resolveProperty(path: "metatag") } +type CampaignUrlRedirect @entity(type: "campaign_url", bundle: "campaign_url") { + source: String! @resolveProperty(path: "campaign_url_source.value") + destination: String! @resolveProperty(path: "campaign_url_destination.value") + statusCode: Int! @resolveProperty(path: "status_code.value") + force: Boolean! @resolveProperty(path: "force.value") +} + type Hero { headline: String! @resolveEditorBlockAttribute(key: "headline") lead: String @resolveEditorBlockAttribute(key: "lead") @@ -251,22 +189,10 @@ type BlockMarkup @type(id: "core/paragraph") { union Media @resolveEntityBundle = MediaImage | MediaVideo type MediaImage @type(id: "image") @entity(type: "media", bundle: "image") { - source( - width: Int - height: Int - sizes: [[Int!]!] - transform: String - ): ImageSource! + url: Url! @resolveProperty(path: "field_media_image.entity") @imageUrl + focalPoint: [Int!] @resolveProperty(path: "field_media_image.entity") - @imageProps @focalPoint - @responsiveImage( - height: "$height" - width: "$width" - sizes: "$sizes" - transform: "$transform" - ) - alt: String! @resolveProperty(path: "field_media_image.alt") } @@ -409,14 +335,14 @@ input PaginationInput { } type Query { - previewDecapPage: DecapPage + previewDecapPage: Page previewDrupalPage( id: ID! rid: ID locale: String! preview_user_id: ID preview_access_token: String - ): DrupalPage + ): Page @fetchEntity( type: "node" id: "$id" @@ -426,20 +352,16 @@ type Query { preview_access_token: "$preview_access_token" ) - metaNavigations: [MetaNavigation] - @gatsbyNodes(type: "MetaNavigation") - @menuTranslations(menu_id: "meta") + mainNavigations: [MainNavigation] @menuTranslations(menu_id: "main") + metaNavigations: [MetaNavigation] @menuTranslations(menu_id: "meta") + + footerNavigations: [FooterNavigation] @menuTranslations(menu_id: "footer") - mainNavigations: [MainNavigation] - @gatsbyNodes(type: "MainNavigation") - @menuTranslations(menu_id: "main") + ssgPages(args: String): SSGPagesResult + @drupalView(id: "ssg_pages:default", args: "$args") - footerNavigations: [FooterNavigation] - @gatsbyNodes(type: "FooterNavigation") - @menuTranslations(menu_id: "footer") + 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! @@ -449,8 +371,20 @@ type Query { @lang(code: "$locale") @drupalView(id: "content_hub:default", args: "$args") - stringTranslations: [TranslatableString!] - @gatsbyNodes(type: "TranslatableString") + stringTranslations: [TranslatableString!] @translatables(context: "gatsby") + + campaignUrlRedirects(args: String): CampaignUrlRedirectsResult + @drupalView(id: "campaign_urls:frontend_redirects", args: "$args") +} + +type SSGPagesResult { + total: Int! + rows: [Page]! +} + +type CampaignUrlRedirectsResult { + total: Int! + rows: [CampaignUrlRedirect] } type Mutation { @@ -498,7 +432,7 @@ type DemoBlock { """ The type provided by translations source (e.g. Decap or Drupal). """ -interface TranslatableString @default @value { +type TranslatableString @value(string: "drupal") @default @value { """ The default message, used in the UI. """ @@ -512,17 +446,3 @@ interface TranslatableString @default @value { """ translation: String } - -type DecapTranslatableString implements TranslatableString - @sourceFrom(fn: "getTranslatables") { - source: String! - language: Locale! - translation: String -} - -type DrupalTranslatableString implements TranslatableString - @translatableString(contextPrefix: "gatsby") { - source: String! - language: Locale! - translation: String -} diff --git a/packages/schema/src/types/gatsby-autoload.d.ts b/packages/schema/src/types/gatsby-autoload.d.ts deleted file mode 100644 index 82234d97d..000000000 --- a/packages/schema/src/types/gatsby-autoload.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { GraphQLFieldResolver } from 'graphql'; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export default Record>; diff --git a/packages/schema/src/types/gatsby-plugin-sharp.d.ts b/packages/schema/src/types/gatsby-plugin-sharp.d.ts deleted file mode 100644 index b2d92d15d..000000000 --- a/packages/schema/src/types/gatsby-plugin-sharp.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module 'gatsby-plugin-sharp' { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - function fluid(props: any): any; -} diff --git a/packages/schema/src/types/mime-types.d.ts b/packages/schema/src/types/mime-types.d.ts deleted file mode 100644 index 89f1d4f69..000000000 --- a/packages/schema/src/types/mime-types.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -declare module 'mime-types' { - function lookup(name: string): string; -} diff --git a/packages/ui/package.json b/packages/ui/package.json index 05bc9d1fc..268dd3d40 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -39,6 +39,7 @@ "report": "mkdir -p coverage/storybook && nyc report --reporter=lcov -t coverage/storybook --report-dir coverage/storybook" }, "dependencies": { + "@amazeelabs/image": "^1.4.3", "@amazeelabs/react-intl": "^1.1.1", "@amazeelabs/silverback-iframe": "^1.3.0", "@custom/schema": "workspace:*", @@ -52,6 +53,7 @@ "js-cookie": "^3.0.5", "query-string": "^9.0.0", "react-hook-form": "^7.49.2", + "react-server-dom-webpack": "19.0.0-rc.0", "swr": "^2.2.4", "unified": "^10.1.2", "zod": "^3.23.8", diff --git a/packages/ui/src/components/Molecules/Breadcrumbs.tsx b/packages/ui/src/components/Molecules/Breadcrumbs.tsx index 2095fde38..9ac9e77e5 100644 --- a/packages/ui/src/components/Molecules/Breadcrumbs.tsx +++ b/packages/ui/src/components/Molecules/Breadcrumbs.tsx @@ -1,4 +1,5 @@ -import { Link } from '@custom/schema'; +'use client'; +import { Link, Locale } from '@custom/schema'; import { ChevronRightIcon, EllipsisHorizontalIcon, @@ -10,6 +11,9 @@ import { isTruthy } from '../../utils/isTruthy'; import { truncateString } from '../../utils/stringTruncation'; import { useBreadcrumbs } from '../Routes/Menu'; +// Paths that lead to the home page and should display the home icon. +const home_paths = Object.values(Locale).map((locale) => `/${locale}`); + export function BreadCrumbs() { const breadcrumbs = useBreadcrumbs(); const [hideInnerBreadcrumbs, setHideInnerBreadcrumbs] = useState(false); @@ -96,7 +100,7 @@ export function BreadCrumbs() { 'hidden', )} > - {target === '/' && ( + {home_paths.includes(target) && (
{teaserImage ? ( - + ) : (
)} diff --git a/packages/ui/src/components/Organisms/ContentHub.stories.tsx b/packages/ui/src/components/Organisms/ContentHub.stories.tsx index 588467845..4c37691bc 100644 --- a/packages/ui/src/components/Organisms/ContentHub.stories.tsx +++ b/packages/ui/src/components/Organisms/ContentHub.stories.tsx @@ -6,13 +6,12 @@ import { OperationVariables, Url, } from '@custom/schema'; -import Landscape from '@stories/landscape.jpg?as=metadata'; -import Portrait from '@stories/portrait.jpg?as=metadata'; +import Landscape from '@stories/landscape.jpg'; +import Portrait from '@stories/portrait.jpg'; import { Meta, StoryObj } from '@storybook/react'; import qs from 'query-string'; import React from 'react'; -import { image } from '../../helpers/image'; import { ContentHub, ContentHubQueryArgs } from './ContentHub'; type ContentHubExecutor = ( @@ -76,10 +75,7 @@ export const WithResults: ContentHubStory = { ? undefined : { alt: `Image for item #${i + 1}`, - source: image(i % 2 === 0 ? Landscape : Portrait, { - width: 400, - height: 300, - }), + url: i % 2 === 0 ? Landscape : Portrait, }, }) satisfies CardItemFragment, ); diff --git a/packages/ui/src/components/Organisms/Header.tsx b/packages/ui/src/components/Organisms/Header.tsx index a1ec51452..448213238 100644 --- a/packages/ui/src/components/Organisms/Header.tsx +++ b/packages/ui/src/components/Organisms/Header.tsx @@ -66,7 +66,10 @@ export function Header() { aria-label="Global" >
- + {intl.formatMessage({ defaultMessage: 'Company name', diff --git a/packages/ui/src/components/Organisms/PageContent/BlockConditional.tsx b/packages/ui/src/components/Organisms/PageContent/BlockConditional.tsx index 67923950a..bbc79b222 100644 --- a/packages/ui/src/components/Organisms/PageContent/BlockConditional.tsx +++ b/packages/ui/src/components/Organisms/PageContent/BlockConditional.tsx @@ -1,3 +1,4 @@ +'use client'; import { BlockConditionalFragment } from '@custom/schema'; import React, { useEffect, useState } from 'react'; diff --git a/packages/ui/src/components/Organisms/PageContent/BlockForm.tsx b/packages/ui/src/components/Organisms/PageContent/BlockForm.tsx index ff22d3f4a..dfed7dce8 100644 --- a/packages/ui/src/components/Organisms/PageContent/BlockForm.tsx +++ b/packages/ui/src/components/Organisms/PageContent/BlockForm.tsx @@ -1,3 +1,4 @@ +'use client'; import { SilverbackIframe } from '@amazeelabs/silverback-iframe'; import { BlockFormFragment, Url, useLocation } from '@custom/schema'; import clsx from 'clsx'; diff --git a/packages/ui/src/components/Organisms/PageContent/BlockImageTeasers.stories.tsx b/packages/ui/src/components/Organisms/PageContent/BlockImageTeasers.stories.tsx index 6b90bf401..c1f970b88 100644 --- a/packages/ui/src/components/Organisms/PageContent/BlockImageTeasers.stories.tsx +++ b/packages/ui/src/components/Organisms/PageContent/BlockImageTeasers.stories.tsx @@ -1,9 +1,8 @@ import { Url } from '@custom/schema'; -import Landscape from '@stories/landscape.jpg?as=metadata'; -import Portrait from '@stories/portrait.jpg?as=metadata'; +import Landscape from '@stories/landscape.jpg'; +import Portrait from '@stories/portrait.jpg'; import { Meta, StoryObj } from '@storybook/react'; -import { image } from '../../../helpers/image'; import { BlockImageTeasers } from './BlockImageTeasers'; export default { @@ -18,7 +17,7 @@ export const Default = { ctaText: 'Call to action', ctaUrl: '/test' as Url, image: { - source: image(Landscape), + url: Landscape, alt: 'Alt text', }, }, @@ -27,7 +26,7 @@ export const Default = { ctaText: 'Call to action', ctaUrl: '/test' as Url, image: { - source: image(Portrait), + url: Portrait, alt: 'Alt text', }, }, diff --git a/packages/ui/src/components/Organisms/PageContent/BlockImageTeasers.tsx b/packages/ui/src/components/Organisms/PageContent/BlockImageTeasers.tsx index 031d3f712..773a9a4ea 100644 --- a/packages/ui/src/components/Organisms/PageContent/BlockImageTeasers.tsx +++ b/packages/ui/src/components/Organisms/PageContent/BlockImageTeasers.tsx @@ -1,4 +1,5 @@ -import { BlockImageTeasersFragment, Image, Link } from '@custom/schema'; +import { Image } from '@amazeelabs/image'; +import { BlockImageTeasersFragment, Link } from '@custom/schema'; import clsx from 'clsx'; import React from 'react'; @@ -35,7 +36,8 @@ export function BlockImageTeaser( {props.image ? ( {props.image.alt} ) : null} diff --git a/packages/ui/src/components/Organisms/PageContent/BlockImageWithText.stories.ts b/packages/ui/src/components/Organisms/PageContent/BlockImageWithText.stories.ts index 133a619f9..97e33169c 100644 --- a/packages/ui/src/components/Organisms/PageContent/BlockImageWithText.stories.ts +++ b/packages/ui/src/components/Organisms/PageContent/BlockImageWithText.stories.ts @@ -1,8 +1,7 @@ import { ImagePosition, Markup } from '@custom/schema'; -import Landscape from '@stories/landscape.jpg?as=metadata'; +import Landscape from '@stories/landscape.jpg'; import { Meta, StoryObj } from '@storybook/react'; -import { image } from '../../../helpers/image'; import { BlockImageWithText } from './BlockImageWithText'; export default { @@ -12,7 +11,7 @@ export default { export const ImageRight = { args: { image: { - source: image(Landscape), + url: Landscape, alt: 'Landscape', }, imagePosition: ImagePosition.Right, @@ -32,7 +31,7 @@ export const ImageRight = { export const ImageLeft = { args: { image: { - source: image(Landscape), + url: Landscape, alt: 'Landscape', }, imagePosition: ImagePosition.Left, @@ -52,7 +51,7 @@ export const ImageLeft = { export const ArrowList = { args: { image: { - source: image(Landscape), + url: Landscape, alt: 'Landscape', }, textContent: { @@ -71,7 +70,7 @@ export const ArrowList = { export const QuestionMarkList = { args: { image: { - source: image(Landscape), + url: Landscape, alt: 'Landscape', }, textContent: { @@ -90,7 +89,7 @@ export const QuestionMarkList = { export const CheckMarkList = { args: { image: { - source: image(Landscape), + url: Landscape, alt: 'Landscape', }, textContent: { diff --git a/packages/ui/src/components/Organisms/PageContent/BlockImageWithText.tsx b/packages/ui/src/components/Organisms/PageContent/BlockImageWithText.tsx index 40c3fbe6d..91ac2dc6f 100644 --- a/packages/ui/src/components/Organisms/PageContent/BlockImageWithText.tsx +++ b/packages/ui/src/components/Organisms/PageContent/BlockImageWithText.tsx @@ -1,8 +1,5 @@ -import { - BlockImageWithTextFragment, - Image, - ImagePosition, -} from '@custom/schema'; +import { Image } from '@amazeelabs/image'; +import { BlockImageWithTextFragment, ImagePosition } from '@custom/schema'; import clsx from 'clsx'; import React from 'react'; @@ -28,11 +25,12 @@ export function BlockImageWithText(props: BlockImageWithTextFragment) { )}
- {!!props.image?.source && ( -
+ {!!props.image?.url && ( +
{props.image.alt
diff --git a/packages/ui/src/components/Organisms/PageContent/BlockMedia.stories.ts b/packages/ui/src/components/Organisms/PageContent/BlockMedia.stories.ts index 0548e7269..9d102fab4 100644 --- a/packages/ui/src/components/Organisms/PageContent/BlockMedia.stories.ts +++ b/packages/ui/src/components/Organisms/PageContent/BlockMedia.stories.ts @@ -1,9 +1,8 @@ import { Markup } from '@custom/schema'; -import Landscape from '@stories/landscape.jpg?as=metadata'; -import Portrait from '@stories/portrait.jpg?as=metadata'; +import Landscape from '@stories/landscape.jpg'; +import Portrait from '@stories/portrait.jpg'; import { Meta, StoryObj } from '@storybook/react'; -import { image } from '../../../helpers/image'; import { BlockMedia } from './BlockMedia'; export default { @@ -14,7 +13,7 @@ export const ImageLandscape = { args: { media: { __typename: 'MediaImage', - source: image(Landscape), + url: Landscape, alt: 'Landscape', }, }, @@ -24,7 +23,7 @@ export const ImagePortrait = { args: { media: { __typename: 'MediaImage', - source: image(Portrait), + url: Portrait, alt: 'Portrait', }, }, diff --git a/packages/ui/src/components/Organisms/PageContent/BlockMedia.tsx b/packages/ui/src/components/Organisms/PageContent/BlockMedia.tsx index f708ff45c..b645d2288 100644 --- a/packages/ui/src/components/Organisms/PageContent/BlockMedia.tsx +++ b/packages/ui/src/components/Organisms/PageContent/BlockMedia.tsx @@ -1,4 +1,5 @@ -import { BlockMediaFragment, Html, Image } from '@custom/schema'; +import { Image } from '@amazeelabs/image'; +import { BlockMediaFragment, Html } from '@custom/schema'; import React from 'react'; import { UnreachableCaseError } from '../../../utils/unreachable-case-error'; @@ -30,7 +31,8 @@ function Media(props: Required['media']) { return ( {props.alt} ); diff --git a/packages/ui/src/components/Organisms/PageContent/BlockQuote.stories.ts b/packages/ui/src/components/Organisms/PageContent/BlockQuote.stories.ts index 6b40c8afd..e07432108 100644 --- a/packages/ui/src/components/Organisms/PageContent/BlockQuote.stories.ts +++ b/packages/ui/src/components/Organisms/PageContent/BlockQuote.stories.ts @@ -1,8 +1,7 @@ import { Markup } from '@custom/schema'; -import Avatar from '@stories/avatar.jpg?as=metadata'; +import Avatar from '@stories/avatar.jpg'; import { Meta, StoryObj } from '@storybook/react'; -import { image } from '../../../helpers/image'; import { BlockQuote } from './BlockQuote'; export default { @@ -14,7 +13,7 @@ export const Quote = { role: 'test role', author: 'Author name', image: { - source: image(Avatar), + url: Avatar, alt: 'Portrait', }, quote: diff --git a/packages/ui/src/components/Organisms/PageContent/BlockQuote.tsx b/packages/ui/src/components/Organisms/PageContent/BlockQuote.tsx index cebfea407..8108dd39d 100644 --- a/packages/ui/src/components/Organisms/PageContent/BlockQuote.tsx +++ b/packages/ui/src/components/Organisms/PageContent/BlockQuote.tsx @@ -1,4 +1,5 @@ -import { BlockQuoteFragment, Html, Image } from '@custom/schema'; +import { Image } from '@amazeelabs/image'; +import { BlockQuoteFragment, Html } from '@custom/schema'; import React from 'react'; import { FadeUp } from '../../Molecules/FadeUp'; @@ -31,7 +32,8 @@ export function BlockQuote(props: BlockQuoteFragment) { {props.image && ( {props.image.alt )} diff --git a/packages/ui/src/components/Organisms/PageDisplay.tsx b/packages/ui/src/components/Organisms/PageDisplay.tsx index 76b67bd96..5044bb3c8 100644 --- a/packages/ui/src/components/Organisms/PageDisplay.tsx +++ b/packages/ui/src/components/Organisms/PageDisplay.tsx @@ -1,4 +1,3 @@ -'use client'; import { BlockConditionalFragment, PageFragment } from '@custom/schema'; import React from 'react'; @@ -20,10 +19,12 @@ import { BlockMedia } from './PageContent/BlockMedia'; import { BlockQuote } from './PageContent/BlockQuote'; import { BlockTeaserList } from './PageContent/BlockTeaserList'; import { PageHero } from './PageHero'; +import { PageMeta } from './PageMeta'; export function PageDisplay(page: PageFragment) { return ( +
{page.editLink ? : null} {!page.hero && } diff --git a/packages/ui/src/components/Organisms/PageHero.tsx b/packages/ui/src/components/Organisms/PageHero.tsx index 3870b8ade..2ac942ec2 100644 --- a/packages/ui/src/components/Organisms/PageHero.tsx +++ b/packages/ui/src/components/Organisms/PageHero.tsx @@ -1,4 +1,5 @@ -import { Image, Link, PageFragment } from '@custom/schema'; +import { Image } from '@amazeelabs/image'; +import { Link, PageFragment } from '@custom/schema'; import React from 'react'; import { BreadCrumbs } from '../Molecules/Breadcrumbs'; @@ -14,28 +15,42 @@ export function PageHero(props: NonNullable) { ); } +function HeroImage( + props: NonNullable['image'] & { dim: boolean }, +) { + return props ? ( + <> + {props.alt} + {props.alt} + {props.dim ? ( +
+ ) : null} + + ) : null; +} + function DefaultHero(props: NonNullable) { return ( <> -
- {props.image ? ( - <> - {props.image.alt} - {props.image.alt} - - ) : null} +
+ {props.image ? : null}

@@ -78,18 +93,7 @@ function FormHero(props: NonNullable) { return (
- {props.image ? ( - <> - {props.image.alt} -
- - ) : null} + {props.image ? : null}
diff --git a/packages/ui/src/components/Organisms/PageMeta.tsx b/packages/ui/src/components/Organisms/PageMeta.tsx new file mode 100644 index 000000000..7f68013b8 --- /dev/null +++ b/packages/ui/src/components/Organisms/PageMeta.tsx @@ -0,0 +1,46 @@ +'use client'; +import { Locale, MetaTag } from '@custom/schema'; +import React, { useEffect } from 'react'; + +type PageMetaType = { + meta?: Array; + locale?: Locale; +}; + +export function PageMeta({ meta, locale }: PageMetaType) { + // Hack to set the lang attribute on the html tag, as this is not possibles + // right now with waku. + useEffect(() => { + document.documentElement.lang = locale || 'en'; + }, [locale]); + + return meta ? ( + <> + {meta.map((metaTag, index) => { + if (metaTag?.tag === 'meta') { + return ( + + + {metaTag.attributes?.name === 'title' ? ( + {metaTag.attributes?.content} + ) : null} + + ); + } else if (metaTag?.tag === 'link') { + return ( + + ); + } + return null; + }) || null} + + ) : null; +} diff --git a/packages/ui/src/components/Routes/Frame.tsx b/packages/ui/src/components/Routes/Frame.tsx index 2ab1ec148..e9ce90e40 100644 --- a/packages/ui/src/components/Routes/Frame.tsx +++ b/packages/ui/src/components/Routes/Frame.tsx @@ -1,6 +1,7 @@ +import { ImageSettings } from '@amazeelabs/image'; import { IntlProvider } from '@amazeelabs/react-intl'; import { FrameQuery, Locale, Operation } from '@custom/schema'; -import React, { PropsWithChildren } from 'react'; +import React, { ComponentProps, PropsWithChildren } from 'react'; import translationSources from '../../../build/translatables.json'; import { useLocale } from '../../utils/locale'; @@ -14,29 +15,28 @@ function filterByLocale(locale: Locale) { str.language === locale; } -function translationsMap(translatables: FrameQuery['stringTranslations']) { +function translationsMap( + translatables: Required['stringTranslations'], +) { return Object.fromEntries( - [ - // Make sure that Drupal translations have higher precedence. - ...(translatables?.filter( - (tr) => tr.__typename === 'DecapTranslatableString', - ) || []), - ...(translatables?.filter( - (tr) => tr.__typename === 'DrupalTranslatableString', - ) || []), - ] + translatables .filter((tr) => tr.translation) .map((tr) => [tr.source, tr.translation]), ); } -export function Frame({ children }: PropsWithChildren) { +export function Frame({ + children, + ...imageSettings +}: PropsWithChildren>) { const locale = useLocale(); return ( - + {(result) => { if (result.state === 'success') { - const rawTranslations = result.data.stringTranslations || []; + const rawTranslations = result.data + .map((res) => res.stringTranslations || []) + .reduce((acc, val) => [...acc, ...val], []); const translations = { ...translationsMap( rawTranslations?.filter(filterByLocale('en')) || [], @@ -57,13 +57,20 @@ export function Frame({ children }: PropsWithChildren) { ]), ); return ( - - -
- {children} -