@ansidev-oss/vitepress-theme-ansidev 1.0.11 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/client/Donation-BBPb8v5p.js +66 -0
  2. package/dist/client/GoogleAnalytics-34XEM7eO.js +25 -0
  3. package/dist/client/Sharing-CpagX9oy.js +185 -0
  4. package/dist/client/Swetrix-k7SUudua.js +624 -0
  5. package/dist/client/components/PostDate.test.d.ts +1 -0
  6. package/dist/client/composables/index.d.ts +1 -0
  7. package/dist/client/composables/lang.d.ts +27 -0
  8. package/dist/client/composables/lang.test.d.ts +1 -0
  9. package/dist/client/index.js +3769 -9
  10. package/dist/client/medium-zoom-DPl0T7qn.js +266 -0
  11. package/dist/client/plugins/sharing/index.d.ts +4 -0
  12. package/dist/client/record-Cgv_fXDH.js +2739 -0
  13. package/dist/client/styles.css +2 -1
  14. package/dist/node/composables.d.mts +7 -17
  15. package/dist/node/composables.mjs +7 -15
  16. package/dist/node/config.d.mts +6 -17
  17. package/dist/node/config.mjs +9 -6
  18. package/package.json +23 -12
  19. package/src/client/components/PostDate.test.ts +73 -0
  20. package/src/client/components/PostDate.vue +18 -8
  21. package/src/client/components/PostItem.vue +1 -1
  22. package/src/client/composables/index.ts +1 -0
  23. package/src/client/composables/lang.test.ts +52 -0
  24. package/src/client/composables/lang.ts +58 -0
  25. package/src/client/index.ts +5 -0
  26. package/src/client/plugins/sharing/index.ts +7 -1
  27. package/src/client/styles/base.css +30 -0
  28. package/src/client/styles/blog.css +352 -0
  29. package/src/client/styles/index.css +3 -30
  30. package/src/vite-env.d.ts +1 -0
  31. package/dist/client/Donation-D9Xn0MPt.js +0 -76
  32. package/dist/client/GoogleAnalytics-Dp3EGn1x.js +0 -29
  33. package/dist/client/Sharing-ZrU7YF-x.js +0 -190
  34. package/dist/client/Swetrix-CM1mbD6X.js +0 -450
  35. package/dist/client/VPAlgoliaSearchBox-BAhNuo7n.js +0 -126
  36. package/dist/client/VPCarbonAds-CSK7KnIu.js +0 -31
  37. package/dist/client/VPLocalSearchBox-TYbANUkJ.js +0 -4086
  38. package/dist/client/index-BtT3qA6T.js +0 -12663
  39. package/dist/client/index-C43AnX3f.js +0 -7616
  40. package/dist/client/index-CCR5sUM8.js +0 -10688
  41. package/dist/client/index-DuYWs0vj.js +0 -265
@@ -1,4 +1,3 @@
1
- //#region src/node/composables/types.d.ts
2
1
  type Pattern = string | string[];
3
2
  type Frontmatter = Record<string, unknown> & {
4
3
  date?: string;
@@ -6,27 +5,18 @@ type Frontmatter = Record<string, unknown> & {
6
5
  };
7
6
  type FrontmatterToString = (frontmatter: Frontmatter) => string;
8
7
  type StringOrFrontmatterToString = string | FrontmatterToString;
9
- //#endregion
10
- //#region src/node/composables/markdown.d.ts
11
- declare const useMarkdownFrontmatter: (pattern: Pattern, excerptSeparator?: string) => Array<Frontmatter>;
12
- //#endregion
13
- //#region src/node/composables/route.d.ts
14
- declare const useMarkdownFrontmatterRoute: (pattern: Pattern, frontmatterKey: string, excerptSeparator?: string) => {
8
+ export declare const useMarkdownFrontmatter: (pattern: Pattern, excerptSeparator?: string) => Array<Frontmatter>;
9
+ export declare const useMarkdownFrontmatterRoute: (pattern: Pattern, frontmatterKey: string, excerptSeparator?: string) => {
15
10
  paths(): any;
16
11
  };
17
- declare const useArchiveRoute: (pattern: Pattern, excerptSeparator?: string) => {
12
+ export declare const useArchiveRoute: (pattern: Pattern, excerptSeparator?: string) => {
18
13
  paths(): any;
19
14
  };
20
- //#endregion
21
- //#region src/shared/composables/useSlug.d.ts
22
- declare const useSlug: (str: string) => string;
23
- declare const useSlugFilter: (slug: string) => (str: string) => boolean;
24
- //#endregion
25
- //#region src/node/composables/slug.d.ts
26
- declare const useSlugFromMarkdownFrontMatter: (pattern: Pattern, frontmatterKeyOrMappingFn: StringOrFrontmatterToString, excerptSeparator?: string) => {
15
+ export declare const useSlug: (str: string) => string;
16
+ export declare const useSlugFilter: (slug: string) => (str: string) => boolean;
17
+ export declare const useSlugFromMarkdownFrontMatter: (pattern: Pattern, frontmatterKeyOrMappingFn: StringOrFrontmatterToString, excerptSeparator?: string) => {
27
18
  params: {
28
19
  slug: any;
29
20
  };
30
21
  }[];
31
- //#endregion
32
- export { useArchiveRoute, useMarkdownFrontmatter, useMarkdownFrontmatterRoute, useSlug, useSlugFilter, useSlugFromMarkdownFrontMatter };
22
+ export {};
@@ -2,11 +2,11 @@ import fs from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { globSync } from "glob";
4
4
  import matter from "gray-matter";
5
-
6
- //#region src/node/composables/markdown.ts
5
+ // src/node/composables/markdown.ts
7
6
  const useMarkdownFrontmatter = (pattern, excerptSeparator = "---") => {
8
7
  return globSync(pattern, { ignore: ["node_modules/**", ".git/**"] }).map((file) => {
9
- const { data, excerpt } = matter(fs.readFileSync(resolve(file), "utf-8"), {
8
+ const src = fs.readFileSync(resolve(file), "utf-8");
9
+ const { data, excerpt } = matter(src, {
10
10
  excerpt: true,
11
11
  excerpt_separator: excerptSeparator
12
12
  });
@@ -16,16 +16,12 @@ const useMarkdownFrontmatter = (pattern, excerptSeparator = "---") => {
16
16
  };
17
17
  });
18
18
  };
19
-
20
- //#endregion
21
- //#region src/shared/composables/useSlug.ts
19
+ // src/shared/composables/useSlug.ts
22
20
  const useSlug = (str) => {
23
21
  return str.replace(/#/, "-sharp-").replace(/\./, "-dot-").replace(/-$/, "").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/g, "d").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
24
22
  };
25
23
  const useSlugFilter = (slug) => (str) => useSlug(str) === slug;
26
-
27
- //#endregion
28
- //#region src/node/composables/slug.ts
24
+ // src/node/composables/slug.ts
29
25
  const useSlugFromMarkdownFrontMatter = (pattern, frontmatterKeyOrMappingFn, excerptSeparator = "---") => {
30
26
  const frontMatters = useMarkdownFrontmatter(pattern, excerptSeparator);
31
27
  const _frontmatterMappingFn = typeof frontmatterKeyOrMappingFn === "string" ? (frontMatter) => frontMatter[frontmatterKeyOrMappingFn] : frontmatterKeyOrMappingFn;
@@ -36,9 +32,7 @@ const useSlugFromMarkdownFrontMatter = (pattern, frontmatterKeyOrMappingFn, exce
36
32
  return { params: { slug } };
37
33
  });
38
34
  };
39
-
40
- //#endregion
41
- //#region src/node/composables/route.ts
35
+ // src/node/composables/route.ts
42
36
  const useMarkdownFrontmatterRoute = (pattern, frontmatterKey, excerptSeparator = "---") => {
43
37
  return { paths() {
44
38
  return useSlugFromMarkdownFrontMatter(pattern, frontmatterKey, excerptSeparator);
@@ -49,6 +43,4 @@ const useArchiveRoute = (pattern, excerptSeparator = "---") => {
49
43
  return useSlugFromMarkdownFrontMatter(pattern, (frontmatter) => new Date(frontmatter.date).getFullYear().toString(), excerptSeparator);
50
44
  } };
51
45
  };
52
-
53
- //#endregion
54
- export { useArchiveRoute, useMarkdownFrontmatter, useMarkdownFrontmatterRoute, useSlug, useSlugFilter, useSlugFromMarkdownFrontMatter };
46
+ export { useArchiveRoute, useMarkdownFrontmatter, useMarkdownFrontmatterRoute, useSlug, useSlugFilter, useSlugFromMarkdownFrontMatter };
@@ -1,8 +1,6 @@
1
1
  import { UserConfig } from "vitepress";
2
2
  import { DefaultTheme } from "vitepress/theme";
3
3
  import * as Swetrix from "swetrix";
4
-
5
- //#region src/client/plugins/donation/index.d.ts
6
4
  declare interface DonationPluginConfig {
7
5
  buymeacoffee?: string;
8
6
  kofi?: string;
@@ -19,8 +17,6 @@ declare interface CustomDonationBaseConfig {
19
17
  declare interface CustomDonationConfig extends CustomDonationBaseConfig {
20
18
  donationId: string;
21
19
  }
22
- //#endregion
23
- //#region src/client/plugins/google-analytics/index.d.ts
24
20
  interface GoogleAnalyticsOptions {
25
21
  /**
26
22
  * The Google Analytics ID.
@@ -29,8 +25,6 @@ interface GoogleAnalyticsOptions {
29
25
  */
30
26
  id: string;
31
27
  }
32
- //#endregion
33
- //#region src/client/plugins/medium-zoom/index.d.ts
34
28
  declare module 'medium-zoom' {
35
29
  interface Zoom {
36
30
  refresh: (selector?: string) => void;
@@ -41,17 +35,17 @@ type MediumZoomPluginConfig = {
41
35
  zoomOptions?: string;
42
36
  delay?: number;
43
37
  };
44
- //#endregion
45
- //#region src/client/plugins/sharing/index.d.ts
46
38
  declare interface SharingPluginConfig {
39
+ wrapperCssClass?: string;
40
+ titleCssClass?: string;
41
+ buttonCssClass?: string;
42
+ title?: string;
47
43
  networks?: string[];
48
44
  displayMode?: string;
49
45
  buttonSize?: number;
50
46
  iconSize?: number;
51
47
  iconStyle?: string;
52
48
  }
53
- //#endregion
54
- //#region src/client/plugins/swetrix/index.d.ts
55
49
  interface SwetrixOptions {
56
50
  /**
57
51
  * The Swetrix ID.
@@ -61,8 +55,6 @@ interface SwetrixOptions {
61
55
  id: string;
62
56
  options?: Swetrix.LibOptions;
63
57
  }
64
- //#endregion
65
- //#region src/client/types/index.d.ts
66
58
  interface ThemeConfig extends DefaultTheme.Config {
67
59
  /**
68
60
  * The site URL.
@@ -80,8 +72,6 @@ interface ThemeConfig extends DefaultTheme.Config {
80
72
  */
81
73
  themeFooter?: DefaultTheme.Footer;
82
74
  }
83
- //#endregion
84
- //#region src/node/config.d.ts
85
75
  /**
86
76
  * @type {import('vitepress').UserConfig}
87
77
  */
@@ -123,6 +113,5 @@ declare const baseConfig: {
123
113
  * Helper function to define theme configuration with proper typing
124
114
  * Note: TailwindCSS plugin should be added in vite.config.ts, not here
125
115
  */
126
- declare function defineWithDefaultThemeConfig(config?: UserConfig<ThemeConfig>): UserConfig<ThemeConfig>;
127
- //#endregion
128
- export { baseConfig as default, defineWithDefaultThemeConfig };
116
+ export declare function defineWithDefaultThemeConfig(config?: UserConfig<ThemeConfig>): UserConfig<ThemeConfig>;
117
+ export { baseConfig as default };
@@ -1,6 +1,5 @@
1
1
  import { loadEnv, mergeConfig } from "vitepress";
2
-
3
- //#region src/node/config.ts
2
+ // src/node/config.ts
4
3
  process.env.VITE_EXTRA_EXTENSIONS = "rss";
5
4
  globalThis.__VUE_PROD_DEVTOOLS__ = process.env.NODE_ENV === "development";
6
5
  const env = loadEnv(process.env.NODE_ENV || "development", process.cwd(), "VITE_");
@@ -21,7 +20,13 @@ const baseConfig = {
21
20
  googleAnalytics: { id: env.VITE_GA_ID },
22
21
  search: {
23
22
  provider: "local",
24
- options: { async _render(src, env, md) {
23
+ options: {
24
+ /**
25
+ * @param {string} src
26
+ * @param {import('vitepress').MarkdownEnv} env
27
+ * @param {import('markdown-it')} md
28
+ */
29
+ async _render(src, env, md) {
25
30
  const html = await md.renderAsync(src, env);
26
31
  if (env.frontmatter?.search === false) return "";
27
32
  let renderedHtml = "";
@@ -51,6 +56,4 @@ function defineWithDefaultThemeConfig(config = {}) {
51
56
  }
52
57
  return mergeConfig(baseConfig, config);
53
58
  }
54
-
55
- //#endregion
56
- export { baseConfig as default, defineWithDefaultThemeConfig };
59
+ export { baseConfig as default, defineWithDefaultThemeConfig };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ansidev-oss/vitepress-theme-ansidev",
3
- "version": "1.0.11",
3
+ "version": "1.1.0",
4
4
  "description": "The VitePress theme for ansidev's blog",
5
5
  "type": "module",
6
6
  "main": "dist/node/index.js",
@@ -49,39 +49,50 @@
49
49
  "vue-i18n": "^11.2.8"
50
50
  },
51
51
  "devDependencies": {
52
- "@biomejs/biome": "2.4.2",
52
+ "@biomejs/biome": "2.6.3",
53
53
  "@iconify/vue": "^5.0.0",
54
- "@tailwindcss/postcss": "^4.1.18",
55
- "@types/node": "^25.2.3",
54
+ "@tailwindcss/postcss": "^4.2.0",
55
+ "@types/node": "^25.3.0",
56
56
  "@vitejs/plugin-vue": "^6.0.4",
57
+ "@vue/test-utils": "^2.5.1",
57
58
  "autoprefixer": "^10.4.24",
58
- "concurrently": "^9.2.1",
59
- "glob": "^13.0.5",
59
+ "concurrently": "^10.0.0",
60
+ "glob": "^13.0.6",
60
61
  "gray-matter": "^4.0.3",
62
+ "happy-dom": "^21.0.4",
61
63
  "medium-zoom": "^1.1.0",
62
64
  "postcss": "^8.5.6",
63
65
  "prettier": "^3.8.1",
64
66
  "rimraf": "^6.1.3",
65
- "rollup-plugin-copy": "^3.5.0",
66
67
  "swetrix": "^4.1.0",
67
- "tailwindcss": "^4.1.18",
68
+ "tailwindcss": "^4.2.0",
68
69
  "ts-node": "^10.9.2",
69
- "tsdown": "^0.20.3",
70
- "typescript": "^5.9.3",
71
- "vite": "^7.3.1",
70
+ "tsdown": "^0.23.0",
71
+ "typescript": "^6.0.0",
72
+ "vite": "^8.0.0",
72
73
  "vitepress": "^2.0.0-alpha.16",
74
+ "vitest": "^5.0.5",
73
75
  "vue": "^3.5.28",
74
- "vue-tsc": "^3.2.4"
76
+ "vue-tsc": "^3.2.5"
75
77
  },
76
78
  "peerDependencies": {
77
79
  "@iconify/vue": "^5.0.0",
80
+ "glob": "^13.0.6",
81
+ "gray-matter": "^4.0.3",
78
82
  "medium-zoom": "^1.1.0",
79
83
  "swetrix": "^4.1.0",
84
+ "ts-node": "^10.9.2",
85
+ "typescript": "^5.9.3",
80
86
  "vitepress": "^2.0.0-alpha.16",
81
87
  "vue": "^3.5.28"
82
88
  },
89
+ "engines": {
90
+ "node": ">=26.2.0",
91
+ "pnpm": ">=11.3.0"
92
+ },
83
93
  "scripts": {
84
94
  "typecheck": "tsc --noEmit",
95
+ "test": "vitest run",
85
96
  "lint:check": "biome check",
86
97
  "lint": "biome check --write",
87
98
  "dev:client": "vite build --watch",
@@ -0,0 +1,73 @@
1
+ import { mount } from '@vue/test-utils'
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import { createI18n } from 'vue-i18n'
4
+ import PostDate from './PostDate.vue'
5
+
6
+ const data = vi.hoisted(() => ({ frontmatter: { value: {} as Record<string, unknown> }, lang: { value: 'en-US' } }))
7
+ vi.mock('vitepress', () => ({ useData: () => data }))
8
+
9
+ const i18n = createI18n({
10
+ legacy: false,
11
+ locale: 'en',
12
+ messages: { en: { posted_on: 'Posted on' } },
13
+ })
14
+
15
+ const render = (date?: Date | string) => mount(PostDate, { props: { date }, global: { plugins: [i18n] } })
16
+ const datetimeOf = (date?: Date | string) => render(date).get('time').attributes('datetime')
17
+
18
+ describe('PostDate locale', () => {
19
+ afterEach(() => {
20
+ data.frontmatter.value = {}
21
+ data.lang.value = 'en-US'
22
+ })
23
+
24
+ const textOf = () => render('2023-04-05T06:07:08.000Z').get('time').text()
25
+
26
+ it('formats in English for en-US', () => {
27
+ expect(textOf()).toBe('April 5, 2023')
28
+ })
29
+
30
+ it('formats in Vietnamese when frontmatter lang is vi', () => {
31
+ data.frontmatter.value = { lang: 'vi' }
32
+ expect(textOf()).toBe('5 tháng 4, 2023')
33
+ })
34
+
35
+ it('falls back to the site lang without frontmatter lang', () => {
36
+ data.lang.value = 'vi'
37
+ expect(textOf()).toBe('5 tháng 4, 2023')
38
+ })
39
+
40
+ it('falls back to English for an invalid lang tag', () => {
41
+ data.frontmatter.value = { lang: 'not_a_lang!' }
42
+ expect(textOf()).toBe('April 5, 2023')
43
+ })
44
+ })
45
+
46
+ describe('PostDate datetime', () => {
47
+ afterEach(() => {
48
+ vi.useRealTimers()
49
+ data.frontmatter.value = {}
50
+ data.lang.value = 'en-US'
51
+ })
52
+
53
+ it('matches the date prop when given a Date', () => {
54
+ const date = new Date('2023-04-05T06:07:08.000Z')
55
+ expect(datetimeOf(date)).toBe(date.toISOString())
56
+ })
57
+
58
+ it('matches the date prop when given a string', () => {
59
+ expect(datetimeOf('2023-04-05T06:07:08.000Z')).toBe('2023-04-05T06:07:08.000Z')
60
+ expect(datetimeOf('2023-04-05')).toBe(new Date('2023-04-05').toISOString())
61
+ })
62
+
63
+ it('does not depend on render time', () => {
64
+ vi.useFakeTimers()
65
+ vi.setSystemTime(new Date('2030-01-01T00:00:00.000Z'))
66
+ expect(datetimeOf('2023-04-05T06:07:08.000Z')).toBe('2023-04-05T06:07:08.000Z')
67
+ })
68
+
69
+ it('omits datetime and does not throw for a missing or invalid date', () => {
70
+ expect(datetimeOf(undefined)).toBeUndefined()
71
+ expect(datetimeOf('not a date')).toBeUndefined()
72
+ })
73
+ })
@@ -1,6 +1,7 @@
1
1
  <script setup lang='ts'>
2
2
  import { computed } from 'vue'
3
3
  import { useI18n } from 'vue-i18n'
4
+ import { usePageLang } from '../composables/lang'
4
5
 
5
6
  const props = defineProps({
6
7
  /**
@@ -14,20 +15,29 @@ const props = defineProps({
14
15
  })
15
16
 
16
17
  const { t } = useI18n()
18
+ const pageLang = usePageLang()
19
+
20
+ const formatDate = (date: Date, locale: string) => {
21
+ const options: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' }
22
+ try {
23
+ return date.toLocaleDateString(locale, options)
24
+ } catch {
25
+ // Invalid lang tag: fall back to English.
26
+ return date.toLocaleDateString('en-US', options)
27
+ }
28
+ }
29
+
17
30
  const dateObject = computed(() => {
18
31
  const date = props.date instanceof Date ? props.date : new Date(props.date as string)
19
32
 
33
+ const isValid = !Number.isNaN(+date)
34
+
20
35
  return {
21
36
  time: +date,
22
- string: date.toLocaleDateString('en-US', {
23
- year: 'numeric',
24
- month: 'long',
25
- day: 'numeric',
26
- }),
37
+ iso: isValid ? date.toISOString() : undefined,
38
+ string: formatDate(date, pageLang.value),
27
39
  }
28
40
  })
29
-
30
- const getDateTime = () => new Date().toISOString()
31
41
  </script>
32
42
 
33
43
  <template>
@@ -36,7 +46,7 @@ const getDateTime = () => new Date().toISOString()
36
46
  {{ t('posted_on') }}
37
47
  </dt>
38
48
  <dd class="font-medium leading-6" :class="[textSize ? `text-${textSize}` : 'text-base']">
39
- <time :datetime="getDateTime()">{{ dateObject.string }}</time>
49
+ <time :datetime="dateObject.iso">{{ dateObject.string }}</time>
40
50
  </dd>
41
51
  </dl>
42
52
  </template>
@@ -27,7 +27,7 @@ const postDate = computed(() => usePostDate(props.post.date))
27
27
  <div class="flex flex-wrap mb-3">
28
28
  <TermLink v-for="category in post.categories" :key="category" :text="category" basePath="/categories/" />
29
29
  </div>
30
- <p>{{ post?.excerpt }}</p>
30
+ <p v-html="post?.excerpt" />
31
31
  </div>
32
32
  </div>
33
33
  </template>
@@ -1,4 +1,5 @@
1
1
  export * from '../../shared/composables'
2
2
  export * from './date'
3
+ export * from './lang'
3
4
  export * from './project'
4
5
  export * from './rank'
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { nextTick, ref } from 'vue'
3
+ import { createI18n } from 'vue-i18n'
4
+ import { matchLocale, usePageLocale } from './lang'
5
+
6
+ const data = { frontmatter: ref<Record<string, unknown>>({}), lang: ref('en') }
7
+ vi.mock('vitepress', () => ({ useData: () => data }))
8
+
9
+ describe('matchLocale', () => {
10
+ const available = ['en', 'vi']
11
+
12
+ it('matches exactly', () => {
13
+ expect(matchLocale('vi', available)).toBe('vi')
14
+ })
15
+
16
+ it('matches by base language and is case-insensitive', () => {
17
+ expect(matchLocale('vi-VN', available)).toBe('vi')
18
+ expect(matchLocale('EN-us', available)).toBe('en')
19
+ })
20
+
21
+ it('returns undefined when unsupported', () => {
22
+ expect(matchLocale('fr', available)).toBeUndefined()
23
+ })
24
+ })
25
+
26
+ describe('usePageLocale', () => {
27
+ const setup = () => {
28
+ const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {}, vi: {} } })
29
+ usePageLocale(i18n)
30
+ return i18n
31
+ }
32
+
33
+ it('sets the locale from front matter lang immediately', () => {
34
+ data.frontmatter.value = { lang: 'vi' }
35
+ expect(setup().global.locale.value).toBe('vi')
36
+ })
37
+
38
+ it('keeps a manual choice until the page lang changes', async () => {
39
+ data.frontmatter.value = { lang: 'vi' }
40
+ const i18n = setup()
41
+ i18n.global.locale.value = 'en'
42
+ data.frontmatter.value = { lang: 'vi' }
43
+ await nextTick()
44
+ expect(i18n.global.locale.value).toBe('en')
45
+ data.frontmatter.value = {}
46
+ await nextTick()
47
+ expect(i18n.global.locale.value).toBe('en')
48
+ data.frontmatter.value = { lang: 'vi' }
49
+ await nextTick()
50
+ expect(i18n.global.locale.value).toBe('vi')
51
+ })
52
+ })
@@ -0,0 +1,58 @@
1
+ import { useData } from 'vitepress'
2
+ import { type ComputedRef, computed, onMounted, watch, watchEffect } from 'vue'
3
+ import type { Locale } from 'vue-i18n'
4
+
5
+ /**
6
+ * Resolved language of the current page: `frontmatter.lang`, falling back to the site lang.
7
+ */
8
+ export const usePageLang = (): ComputedRef<string> => {
9
+ const { frontmatter, lang } = useData()
10
+ return computed(() => {
11
+ const pageLang = frontmatter.value.lang
12
+ return typeof pageLang === 'string' && pageLang.trim() ? pageLang.trim() : lang.value
13
+ })
14
+ }
15
+
16
+ /**
17
+ * Keeps `<html lang>` in sync with the resolved page lang.
18
+ * VitePress resets it to the site lang on mount, so re-apply it after mount and on navigation.
19
+ */
20
+ export const useHtmlLang = (): void => {
21
+ const pageLang = usePageLang()
22
+ // Registered inside onMounted so it runs after VitePress's own mount-time reset.
23
+ onMounted(() => {
24
+ watchEffect(
25
+ () => {
26
+ document.documentElement.lang = pageLang.value
27
+ },
28
+ { flush: 'post' },
29
+ )
30
+ })
31
+ }
32
+
33
+ /**
34
+ * Picks the available locale matching `lang` exactly or by base language (`vi-VN` -> `vi`).
35
+ */
36
+ export const matchLocale = (lang: string, availableLocales: readonly Locale[]): Locale | undefined => {
37
+ const normalized = lang.toLowerCase()
38
+ return (
39
+ availableLocales.find((l) => l.toLowerCase() === normalized) ??
40
+ availableLocales.find((l) => l.toLowerCase() === normalized.split('-')[0])
41
+ )
42
+ }
43
+
44
+ /**
45
+ * Syncs the vue-i18n locale with the resolved page lang, synchronously so SSR renders the right strings.
46
+ * A manual `LocaleSwitcher` choice lasts until the page lang changes.
47
+ */
48
+ export const usePageLocale = (i18n: { global: { locale: { value: Locale }; availableLocales: Locale[] } }): void => {
49
+ const pageLang = usePageLang()
50
+ watch(
51
+ pageLang,
52
+ (lang) => {
53
+ const matched = matchLocale(lang, i18n.global.availableLocales)
54
+ if (matched) i18n.global.locale.value = matched
55
+ },
56
+ { immediate: true },
57
+ )
58
+ }
@@ -1,6 +1,7 @@
1
1
  import { Icon } from '@iconify/vue'
2
2
  import type { Theme } from 'vitepress'
3
3
  import DefaultTheme from 'vitepress/theme'
4
+ import { useHtmlLang, usePageLocale } from './composables/lang'
4
5
  import Layout from './layouts/Layout.vue'
5
6
  import ArchivesPage from './pages/ArchivesPage.vue'
6
7
  import CategoriesPage from './pages/CategoriesPage.vue'
@@ -29,6 +30,10 @@ const theme: Theme = {
29
30
  app.component('ArchivesPage', ArchivesPage)
30
31
  app.use(i18n)
31
32
  },
33
+ setup() {
34
+ useHtmlLang()
35
+ usePageLocale(i18n)
36
+ },
32
37
  } satisfies Theme
33
38
 
34
39
  export * from './composables'
@@ -1,4 +1,8 @@
1
1
  export declare interface SharingPluginConfig {
2
+ wrapperCssClass?: string
3
+ titleCssClass?: string
4
+ buttonCssClass?: string
5
+ title?: string
2
6
  networks?: string[]
3
7
  displayMode?: string
4
8
  buttonSize?: number
@@ -65,7 +69,9 @@ export const useUrlQueryParamsBuilder = (params: Record<string, string | undefin
65
69
  }
66
70
  const esc = encodeURIComponent
67
71
  const paramKeys = Object.keys(params)
68
- if (paramKeys.length < 1) return ''
72
+ if (paramKeys.length < 1) {
73
+ return ''
74
+ }
69
75
 
70
76
  const s = paramKeys
71
77
  .filter((key) => params[key] !== undefined)
@@ -0,0 +1,30 @@
1
+ @import "tailwindcss";
2
+ @variant dark (&:where(.dark, .dark *));
3
+
4
+ @theme {
5
+ --color-primary: rgb(59, 130, 246);
6
+ --color-primary-alt: rgb(29, 78, 216);
7
+ --color-primary-alt-dark: rgb(147, 197, 253);
8
+ --color-secondary: rgb(239, 68, 68);
9
+ --color-secondary-dark: rgb(234, 179, 8);
10
+ }
11
+
12
+ @utility component-border {
13
+ @apply border rounded-md border-gray-500 hover:border-secondary dark:hover:border-secondary-dark;
14
+ }
15
+
16
+ @utility link {
17
+ @apply no-underline hover:text-secondary dark:hover:text-secondary-dark;
18
+ }
19
+
20
+ @utility link-alt {
21
+ @apply text-primary-alt! dark:text-primary-alt-dark! link!;
22
+ }
23
+
24
+ :root {
25
+ --vp-c-brand-1: var(--color-primary);
26
+ }
27
+
28
+ .vp-doc a {
29
+ @apply link;
30
+ }