From 5d0d626eeff2bf7db73f73275728ba7c3e15757e Mon Sep 17 00:00:00 2001 From: pengzhanbo Date: Mon, 22 Jul 2024 00:42:41 +0800 Subject: [PATCH] feat(theme): add support for `iconify` localization --- .../src/node/features/icons/writer.ts | 58 ++++-- pnpm-lock.yaml | 9 +- theme/package.json | 1 - theme/src/client/components/VPIcon.vue | 66 +++++- theme/src/client/components/VPIconify.vue | 75 +++++++ .../src/client/components/VPSidebarGroup.vue | 1 + theme/src/client/composables/icons.ts | 16 ++ theme/src/client/composables/index.ts | 1 + theme/src/client/globalComponents.ts | 10 +- theme/src/client/shim.d.ts | 7 + theme/src/client/styles/code.css | 2 +- theme/src/client/styles/icons.css | 4 + theme/src/node/plugins/getPlugins.ts | 2 - theme/src/node/prepare/index.ts | 2 + theme/src/node/prepare/prepareIcons.ts | 196 ++++++++++++++++++ theme/src/node/utils/index.ts | 1 + theme/src/node/utils/interopDefault.ts | 6 + 17 files changed, 409 insertions(+), 48 deletions(-) create mode 100644 theme/src/client/components/VPIconify.vue create mode 100644 theme/src/client/composables/icons.ts create mode 100644 theme/src/node/prepare/prepareIcons.ts create mode 100644 theme/src/node/utils/interopDefault.ts diff --git a/plugins/plugin-md-power/src/node/features/icons/writer.ts b/plugins/plugin-md-power/src/node/features/icons/writer.ts index 5ee96470..44388a81 100644 --- a/plugins/plugin-md-power/src/node/features/icons/writer.ts +++ b/plugins/plugin-md-power/src/node/features/icons/writer.ts @@ -10,6 +10,7 @@ import { parseRect } from '../../utils/parseRect.js' export interface IconCacheItem { className: string + background: boolean content: string } @@ -18,6 +19,8 @@ const iconDataCache = new Map() const URL_CONTENT_RE = /(url\([\s\S]+?\))/ const CSS_PATH = 'internal/md-power/icons.css' +let locate: ((name: string) => any) | undefined + function resolveOption(opt?: boolean | IconsOptions): Required { const options = typeof opt === 'object' ? opt : {} options.prefix ??= 'vp-mdi' @@ -65,19 +68,18 @@ export function createIconCSSWriter(app: App, opt?: boolean | IconsOptions) { if (!isInstalled) return - if (cache.has(iconName)) - return cache.get(iconName)!.className + if (cache.has(iconName)) { + const item = cache.get(iconName)! + return `${item.className}${item.background ? ' bg' : ''}` + } const item: IconCacheItem = { className: `${prefix}-${nanoid()}`, - content: '', + ...genIcon(iconName), } cache.set(iconName, item) - genIconContent(iconName, (content) => { - item.content = content - writeCss() - }) - return item.className + writeCss() + return `${item.className}${item.background ? ' bg' : ''}` } async function initIcon() { @@ -89,6 +91,11 @@ export function createIconCSSWriter(app: App, opt?: boolean | IconsOptions) { return } + if (!locate) { + const mod = await interopDefault(import('@iconify/json')) + locate = mod.locate + } + return await writeCss() } @@ -97,12 +104,13 @@ export function createIconCSSWriter(app: App, opt?: boolean | IconsOptions) { function getDefaultContent(options: Required) { const { prefix, size, color } = options - return `[class^="${prefix}-"], -[class*=" ${prefix}-"] { + return `[class^="${prefix}-"] { display: inline-block; width: ${size}; height: ${size}; vertical-align: middle; +} +[class^="${prefix}-"]:not(.bg) { color: inherit; background-color: ${color}; -webkit-mask: var(--svg) no-repeat; @@ -110,24 +118,29 @@ function getDefaultContent(options: Required) { -webkit-mask-size: 100% 100%; mask-size: 100% 100%; } +[class^="${prefix}-"].bg { + background-color: transparent; + background-image: var(--svg); + background-repeat: no-repeat; + background-size: 100% 100%; +} ` } -let locate: ((name: string) => any) | undefined - -async function genIconContent(iconName: string, cb: (content: string) => void) { +function genIcon(iconName: string): { + content: string + background: boolean +} { if (!locate) { - const mod = await interopDefault(import('@iconify/json')) - locate = mod.locate + return { content: '', background: false } } - const [collect, name] = iconName.split(':') let iconJson: any = iconDataCache.get(collect) if (!iconJson) { const filename = locate(collect) try { - iconJson = JSON.parse(await fs.readFile(filename, 'utf-8')) + iconJson = JSON.parse(fs.readFileSync(filename, 'utf-8')) iconDataCache.set(collect, iconJson) } catch { @@ -135,14 +148,19 @@ async function genIconContent(iconName: string, cb: (content: string) => void) { } } const data = getIconData(iconJson, name) - if (!data) - return logger.error(`[plugin-md-power] Can not read icon in ${collect}, ${name} is missing!`) + if (!data) { + logger.error(`[plugin-md-power] Can not read icon in ${collect}, ${name} is missing!`) + return { content: '', background: false } + } const content = getIconContentCSS(data, { height: data.height || 24, }) const match = content.match(URL_CONTENT_RE) - return cb(match ? match[1] : '') + return { + content: match ? match[1] : '', + background: !data.body.includes('currentColor'), + } } function existsSync(fp: string) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a1528d53..f06d3b91 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -274,9 +274,6 @@ importers: '@vuepress-plume/plugin-fonts': specifier: workspace:* version: link:../plugins/plugin-fonts - '@vuepress-plume/plugin-iconify': - specifier: workspace:* - version: link:../plugins/plugin-iconify '@vuepress-plume/plugin-search': specifier: workspace:* version: link:../plugins/plugin-search @@ -403,9 +400,6 @@ packages: peerDependencies: '@algolia/client-search': '>= 4.9.1 < 6' algoliasearch: '>= 4.9.1 < 6' - peerDependenciesMeta: - '@algolia/client-search': - optional: true '@algolia/cache-browser-local-storage@4.20.0': resolution: {integrity: sha512-uujahcBt4DxduBTvYdwO3sBfHuJvJokiC3BP1+O70fglmE1ShkH8lpXqZBac1rrU3FnNYSUs4pL9lBdTKeRPOQ==} @@ -5708,9 +5702,8 @@ snapshots: '@algolia/autocomplete-shared@1.9.3(@algolia/client-search@4.20.0)(algoliasearch@4.20.0)': dependencies: - algoliasearch: 4.20.0 - optionalDependencies: '@algolia/client-search': 4.20.0 + algoliasearch: 4.20.0 '@algolia/cache-browser-local-storage@4.20.0': dependencies: diff --git a/theme/package.json b/theme/package.json index bd70097e..6c958d2b 100644 --- a/theme/package.json +++ b/theme/package.json @@ -73,7 +73,6 @@ "@pengzhanbo/utils": "^1.1.2", "@vuepress-plume/plugin-content-update": "workspace:*", "@vuepress-plume/plugin-fonts": "workspace:*", - "@vuepress-plume/plugin-iconify": "workspace:*", "@vuepress-plume/plugin-search": "workspace:*", "@vuepress-plume/plugin-shikiji": "workspace:*", "@vuepress/helper": "2.0.0-rc.39", diff --git a/theme/src/client/components/VPIcon.vue b/theme/src/client/components/VPIcon.vue index 5a587d9a..d8ec94c8 100644 --- a/theme/src/client/components/VPIcon.vue +++ b/theme/src/client/components/VPIcon.vue @@ -2,35 +2,83 @@ import { computed } from 'vue' import { isLinkHttp } from 'vuepress/shared' import { withBase } from 'vuepress/client' +import VPIconify from '@theme/VPIconify.vue' +import { useIconsData } from '../composables/index.js' const props = defineProps<{ name: string | { svg: string } + size?: string | number + color?: string }>() -const isLink = computed(() => - typeof props.name === 'string' && (isLinkHttp(props.name) || props.name[0] === '/'), -) -const isSvg = computed(() => typeof props.name === 'object' && !!props.name.svg) +const iconsData = useIconsData() + +const type = computed(() => { + if (typeof props.name === 'string' && (isLinkHttp(props.name) || props.name[0] === '/')) { + return 'link' + } + if (typeof props.name === 'object' && !!props.name.svg) { + return 'svg' + } + if (typeof props.name === 'string' && iconsData.value[props.name]) { + return 'local' + } + return 'remote' +}) const svg = computed(() => { - if (isSvg.value) + if (type.value === 'svg') return (props.name as { svg: string }).svg return '' }) const link = computed(() => { - if (isLink.value) { + if (type.value === 'link') { const link = props.name as string return isLinkHttp(link) ? link : withBase(link) } return '' }) + +const className = computed(() => { + if (type.value === 'local') { + const name = props.name as string + return iconsData.value[name] || '' + } + return '' +}) + +const size = computed(() => { + const size = props.size + if (!size) + return undefined + if (String(Number(size)) === size) + return `${size}px` + + return size +}) + +const style = computed(() => ({ + 'background-color': props.color, + 'width': size.value, + 'height': size.value, +})) diff --git a/theme/src/client/components/VPSidebarGroup.vue b/theme/src/client/components/VPSidebarGroup.vue index 502b9179..d34683d8 100644 --- a/theme/src/client/components/VPSidebarGroup.vue +++ b/theme/src/client/components/VPSidebarGroup.vue @@ -44,6 +44,7 @@ onBeforeUnmount(() => { .group + .group { padding-top: 10px; border-top: 1px solid var(--vp-c-divider); + transition: border var(--t-color); } @media (min-width: 960px) { diff --git a/theme/src/client/composables/icons.ts b/theme/src/client/composables/icons.ts new file mode 100644 index 00000000..d382fcbf --- /dev/null +++ b/theme/src/client/composables/icons.ts @@ -0,0 +1,16 @@ +import { icons } from '@internal/iconify' +import { ref } from 'vue' +import type { Ref } from 'vue' + +type IconsData = Record +type IconsDataRef = Ref + +const iconsData: IconsDataRef = ref(icons) + +export const useIconsData = (): IconsDataRef => iconsData + +if (__VUEPRESS_DEV__ && (import.meta.webpackHot || import.meta.hot)) { + __VUE_HMR_RUNTIME__.updateIcons = (data: IconsData) => { + iconsData.value = data + } +} diff --git a/theme/src/client/composables/index.ts b/theme/src/client/composables/index.ts index 162d5cb1..7610b76b 100644 --- a/theme/src/client/composables/index.ts +++ b/theme/src/client/composables/index.ts @@ -1,6 +1,7 @@ export * from './theme-data.js' export * from './dark-mode.js' export * from './data.js' +export * from './icons.js' export * from './scroll-promise.js' export * from './scroll-behavior.js' diff --git a/theme/src/client/globalComponents.ts b/theme/src/client/globalComponents.ts index 2c619d18..4f099711 100644 --- a/theme/src/client/globalComponents.ts +++ b/theme/src/client/globalComponents.ts @@ -5,6 +5,7 @@ import VPCard from '@theme/global/VPCard.vue' import VPLinkCard from '@theme/global/VPLinkCard.vue' import VPBadge from '@theme/global/VPBadge.vue' import VPCardGrid from '@theme/global/VPCardGrid.vue' +import VPIcon from '@theme/VPIcon.vue' export function globalComponents(app: App) { app.component('Badge', VPBadge) @@ -36,13 +37,8 @@ export function globalComponents(app: App) { return null }) - app.component('Icon', (props) => { - const Iconify = app.component('Iconify') - if (Iconify) - return h(Iconify, props) - - return null - }) + app.component('Icon', VPIcon) + app.component('VPIcon', VPIcon) /** @deprecated */ app.component('HomeBox', VPHomeBox) diff --git a/theme/src/client/shim.d.ts b/theme/src/client/shim.d.ts index 50d20c20..e456184f 100644 --- a/theme/src/client/shim.d.ts +++ b/theme/src/client/shim.d.ts @@ -58,3 +58,10 @@ declare module '@internal/encrypt' { encrypt, } } + +declare module '@internal/iconify' { + const icons: Record + export { + icons, + } +} diff --git a/theme/src/client/styles/code.css b/theme/src/client/styles/code.css index b66be19d..dcdd388a 100644 --- a/theme/src/client/styles/code.css +++ b/theme/src/client/styles/code.css @@ -12,7 +12,7 @@ html:not(.dark) .vp-code span { margin: 16px -24px; overflow-x: auto; background-color: var(--vp-code-block-bg); - transition: background-color 0.5s; + transition: background-color var(--t-color); } @media (min-width: 640px) { diff --git a/theme/src/client/styles/icons.css b/theme/src/client/styles/icons.css index 689d65d3..97dcdf79 100644 --- a/theme/src/client/styles/icons.css +++ b/theme/src/client/styles/icons.css @@ -1,14 +1,18 @@ [class^="vpi-"], [class*=" vpi-"], .vp-icon { + display: inline-block; width: 1em; height: 1em; + vertical-align: middle; } [class^="vpi-"].bg, [class*=" vpi-"].bg, .vp-icon.bg { background-color: transparent; + background-image: var(--icon); + background-repeat: no-repeat; background-size: 100% 100%; } diff --git a/theme/src/node/plugins/getPlugins.ts b/theme/src/node/plugins/getPlugins.ts index 02db9c3d..9bae6cfe 100644 --- a/theme/src/node/plugins/getPlugins.ts +++ b/theme/src/node/plugins/getPlugins.ts @@ -5,7 +5,6 @@ import { docsearchPlugin } from '@vuepress/plugin-docsearch' import { gitPlugin } from '@vuepress/plugin-git' import { photoSwipePlugin } from '@vuepress/plugin-photo-swipe' import { nprogressPlugin } from '@vuepress/plugin-nprogress' -import { iconifyPlugin } from '@vuepress-plume/plugin-iconify' import { shikiPlugin } from '@vuepress-plume/plugin-shikiji' import { commentPlugin } from '@vuepress/plugin-comment' import { type MarkdownEnhancePluginOptions, mdEnhancePlugin } from 'vuepress-plugin-md-enhance' @@ -41,7 +40,6 @@ export function getPlugins({ const plugins: PluginConfig = [ - iconifyPlugin(), fontsPlugin(), contentUpdatePlugin(), activeHeaderLinksPlugin({ diff --git a/theme/src/node/prepare/index.ts b/theme/src/node/prepare/index.ts index ca117c99..00a39ca7 100644 --- a/theme/src/node/prepare/index.ts +++ b/theme/src/node/prepare/index.ts @@ -5,6 +5,7 @@ import { prepareArticleTagColors } from './prepareArticleTagColor.js' import { preparedBlogData } from './prepareBlogData.js' import { prepareEncrypt } from './prepareEncrypt.js' import { prepareSidebar } from './prepareSidebar.js' +import { prepareIcons } from './prepareIcons.js' export async function prepareData( app: App, @@ -15,6 +16,7 @@ export async function prepareData( preparedBlogData(app, localeOptions, encrypt), prepareSidebar(app, localeOptions), prepareEncrypt(app, encrypt), + prepareIcons(app, localeOptions), ]) } diff --git a/theme/src/node/prepare/prepareIcons.ts b/theme/src/node/prepare/prepareIcons.ts new file mode 100644 index 00000000..44dd6120 --- /dev/null +++ b/theme/src/node/prepare/prepareIcons.ts @@ -0,0 +1,196 @@ +import type { App, Page } from 'vuepress' +import { isArray, isString, uniq } from '@pengzhanbo/utils' +import { fs } from 'vuepress/utils' +import { entries, isLinkAbsolute, isLinkHttp, isPlainObject } from '@vuepress/helper' +import { isPackageExists } from 'local-pkg' +import { getIconContentCSS, getIconData } from '@iconify/utils' +import type { NavItem, PlumeThemeLocaleOptions, Sidebar } from '../../shared/index.js' +import { interopDefault, logger, nanoid, resolveContent, writeTemp } from '../utils/index.js' + +interface IconData { + className: string + background?: boolean + content: string +} + +type CollectMap = Record +type IconDataMap = Record + +const ICON_REGEXP = /<(?:VP)?Icon(?:ify)?([^>]*)>/g +const ICON_NAME_REGEXP = /name="([^"]+)"/ +const URL_CONTENT_REGEXP = /(url\([\s\S]+\))/ +const JS_FILENAME = 'internal/iconify.js' +const CSS_FILENAME = 'internal/iconify.css' + +const isInstalled = isPackageExists('@iconify/json') +let locate!: ((name: string) => any) + +// { iconName: { className, content } } +const cache: IconDataMap = {} + +export async function prepareIcons(app: App, localeOptions: PlumeThemeLocaleOptions) { + if (!isInstalled) { + await writeTemp(app, JS_FILENAME, resolveContent(app, { name: 'icons', content: '{}' })) + return + } + + const iconList: string[] = [] + app.pages.forEach(page => iconList.push(...getIconsWithPage(page))) + iconList.push(...getIconWithThemeConfig(localeOptions)) + + const collectMap: CollectMap = {} + uniq(iconList).filter(icon => !cache[icon]).forEach((iconName) => { + const [collect, name] = iconName.split(':') + if (!collectMap[collect]) + collectMap[collect] = [] + + collectMap[collect].push(name) + }) + + if (!locate) { + const mod = await interopDefault(import('@iconify/json')) + locate = mod.locate + } + + const unknownList = (await Promise.all( + entries(collectMap).map(([collect, names]) => resolveCollect(collect, names)), + )).flat() + + if (unknownList.length) { + logger.warn(`[iconify] Unknown icons: ${unknownList.join(', ')}`) + } + + let cssCode = '' + const map: Record = {} + for (const [iconName, { className, content, background }] of entries(cache)) { + map[iconName] = `${className}${background ? ' bg' : ''}` + cssCode += `.${className} {\n --icon: ${content};\n}\n` + } + + await Promise.all([ + writeTemp(app, CSS_FILENAME, cssCode), + writeTemp(app, JS_FILENAME, resolveContent(app, { + name: 'icons', + content: map, + before: `import './iconify.css'`, + })), + ]) +} + +function getIconsWithPage(page: Page): string[] { + const list = page.contentRendered + .match(ICON_REGEXP)?.map(match => match.match(ICON_NAME_REGEXP)?.[1]) + .filter(Boolean) as string[] || [] + + if (page.frontmatter.icon && isString(page.frontmatter.icon)) { + list.push(page.frontmatter.icon) + } + + return list +} + +function getIconWithThemeConfig(localeOptions: PlumeThemeLocaleOptions): string[] { + const list: string[] = [] + // navbar notes sidebar + const locales = localeOptions.locales || {} + entries(locales).forEach(([, { navbar, sidebar, notes }]) => { + if (navbar) { + list.push(...getIconWithNavbar(navbar)) + } + const sidebarList: Sidebar[] = Object.values(sidebar || {}) as Sidebar[] + if (notes) { + notes.notes.forEach((note) => { + if (note.sidebar) + sidebarList.push(note.sidebar) + }) + } + sidebarList.forEach(sidebar => list.push(...getIconWithSidebar(sidebar))) + }) + + return list +} + +function getIconWithNavbar(navbar: NavItem[]): string[] { + const list: string[] = [] + navbar.forEach((item) => { + if (typeof item !== 'string') { + if (typeof item.icon === 'string' && !isLinkHttp(item.icon) && !isLinkAbsolute(item.icon)) + list.push(item.icon) + if (item.items?.length) + list.push(...getIconWithNavbar(item.items)) + } + }) + return list +} + +function getIconWithSidebar(sidebar: Sidebar): string[] { + const list: string[] = [] + if (isArray(sidebar)) { + sidebar.forEach((item) => { + if (typeof item !== 'string') { + if (typeof item.icon === 'string' && !isLinkHttp(item.icon) && !isLinkAbsolute(item.icon)) + list.push(item.icon) + if (item.items?.length) + list.push(...getIconWithSidebar(item.items)) + } + }) + } + else if (isPlainObject(sidebar)) { + entries(sidebar).forEach(([, item]) => { + if (typeof item !== 'string') { + if (isArray(item)) { + list.push(...getIconWithSidebar(item)) + } + else if (item.items?.length) { + list.push(...getIconWithSidebar(item.items)) + } + } + }) + } + return list +} + +async function resolveCollect(collect: string, names: string[]) { + const filepath = locate(collect) + const config = await readJSON(filepath) + + if (!config) { + logger.warn(`[iconify] Can not find icon collect: ${collect}!`) + return [] + } + + const unknownList: string[] = [] + + for (const name of names) { + const data = getIconData(config, name) + const icon = `${collect}:${name}` + if (!data) { + unknownList.push(icon) + } + else if (!cache[icon]) { + const content = getIconContentCSS(data, { + height: data.height || 24, + }) + const matched = content.match(URL_CONTENT_REGEXP)?.[1] ?? '' + /** + * @see - https://iconify.design/docs/libraries/utils/get-icon-css.html#options + */ + const background = !data.body.includes('currentColor') + cache[icon] = { + className: `vpi-${nanoid()}`, + background, + content: matched, + } + } + } + return unknownList +} + +async function readJSON(filepath: string) { + try { + return await fs.readJSON(filepath, 'utf-8') + } + catch { + return null + } +} diff --git a/theme/src/node/utils/index.ts b/theme/src/node/utils/index.ts index 35c75a61..0efa81b7 100644 --- a/theme/src/node/utils/index.ts +++ b/theme/src/node/utils/index.ts @@ -7,5 +7,6 @@ export const logger = new Logger(THEME_NAME) export * from './hash.js' export * from './path.js' export * from './package.js' +export * from './interopDefault.js' export * from './resolveContent.js' export * from './writeTemp.js' diff --git a/theme/src/node/utils/interopDefault.ts b/theme/src/node/utils/interopDefault.ts new file mode 100644 index 00000000..5be4386c --- /dev/null +++ b/theme/src/node/utils/interopDefault.ts @@ -0,0 +1,6 @@ +export type Awaitable = T | Promise + +export async function interopDefault(m: Awaitable): Promise { + const resolved = await m + return (resolved as any).default || resolved +}