feat(theme): add support for iconify localization

This commit is contained in:
pengzhanbo 2024-07-22 00:42:41 +08:00
parent 2fc28878fa
commit 5d0d626eef
17 changed files with 409 additions and 48 deletions

View File

@ -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<string, any>()
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<IconsOptions> {
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<IconsOptions>) {
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<IconsOptions>) {
-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) {

9
pnpm-lock.yaml generated
View File

@ -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:

View File

@ -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",

View File

@ -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,
}))
</script>
<template>
<img v-if="isLink" class="vp__img" :src="link" alt="">
<span v-else-if="isSvg" class="vp-iconify" v-html="svg" />
<Icon v-else :name="name" />
<img v-if="type === 'link'" class="vp__img" :src="link" alt="" :style="{ height: size }">
<span
v-else-if="type === 'svg'"
class="vp-iconify"
:style="style"
v-html="svg"
/>
<span
v-else-if="type === 'local' && className"
class="vp-iconify" :class="[className]"
:style="style"
/>
<VPIconify v-else :name="(name as string)" :size="size" :color="color" />
</template>
<style scoped>

View File

@ -0,0 +1,75 @@
<script lang="ts" setup>
import type { IconifyIcon } from '@iconify/vue/offline'
import { Icon as OfflineIcon } from '@iconify/vue/offline'
import { loadIcon } from '@iconify/vue'
import { computed, ref, watch } from 'vue'
const props = withDefaults(
defineProps<{
name?: string
size?: string | number
color?: string
}>(),
{
name: '',
size: '',
color: '',
},
)
const icon = ref<IconifyIcon | null>(null)
const loaded = ref(false)
async function loadIconComponent() {
if (icon.value)
return
if (!__VUEPRESS_SSR__) {
try {
loaded.value = false
icon.value = await loadIcon(props.name)
}
finally {
loaded.value = true
}
}
else {
loaded.value = true
}
}
watch(() => props.name, loadIconComponent, { immediate: true })
const size = computed(() => {
const size = props.size || '1em'
if (String(Number(size)) === size)
return `${size}px`
return size
})
const color = computed(() => props.color || 'currentColor')
const bind = computed<any>(() => ({
icon: icon.value,
color: props.color,
height: size.value,
}))
</script>
<template>
<ClientOnly>
<span v-if="!loaded" class="vp-iconify" :style="{ color, width: size, height: size }" />
<OfflineIcon
v-else-if="icon"
class="vp-iconify"
v-bind="bind"
/>
</ClientOnly>
</template>
<style>
.vp-iconify {
display: inline-block;
vertical-align: middle;
}
</style>

View File

@ -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) {

View File

@ -0,0 +1,16 @@
import { icons } from '@internal/iconify'
import { ref } from 'vue'
import type { Ref } from 'vue'
type IconsData = Record<string, string>
type IconsDataRef = Ref<IconsData>
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
}
}

View File

@ -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'

View File

@ -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)

View File

@ -58,3 +58,10 @@ declare module '@internal/encrypt' {
encrypt,
}
}
declare module '@internal/iconify' {
const icons: Record<string, string>
export {
icons,
}
}

View File

@ -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) {

View File

@ -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%;
}

View File

@ -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({

View File

@ -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),
])
}

View File

@ -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<string, string[]>
type IconDataMap = Record<string, IconData>
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<string, string> = {}
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
}
}

View File

@ -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'

View File

@ -0,0 +1,6 @@
export type Awaitable<T> = T | Promise<T>
export async function interopDefault<T>(m: Awaitable<T>): Promise<T extends { default: infer U } ? U : T> {
const resolved = await m
return (resolved as any).default || resolved
}