From e0284eb145c2f25f11ce55d8e509fd01a3616900 Mon Sep 17 00:00:00 2001 From: Akayy Date: Sat, 5 Oct 2024 21:16:50 +1000 Subject: [PATCH 1/3] feat: enhance-islands API --- packages/hydration/hydration.ts | 23 ++++++++++--------- packages/hydration/types.ts | 2 ++ packages/hydration/vue.ts | 11 +++++---- .../iles/src/client/app/components/Island.vue | 3 ++- packages/iles/src/node/alias.ts | 4 ++++ packages/iles/src/node/plugin/plugin.ts | 16 ++++++++++++- packages/iles/types/shared.d.ts | 10 +++++++- 7 files changed, 51 insertions(+), 18 deletions(-) diff --git a/packages/hydration/hydration.ts b/packages/hydration/hydration.ts index 6c4bfc68..19a734d8 100644 --- a/packages/hydration/hydration.ts +++ b/packages/hydration/hydration.ts @@ -1,38 +1,39 @@ -import { AsyncFrameworkFn, FrameworkFn, Component, AsyncComponent, Props, Slots } from './types' +import type { AsyncComponent, AsyncFrameworkFn, Component, EnhanceIslands, FrameworkFn, Props, Slots } from './types' + export { Framework, Props, Slots } from './types' const findById = (id: string) => document.getElementById(id) || console.error(`Missing #${id}, could not mount island.`) // Public: Hydrates the component immediately. -export function hydrateNow (framework: FrameworkFn, component: Component, id: string, props: Props, slots: Slots) { +export function hydrateNow (framework: FrameworkFn, component: Component, id: string, props: Props, slots: Slots, enhanceIslands: EnhanceIslands) { const el = findById(id) if (el) { - framework(component, id, el, props, slots) + framework(component, id, el, props, slots, enhanceIslands) el.setAttribute('hydrated', '') } } -async function resolveAndHydrate (frameworkFn: AsyncFrameworkFn, componentFn: AsyncComponent, id: string, props: Props, slots: Slots) { +async function resolveAndHydrate (frameworkFn: AsyncFrameworkFn, componentFn: AsyncComponent, id: string, props: Props, slots: Slots, enhanceIslands: EnhanceIslands) { const [framework, component] = await Promise.all([frameworkFn(), componentFn()]) - hydrateNow(framework, component, id, props, slots) + hydrateNow(framework, component, id, props, slots, enhanceIslands) } // Public: Hydrate this component as soon as the main thread is free. // If `requestIdleCallback` isn't supported, it uses a small delay. -export function hydrateWhenIdle (framework: AsyncFrameworkFn, component: AsyncComponent, id: string, props: Props, slots: Slots) { +export function hydrateWhenIdle (framework: AsyncFrameworkFn, component: AsyncComponent, id: string, props: Props, slots: Slots, enhanceIslands: EnhanceIslands) { const whenIdle = window.requestIdleCallback || setTimeout const cancelIdle = window.cancelIdleCallback || clearTimeout const idleId: any = whenIdle(() => - resolveAndHydrate(framework, component, id, props, slots)) + resolveAndHydrate(framework, component, id, props, slots, enhanceIslands)) if (import.meta.env.DISPOSE_ISLANDS) onDispose(id, () => cancelIdle(idleId)) } // Public: Hydrate this component when the specified media query is matched. -export function hydrateOnMediaQuery (framework: AsyncFrameworkFn, component: AsyncComponent, id: string, props: Props, slots: Slots) { +export function hydrateOnMediaQuery (framework: AsyncFrameworkFn, component: AsyncComponent, id: string, props: Props, slots: Slots, enhanceIslands: EnhanceIslands) { const mediaQuery = matchMedia(props._mediaQuery as string) delete props._mediaQuery @@ -40,7 +41,7 @@ export function hydrateOnMediaQuery (framework: AsyncFrameworkFn, component: Asy const hydrate = () => { onChange() - resolveAndHydrate(framework, component, id, props, slots) + resolveAndHydrate(framework, component, id, props, slots, enhanceIslands) } mediaQuery.matches ? hydrate() : onChange(hydrate) @@ -50,7 +51,7 @@ export function hydrateOnMediaQuery (framework: AsyncFrameworkFn, component: Asy } // Public: Hydrate this component when one of it's children becomes visible. -export function hydrateWhenVisible (framework: AsyncFrameworkFn, component: AsyncComponent, id: string, props: Props, slots: Slots) { +export function hydrateWhenVisible (framework: AsyncFrameworkFn, component: AsyncComponent, id: string, props: Props, slots: Slots, enhanceIslands: EnhanceIslands) { const el = findById(id) if (el) { // NOTE: Force detection of the element for non-Vue frameworks. @@ -65,7 +66,7 @@ export function hydrateWhenVisible (framework: AsyncFrameworkFn, component: Asyn if (import.meta.env.DEV) el.style.display = '' - resolveAndHydrate(framework, component, id, props, slots) + resolveAndHydrate(framework, component, id, props, slots, enhanceIslands) } }) const stopObserver = () => observer.disconnect() diff --git a/packages/hydration/types.ts b/packages/hydration/types.ts index ddf1e8d2..008e0ef2 100644 --- a/packages/hydration/types.ts +++ b/packages/hydration/types.ts @@ -2,6 +2,8 @@ import type Vue from './vue' +export type { EnhanceIslands } from '../iles/types/shared' + export type Framework = 'vue' | 'preact' | 'solid' | 'svelte' | 'vanilla' export type FrameworkFn = typeof Vue export type AsyncFrameworkFn = () => Promise diff --git a/packages/hydration/vue.ts b/packages/hydration/vue.ts index 1948c7dc..3d9100ce 100644 --- a/packages/hydration/vue.ts +++ b/packages/hydration/vue.ts @@ -1,12 +1,12 @@ -import { h, createApp as createClientApp, createStaticVNode, createSSRApp } from 'vue' -import type { DefineComponent as Component, Component as App } from 'vue' -import type { Props, Slots } from './types' +import { createApp as createClientApp, createSSRApp, createStaticVNode, h } from 'vue' +import type { Component as App, DefineComponent as Component } from 'vue' +import type { EnhanceIslands, Props, Slots } from './types' import { onDispose } from './hydration' const createVueApp = import.meta.env.SSR ? createSSRApp : createClientApp // Internal: Creates a Vue app and mounts it on the specified island root. -export default function createVueIsland (component: Component, id: string, el: Element, props: Props, slots: Slots | undefined) { +export default async function createVueIsland (component: Component, id: string, el: Element, props: Props, slots: Slots | undefined, enhanceIslands: EnhanceIslands) { const slotFns = slots && Object.fromEntries(Object.entries(slots).map(([slotName, content]) => { return [slotName, () => (createStaticVNode as any)(content)] })) @@ -17,6 +17,9 @@ export default function createVueIsland (component: Component, id: string, el: E appDefinition.name = `Island: ${nameFromFile(component.__file)}` const app = createVueApp(appDefinition) + if (enhanceIslands) + await enhanceIslands({ app }) + app.mount(el!, Boolean(slots)) if (import.meta.env.DISPOSE_ISLANDS) diff --git a/packages/iles/src/client/app/components/Island.vue b/packages/iles/src/client/app/components/Island.vue index d8978932..98e5d3a1 100644 --- a/packages/iles/src/client/app/components/Island.vue +++ b/packages/iles/src/client/app/components/Island.vue @@ -94,13 +94,14 @@ export default defineComponent({ const frameworkPath = `${hydrationPkg}/${this.framework}` return `import { ${hydrationFns[this.strategy]} as hydrate } from '${hydrationPkg}' +import { enhanceIslands } from '/@id/virtual:enhance-islands' ${isEager(this.strategy) ? `import framework from '${frameworkPath}' import { ${this.importName} as component } from '${componentPath}'` : `const framework = async () => (await import('${frameworkPath}')).default const component = async () => (await import('${componentPath}')).${this.importName}` } -hydrate(framework, component, '${this.id}', ${serialize(props)}, ${serialize(slots)}) +hydrate(framework, component, '${this.id}', ${serialize(props)}, ${serialize(slots)}, enhanceIslands) ` } diff --git a/packages/iles/src/node/alias.ts b/packages/iles/src/node/alias.ts index aa5fa37d..bf48f190 100644 --- a/packages/iles/src/node/alias.ts +++ b/packages/iles/src/node/alias.ts @@ -31,6 +31,9 @@ export const APP_CONFIG_REQUEST_PATH = `/${APP_CONFIG_ID}` export const USER_APP_ID = '@islands/user-app' export const USER_APP_REQUEST_PATH = `/${USER_APP_ID}` +export const USER_APP_ENHANCE_ISLANDS = 'virtual:enhance-islands' +export const USER_APP_ENHANCE_ISLANDS_RESOLVED = `\0${USER_APP_ENHANCE_ISLANDS}` + export const USER_SITE_ID = '@islands/user-site' export const USER_SITE_REQUEST_PATH = `/${USER_SITE_ID}` @@ -40,6 +43,7 @@ export function resolveAliases (root: string, userConfig: UserConfig): AliasOpti const paths: Record = { '/@shared': SHARED_PATH, [USER_APP_ID]: USER_APP_REQUEST_PATH, + [USER_APP_ENHANCE_ISLANDS]: USER_APP_ENHANCE_ISLANDS_RESOLVED, [USER_SITE_ID]: USER_SITE_REQUEST_PATH, [APP_CONFIG_ID]: APP_CONFIG_REQUEST_PATH, } diff --git a/packages/iles/src/node/plugin/plugin.ts b/packages/iles/src/node/plugin/plugin.ts index 5b29d434..ad0b943d 100644 --- a/packages/iles/src/node/plugin/plugin.ts +++ b/packages/iles/src/node/plugin/plugin.ts @@ -7,7 +7,7 @@ import MagicString from 'magic-string' import type { AppConfig, AppClientConfig } from '../shared' import { ILES_APP_ENTRY } from '../constants' -import { APP_PATH, APP_COMPONENT_PATH, USER_APP_REQUEST_PATH, USER_SITE_REQUEST_PATH, APP_CONFIG_REQUEST_PATH, NOT_FOUND_COMPONENT_PATH, NOT_FOUND_REQUEST_PATH, DEBUG_COMPONENT_PATH } from '../alias' +import { APP_COMPONENT_PATH, APP_CONFIG_REQUEST_PATH, APP_PATH, DEBUG_COMPONENT_PATH, NOT_FOUND_COMPONENT_PATH, NOT_FOUND_REQUEST_PATH, USER_APP_ENHANCE_ISLANDS, USER_APP_ENHANCE_ISLANDS_RESOLVED, USER_APP_REQUEST_PATH, USER_SITE_REQUEST_PATH } from '../alias' import { configureMiddleware } from './middleware' import { serialize, pascalCase, exists, debug } from './utils' import { parseId } from './parse' @@ -149,6 +149,20 @@ export default function IslandsPlugins (appConfig: AppConfig): PluginOption[] { return wrapLayout(code, path) }, }, + { + name: 'iles:enhance-islands', + resolveId(id) { + if (id === USER_APP_ENHANCE_ISLANDS) { + return USER_APP_ENHANCE_ISLANDS_RESOLVED + } + }, + load(id) { + if (id === USER_APP_ENHANCE_ISLANDS_RESOLVED) { + return `import userApp from '@islands/user-app' +export const { enhanceIslands } = userApp` + } + }, + }, plugins.vue, ...appConfig.vitePlugins, diff --git a/packages/iles/types/shared.d.ts b/packages/iles/types/shared.d.ts index 5c23cf59..30a0afce 100644 --- a/packages/iles/types/shared.d.ts +++ b/packages/iles/types/shared.d.ts @@ -80,6 +80,10 @@ export interface AppContext extends PageData { routes: RouteRecordRaw[] } +export interface IslandContext { + app: App +} + export interface StaticPath> { params: RouteParams props: T @@ -181,11 +185,15 @@ export interface IlesModule extends Partial { } export type EnhanceAppContext = AppContext +export type EnhanceApp = (ctx: EnhanceAppContext) => void | Promise +export type EnhanceIslandContext = IslandContext +export type EnhanceIslands = (ctx: EnhanceIslandContext) => void | Promise export type MDXComponents = Record export interface UserApp { head?: HeadConfig | ((ctx: EnhanceAppContext) => HeadConfig) - enhanceApp?: (ctx: EnhanceAppContext) => void | Promise + enhanceApp?: EnhanceApp + enhanceIslands?: EnhanceIslands mdxComponents?: MDXComponents | ((ctx: EnhanceAppContext) => MDXComponents | Promise) router?: Omit socialTags?: boolean From 5b8f36e83544084299c20c17413d73c3e0c2152a Mon Sep 17 00:00:00 2001 From: Akayy Date: Sat, 5 Oct 2024 23:54:24 +1000 Subject: [PATCH 2/3] refactor: enhance-islands API --- packages/iles/src/client/app/components/Island.vue | 3 ++- packages/iles/src/node/alias.ts | 6 +++++- packages/iles/src/node/config.ts | 1 + packages/iles/src/node/plugin/plugin.ts | 4 ++-- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/iles/src/client/app/components/Island.vue b/packages/iles/src/client/app/components/Island.vue index 98e5d3a1..5ac60eea 100644 --- a/packages/iles/src/client/app/components/Island.vue +++ b/packages/iles/src/client/app/components/Island.vue @@ -94,7 +94,8 @@ export default defineComponent({ const frameworkPath = `${hydrationPkg}/${this.framework}` return `import { ${hydrationFns[this.strategy]} as hydrate } from '${hydrationPkg}' -import { enhanceIslands } from '/@id/virtual:enhance-islands' +import userApp from '/@id/virtual:user-app' +const { enhanceIslands } = userApp ${isEager(this.strategy) ? `import framework from '${frameworkPath}' import { ${this.importName} as component } from '${componentPath}'` diff --git a/packages/iles/src/node/alias.ts b/packages/iles/src/node/alias.ts index bf48f190..bde99bfa 100644 --- a/packages/iles/src/node/alias.ts +++ b/packages/iles/src/node/alias.ts @@ -31,7 +31,7 @@ export const APP_CONFIG_REQUEST_PATH = `/${APP_CONFIG_ID}` export const USER_APP_ID = '@islands/user-app' export const USER_APP_REQUEST_PATH = `/${USER_APP_ID}` -export const USER_APP_ENHANCE_ISLANDS = 'virtual:enhance-islands' +export const USER_APP_ENHANCE_ISLANDS = 'virtual:user-app' export const USER_APP_ENHANCE_ISLANDS_RESOLVED = `\0${USER_APP_ENHANCE_ISLANDS}` export const USER_SITE_ID = '@islands/user-site' @@ -78,6 +78,10 @@ export function resolveAliases (root: string, userConfig: UserConfig): AliasOpti 'vue-router/dist/vue-router.esm-bundler.js', ), }, + { + find: /^\/@id\/virtual:user-app$/, + replacement: require.resolve(`${resolve(root, srcDir)}/app.ts`), + }, { find: /^@islands\/hydration$/, replacement: require.resolve('@islands/hydration'), diff --git a/packages/iles/src/node/config.ts b/packages/iles/src/node/config.ts index 8a5d84f0..9bd1d0d1 100644 --- a/packages/iles/src/node/config.ts +++ b/packages/iles/src/node/config.ts @@ -310,6 +310,7 @@ function viteConfigDefaults (root: string, userConfig: UserConfig): ViteOptions '@islands/hydration', '@islands/prerender', 'vue/server-renderer', + 'virtual:user-app', ], }, } diff --git a/packages/iles/src/node/plugin/plugin.ts b/packages/iles/src/node/plugin/plugin.ts index ad0b943d..58e367f8 100644 --- a/packages/iles/src/node/plugin/plugin.ts +++ b/packages/iles/src/node/plugin/plugin.ts @@ -158,8 +158,8 @@ export default function IslandsPlugins (appConfig: AppConfig): PluginOption[] { }, load(id) { if (id === USER_APP_ENHANCE_ISLANDS_RESOLVED) { - return `import userApp from '@islands/user-app' -export const { enhanceIslands } = userApp` + return `import userApp from "${appPath.replace('.ts', '')}" +export default userApp` } }, }, From cb2e96a06564c0f3edcf2447dfc78a8bd170fb94 Mon Sep 17 00:00:00 2001 From: Akayy Date: Sun, 6 Oct 2024 10:18:04 +1100 Subject: [PATCH 3/3] enhanceIslands (docs) --- docs/src/pages/config/index.mdx | 77 +++++++++++++++++++++++++-------- 1 file changed, 60 insertions(+), 17 deletions(-) diff --git a/docs/src/pages/config/index.mdx b/docs/src/pages/config/index.mdx index a9232c33..2c7201fa 100644 --- a/docs/src/pages/config/index.mdx +++ b/docs/src/pages/config/index.mdx @@ -18,6 +18,7 @@ sidebar: auto [documents]: /guide/documents [drafts]: /guide/markdown [useDocuments]: /guide/documents +[discussion]: https://github.com/ElMassimo/iles/discussions/6#discussioncomment-1479755 # Configuration @@ -158,16 +159,23 @@ Whether to output more information about islands and hydration in development. ## Your App - will pre-configure a Vue 3 app that will load any [pages] defined in the -site. + will pre-configure a Vue 3 shell app to enhance your development experience that will load any [pages] defined in your site. -You may provide additional configuration in `src/app.ts`, and leverage -intellisense by using the `defineApp` helper. +Note that this "outer" shell app is used only during development and is not available once your site is built. + +You may provide additional configuration in `src/app.ts` by using the `defineApp` helper. These additional configuration can be for the shell app to customise your development/build logic, as well as for your whole site and the islands within your site. ```ts import { defineApp } from 'iles' export default defineApp({ + enhanceApp ({ app, head, router }) { + // Configure the shell app to customise with development/build logic + }, + enhanceIslands ({ app }) { + // Configure all Vue Islands to add Vue plugins + app.use(pinia) + }, head ({ frontmatter, site }) { return { meta: [ @@ -176,14 +184,56 @@ export default defineApp({ ] } }, - enhanceApp ({ app, head, router }) { - // Configure the app to add plugins. - }, router: { scrollBehavior (current, previous, savedPosition) { // Configure the scroll behavior }, }, + mdxComponents: { + b: 'strong', + img: Image, + }, + socialTags: true // default +}) +``` + +### `enhanceApp` (Development only) + +- **Type:** `(context: AppContext) => void | Promise` + +A hook where you can add additional development/build logic. See this [discussion] thread for few suggestions. + +### `enhanceIslands` (Vue Islands only) + +- **Type:** `(context: IslandContext) => void | Promise` + +A hook where you can extend all your Vue Islands in your site with plugins such as pinia, i18n, vuetify, etc. + +The hook will be invoked for every Vue Island in your site. + +```ts +import { defineApp } from 'iles' +import { createI18n } from 'vue-i18n' +import { createPinia } from 'pinia' +import { createVuetify } from 'vuetify' + +const i18n = createI18n({ + // vue-i18n options ... +}) +const pinia = createPinia() +const vuetify = createVuetify({ + // vuetify options ... +}) + +export default defineApp({ + enhanceIslands({ app }) { + app.use(i18n) + app.use(pinia) + if(app._component.name === 'Island: ChatboxIsland') { + // To initialise Vuetify only within ChatboxIsland.vue + app.use(vuetify) + } + }, }) ``` @@ -193,12 +243,11 @@ export default defineApp({ Set the page title, meta tags, additional CSS, or scripts using [`@unhead/vue`][@unhead/vue]. -### `enhanceApp` +### `router` -- **Type:** `(context: AppContext) => Promise` +- **Type:** `RouterOptions` -A hook where you can add plugins to the Vue app, or do anything else prior to -the app being mounted. +Configure [`vue-router`][vue-router] by providing options such as `scrollBehavior` and `linkActiveClass`. ### `mdxComponents` @@ -218,12 +267,6 @@ export default defineApp({ }) ``` -### `router` - -- **Type:** `RouterOptions` - -Configure [`vue-router`][vue-router] by providing options such as `scrollBehavior` and `linkActiveClass`. - ### `socialTags` - **Type:** `boolean`