From 03f00610f486c255c6dc6c56ee5391beed488e8a Mon Sep 17 00:00:00 2001 From: mamaoyuan Date: Thu, 24 Jul 2025 13:41:30 +0800 Subject: [PATCH 1/4] feat: support global unique ID generation with SSR compatibility and Vue 3.5+ useId integration - Implement a global unique ID generation mechanism to ensure consistent IDs across client and server rendering, preventing hydration mismatches. - Maintain backward compatibility with existing usage scenarios; no breaking changes introduced. - For Vue 3.5 and above, leverage the official `useId` API to align with framework conventions and reduce maintenance overhead. - Fallback gracefully to custom ID generation logic in earlier Vue versions. - Added warnings for SSR environments when no ID provider is configured to avoid potential hydration issues. --- packages/renderless/src/autocomplete/vue.ts | 4 +- packages/renderless/src/collapse-item/vue.ts | 4 +- packages/renderless/src/dropdown/vue.ts | 4 +- packages/renderless/src/popover/index.ts | 5 +- packages/renderless/src/tooltip/vue.ts | 4 +- packages/utils/src/string/index.ts | 7 ++ packages/vue-hooks/index.ts | 1 + packages/vue-hooks/src/useId.ts | 80 ++++++++++++++++++++ 8 files changed, 99 insertions(+), 10 deletions(-) create mode 100644 packages/vue-hooks/src/useId.ts diff --git a/packages/renderless/src/autocomplete/vue.ts b/packages/renderless/src/autocomplete/vue.ts index 824f9199e5..ef0a92d796 100644 --- a/packages/renderless/src/autocomplete/vue.ts +++ b/packages/renderless/src/autocomplete/vue.ts @@ -13,7 +13,7 @@ import { debounce } from '@opentiny/utils' import { userPopper } from '@opentiny/vue-hooks' import type { Ref } from 'vue' -import { guid } from '@opentiny/utils' +import { useId } from '@opentiny/vue-hooks' import { computedVisible, watchVisible, @@ -68,7 +68,7 @@ const initState = ({ loading: false, highlightedIndex: -1, suggestionDisabled: false, - id: $prefix + '-' + guid(), + id: $prefix + '-' + useId({}), suggestionVisible: computed(() => computedVisible(state)), // props.validateEvent优先级大于inject,都没有配置默认为true validateEvent: props.validateEvent ?? inject('validateEvent', true) diff --git a/packages/renderless/src/collapse-item/vue.ts b/packages/renderless/src/collapse-item/vue.ts index 9e0ed7d2da..3c692ba77b 100644 --- a/packages/renderless/src/collapse-item/vue.ts +++ b/packages/renderless/src/collapse-item/vue.ts @@ -18,7 +18,7 @@ import type { ICollapseItemRenderlessParamUtils } from '@/types' import { handleFocus, handleEnterClick, handleHeaderClick, handleHeaderContainerClick } from './index' -import { guid } from '@opentiny/utils' +import { useId } from '@opentiny/vue-hooks' export const api = [ 'state', @@ -39,7 +39,7 @@ export const renderless = ( const eventName = _constants.EVENT_NAME.CollapseItemClick const state: ICollapseItemState = reactive({ - id: guid(), + id: useId({}), isClick: false, focusing: false, contentHeight: 0, diff --git a/packages/renderless/src/dropdown/vue.ts b/packages/renderless/src/dropdown/vue.ts index 7039d5cbbe..c0cf843cce 100644 --- a/packages/renderless/src/dropdown/vue.ts +++ b/packages/renderless/src/dropdown/vue.ts @@ -10,7 +10,7 @@ * */ -import { guid } from '@opentiny/utils' +import { useId } from '@opentiny/vue-hooks' import type { IDropdownState, IDropdownApi, @@ -56,7 +56,7 @@ export const renderless = ( menuItemsArray: [], triggerElm: null, dropdownElm: null, - listId: `dropdown-menu-${guid()}`, + listId: `dropdown-menu-${useId({})}`, showIcon: props.showIcon, showSelfIcon: props.showSelfIcon, designConfig, diff --git a/packages/renderless/src/popover/index.ts b/packages/renderless/src/popover/index.ts index 861e3f763f..1581ecce44 100644 --- a/packages/renderless/src/popover/index.ts +++ b/packages/renderless/src/popover/index.ts @@ -11,7 +11,7 @@ */ import type { IPopoverRenderlessParams, IPopoverState } from 'types/popover.type' import { on, off, addClass, removeClass } from '@opentiny/utils' -import { guid } from '@opentiny/utils' +import { useId } from '@opentiny/vue-hooks' import { KEY_CODE } from '@opentiny/utils' const processTrigger = ({ @@ -255,7 +255,8 @@ export const destroyed = off(referenceElm, 'keydown', api.handleKeydown) } -export const computedTooltipId = (constants: { IDPREFIX: string }) => () => `${constants.IDPREFIX}-${guid('', 4)}` +export const computedTooltipId = (constants: { IDPREFIX: string }) => () => + `${constants.IDPREFIX}-${useId({ length: 4 })}` export const wrapMounted = ({ api, props, vm, state }: Pick) => diff --git a/packages/renderless/src/tooltip/vue.ts b/packages/renderless/src/tooltip/vue.ts index bd9cc976d8..ae55177225 100644 --- a/packages/renderless/src/tooltip/vue.ts +++ b/packages/renderless/src/tooltip/vue.ts @@ -29,7 +29,7 @@ import { handleDocumentClick } from './index' import { userPopper } from '@opentiny/vue-hooks' -import { guid } from '@opentiny/utils' +import { useId } from '@opentiny/vue-hooks' import type { ISharedRenderlessParamHooks, ISharedRenderlessParamUtils } from 'types/shared.type' import type { ITooltipApi, ITooltipProps, ITooltipState } from 'types/tooltip.type' @@ -58,7 +58,7 @@ const initState = ({ reactive, showPopper, popperElm, referenceElm, props, injec timeout: null, focusing: false, expectedState: undefined, - tooltipId: guid('tiny-tooltip-', 4), + tooltipId: useId({ nameSpace: 'tiny-tooltip', length: 4 }), tabindex: props.tabindex, xPlacement: 'bottom', showContent: inject('showContent', null), diff --git a/packages/utils/src/string/index.ts b/packages/utils/src/string/index.ts index 0d200937eb..a1849e3661 100644 --- a/packages/utils/src/string/index.ts +++ b/packages/utils/src/string/index.ts @@ -243,6 +243,13 @@ export const fillChar = (string, length, append, chr = '0') => { export const random = () => { let MAX_UINT32_PLUS_ONE = 4294967296 + + if (!globalThis?.crypto) { + // 服务端使用 Math.random() 作为降级方案 + return Math.random() + } + + // 客户端使用更安全的 crypto API return globalThis.crypto.getRandomValues(new Uint32Array(1))[0] / MAX_UINT32_PLUS_ONE } diff --git a/packages/vue-hooks/index.ts b/packages/vue-hooks/index.ts index 4c8e1bd425..d8a12b9716 100644 --- a/packages/vue-hooks/index.ts +++ b/packages/vue-hooks/index.ts @@ -21,3 +21,4 @@ export { useUserAgent } from './src/useUserAgent' export { useWindowSize } from './src/useWindowSize' export { userPopper } from './src/vue-popper' export { usePopup } from './src/vue-popup' +export { useId, ID_INJECTION_KEY } from './src/useId' diff --git a/packages/vue-hooks/src/useId.ts b/packages/vue-hooks/src/useId.ts new file mode 100644 index 0000000000..45f70c8036 --- /dev/null +++ b/packages/vue-hooks/src/useId.ts @@ -0,0 +1,80 @@ +import type { InjectionKey, Ref } from 'vue' +import { inject, getCurrentInstance, unref } from 'vue' +import { computedEager } from '@vueuse/core' +import { isServer } from '@opentiny/utils' +import * as Vue from 'vue' + +export interface TyIdInjectionContext { + prefix: string | number + current: number +} +interface useIdParams { + nameSpace?: string + length?: number + deterministicId?: Ref | string +} +/** + * 用于Vue provide/inject的注入键,共享ID生成器状态。 + */ +export const ID_INJECTION_KEY: InjectionKey = Symbol('tiny-vue-id-injection') + +/** + * 默认的ID注入上下文,当provide未提供时使用。 + */ +const defaultIdInjection: TyIdInjectionContext = { + prefix: Math.floor(Math.random() * 1000000), + current: 0 +} + +/** + * 获取ID注入上下文的组合式函数,会尝试从组件树中注入,若失败则返回默认上下文。 + */ +export const useIdInjection = (): TyIdInjectionContext => { + return getCurrentInstance() ? inject(ID_INJECTION_KEY, defaultIdInjection) : defaultIdInjection +} + +/** + * 生成唯一的ID,支持SSR,避免水合不匹配。 + * + * @param {useIdParams} options - 配置选项。 + * @param {string} [options.nameSpace] - ID命名空间。 + * @param {number} [options.length] - ID中数字部分的长度。 + * @param {Ref | string} [options.deterministicId] - 一个确定的ID,如果提供,则直接返回该ID。 + * @returns {string} 生成的唯一ID。 + */ +let useIdFromVue: (() => string) | null = null +export const useId = ({ nameSpace = '', length = 8, deterministicId }: useIdParams): string => { + // 判断vue自身是否有useId(vue3.5+) + const hasVueUseId = parseFloat(Vue.version) >= 3.5 + // 如果vue自身有useId,则优先使用vue自身的useId + if (!useIdFromVue && hasVueUseId) { + try { + // eslint-disable-next-line dot-notation + useIdFromVue = (Vue as any)?.['useId'] || null + } catch (e) { + useIdFromVue = null + } + } + const idFromVue = useIdFromVue ? useIdFromVue() : null + const idInjection = useIdInjection() + // 在SSR期间,如果使用的是默认ID注入上下文,会发出警告。 + // 随机前缀会导致客户端与服务端ID不一致,从而引发水合错误。 + if (isServer && idInjection === defaultIdInjection) { + console.warn( + 'IdInjection', + `Looks like you are using server rendering, you must provide a id provider to ensure the hydration process to be succeed +usage: app.provide(ID_INJECTION_KEY, { + prefix: number, + current: number, +})` + ) + } + + const current = idInjection.current++ + const currentStr = String(current) + // 如果数字长度小于指定长度,前面补0 + const paddedCurrent = + idFromVue || (currentStr.length >= length ? currentStr : '0'.repeat(length - currentStr.length) + currentStr) + const idRef = computedEager(() => unref(deterministicId) || `${nameSpace}id-${idInjection.prefix}-${paddedCurrent}`) + return idRef.value +} From 6d0a7d6da745cfa0c23c7a2bef61b46860bfd43d Mon Sep 17 00:00:00 2001 From: mamaoyuan Date: Sat, 26 Jul 2025 16:56:51 +0800 Subject: [PATCH 2/4] fix: useId dependency from vue-common --- packages/vue-hooks/package.json | 4 +++- packages/vue-hooks/src/useId.ts | 9 ++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/vue-hooks/package.json b/packages/vue-hooks/package.json index 7c10625199..327aebd35a 100644 --- a/packages/vue-hooks/package.json +++ b/packages/vue-hooks/package.json @@ -17,7 +17,9 @@ }, "dependencies": { "@floating-ui/dom": "^1.6.9", - "@opentiny/utils": "workspace:~" + "@opentiny/utils": "workspace:~", + "@opentiny/vue-common": "workspace:~", + "@vueuse/core": "^12.7.0" }, "devDependencies": { "typescript": "catalog:", diff --git a/packages/vue-hooks/src/useId.ts b/packages/vue-hooks/src/useId.ts index 45f70c8036..95b2680623 100644 --- a/packages/vue-hooks/src/useId.ts +++ b/packages/vue-hooks/src/useId.ts @@ -1,9 +1,8 @@ -import type { InjectionKey, Ref } from 'vue' -import { inject, getCurrentInstance, unref } from 'vue' +import { hooks as Vue } from '@opentiny/vue-common' import { computedEager } from '@vueuse/core' import { isServer } from '@opentiny/utils' -import * as Vue from 'vue' +const { inject, getCurrentInstance, unref } = Vue export interface TyIdInjectionContext { prefix: string | number current: number @@ -11,12 +10,12 @@ export interface TyIdInjectionContext { interface useIdParams { nameSpace?: string length?: number - deterministicId?: Ref | string + deterministicId?: Vue.Ref | string } /** * 用于Vue provide/inject的注入键,共享ID生成器状态。 */ -export const ID_INJECTION_KEY: InjectionKey = Symbol('tiny-vue-id-injection') +export const ID_INJECTION_KEY: Vue.InjectionKey = Symbol('tiny-vue-id-injection') /** * 默认的ID注入上下文,当provide未提供时使用。 From b8a1e78605d6cb7e542374a142e4f47dd8afa894 Mon Sep 17 00:00:00 2001 From: mamaoyuan Date: Sat, 26 Jul 2025 16:57:37 +0800 Subject: [PATCH 3/4] feat: useZindex for global manage zindex --- packages/vue-hooks/index.ts | 1 + packages/vue-hooks/src/useZindex.ts | 76 +++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 packages/vue-hooks/src/useZindex.ts diff --git a/packages/vue-hooks/index.ts b/packages/vue-hooks/index.ts index d8a12b9716..32ec632bdb 100644 --- a/packages/vue-hooks/index.ts +++ b/packages/vue-hooks/index.ts @@ -22,3 +22,4 @@ export { useWindowSize } from './src/useWindowSize' export { userPopper } from './src/vue-popper' export { usePopup } from './src/vue-popup' export { useId, ID_INJECTION_KEY } from './src/useId' +export { useZIndex, ZINDEX_INJECTION_KEY, INITIAL_ZINDEX_KEY } from './src/useZindex' diff --git a/packages/vue-hooks/src/useZindex.ts b/packages/vue-hooks/src/useZindex.ts new file mode 100644 index 0000000000..b9e697899a --- /dev/null +++ b/packages/vue-hooks/src/useZindex.ts @@ -0,0 +1,76 @@ +import { hooks as Vue } from '@opentiny/vue-common' +import { isNumber, isServer } from '@opentiny/utils' + +const { computed, getCurrentInstance, inject, ref, unref } = Vue +let getNuxtApp: any = () => undefined +try { + getNuxtApp = (await import('#app')).useNuxtApp +} catch (error) { + // empty +} + +export interface GlobalZIndexState { + current: number +} + +/** + * 全局 Z-index 注入键。 + */ +export const ZINDEX_INJECTION_KEY: Vue.InjectionKey = Symbol('tiny-vue-zIndex-injection') + +export const INITIAL_ZINDEX_KEY: Vue.InjectionKey> = Symbol('tiny-vue-initial-zIndex') + +const defaultGlobalZIndexState: GlobalZIndexState = { + current: 0 +} +// z-index 偏移量,由全局计数器控制。 +const zIndexOffset = ref(0) +const step = 2 +const defaultInitialZIndex = 2000 + +export const useZIndexInjection = (): GlobalZIndexState => { + if (isServer && !inject(ZINDEX_INJECTION_KEY)) { + const requestEvent = getNuxtApp() + if (requestEvent && !requestEvent.context.zIndexState) { + requestEvent.context.zIndexState = { ...defaultGlobalZIndexState } + } + return requestEvent?.context?.zIndexState || defaultGlobalZIndexState + } + return getCurrentInstance() ? inject(ZINDEX_INJECTION_KEY, defaultGlobalZIndexState) : defaultGlobalZIndexState +} + +export const useZIndex = (zIndexOverrides?: Vue.Ref) => { + if (isServer && !inject(ZINDEX_INJECTION_KEY)) { + console.warn( + 'ZIndexInjection', + `Looks like you are using server rendering, you must provide a z-index provider to ensure the hydration process to be succeed + usage: app.provide(ZINDEX_INJECTION_KEY, { current: 0 })` + ) + } + + const globalZIndexState = useZIndexInjection() + // 优先使用 zIndexOverrides,其次从上下文注入。 + const contextualInitialZIndex = + zIndexOverrides || (getCurrentInstance() ? inject(INITIAL_ZINDEX_KEY, undefined) : undefined) + + // 计算 z-index 的起始值,若无则使用默认值。 + const initialZIndex = computed(() => { + const zIndexContextValue = unref(contextualInitialZIndex) + return isNumber(zIndexContextValue) ? zIndexContextValue : defaultInitialZIndex + }) + + // 当前 z-index = 起始值 + 全局偏移量。 + const currentZIndex = computed(() => { + return (initialZIndex.value || defaultInitialZIndex) + zIndexOffset.value + }) + + const nextZIndex = () => { + globalZIndexState.current += step + zIndexOffset.value = globalZIndexState.current + return currentZIndex.value + } + return { + nextZIndex, + currentZIndex + } +} From 4e9678c7cb2a538645031d230bd4d1c608dd96c9 Mon Sep 17 00:00:00 2001 From: mamaoyuan Date: Mon, 4 Aug 2025 14:57:43 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat:=E9=83=A8=E5=88=86=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cli/src/commands/build/build-runtime.ts | 11 +++++---- internals/cli/src/shared/config.ts | 5 +++- internals/vue-vite-import/package.json | 18 +++++++++++++++ package.json | 23 +++++++++++-------- packages/utils/src/xss/index.ts | 10 ++++---- 5 files changed, 49 insertions(+), 18 deletions(-) create mode 100644 internals/vue-vite-import/package.json diff --git a/internals/cli/src/commands/build/build-runtime.ts b/internals/cli/src/commands/build/build-runtime.ts index 1bec2e8257..cbd0933ec5 100644 --- a/internals/cli/src/commands/build/build-runtime.ts +++ b/internals/cli/src/commands/build/build-runtime.ts @@ -82,9 +82,9 @@ async function batchBuildAll({ vueVersion, tasks, message, emptyOutDir, npmScope }), isVisualizer ? visualizer({ - filename: `${tasks[0].libPath}.html`, - open: true - }) + filename: `${tasks[0].libPath}.html`, + open: true + }) : null, { name: 'vite-plugin-transfer-mode', @@ -118,7 +118,10 @@ async function batchBuildAll({ vueVersion, tasks, message, emptyOutDir, npmScope rollupOptions: { external: (source, importer, isResolved) => { if (isResolved || !importer) return false - + // 明确排除 ./pc.vue + if (source === './pc.vue') { + return false + } if (libPath === 'tiny-vue-saas-common') { return ['@vue/composition-api', 'vue'].includes(source) } diff --git a/internals/cli/src/shared/config.ts b/internals/cli/src/shared/config.ts index ff2f73181b..fca51f4e7a 100644 --- a/internals/cli/src/shared/config.ts +++ b/internals/cli/src/shared/config.ts @@ -9,11 +9,14 @@ const EXTENERAL = [ 'streamsaver', 'shepherd.js', './label-wrap', - './tall-storage.vue', + // './tall-storage.vue', 'highlight.js', 'lowlight' ] const external = (deps) => { + if (deps === './pc.vue') { + return false + } return EXTENERAL.includes(deps) || /^@opentiny[\\/]|@originjs|@tiptap|echarts|cropperjs|@better-scroll/.test(deps) } diff --git a/internals/vue-vite-import/package.json b/internals/vue-vite-import/package.json new file mode 100644 index 0000000000..74f6adb6d2 --- /dev/null +++ b/internals/vue-vite-import/package.json @@ -0,0 +1,18 @@ +{ + "name": "@opentiny/vue-vite-import", + "version": "0.1.0", + "description": "Vite plugin for auto import components", + "author": "OpenTiny", + "license": "MIT", + "keywords": [ + "vite", + "vite-plugin", + "import" + ], + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ] +} diff --git a/package.json b/package.json index 3a43cdbdea..227e8e5c05 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "dev:saas": "pnpm create:icon-saas && pnpm build:entry && pnpm -C examples/vue3 dev:saas", "dev2": "pnpm build:entry && pnpm -C examples/vue2 dev", "dev2:saas": "pnpm create:icon-saas && pnpm build:entry && pnpm -C examples/vue2 dev:saas", + "dev:nuxt": "pnpm build:entry && pnpm -C examples/nuxt dev", "// ---------- 启动官网文档 ----------": "", "site": "pnpm build:entry && pnpm -C examples/sites start", "site:open": "pnpm build:entry && pnpm -C examples/sites start:open", @@ -89,7 +90,11 @@ "// ---------- e2e自动化测试 ----------": "", "test:e2e": "pnpm test:e2e3", "test:e2e2": "pnpm -C examples/vue2 test:e2e --project=chromium", - "test:e2e3": "pnpm -C examples/vue3 test:e2e --project=chromium", + "test:e2e3": "CI=true pnpm -C examples/vue3 test:e2e --project=chromium", + "test:component": "pnpm -C examples/sites start & sleep 10 && CI=true pnpm -C examples/vue3 test:e2e --project=chromium -g", + "test:button": "pnpm -C examples/sites start & sleep 10 && pnpm -C examples/vue3 test:e2e --project=chromium -g \"app/button\" && pnpm -C examples/vue3 exec playwright show-report", + "test:alert": "pnpm -C examples/sites start & sleep 10 && CI=true pnpm -C examples/vue3 test:e2e --project=chromium -g \"app/alert\"", + "show-report": "pnpm -C examples/vue3 exec playwright show-report", "// ---------- playwright下载chromium、firefox等浏览器内核 ----------": "", "install:browser": "pnpm -C examples/vue3 install:browser", "// ---------- e2e测试代码生成器 ----------": "", @@ -147,7 +152,7 @@ "shx": "^0.3.4", "typescript": "catalog:", "vite": "catalog:", - "vue": "^3.4.38", + "vue": "^3.5.13", "vue-tsc": "^1.6.5" }, "pnpm": { @@ -156,10 +161,10 @@ "tsup@7.2.0": "patches/tsup@7.2.0.patch" }, "overrides": { - "@vue/compiler-sfc@3": "3.4.38", - "@vue/runtime-core@3": "3.4.38", - "@vue/runtime-dom@3": "3.4.38", - "@vue/shared@3": "3.4.38", + "@vue/compiler-sfc@3": "3.5.13", + "@vue/runtime-core@3": "3.5.13", + "@vue/runtime-dom@3": "3.5.13", + "@vue/shared@3": "3.5.13", "cropperjs": "1.6.2", "echarts": "5.4.1", "follow-redirects": "1.14.8", @@ -175,10 +180,10 @@ "vue-template-compiler@2.7": "2.7.10", "vue@2.6": "2.6.14", "vue@2.7": "2.7.10", - "vue@3": "3.4.38", + "vue@3": "3.5.13", "vue2": "npm:vue@2.6.14", "vue2.7": "npm:vue@2.7.10", - "vue3": "npm:vue@3.4.38" + "vue3": "npm:vue@3.5.13" }, "packageExtensions": { "vue-template-compiler@2.6.14": { @@ -198,7 +203,7 @@ }, "vite-plugin-dts": { "peerDependencies": { - "vue": "^3.4.38" + "vue": "^3.5.13" } }, "vite-plugin-md": { diff --git a/packages/utils/src/xss/index.ts b/packages/utils/src/xss/index.ts index b701434f60..22939d48ae 100644 --- a/packages/utils/src/xss/index.ts +++ b/packages/utils/src/xss/index.ts @@ -1,4 +1,6 @@ -import * as xss from 'xss' +import xssImport from 'xss' + +const { FilterXSS, getDefaultWhiteList } = xssImport as any let xssOptions: any = { enableAttrs: true, @@ -94,11 +96,11 @@ let xssOptions: any = { } } -const defaultWhiteList = (xss.getDefaultWhiteList && xss.getDefaultWhiteList()) || {} +const defaultWhiteList = (getDefaultWhiteList && getDefaultWhiteList()) || {} xssOptions.html.whiteList = Object.assign(defaultWhiteList, xssOptions.html.whiteList) -let xssFilterHtml = new xss.FilterXSS(xssOptions.html) +let xssFilterHtml = new FilterXSS(xssOptions.html) export const getXssOption = (): object => { return xssOptions @@ -117,7 +119,7 @@ export const setXssOption = (option: any): void => { xssOptions.html.whiteList = whiteList } - xssFilterHtml = new xss.FilterXSS(xssOptions.html) + xssFilterHtml = new FilterXSS(xssOptions.html) } let filterHtml = (content: string): string => {