From 053bb1c0e591aae2dcbcd558a799eb736903e1e6 Mon Sep 17 00:00:00 2001 From: Steven Le Date: Fri, 23 Aug 2024 16:47:49 -0700 Subject: [PATCH 01/14] feat: [v2] add "startup" hook to plugins (#411) --- .changeset/twelve-months-explain.md | 5 +++++ packages/root/src/cli/build.ts | 8 ++++++++ packages/root/src/cli/dev.ts | 7 +++++++ packages/root/src/cli/preview.ts | 8 ++++++++ packages/root/src/cli/start.ts | 8 ++++++++ packages/root/src/core/plugin.ts | 10 +++++++++- 6 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 .changeset/twelve-months-explain.md diff --git a/.changeset/twelve-months-explain.md b/.changeset/twelve-months-explain.md new file mode 100644 index 00000000..882ed4b2 --- /dev/null +++ b/.changeset/twelve-months-explain.md @@ -0,0 +1,5 @@ +--- +'@blinkk/root': patch +--- + +feat: [v2] add "startup" hook to plugins diff --git a/packages/root/src/cli/build.ts b/packages/root/src/cli/build.ts index 0130ea72..fd366015 100644 --- a/packages/root/src/cli/build.ts +++ b/packages/root/src/cli/build.ts @@ -82,6 +82,14 @@ export async function build(rootProjectDir?: string, options?: BuildOptions) { } const rootPlugins = rootConfig.plugins || []; + + // Run any "startup" hooks. + for (const plugin of rootPlugins) { + if (typeof plugin.hooks?.startup === 'function') { + await plugin.hooks.startup({command: 'build', rootConfig}); + } + } + const viteConfig = rootConfig.vite || {}; const vitePlugins = [ ...(viteConfig.plugins || []), diff --git a/packages/root/src/cli/dev.ts b/packages/root/src/cli/dev.ts index d128f62a..ec613934 100644 --- a/packages/root/src/cli/dev.ts +++ b/packages/root/src/cli/dev.ts @@ -135,6 +135,13 @@ export async function createDevServer(options?: { {type: 'dev', rootConfig} ); + // Run any "startup" hooks. + for (const plugin of plugins) { + if (typeof plugin.hooks?.startup === 'function') { + await plugin.hooks.startup({command: 'dev', rootConfig}); + } + } + return server; } diff --git a/packages/root/src/cli/preview.ts b/packages/root/src/cli/preview.ts index ac993814..aadf3b15 100644 --- a/packages/root/src/cli/preview.ts +++ b/packages/root/src/cli/preview.ts @@ -120,6 +120,14 @@ export async function createPreviewServer(options: { plugins, {type: 'preview', rootConfig} ); + + // Run any "startup" hooks. + for (const plugin of plugins) { + if (typeof plugin.hooks?.startup === 'function') { + await plugin.hooks.startup({command: 'preview', rootConfig}); + } + } + return server; } diff --git a/packages/root/src/cli/start.ts b/packages/root/src/cli/start.ts index 9245ad6d..11a7d597 100644 --- a/packages/root/src/cli/start.ts +++ b/packages/root/src/cli/start.ts @@ -115,6 +115,14 @@ export async function createProdServer(options: { plugins, {type: 'prod', rootConfig} ); + + // Run any "startup" hooks. + for (const plugin of plugins) { + if (typeof plugin.hooks?.startup === 'function') { + await plugin.hooks.startup({command: 'start', rootConfig}); + } + } + return server; } diff --git a/packages/root/src/core/plugin.ts b/packages/root/src/core/plugin.ts index cbb18f28..81770811 100644 --- a/packages/root/src/core/plugin.ts +++ b/packages/root/src/core/plugin.ts @@ -15,12 +15,20 @@ export interface ConfigureServerOptions { } export interface PluginHooks { + /** + * Startup hook called when root is initialized. + */ + startup?: (options: { + command: string; + rootConfig: RootConfig; + }) => void | Promise; + /** * Post-render hook that's called before the HTML is rendered to the response * object. If a string is returned from this hook, it will replace the * rendered HTML. */ - preRender: (html: string) => void | string | Promise; + preRender?: (html: string) => void | string | Promise; } export interface Plugin { From f5bd04162d8da4afbd4485784c0c81f6abeebcf0 Mon Sep 17 00:00:00 2001 From: Steven Le Date: Sat, 24 Aug 2024 14:45:26 -0700 Subject: [PATCH 02/14] feat: [v2] add compatibility check for v2 translations (#412) --- .changeset/kind-pans-check.md | 5 ++ .changeset/strong-planes-float.md | 6 ++ packages/root-cms/core/client.ts | 15 +--- packages/root-cms/core/compatibility.ts | 102 ++++++++++++++++++++++++ packages/root-cms/core/plugin.ts | 25 +++++- packages/root/src/core/config.ts | 10 ++- 6 files changed, 147 insertions(+), 16 deletions(-) create mode 100644 .changeset/kind-pans-check.md create mode 100644 .changeset/strong-planes-float.md create mode 100644 packages/root-cms/core/compatibility.ts diff --git a/.changeset/kind-pans-check.md b/.changeset/kind-pans-check.md new file mode 100644 index 00000000..7186e830 --- /dev/null +++ b/.changeset/kind-pans-check.md @@ -0,0 +1,5 @@ +--- +'@blinkk/root-cms': patch +--- + +feat: [v2] store compatibility versions in db diff --git a/.changeset/strong-planes-float.md b/.changeset/strong-planes-float.md new file mode 100644 index 00000000..20b7ff3b --- /dev/null +++ b/.changeset/strong-planes-float.md @@ -0,0 +1,6 @@ +--- +'@blinkk/root-cms': patch +'@blinkk/root': patch +--- + +feat: [v2] add compatibility check for v2 translations diff --git a/packages/root-cms/core/client.ts b/packages/root-cms/core/client.ts index 5b16ef65..d33a37c1 100644 --- a/packages/root-cms/core/client.ts +++ b/packages/root-cms/core/client.ts @@ -1,5 +1,5 @@ import crypto from 'node:crypto'; -import {Plugin, RootConfig} from '@blinkk/root'; +import {RootConfig} from '@blinkk/root'; import {App} from 'firebase-admin/app'; import { FieldValue, @@ -8,7 +8,7 @@ import { Timestamp, WriteBatch, } from 'firebase-admin/firestore'; -import {CMSPlugin} from './plugin.js'; +import {CMSPlugin, getCmsPlugin} from './plugin.js'; export interface Doc { /** The id of the doc, e.g. "Pages/foo-bar". */ @@ -968,15 +968,6 @@ export function isRichTextData(data: any) { ); } -export function getCmsPlugin(rootConfig: RootConfig): CMSPlugin { - const plugins: Plugin[] = rootConfig.plugins || []; - const plugin = plugins.find((plugin) => plugin.name === 'root-cms'); - if (!plugin) { - throw new Error('could not find root-cms plugin config in root.config.ts'); - } - return plugin as CMSPlugin; -} - /** * Walks the data tree and converts any array of objects into "array objects" * for storage in firestore. @@ -1178,3 +1169,5 @@ export function parseDocId(docId: string) { } return {collection, slug}; } + +export {getCmsPlugin}; diff --git a/packages/root-cms/core/compatibility.ts b/packages/root-cms/core/compatibility.ts new file mode 100644 index 00000000..71056394 --- /dev/null +++ b/packages/root-cms/core/compatibility.ts @@ -0,0 +1,102 @@ +import {RootConfig} from '@blinkk/root'; +import {FieldValue} from 'firebase-admin/firestore'; +import type {CMSPlugin} from './plugin.js'; + +/** + * Runs compatibility checks to ensure the current version of root supports + * any backwards-incompatible db changes. + * + * Compatibility versions are stored in the db in Projects/ under + * the "compatibility" key. If the compatibility version is less than the + * latest version, a backwards-friendly "migration" is done to ensure + * compatibility on both the old and new versions. + */ +export async function runCompatibilityChecks( + rootConfig: RootConfig, + cmsPlugin: CMSPlugin +) { + const projectId = cmsPlugin.getConfig().id || 'default'; + const db = cmsPlugin.getFirestore(); + const projectConfigDocRef = db.doc(`Projects/${projectId}`); + const projectConfigDoc = await projectConfigDocRef.get(); + const projectConfig = projectConfigDoc.data() || {}; + + const compatibilityVersions = projectConfig.compatibility || {}; + let versionsChanged = false; + + const translationsVersion = compatibilityVersions.translations || 0; + if (translationsVersion < 2) { + await migrateTranslationsToV2(rootConfig, cmsPlugin); + compatibilityVersions.translations = 2; + versionsChanged = true; + } + + if (versionsChanged) { + await projectConfigDocRef.update({compatibility: compatibilityVersions}); + console.log('[root cms] updated db compatibility'); + } +} + +/** + * Migrates translations from the "v1" format to "v2". + */ +async function migrateTranslationsToV2( + rootConfig: RootConfig, + cmsPlugin: CMSPlugin +) { + if (rootConfig.experiments?.rootCmsDisableTranslationsToV2Check) { + return; + } + + const projectId = cmsPlugin.getConfig().id || 'default'; + const db = cmsPlugin.getFirestore(); + const dbPath = `Projects/${projectId}/Translations`; + const query = db.collection(dbPath); + const querySnapshot = await query.get(); + if (querySnapshot.size === 0) { + return; + } + + console.log('[root cms] updating translations v2 compatibility'); + const translationsMemories: Record> = {}; + querySnapshot.forEach((doc) => { + const hash = doc.id; + const translation = doc.data(); + const tags = translation.tags || []; + delete translation.tags; + for (const tag of tags) { + if (tag.includes('/')) { + const translationsMemoryId = tag.replaceAll('/', '--'); + translationsMemories[translationsMemoryId] ??= {}; + translationsMemories[translationsMemoryId][hash] = translation; + } + } + }); + + const batch = db.batch(); + Object.entries(translationsMemories).forEach( + ([translationsMemoryId, strings]) => { + const updates = { + sys: { + modifiedAt: FieldValue.serverTimestamp(), + modifiedBy: 'root-cms-client', + }, + strings: strings, + }; + const draftRef = db.doc( + `Projects/${projectId}/TranslationsMemory/draft/Translations/${translationsMemoryId}` + ); + const publishedRef = db.doc( + `Projects/${projectId}/TranslationsMemory/published/Translations/${translationsMemoryId}` + ); + batch.set(draftRef, updates, {merge: true}); + batch.set(publishedRef, updates, {merge: true}); + const len = Object.keys(strings).length; + console.log( + `[root cms] saving ${len} string(s) to ${translationsMemoryId}...` + ); + } + ); + await batch.commit(); + console.log('[root cms] done migrating translations to v2'); +} diff --git a/packages/root-cms/core/plugin.ts b/packages/root-cms/core/plugin.ts index 4fe55128..dcba7f38 100644 --- a/packages/root-cms/core/plugin.ts +++ b/packages/root-cms/core/plugin.ts @@ -8,6 +8,7 @@ import { Plugin, Request, Response, + RootConfig, Server, } from '@blinkk/root'; import bodyParser from 'body-parser'; @@ -21,9 +22,9 @@ import {getAuth, DecodedIdToken} from 'firebase-admin/auth'; import {Firestore, getFirestore} from 'firebase-admin/firestore'; import * as jsonwebtoken from 'jsonwebtoken'; import sirv from 'sirv'; -import {generateTypes} from '../cli/generate-types.js'; import {api} from './api.js'; import {Action, RootCMSClient} from './client.js'; +import {runCompatibilityChecks} from './compatibility.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -439,6 +440,19 @@ export function cmsPlugin(options: CMSPluginOptions): CMSPlugin { cms: path.resolve(__dirname, './app.js'), }), + hooks: { + /** + * Startup hook. On `dev` and `build` commands, the plugin does + * compatibility checks. + */ + startup: async ({command, rootConfig}) => { + if (command === 'dev' || command === 'build') { + const cmsPlugin = getCmsPlugin(rootConfig); + await runCompatibilityChecks(rootConfig, cmsPlugin); + } + }, + }, + /** * Attaches CMS-specific middleware to the Root.js server. */ @@ -607,3 +621,12 @@ function fileExists(filepath: string): Promise { .then(() => true) .catch(() => false); } + +export function getCmsPlugin(rootConfig: RootConfig): CMSPlugin { + const plugins: Plugin[] = rootConfig.plugins || []; + const plugin = plugins.find((plugin) => plugin.name === 'root-cms'); + if (!plugin) { + throw new Error('could not find root-cms plugin config in root.config.ts'); + } + return plugin as CMSPlugin; +} diff --git a/packages/root/src/core/config.ts b/packages/root/src/core/config.ts index a6ba937e..c8c8fa7d 100644 --- a/packages/root/src/core/config.ts +++ b/packages/root/src/core/config.ts @@ -4,6 +4,11 @@ import {HtmlPrettyOptions} from '../render/html-pretty.js'; import {Plugin} from './plugin.js'; import {RequestMiddleware} from './types.js'; +export interface RootExperimentsConfig { + [name: string]: boolean | undefined; + enableScriptAsync?: boolean; +} + export interface RootUserConfig { /** * Canonical domain the website will serve on. Useful for things like the @@ -83,10 +88,7 @@ export interface RootUserConfig { /** * Experimental config options. Note: these are subject to change at any time. */ - experiments?: { - /** Whether to render `