feat(i18n): add English and Simplified Chinese

This commit is contained in:
eezd committed 2026-10-26 22:04:33 +08:00
1 parent 38c4c8b781
commit 1e9e0678ee
149 files changed
+5356 -1144

No files matched your search

@@ -81,8 +81,8 @@ it('字体失败显示可刷新提示,之后 ready 完成和其他字体加载
await Promise.resolve()
expect(document.documentElement.dataset.rmFonts).toBe('error')
expect(document.querySelectorAll('[role="alert"]')).toHaveLength(1)
expect(document.querySelector('[role="alert"]')!.textContent).toContain('字体加载失败')
expect(document.querySelector('[role="alert"] button')!.textContent).toBe('刷新页面')
expect(document.querySelector('[role="alert"]')!.textContent).toContain('Fonts could not be loaded')
expect(document.querySelector('[role="alert"] button')!.textContent).toBe('Refresh page')
fonts.load()
expect(document.documentElement.dataset.rmFonts).toBe('error')
})
+3 -2
View File
@@ -1,3 +1,4 @@
import { t as translate } from '@@/i18n/runtime'
// 使用浏览器实际排版触发的字体集合,覆盖首屏、切换笔记与后来出现的字形。
export function watchFontLoading() {
const root = document.documentElement
@@ -11,9 +12,9 @@ export function watchFontLoading() {
panel.className = 'rm-font-error'
panel.setAttribute('role', 'alert')
const message = document.createElement('p')
message.textContent = '字体加载失败,请检查网络后刷新页面。'
message.textContent = translate('common.fonts_could_not_be_loaded_42a574')
const retry = document.createElement('button')
retry.textContent = '刷新页面'
retry.textContent = translate('common.refresh_page_9366e1')
retry.addEventListener('click', () => location.reload())
panel.append(message, retry)
document.body.append(panel)
+1 -1
View File
@@ -5,7 +5,7 @@ import { applyFontCatalog, parseFontCatalog } from './fonts'
describe('导入字体资源边界', () => {
it('拒绝可插入CSS的标识和不支持的格式', () => {
for (const value of [null, {}, [{}], [{ id: 'custom-\');}body{color:red}', name: 'bad', format: 'woff2' }], [{ id: `custom-${'a'.repeat(64)}`, name: 'bad', format: 'exe' }]]) {
expect(() => parseFontCatalog(value)).toThrow('字体列表格式无效')
expect(() => parseFontCatalog(value)).toThrow('Invalid font catalog')
}
})
it('字体名称只供界面显示,不进入CSS;设置同步不重复替换相同字体声明', () => {
+2 -1
View File
@@ -1,5 +1,6 @@
import type { CustomFont } from '@@/apis/fonts/type'
import { getFontsApi } from '@@/apis/fonts'
import { t as translate } from '@@/i18n/runtime'
import { fontResourceRoot, prepareResources } from '@@/utils/resource-tickets'
import { shallowRef } from 'vue'
@@ -18,7 +19,7 @@ export function applyFontCatalog(fonts: CustomFont[]) {
}
export function parseFontCatalog(value: unknown): CustomFont[] {
if (!Array.isArray(value) || value.some(font => !font || typeof font !== 'object' || !/^custom-[a-f0-9]{64}$/.test(font.id) || typeof font.name !== 'string' || !['ttf', 'otf', 'woff', 'woff2'].includes(font.format))) throw new Error('字体列表格式无效')
if (!Array.isArray(value) || value.some(font => !font || typeof font !== 'object' || !/^custom-[a-f0-9]{64}$/.test(font.id) || typeof font.name !== 'string' || !['ttf', 'otf', 'woff', 'woff2'].includes(font.format))) throw new Error(translate('common.invalid_font_catalog_b577df'))
return value
}
+13 -4
View File
@@ -1,15 +1,24 @@
import { t as translate } from '@@/i18n/runtime'
// 编辑器排版设置;账户、快捷键等设置不进入此模块。
const maple = { label: 'Maple Mono Normal NL NF CN', css: '\'Maple Mono Normal NL NF CN\', monospace' }
export const TEXT_FONTS = {
maple,
sans: { label: 'Manrope · 清晰无衬线', css: '\'Manrope\', \'PingFang SC\', \'Microsoft YaHei\', sans-serif' },
serif: { label: 'Noto Serif SC · 思源宋体', css: '\'Noto Serif SC\', \'Source Han Serif SC\', serif' },
system: { label: '系统字体', css: 'system-ui, sans-serif' },
sans: { get label() {
return translate('common.manrope_sans_serif_a8923c')
}, css: '\'Manrope\', \'PingFang SC\', \'Microsoft YaHei\', sans-serif' },
serif: { get label() {
return translate('common.noto_serif_sc_serif_18133d')
}, css: '\'Noto Serif SC\', \'Source Han Serif SC\', serif' },
system: { get label() {
return translate('common.system_font_96a2a0')
}, css: 'system-ui, sans-serif' },
}
export const CODE_FONTS = {
maple,
jetbrains: { label: 'JetBrains Mono', css: '\'JetBrains Mono\', ui-monospace, monospace' },
system: { label: '系统等宽字体', css: 'ui-monospace, monospace' },
system: { get label() {
return translate('common.system_monospace_e32ca0')
}, css: 'ui-monospace, monospace' },
}
export const UI_FONT_SIZE = { min: 14, max: 20, default: 14 }
export const FONT_PRESETS: Record<string, { label: string, css: string }> = { ...TEXT_FONTS, jetbrains: CODE_FONTS.jetbrains }
+10 -8
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t as translate } from '@@/i18n/runtime'
import { toast } from '@@/utils/notifications'
import { online, waitingWorker } from '@@/utils/pwa'
import { ref } from 'vue'
@@ -14,7 +16,7 @@ async function update() {
busy.value = true
try {
if (!await props.beforeReload()) {
toast('请先完成保存、图片上传或文件操作,再更新应用')
toast(translate('common.finish_saving_uploading_images_or_b9e96e'))
return
}
if (!activated.value && worker) {
@@ -25,11 +27,11 @@ async function update() {
clearTimeout(timer)
worker.removeEventListener('statechange', finish)
if (worker.state === 'activated') resolve()
else reject(new Error('新版本激活失败'))
else reject(new Error(translate('common.could_not_activate_the_new_c0fd5b')))
}
const timer = setTimeout(() => {
worker.removeEventListener('statechange', finish)
reject(new Error('等待新版本超时'))
reject(new Error(translate('common.timed_out_waiting_for_the_57775d')))
}, 15000)
worker.addEventListener('statechange', finish)
worker.postMessage('ACTIVATE')
@@ -39,9 +41,9 @@ async function update() {
}
// 激活期间仍可能继续输入,刷新前再次确认;保留按钮供保存后重试。
if (await props.beforeReload()) location.reload()
else toast('仍有未完成的保存或操作,请完成后再次点击刷新')
else toast(translate('common.some_changes_or_operations_are_a5fda9'))
} catch {
toast('暂时无法更新,请稍后重试')
toast(translate('common.cannot_update_right_now_please_c0de6a'))
} finally {
busy.value = false
}
@@ -50,11 +52,11 @@ async function update() {
<template>
<div v-if="!online || waitingWorker || activated" class="rm-app-status" role="status">
<span v-if="!online">离线,无法加载或保存笔记</span>
<span v-if="!online">{{ translate('common.offline_notes_cannot_be_loaded_8ecb92') }}</span>
<template v-else-if="waitingWorker || activated">
<span>有新版本</span>
<span>{{ translate('common.update_available_daedea') }}</span>
<el-button class="rm-btn" :disabled="busy" @click="update">
{{ busy ? '正在保存…' : '保存并刷新' }}
{{ busy ? translate('common.saving_6bdb44') : translate('common.save_and_refresh_d080ec') }}
</el-button>
</template>
</div>
+13 -11
View File
@@ -3,6 +3,8 @@ import type { Settings } from '@/pinia/stores/settings'
import { deleteFontApi, importFontApi } from '@@/apis/fonts'
import { t as translate } from '@@/i18n/runtime'
import { toast } from '@@/utils/notifications'
import { isCancel } from 'axios'
import { computed, ref } from 'vue'
@@ -48,7 +50,7 @@ async function save(patch: Partial<Settings>) {
try {
await loadSettings()
} catch (reload) {
error.value += `;重新读取设置失败:${String(reload)}`
error.value += translate('common.could_not_reload_settings_86605a', { p0: String(reload) })
}
return false
}
@@ -63,21 +65,21 @@ async function importFont(event: Event) {
busy.value = true
error.value = ''
try {
if (file.size > 30 * 1024 * 1024) throw new Error('字体文件超过 30 MB')
if (!/\.(ttf|otf|woff2?)$/i.test(file.name)) throw new Error('请选择 TTF、OTF、WOFF 或 WOFF2 字体')
if (file.size > 30 * 1024 * 1024) throw new Error(translate('common.font_files_must_not_exceed_cc001c'))
if (!/\.(ttf|otf|woff2?)$/i.test(file.name)) throw new Error(translate('common.choose_a_ttf_otf_woff_eb6ab7'))
// 让浏览器校验实际字体;无效字体不会上传或进入页面的字体加载集合。
const data = await file.arrayBuffer()
try {
await new FontFace('rawmark-import-check', data).load()
} catch (cause) {
throw new Error('无法解析字体文件,请选择有效的字体。', { cause })
throw new Error(translate('common.could_not_read_this_font_ecc62f'), { cause })
}
user.assertSession(version)
const font = (await importFontApi(file)).data
await loadFonts()
user.assertSession(version)
if (!await save({ [props.setting]: font.id })) return
toast('字体已导入')
toast(translate('common.font_imported_3dc232'))
} catch (cause) {
if (isCancel(cause)) return
error.value = cause instanceof Error ? cause.message : String(cause)
@@ -100,7 +102,7 @@ async function remove(id: string) {
await deleteFontApi(id)
await loadSettings()
}
toast('字体已删除,使用它的位置已恢复 Maple')
toast(translate('common.font_deleted_its_uses_have_b83e92'))
} catch (cause) {
if (isCancel(cause)) return
error.value = cause instanceof Error ? cause.message : String(cause)
@@ -115,22 +117,22 @@ async function remove(id: string) {
<el-select :id="id" :model-value="settings[setting]" :disabled="busy" :loading="busy" filterable popper-class="rm-font-options" @change="choose">
<el-option v-for="font in fonts" :key="font.id" :label="font.name" :value="font.id">
<div class="rm-font-option">
<span>{{ font.name }}</span><small v-if="font.id === 'maple'">默认</small>
<el-button v-else text size="small" :aria-label="`删除字体 ${font.name}`" :disabled="busy" @mousedown.prevent.stop @click.stop="remove(font.id)">
<span>{{ font.name }}</span><small v-if="font.id === 'maple'">{{ translate('common.default_844b8c') }}</small>
<el-button v-else text size="small" :aria-label="translate('common.delete_font_3a6b32', { p0: font.name })" :disabled="busy" @mousedown.prevent.stop @click.stop="remove(font.id)">
<Icon name="trash" />
</el-button>
</div>
</el-option>
<template #footer>
<el-button class="rm-font-import" :disabled="busy" @click="fileInput!.click()">
<Icon name="import" />导入字体
<Icon name="import" />{{ translate('common.import_font_50751a') }}
</el-button>
<p class="rm-share-note">
TTF、OTF、WOFF、WOFF2,最多 30 MB。
{{ translate('common.ttf_otf_woff_or_woff_0cc484') }}
</p>
</template>
</el-select>
<input ref="fileInput" type="file" accept=".ttf,.otf,.woff,.woff2" hidden :aria-label="`导入字体 ${id}`" @change="importFont">
<input ref="fileInput" type="file" accept=".ttf,.otf,.woff,.woff2" hidden :aria-label="translate('common.import_font_183cd9', { p0: id })" @change="importFont">
<p v-if="error" class="rm-auth-error" role="alert">
{{ error }}
</p>
@@ -1,5 +1,7 @@
<script setup lang="ts">
import type { InlineStyles } from '../../markdown/inline-styles'
import { t as translate } from '@@/i18n/runtime'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { pinia } from '@/pinia'
import { useSettingsStore } from '@/pinia/stores/settings'
@@ -44,49 +46,49 @@ function switchSize() {
</script>
<template>
<div class="rm-inline-appearance" role="region" aria-label="文字外观">
<div class="rm-inline-appearance" role="region" :aria-label="translate('common.text_appearance_09f8f2')">
<fieldset>
<legend>配色</legend><div class="rm-inline-swatches">
<button type="button" title="默认配色" aria-label="默认配色" @mousedown.prevent @click="emit('change', { 'color': undefined, 'background-color': undefined })">
<legend>{{ translate('common.color_scheme_2ae75e') }}</legend><div class="rm-inline-swatches">
<button type="button" :title="translate('common.default_color_scheme_7f0813')" :aria-label="translate('common.default_color_scheme_7f0813')" @mousedown.prevent @click="emit('change', { 'color': undefined, 'background-color': undefined })">
A
</button>
<button v-for="(color, index) in foreground" :key="index" type="button" :title="`配色 ${index + 1}`" :aria-label="`配色 ${index + 1}`" :style="{ color, backgroundColor: background[index] }" @mousedown.prevent @click="emit('change', { color, 'background-color': background[index] })">
<button v-for="(color, index) in foreground" :key="index" type="button" :title="translate('common.color_scheme_d2f13c', { p0: index + 1 })" :aria-label="translate('common.color_scheme_d2f13c', { p0: index + 1 })" :style="{ color, backgroundColor: background[index] }" @mousedown.prevent @click="emit('change', { color, 'background-color': background[index] })">
A
</button>
</div>
</fieldset>
<fieldset v-for="group in [{ key: 'color', title: '字体颜色', colors: foreground }, { key: 'background-color', title: '背景色', colors: background }]" :key="group.key">
<fieldset v-for="group in [{ key: 'color', get title() { return translate('common.text_color_fcaea5') }, colors: foreground }, { key: 'background-color', get title() { return translate('common.background_color_6d2b29') }, colors: background }]" :key="group.key">
<legend>{{ group.title }}</legend><div class="rm-inline-swatches">
<button type="button" :title="`默认${group.title}`" :aria-label="`默认${group.title}`" @mousedown.prevent @click="emit('change', { [group.key]: undefined })">
<button type="button" :title="translate('common.default_4a2e3e', { p0: group.title })" :aria-label="translate('common.default_4a2e3e', { p0: group.title })" @mousedown.prevent @click="emit('change', { [group.key]: undefined })">
↺
</button>
<button v-for="(color, index) in group.colors" :key="color" type="button" :title="`${group.title} ${index + 1}`" :aria-label="`${group.title} ${index + 1}`" :aria-pressed="styles[group.key as keyof InlineStyles] === color" :style="group.key === 'color' ? { color } : { backgroundColor: color, color: foreground[index] }" @mousedown.prevent @click="emit('change', { [group.key]: color })">
A
</button>
<label class="rm-inline-custom" :title="`自定义${group.title}`"><span>自定义</span><input type="color" :aria-label="`自定义${group.title}`" :value="styles[group.key as keyof InlineStyles] ?? (group.key === 'color' ? '#333333' : '#fff88f')" @change="emit('change', { [group.key]: ($event.target as HTMLInputElement).value })"></label>
<label class="rm-inline-custom" :title="translate('common.custom_fc732e', { p0: group.title })"><span>{{ translate('common.custom_4eafa9') }}</span><input type="color" :aria-label="translate('common.custom_fc732e', { p0: group.title })" :value="styles[group.key as keyof InlineStyles] ?? (group.key === 'color' ? '#333333' : '#fff88f')" @change="emit('change', { [group.key]: ($event.target as HTMLInputElement).value })"></label>
</div>
</fieldset>
<fieldset>
<legend>字体效果</legend><div class="rm-inline-effects">
<button type="button" title="空心" aria-label="空心" :aria-pressed="!!styles['-webkit-text-stroke']" :style="{ WebkitTextStroke: HOLLOW, WebkitTextFillColor: 'transparent' }" @mousedown.prevent @click="emit('change', { '-webkit-text-stroke': styles['-webkit-text-stroke'] ? undefined : HOLLOW, '-webkit-text-fill-color': styles['-webkit-text-stroke'] ? undefined : 'transparent' })">
<legend>{{ translate('common.text_effects_b8be49') }}</legend><div class="rm-inline-effects">
<button type="button" :title="translate('common.outline_837aa5')" :aria-label="translate('common.outline_837aa5')" :aria-pressed="!!styles['-webkit-text-stroke']" :style="{ WebkitTextStroke: HOLLOW, WebkitTextFillColor: 'transparent' }" @mousedown.prevent @click="emit('change', { '-webkit-text-stroke': styles['-webkit-text-stroke'] ? undefined : HOLLOW, '-webkit-text-fill-color': styles['-webkit-text-stroke'] ? undefined : 'transparent' })">
A
</button>
<button type="button" title="投影" aria-label="投影" :aria-pressed="!!styles['text-shadow']" :style="{ textShadow: SHADOW }" @mousedown.prevent @click="emit('change', { 'text-shadow': styles['text-shadow'] ? undefined : SHADOW })">
<button type="button" :title="translate('common.shadow_caabf3')" :aria-label="translate('common.shadow_caabf3')" :aria-pressed="!!styles['text-shadow']" :style="{ textShadow: SHADOW }" @mousedown.prevent @click="emit('change', { 'text-shadow': styles['text-shadow'] ? undefined : SHADOW })">
A
</button>
</div>
</fieldset>
<label class="rm-inline-control">字体<select aria-label="局部字体" :value="styles['font-family'] ?? ''" @change="emit('change', { 'font-family': ($event.target as HTMLSelectElement).value || undefined })"><option value="">继承正文</option><option v-for="font in INLINE_FONTS" :key="font.css" :value="font.css">{{ font.label }}</option></select></label>
<label class="rm-inline-control">{{ translate('common.font_078838') }}<select :aria-label="translate('common.selection_font_3ff21e')" :value="styles['font-family'] ?? ''" @change="emit('change', { 'font-family': ($event.target as HTMLSelectElement).value || undefined })"><option value="">{{ translate('common.inherit_body_font_20476e') }}</option><option v-for="font in INLINE_FONTS" :key="font.css" :value="font.css">{{ font.label }}</option></select></label>
<div class="rm-inline-control">
<span>字号</span><label><input v-model="relative" type="checkbox" aria-label="相对字号" @change="switchSize"> 相对字号</label><el-button native-type="button" class="rm-btn" @mousedown.prevent @click="emit('change', { 'font-size': undefined })">
默认
<span>{{ translate('common.font_size_f71561') }}</span><label><input v-model="relative" type="checkbox" :aria-label="translate('common.relative_font_size_45afe1')" @change="switchSize"> {{ translate('common.relative_font_size_45afe1') }}</label><el-button native-type="button" class="rm-btn" @mousedown.prevent @click="emit('change', { 'font-size': undefined })">
{{ translate('common.default_844b8c') }}
</el-button>
</div>
<form class="rm-inline-size" @submit.prevent="setSize">
<input v-model.number="size" type="range" :min="relative ? 56 : 9" :max="relative ? 450 : 72" step="1" aria-label="局部字号滑块" @change="setSize"><input v-model.number="size" type="number" :min="relative ? 56 : 9" :max="relative ? 450 : 72" step="1" aria-label="局部字号" @change="setSize"><span>{{ relative ? '%' : 'px' }}</span>
<input v-model.number="size" type="range" :min="relative ? 56 : 9" :max="relative ? 450 : 72" step="1" :aria-label="translate('common.selection_font_size_slider_494d79')" @change="setSize"><input v-model.number="size" type="number" :min="relative ? 56 : 9" :max="relative ? 450 : 72" step="1" :aria-label="translate('common.selection_font_size_5a5162')" @change="setSize"><span>{{ relative ? '%' : 'px' }}</span>
</form>
<el-button native-type="button" class="rm-btn rm-inline-clear" @mousedown.prevent @click="emit('clear')">
清除样式
{{ translate('common.clear_appearance_384fbf') }}
</el-button>
</div>
</template>
@@ -0,0 +1,13 @@
<script setup lang="ts">
import type { Locale } from '@@/i18n/runtime'
import { locale } from '@@/i18n/runtime'
defineEmits<{ change: [value: Locale] }>()
</script>
<template>
<el-select :model-value="locale" aria-label="Language / 语言" style="width: 160px" @change="$emit('change', $event)">
<el-option label="English" value="en" />
<el-option label="简体中文" value="zh-CN" />
</el-select>
</template>
+124
View File
@@ -0,0 +1,124 @@
{
"retry_b8784c": "Retry",
"fonts_could_not_be_loaded_42a574": "Fonts could not be loaded. Check your connection and refresh the page.",
"refresh_page_9366e1": "Refresh page",
"invalid_font_catalog_b577df": "Invalid font catalog",
"manrope_sans_serif_a8923c": "Manrope · Sans serif",
"noto_serif_sc_serif_18133d": "Noto Serif SC · Serif",
"system_font_96a2a0": "System font",
"system_monospace_e32ca0": "System monospace",
"finish_saving_uploading_images_or_b9e96e": "Finish saving, uploading images, or working with files before updating the app",
"could_not_activate_the_new_c0fd5b": "Could not activate the new version",
"timed_out_waiting_for_the_57775d": "Timed out waiting for the new version",
"some_changes_or_operations_are_a5fda9": "Some changes or operations are still pending. Finish them before refreshing",
"cannot_update_right_now_please_c0de6a": "Cannot update right now. Please try again later",
"offline_notes_cannot_be_loaded_8ecb92": "Offline. Notes cannot be loaded or saved",
"update_available_daedea": "Update available",
"saving_6bdb44": "Saving…",
"save_and_refresh_d080ec": "Save and refresh",
"could_not_reload_settings_86605a": "; could not reload settings: {p0}",
"font_files_must_not_exceed_cc001c": "Font files must not exceed 30 MB",
"choose_a_ttf_otf_woff_eb6ab7": "Choose a TTF, OTF, WOFF, or WOFF2 font",
"could_not_read_this_font_ecc62f": "Could not read this font. Choose a valid font file.",
"font_imported_3dc232": "Font imported",
"font_deleted_its_uses_have_b83e92": "Font deleted. Its uses have been reset to Maple",
"default_844b8c": "Default",
"delete_font_3a6b32": "Delete font {p0}",
"import_font_50751a": "Import font",
"ttf_otf_woff_or_woff_0cc484": "TTF, OTF, WOFF, or WOFF2, up to 30 MB.",
"import_font_183cd9": "Import font {p0}",
"text_appearance_09f8f2": "Text appearance",
"color_scheme_2ae75e": "Color scheme",
"default_color_scheme_7f0813": "Default color scheme",
"color_scheme_d2f13c": "Color scheme {p0}",
"text_color_fcaea5": "Text color",
"background_color_6d2b29": "Background color",
"default_4a2e3e": "Default {p0}",
"custom_fc732e": "Custom {p0}",
"custom_4eafa9": "Custom",
"text_effects_b8be49": "Text effects",
"outline_837aa5": "Outline",
"shadow_caabf3": "Shadow",
"font_078838": "Font",
"selection_font_3ff21e": "Selection font",
"inherit_body_font_20476e": "Inherit body font",
"font_size_f71561": "Font size",
"relative_font_size_45afe1": "Relative font size",
"selection_font_size_slider_494d79": "Selection font size slider",
"selection_font_size_5a5162": "Selection font size",
"clear_appearance_384fbf": "Clear appearance",
"this_plugin_has_stopped_and_796b24": "This plugin has stopped and can no longer use the SDK",
"plugin_cleanup_failed_6388d7": "Plugin “{p0}” cleanup failed",
"plugin_cleanup_exceeded_seconds_32945c": "Plugin “{p0}” cleanup exceeded 5 seconds",
"failed_and_has_been_disabled_aa6412": "{p0} failed and has been disabled on this page. Retry in plugin settings",
"plugin_44a1b7": "Plugin “{p0}”: {p1}",
"invalid_plugin_registration_id_9702fe": "Invalid plugin registration ID",
"plugin_views_require_a_title_79ae1b": "Plugin views require a title and a mount function",
"view_1c5c06": "View",
"invalid_or_duplicate_command_f6503b": "Invalid or duplicate command",
"command_condition_df314d": "Command condition",
"command_928f87": "Command",
"a_code_block_renderer_must_952f83": "A code block renderer must be a function",
"code_block_rendering_1d4ae4": "Code block rendering",
"a_cleanup_callback_must_be_6ce04e": "A cleanup callback must be a function",
"invalid_vault_event_type_8621e4": "Invalid vault event type",
"vault_event_03582e": "Vault event",
"plugin_stopped_9ac692": "Plugin stopped",
"plugin_activation_exceeded_seconds_cffeae": "Plugin activation exceeded 10 seconds",
"a_plugin_must_export_an_1fbc50": "A plugin must export an activation function as its default export",
"activation_must_return_a_cleanup_ea3a8c": "Activation must return a cleanup function",
"activation_dd1286": "Activation",
"plugin_host_stopped_901067": "Plugin host stopped",
"plugin_refresh_superseded_2fa245": "Plugin refresh superseded",
"could_not_load_plugins_a1c343": "Could not load plugins: {p0}",
"editor_extension_28bb35": "Editor extension",
"plugin_view_rendering_exceeded_seconds_0d20d2": "Plugin view rendering exceeded 10 seconds",
"plugin_mount_must_return_a_c66e82": "Plugin mount must return a cleanup function",
"duplicate_plugin_registration_cannot_replace_fbc39b": "Duplicate plugin registration cannot replace existing functionality",
"invalid_code_block_language_name_8de01a": "Invalid code block language name",
"empty_diagram_37edb7": "Empty diagram",
"invalid_diagram_syntax_333e74": "Invalid diagram syntax",
"built_in_diagram_rendering_failed_e93cc6": "Built-in diagram rendering failed. Please retry",
"command_not_found_1cdb0c": "Command not found: {p0}",
"confirm_action_f8347f": "Confirm action",
"confirm_fac2a6": "Confirm",
"cancel_2cd0f3": "Cancel",
"invalid_shortcut_format_5e48e7": "Invalid shortcut format",
"space_bfa4a6": "Space",
"image_resource_authentication_is_not_73d285": "Image resource authentication is not ready",
"font_resource_authentication_is_not_796b9b": "Font resource authentication is not ready",
"invalid_server_response_protocol_194dc7": "Invalid server response protocol",
"request_failed_126fbf": "Request failed",
"request_failed_http_f21a09": "Request failed (HTTP {p0})",
"session_changed_073f28": "Session changed",
"sign_in_1e2df9": "Sign in",
"api_tokens_79db53": "API tokens",
"sign_out_498e1d": "Sign out",
"edit_051836": "Edit",
"delete_2f9daa": "Delete",
"restore_e0534b": "Restore",
"import_576d81": "Import",
"trash_ba35dc": "Trash",
"network_error_check_your_connection_566064": "Network error. Check your connection",
"name_d44e9b": "Name",
"copy_63d90d": "Copy",
"could_not_delete_9619d7": "Could not delete: {p0}",
"settings_df3d58": "Settings",
"search_results_88d72e": "Search results",
"appearance_86a63f": "Appearance",
"sign_out_3ab8cc": "Sign out",
"narrow_a614db": "Narrow",
"standard_6bea77": "Standard",
"wide_8dd966": "Wide",
"full_width_8ecc7d": "Full width",
"settings_saved_but_the_theme_9f87f8": "Settings saved, but the theme could not be applied: {p0}",
"invalid_shortcut_settings_2f07e6": "Invalid shortcut settings",
"could_not_save_settings_1cdd7f": "Could not save settings: {p0}",
"invalid_identity_information_e69b1a": "Invalid identity information",
"setup_65622e": "Setup",
"access_denied_25fb07": "Access denied",
"page_not_found_e75737": "Page not found",
"network_error": "Network error",
"move": "Move",
"network_or_local_storage_error": "Network or local storage error"
}
+49
View File
@@ -0,0 +1,49 @@
// @vitest-environment happy-dom
import { afterEach, expect, it } from 'vitest'
import { applyLocale, DEFAULT_LOCALE, locale, t } from './runtime'
const catalogs = import.meta.glob<Record<string, string>>(['./*.json', '../../pages/*/locales/*.json'], { eager: true, import: 'default' })
afterEach(() => applyLocale(DEFAULT_LOCALE))
it('默认英文,语言切换同时更新 HTML 语言与带参数提示', () => {
expect(DEFAULT_LOCALE).toBe('en')
applyLocale('en')
expect(document.documentElement.lang).toBe('en')
expect(t('common.retry_b8784c')).toBe('Retry')
expect(t('workspace.heading_a4f510', { p0: 2 })).toBe('Heading 2')
expect(t('workspace.notes_fd9460', { count: 1 })).toBe('1 note')
expect(t('workspace.notes_fd9460', { count: 2 })).toBe('2 notes')
expect(t('settings.permanently_deleted_with_failures', { count: 1, failed: 2 })).toBe('Permanently deleted 1 item; 2 failed')
applyLocale('zh-CN')
expect(locale.value).toBe('zh-CN')
expect(document.documentElement.lang).toBe('zh-CN')
expect(t('common.retry_b8784c')).toBe('重试')
expect(t('workspace.heading_a4f510', { p0: 2 })).toBe('设置 2 级标题')
expect(t('settings.permanently_deleted_with_failures', { count: 1, failed: 2 })).toBe('已永久删除 1 项,2 项失败')
})
it('每个模块的英文与中文消息键和插值参数一致', () => {
for (const [path, en] of Object.entries(catalogs).filter(([path]) => path.endsWith('/en.json'))) {
const zh = catalogs[path.replace('/en.json', '/zh-CN.json')]!
expect(Object.keys(en).sort(), path).toEqual(Object.keys(zh).sort())
for (const [key, value] of Object.entries(en)) {
expect(zh[key], `${path}: ${key}`).toBeTruthy()
expect([...new Set([...value.matchAll(/\{(\w+)\}/g)].map(m => m[1]))].sort(), key)
.toEqual([...new Set([...zh[key]!.matchAll(/\{(\w+)\}/g)].map(m => m[1]))].sort())
}
}
})
it('源码中使用的消息键均存在,默认英文不遗留中文界面文案', () => {
const sources = import.meta.glob<string>(['../../**/*.ts', '../../**/*.vue', '!../../**/*.test.ts'], { eager: true, query: '?raw', import: 'default' })
for (const [path, source] of Object.entries(sources)) {
for (const [, key] of source.matchAll(/(?:translate|\bt)\('((?:common|workspace|settings|login|error)\.[^']+)'/g)) {
const [module, id] = key!.split('.')
const catalogPath = module === 'common' ? './en.json' : `../../pages/${module}/locales/en.json`
expect(catalogs[catalogPath]?.[id!], `${path}: ${key}`).toBeTruthy()
}
}
for (const [path, catalog] of Object.entries(catalogs).filter(([path]) => path.endsWith('/en.json'))) {
expect(/[\u3400-\u9FFF]/.test(JSON.stringify(catalog)), path).toBe(false)
}
})
+36
View File
@@ -0,0 +1,36 @@
import { createI18n } from 'vue-i18n'
import en from './en.json'
import zhCN from './zh-CN.json'
export const LOCALES = ['en', 'zh-CN'] as const
export type Locale = typeof LOCALES[number]
export const DEFAULT_LOCALE: Locale = 'en'
export function isLocale(value: unknown): value is Locale {
return value === 'en' || value === 'zh-CN'
}
const cached = typeof localStorage === 'undefined' ? {} : JSON.parse(localStorage.getItem('rawmark.settings') ?? '{}')
const initialLocale = isLocale(cached.locale) ? cached.locale : DEFAULT_LOCALE
export const i18n = createI18n({
legacy: false,
locale: initialLocale,
fallbackLocale: false,
messages: { 'en': { common: en }, 'zh-CN': { common: zhCN } },
})
export const locale = i18n.global.locale
export function t(key: string, params: Record<string, unknown> = {}): string {
return i18n.global.t(key, params)
}
export function applyLocale(value: Locale) {
locale.value = value
if (typeof document !== 'undefined') document.documentElement.lang = value
}
/** 登录页仅缓存显示语言;登录后由服务器账户设置覆盖。 */
export function chooseAnonymousLocale(value: Locale) {
const cached = JSON.parse(localStorage.getItem('rawmark.settings') ?? '{}')
localStorage.setItem('rawmark.settings', JSON.stringify({ ...cached, locale: value }))
applyLocale(value)
}
applyLocale(initialLocale)
+124
View File
@@ -0,0 +1,124 @@
{
"retry_b8784c": "重试",
"fonts_could_not_be_loaded_42a574": "字体加载失败,请检查网络后刷新页面。",
"refresh_page_9366e1": "刷新页面",
"invalid_font_catalog_b577df": "字体列表格式无效",
"manrope_sans_serif_a8923c": "Manrope · 清晰无衬线",
"noto_serif_sc_serif_18133d": "Noto Serif SC · 思源宋体",
"system_font_96a2a0": "系统字体",
"system_monospace_e32ca0": "系统等宽字体",
"finish_saving_uploading_images_or_b9e96e": "请先完成保存、图片上传或文件操作,再更新应用",
"could_not_activate_the_new_c0fd5b": "新版本激活失败",
"timed_out_waiting_for_the_57775d": "等待新版本超时",
"some_changes_or_operations_are_a5fda9": "仍有未完成的保存或操作,请完成后再次点击刷新",
"cannot_update_right_now_please_c0de6a": "暂时无法更新,请稍后重试",
"offline_notes_cannot_be_loaded_8ecb92": "离线,无法加载或保存笔记",
"update_available_daedea": "有新版本",
"saving_6bdb44": "正在保存…",
"save_and_refresh_d080ec": "保存并刷新",
"could_not_reload_settings_86605a": ";重新读取设置失败:{p0}",
"font_files_must_not_exceed_cc001c": "字体文件超过 30 MB",
"choose_a_ttf_otf_woff_eb6ab7": "请选择 TTF、OTF、WOFF 或 WOFF2 字体",
"could_not_read_this_font_ecc62f": "无法解析字体文件,请选择有效的字体。",
"font_imported_3dc232": "字体已导入",
"font_deleted_its_uses_have_b83e92": "字体已删除,使用它的位置已恢复 Maple",
"default_844b8c": "默认",
"delete_font_3a6b32": "删除字体 {p0}",
"import_font_50751a": "导入字体",
"ttf_otf_woff_or_woff_0cc484": "TTF、OTF、WOFF、WOFF2,最多 30 MB。",
"import_font_183cd9": "导入字体 {p0}",
"text_appearance_09f8f2": "文字外观",
"color_scheme_2ae75e": "配色",
"default_color_scheme_7f0813": "默认配色",
"color_scheme_d2f13c": "配色 {p0}",
"text_color_fcaea5": "字体颜色",
"background_color_6d2b29": "背景色",
"default_4a2e3e": "默认{p0}",
"custom_fc732e": "自定义{p0}",
"custom_4eafa9": "自定义",
"text_effects_b8be49": "字体效果",
"outline_837aa5": "空心",
"shadow_caabf3": "投影",
"font_078838": "字体",
"selection_font_3ff21e": "局部字体",
"inherit_body_font_20476e": "继承正文",
"font_size_f71561": "字号",
"relative_font_size_45afe1": "相对字号",
"selection_font_size_slider_494d79": "局部字号滑块",
"selection_font_size_5a5162": "局部字号",
"clear_appearance_384fbf": "清除样式",
"this_plugin_has_stopped_and_796b24": "插件已停止,不能继续调用 SDK",
"plugin_cleanup_failed_6388d7": "插件“{p0}”清理失败",
"plugin_cleanup_exceeded_seconds_32945c": "插件“{p0}”清理超过 5 秒",
"failed_and_has_been_disabled_aa6412": "{p0}失败,已在当前页面停用;可在插件设置中重试",
"plugin_44a1b7": "插件“{p0}”:{p1}",
"invalid_plugin_registration_id_9702fe": "插件注册 id 无效",
"plugin_views_require_a_title_79ae1b": "插件视图必须提供标题与挂载函数",
"view_1c5c06": "视图",
"invalid_or_duplicate_command_f6503b": "命令无效或重复注册",
"command_condition_df314d": "命令条件",
"command_928f87": "命令",
"a_code_block_renderer_must_952f83": "代码块渲染器必须为函数",
"code_block_rendering_1d4ae4": "代码块渲染",
"a_cleanup_callback_must_be_6ce04e": "清理回调必须为函数",
"invalid_vault_event_type_8621e4": "仓库事件类型无效",
"vault_event_03582e": "仓库事件",
"plugin_stopped_9ac692": "插件已停止",
"plugin_activation_exceeded_seconds_cffeae": "插件激活超过 10 秒",
"a_plugin_must_export_an_1fbc50": "插件必须默认导出激活函数",
"activation_must_return_a_cleanup_ea3a8c": "激活返回值必须为清理函数",
"activation_dd1286": "激活",
"plugin_host_stopped_901067": "插件宿主已停止",
"plugin_refresh_superseded_2fa245": "插件刷新已过期",
"could_not_load_plugins_a1c343": "读取插件列表失败:{p0}",
"editor_extension_28bb35": "编辑器扩展",
"plugin_view_rendering_exceeded_seconds_0d20d2": "插件视图渲染超过 10 秒",
"plugin_mount_must_return_a_c66e82": "插件挂载返回值必须为清理函数",
"duplicate_plugin_registration_cannot_replace_fbc39b": "插件重复注册,不能覆盖已有功能",
"invalid_code_block_language_name_8de01a": "代码块语言名无效",
"empty_diagram_37edb7": "空图表",
"invalid_diagram_syntax_333e74": "图表语法错误",
"built_in_diagram_rendering_failed_e93cc6": "内置图表渲染失败,请重试",
"command_not_found_1cdb0c": "命令不存在:{p0}",
"confirm_action_f8347f": "确认操作",
"confirm_fac2a6": "确定",
"cancel_2cd0f3": "取消",
"invalid_shortcut_format_5e48e7": "快捷键格式无效",
"space_bfa4a6": "空格",
"image_resource_authentication_is_not_73d285": "图片资源认证尚未就绪",
"font_resource_authentication_is_not_796b9b": "字体资源认证尚未就绪",
"invalid_server_response_protocol_194dc7": "服务器响应协议无效",
"request_failed_126fbf": "请求失败",
"request_failed_http_f21a09": "请求失败(HTTP {p0})",
"session_changed_073f28": "会话已变化",
"sign_in_1e2df9": "登录",
"api_tokens_79db53": "API 令牌",
"sign_out_498e1d": "退出",
"edit_051836": "编辑",
"delete_2f9daa": "删除",
"restore_e0534b": "恢复",
"import_576d81": "导入",
"trash_ba35dc": "回收站",
"network_error_check_your_connection_566064": "网络错误,请检查连接",
"name_d44e9b": "名称",
"copy_63d90d": "复制",
"could_not_delete_9619d7": "删除失败:{p0}",
"settings_df3d58": "设置",
"search_results_88d72e": "搜索结果",
"appearance_86a63f": "外观",
"sign_out_3ab8cc": "退出登录",
"narrow_a614db": "窄",
"standard_6bea77": "标准",
"wide_8dd966": "宽",
"full_width_8ecc7d": "全宽",
"settings_saved_but_the_theme_9f87f8": "设置已保存,但主题应用失败:{p0}",
"invalid_shortcut_settings_2f07e6": "快捷键设置格式无效",
"could_not_save_settings_1cdd7f": "设置保存失败:{p0}",
"invalid_identity_information_e69b1a": "身份信息格式错误",
"setup_65622e": "初始化",
"access_denied_25fb07": "访问受限",
"page_not_found_e75737": "页面不存在",
"network_error": "网络错误",
"move": "移动",
"network_or_local_storage_error": "网络或本地存储错误"
}
+2 -2
View File
@@ -37,7 +37,7 @@ it('命令失败保留原始异常并清理注册,正常插件列表读取不
await vi.waitFor(() => expect(pluginState.status.fault?.cause).toBe(cause))
expect(pluginState.status.fault?.phase).toBe('error')
expect(allCommands().some(command => command.id === 'plugin:fault:fail')).toBe(false)
expect(notices.some(message => message.includes('命令失败'))).toBe(true)
expect(notices.some(message => message.includes('Command failed'))).toBe(true)
list.mockClear()
await retryPlugin('fault')
await vi.waitFor(() => expect(pluginState.status.fault?.phase).toBe('active'))
@@ -64,7 +64,7 @@ it('启动失败由入口通知一次,取消刷新不产生故障状态', asyn
await vi.waitFor(() => expect(notice).toHaveBeenCalledOnce())
list.mockResolvedValueOnce(apiSuccess({ plugins: [] }))
await refreshPlugins()
list.mockRejectedValueOnce(new CanceledError('会话已变化'))
list.mockRejectedValueOnce(new CanceledError('Session changed'))
await expect(refreshPlugins()).rejects.toSatisfy(isCancel)
expect(pluginState.error).toBe('')
expect(notice).toHaveBeenCalledOnce()
+29 -28
View File
@@ -2,6 +2,7 @@ import type { InstalledPlugin } from '@@/apis/plugins/type'
import type { AsyncCleanup, JSONValue, PluginAPI, PluginView, PluginWorkspace, VaultEvents } from '@@/plugin-api'
import { getPluginDataApi, getPluginsApi, savePluginDataApi } from '@@/apis/plugins'
import { getTreeApi } from '@@/apis/tree'
import { t as translate } from '@@/i18n/runtime'
import { readNoteFile } from '@@/utils/note-files'
import { pluginResourceURL } from '@@/utils/resource-tickets'
@@ -35,12 +36,12 @@ function status(id: string, phase: string, error = '', cause?: unknown) {
pluginState.status[id] = { phase, error, cause, cleanupErrors: pluginState.status[id]?.cleanupErrors }
}
function assertActive(instance: Instance) {
if (instance.stopped || !running) throw new Error('插件已停止,不能继续调用 SDK')
if (instance.stopped || !running) throw new Error(translate('common.this_plugin_has_stopped_and_796b24'))
}
function cleanupFailure(instance: Instance, cause: unknown) {
instance.cleanupErrors.push(cause)
adapter.notice(`插件“${instance.info.name}”清理失败`)
adapter.notice(translate('common.plugin_cleanup_failed_6388d7', { p0: instance.info.name }))
}
function track(instance: Instance, cleanup: AsyncCleanup): () => void {
@@ -75,27 +76,27 @@ async function stop(instance: Instance) {
}),
])
clearTimeout(timer!)
if (!finished) adapter.notice(`插件“${instance.info.name}”清理超过 5 秒`)
if (!finished) adapter.notice(translate('common.plugin_cleanup_exceeded_seconds_32945c', { p0: instance.info.name }))
}
function fail(instance: Instance, phase: string, cause: unknown) {
if (instance.stopped) return
const message = `${phase}失败,已在当前页面停用;可在插件设置中重试`
const message = translate('common.failed_and_has_been_disabled_aa6412', { p0: phase })
faults.set(instance.info.id, instance.info.revision)
status(instance.info.id, 'error', message, cause)
adapter.notice(`插件“${instance.info.name}”:${message}`)
adapter.notice(translate('common.plugin_44a1b7', { p0: instance.info.name, p1: message }))
void stop(instance)
}
function localID(instance: Instance, id: string) {
if (!/^[a-z0-9][\w.-]{0,63}$/i.test(id)) throw new Error('插件注册 id 无效')
if (!/^[a-z0-9][\w.-]{0,63}$/i.test(id)) throw new Error(translate('common.invalid_plugin_registration_id_9702fe'))
return `plugin:${instance.info.id}:${id}`
}
function addView(instance: Instance, map: typeof sidebarViews, view: PluginView) {
assertActive(instance)
if (!view.title.trim() || typeof view.mount !== 'function') throw new Error('插件视图必须提供标题与挂载函数')
return track(instance, registerEntry(map, localID(instance, view.id), { ...view, owner: instance.info.id, fail: cause => fail(instance, '视图', cause) }))
if (!view.title.trim() || typeof view.mount !== 'function') throw new Error(translate('common.plugin_views_require_a_title_79ae1b'))
return track(instance, registerEntry(map, localID(instance, view.id), { ...view, owner: instance.info.id, fail: cause => fail(instance, translate('common.view_1c5c06'), cause) }))
}
async function createAPI(instance: Instance): Promise<PluginAPI> {
@@ -109,20 +110,20 @@ async function createAPI(instance: Instance): Promise<PluginAPI> {
addCommand(command) {
check()
const commandID = localID(instance, command.id)
if (!command.title.trim() || typeof command.run !== 'function' || allCommands().some(c => c.id === commandID)) throw new Error('命令无效或重复注册')
if (!command.title.trim() || typeof command.run !== 'function' || allCommands().some(c => c.id === commandID)) throw new Error(translate('common.invalid_or_duplicate_command_f6503b'))
return track(instance, registerCommand({ ...command, id: commandID, keys: [], when: () => {
if (instance.stopped) return false
try {
return command.when?.() ?? true
} catch (cause) {
fail(instance, '命令条件', cause); return false
fail(instance, translate('common.command_condition_df314d'), cause); return false
}
}, run: () => {
if (instance.stopped) return
try {
void Promise.resolve(command.run()).catch(cause => fail(instance, '命令', cause))
void Promise.resolve(command.run()).catch(cause => fail(instance, translate('common.command_928f87'), cause))
} catch (cause) {
fail(instance, '命令', cause)
fail(instance, translate('common.command_928f87'), cause)
}
} }))
},
@@ -136,13 +137,13 @@ async function createAPI(instance: Instance): Promise<PluginAPI> {
},
registerCodeBlockRenderer(language, render) {
check()
if (typeof render !== 'function') throw new Error('代码块渲染器必须为函数')
return track(instance, registerCodeRenderer(language, { owner: id, render, fail: cause => fail(instance, '代码块渲染', cause) }))
if (typeof render !== 'function') throw new Error(translate('common.a_code_block_renderer_must_952f83'))
return track(instance, registerCodeRenderer(language, { owner: id, render, fail: cause => fail(instance, translate('common.code_block_rendering_1d4ae4'), cause) }))
},
addSidebarView: view => addView(instance, sidebarViews, view),
addSettingTab: view => addView(instance, settingTabs, view),
registerCleanup: (cleanup) => {
if (typeof cleanup !== 'function') throw new Error('清理回调必须为函数')
if (typeof cleanup !== 'function') throw new Error(translate('common.a_cleanup_callback_must_be_6ce04e'))
return track(instance, cleanup)
},
vault: {
@@ -157,13 +158,13 @@ async function createAPI(instance: Instance): Promise<PluginAPI> {
},
on<K extends keyof VaultEvents>(event: K, callback: (value: VaultEvents[K]) => void | Promise<void>) {
check()
if (!['changed', 'moved', 'deleted', 'resync'].includes(event)) throw new Error('仓库事件类型无效')
if (!['changed', 'moved', 'deleted', 'resync'].includes(event)) throw new Error(translate('common.invalid_vault_event_type_8621e4'))
const listener = (e: Event) => {
if (instance.stopped) return
try {
void Promise.resolve(callback(event === 'resync' ? undefined : (e as CustomEvent).detail)).catch(cause => fail(instance, '仓库事件', cause))
void Promise.resolve(callback(event === 'resync' ? undefined : (e as CustomEvent).detail)).catch(cause => fail(instance, translate('common.vault_event_03582e'), cause))
} catch (cause) {
fail(instance, '仓库事件', cause)
fail(instance, translate('common.vault_event_03582e'), cause)
}
}
pluginEvents.addEventListener(event, listener)
@@ -197,9 +198,9 @@ async function activate(instance: Instance) {
const signal = instance.controller.signal
let onAbort: () => void = () => {}
const interrupted = new Promise<never>((_, reject) => {
onAbort = () => reject(new Error('插件已停止'))
onAbort = () => reject(new Error(translate('common.plugin_stopped_9ac692')))
signal.addEventListener('abort', onAbort, { once: true })
timer = setTimeout(() => reject(new Error('插件激活超过 10 秒')), 10_000)
timer = setTimeout(() => reject(new Error(translate('common.plugin_activation_exceeded_seconds_cffeae'))), 10_000)
})
const pending = (async () => {
const sdk = await createAPI(instance)
@@ -209,9 +210,9 @@ async function activate(instance: Instance) {
assertActive(instance)
const module = await import(/* @vite-ignore */ url)
assertActive(instance)
if (typeof module.default !== 'function') throw new Error('插件必须默认导出激活函数')
if (typeof module.default !== 'function') throw new Error(translate('common.a_plugin_must_export_an_1fbc50'))
const cleanup: unknown = await module.default(sdk)
if (cleanup !== undefined && typeof cleanup !== 'function') throw new Error('激活返回值必须为清理函数')
if (cleanup !== undefined && typeof cleanup !== 'function') throw new Error(translate('common.activation_must_return_a_cleanup_ea3a8c'))
if (cleanup) {
if (instance.stopped) {
try {
@@ -228,7 +229,7 @@ async function activate(instance: Instance) {
await Promise.race([pending, interrupted])
if (!instance.stopped) status(instance.info.id, 'active')
} catch (cause) {
fail(instance, '激活', cause)
fail(instance, translate('common.activation_dd1286'), cause)
} finally {
clearTimeout(timer!)
signal.removeEventListener('abort', onAbort)
@@ -256,12 +257,12 @@ async function pump() {
}
export async function refreshPlugins() {
if (!running) throw new CanceledError('插件宿主已停止')
if (!running) throw new CanceledError(translate('common.plugin_host_stopped_901067'))
const currentSession = session
const sequence = ++request
try {
const installed = (await getPluginsApi()).data.plugins
if (!running || currentSession !== session || sequence !== request) throw new CanceledError('插件刷新已过期')
if (!running || currentSession !== session || sequence !== request) throw new CanceledError(translate('common.plugin_refresh_superseded_2fa245'))
pluginState.installed = installed
pluginState.error = ''
pluginState.errorCause = undefined
@@ -277,9 +278,9 @@ export async function refreshPlugins() {
}
void pump()
} catch (cause) {
if (!running || currentSession !== session || sequence !== request) throw new CanceledError('插件刷新已过期')
if (!running || currentSession !== session || sequence !== request) throw new CanceledError(translate('common.plugin_refresh_superseded_2fa245'))
if (isCancel(cause)) throw cause
pluginState.error = `读取插件列表失败:${cause instanceof Error ? cause.message : String(cause)}`
pluginState.error = translate('common.could_not_load_plugins_a1c343', { p0: cause instanceof Error ? cause.message : String(cause) })
pluginState.errorCause = cause
for (const instance of instances.values()) {
status(instance.info.id, 'paused')
@@ -319,7 +320,7 @@ export function pluginEditorFailure(error: unknown) {
queueMicrotask(() => {
for (const owner of owners) {
const instance = instances.get(owner)
if (instance) fail(instance, '编辑器扩展', error)
if (instance) fail(instance, translate('common.editor_extension_28bb35'), error)
}
})
}
+3 -2
View File
@@ -1,4 +1,5 @@
import type { AsyncCleanup, Awaitable, MountContext } from '@@/plugin-api'
import { t as translate } from '@@/i18n/runtime'
// 每次挂载使用独立容器;超时或停用后的异步渲染只能修改已脱离页面的容器。
export function mountContent(
@@ -30,14 +31,14 @@ export function mountContent(
const timer = setTimeout(() => {
if (!pending || stopped) return
stop()
fail(new Error('插件视图渲染超过 10 秒'))
fail(new Error(translate('common.plugin_view_rendering_exceeded_seconds_0d20d2')))
}, 10_000)
void Promise.resolve().then(() => {
if (!stopped) return mount({ container, signal: controller.signal })
}).then((result) => {
pending = false
clearTimeout(timer)
if (result !== undefined && typeof result !== 'function') throw new Error('插件挂载返回值必须为清理函数')
if (result !== undefined && typeof result !== 'function') throw new Error(translate('common.plugin_mount_must_return_a_c66e82'))
if (stopped) {
if (result) runCleanup(result)
} else {
+8 -5
View File
@@ -1,6 +1,7 @@
import type { CodeBlockRenderer, PluginView } from '@@/plugin-api'
import type { Extension } from '@codemirror/state'
import type { MarkdownExtension } from '@lezer/markdown'
import { t as translate } from '@@/i18n/runtime'
import { Facet, StateEffect } from '@codemirror/state'
import { shallowReactive } from 'vue'
import { renderMermaid } from '../markdown/render'
@@ -33,7 +34,7 @@ export function pluginsChanged() {
}
export function registerEntry<K, V>(map: Map<K, V>, key: K, value: V): () => void {
if (map.has(key)) throw new Error('插件重复注册,不能覆盖已有功能')
if (map.has(key)) throw new Error(translate('common.duplicate_plugin_registration_cannot_replace_fbc39b'))
map.set(key, value)
try {
pluginsChanged()
@@ -49,7 +50,7 @@ export function registerEntry<K, V>(map: Map<K, V>, key: K, value: V): () => voi
export function registerCodeRenderer(language: string, value: OwnedRenderer) {
const key = language.trim().toLowerCase()
if (!/^[a-z0-9][a-z0-9_+-]*$/.test(key)) throw new Error('代码块语言名无效')
if (!/^[a-z0-9][a-z0-9_+-]*$/.test(key)) throw new Error(translate('common.invalid_code_block_language_name_8de01a'))
return registerEntry(codeRenderers, key, value)
}
@@ -57,7 +58,7 @@ registerCodeRenderer('mermaid', {
owner: 'rawmark-mermaid',
async render({ source, container, signal }) {
if (!source.trim()) {
container.textContent = '空图表'; return
container.textContent = translate('common.empty_diagram_37edb7'); return
}
try {
const svg = await renderMermaid(source)
@@ -65,8 +66,10 @@ registerCodeRenderer('mermaid', {
} catch (error) {
if (signal.aborted) return
container.className = 'cm-rm-render-error'
container.textContent = error instanceof Error ? error.message : '图表语法错误'
container.textContent = error instanceof Error ? error.message : translate('common.invalid_diagram_syntax_333e74')
}
},
fail: () => window.dispatchEvent(new CustomEvent('rawmark-plugin-notice', { detail: '内置图表渲染失败,请重试' })),
fail: () => window.dispatchEvent(new CustomEvent('rawmark-plugin-notice', { get detail() {
return translate('common.built_in_diagram_rendering_failed_e93cc6')
} })),
})
+2 -1
View File
@@ -1,3 +1,4 @@
import { t as translate } from '@@/i18n/runtime'
// 命令注册表:命令面板列出的操作与全局快捷键都来自这里;插件也通过 registerCommand 添加命令
import { shallowReactive } from 'vue'
import { pinia } from '@/pinia'
@@ -55,7 +56,7 @@ export function commandKeys(command: Command): string[] {
export function commandTitle(id: string): string {
const command = commands.get(id)
if (!command) throw new Error(`命令不存在:${id}`)
if (!command) throw new Error(translate('common.command_not_found_1cdb0c', { p0: id }))
const keys = commandKeys(command)
return command.title + (keys.length ? `(${keys.map(formatKeys).join(' / ')})` : '')
}
+7 -2
View File
@@ -1,13 +1,18 @@
import { t as translate } from '@@/i18n/runtime'
import { ElMessageBox } from 'element-plus'
import { pinia } from '@/pinia'
import { useUserStore } from '@/pinia/stores/user'
/** 确认等待期间换会话时,旧操作视为取消。 */
export async function confirmAction(message: string, title = '确认操作'): Promise<boolean> {
export async function confirmAction(message: string, title = translate('common.confirm_action_f8347f')): Promise<boolean> {
const user = useUserStore(pinia)
const version = user.getSessionVersion()
try {
await ElMessageBox.confirm(message, title, { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning', distinguishCancelAndClose: true })
await ElMessageBox.confirm(message, title, { get confirmButtonText() {
return translate('common.confirm_fac2a6')
}, get cancelButtonText() {
return translate('common.cancel_2cd0f3')
}, type: 'warning', distinguishCancelAndClose: true })
return version === user.getSessionVersion()
} catch (cause) {
if (cause === 'cancel' || cause === 'close') return false
+5 -2
View File
@@ -1,3 +1,4 @@
import { t as translate } from '@@/i18n/runtime'
// 应用、编辑器和快捷键录入共用同一套按键表示。
export const isMac = /Mac|iPhone|iPad/.test(navigator.platform)
const punctuation: Record<string, string> = { BracketLeft: '[', BracketRight: ']', Backquote: '`' }
@@ -13,7 +14,7 @@ function physicalCharacter(event: KeyboardEvent): string | undefined {
export function normalizeKeys(value: string): string {
const parts = value.split('+')
const key = parts.pop()
if (!key || parts.some(part => !modifiers.includes(part)) || new Set(parts).size !== parts.length || modifiers.includes(key)) throw new Error('快捷键格式无效')
if (!key || parts.some(part => !modifiers.includes(part)) || new Set(parts).size !== parts.length || modifiers.includes(key)) throw new Error(translate('common.invalid_shortcut_format_5e48e7'))
return [...modifiers.filter(part => parts.includes(part)), key.length === 1 ? key.toUpperCase() : key].join('+')
}
@@ -44,5 +45,7 @@ export function recordedKeys(event: KeyboardEvent): string | null {
}
export function formatKeys(value: string): string {
return value.split('+').map(part => ({ Mod: isMac ? '⌘' : 'Ctrl', Meta: isMac ? '⌘' : 'Win', Ctrl: isMac ? '⌃' : 'Ctrl', Alt: isMac ? '⌥' : 'Alt', Shift: isMac ? '⇧' : 'Shift', Space: '空格', Plus: '+' })[part] ?? part).join(isMac ? '' : '+')
return value.split('+').map(part => ({ Mod: isMac ? '⌘' : 'Ctrl', Meta: isMac ? '⌘' : 'Win', Ctrl: isMac ? '⌃' : 'Ctrl', Alt: isMac ? '⌥' : 'Alt', Shift: isMac ? '⇧' : 'Shift', get Space() {
return translate('common.space_bfa4a6')
}, Plus: '+' })[part] ?? part).join(isMac ? '' : '+')
}
+3 -2
View File
@@ -1,4 +1,5 @@
import { createResourceTicketApi } from '@@/apis/auth'
import { t as translate } from '@@/i18n/runtime'
import { pinia } from '@/pinia'
import { useUserStore } from '@/pinia/stores/user'
@@ -28,11 +29,11 @@ export async function prepareResources() {
user.assertSession(current)
}
export function imageResourceRoot(): string {
if (!imageRoot || version !== useUserStore(pinia).getSessionVersion()) throw new Error('图片资源认证尚未就绪')
if (!imageRoot || version !== useUserStore(pinia).getSessionVersion()) throw new Error(translate('common.image_resource_authentication_is_not_73d285'))
return imageRoot
}
export function fontResourceRoot(): string {
if (!fontRoot || version !== useUserStore(pinia).getSessionVersion()) throw new Error('字体资源认证尚未就绪')
if (!fontRoot || version !== useUserStore(pinia).getSessionVersion()) throw new Error(translate('common.font_resource_authentication_is_not_796b9b'))
return fontRoot
}
export async function pluginResourceURL(pluginId: string, revision: string): Promise<string> {