1+ <template >
2+ <section class =" checklist" :aria-label =" title || 'Checklist'" >
3+ <header class =" checklist__head" >
4+ <h3 >{{ title || 'Checklist' }}</h3 >
5+ <span class =" checklist__count" >{{ doneCount }}/{{ items.length }}</span >
6+ </header >
7+
8+ <ul class =" checklist__list" >
9+ <li v-for =" (text, i) in items" :key =" i" class =" checklist__item" >
10+ <label class =" checklist__row" >
11+ <input type =" checkbox" v-model =" doneMask[i]" />
12+ <!-- Render Markdown-style links and resolve VitePress-relative links -->
13+ <span :class =" { done: doneMask[i] }" v-html =" renderItem(text)" ></span >
14+ </label >
15+ </li >
16+ </ul >
17+
18+ <footer class =" checklist__foot" >
19+ <button class =" checklist__btn" @click =" clearDone" :disabled =" doneCount === 0" >
20+ Clear completed
21+ </button >
22+ </footer >
23+ </section >
24+ </template >
25+
26+ <script setup>
27+ import { ref , computed , onMounted , watch } from ' vue'
28+ import { useRoute } from ' vitepress' // withBase is handled manually to avoid double-base bugs
29+ import DOMPurify from ' dompurify' // ensure this component is wrapped in <ClientOnly> in MD
30+
31+ const props = defineProps ({
32+ items: { type: Array , default : () => [] },
33+ title: { type: String , default: ' Checklist' },
34+ storageKey: { type: String , default: ' vp-checklist' }
35+ })
36+
37+ // Persist completion state by index — not item text.
38+ const doneMask = ref ([])
39+
40+ function resizeMask (len ) {
41+ const mask = Array .isArray (doneMask .value ) ? doneMask .value : []
42+ if (mask .length === len) return
43+ if (mask .length > len) doneMask .value = mask .slice (0 , len)
44+ else doneMask .value = mask .concat (Array (len - mask .length ).fill (false ))
45+ }
46+
47+ function load () {
48+ resizeMask (props .items .length )
49+ try {
50+ const raw = localStorage .getItem (props .storageKey )
51+ if (! raw) return
52+ const arr = JSON .parse (raw)
53+ if (Array .isArray (arr)) {
54+ doneMask .value = arr .map (Boolean )
55+ resizeMask (props .items .length )
56+ }
57+ } catch {}
58+ }
59+
60+ function persist () {
61+ try { localStorage .setItem (props .storageKey , JSON .stringify (doneMask .value )) } catch {}
62+ }
63+
64+ const doneCount = computed (() => doneMask .value .filter (Boolean ).length )
65+
66+ function clearDone () {
67+ doneMask .value = doneMask .value .map (() => false )
68+ }
69+
70+ // --- Link resolution helpers -------------------------------------------------
71+
72+ const route = useRoute ()
73+ // Vite/VitePress base (e.g., '/documentation/')
74+ const BASE = (import .meta.env.BASE_URL || ' /' ).replace(/\/+$/, ' /' ) // ensure trailing slash
75+
76+ function isExternal (href ) {
77+ return / ^ (?:https? :)? \/\/ / i .test (href) || href .startsWith (' mailto:' )
78+ }
79+
80+ // Normalize internal path so base is applied exactly once
81+ function applyBase (pathname ) {
82+ // ensure leading slash
83+ let p = pathname .startsWith (' /' ) ? pathname : ' /' + pathname .replace (/ ^ \. ? \/ / , ' ' )
84+ // avoid double base (e.g., '/documentation/documentation/...'):
85+ if (BASE !== ' /' && p .startsWith (BASE )) return p
86+ // prepend base (strip trailing slash from base when concatenating)
87+ const base = BASE .endsWith (' /' ) ? BASE .slice (0 , - 1 ) : BASE
88+ return base + p
89+ }
90+
91+ // Resolve internal/relative links similar to VitePress router behavior.
92+ function resolveInternalLink (href ) {
93+ if (! href) return href
94+ if (isExternal (href) || href .startsWith (' #' )) return href
95+
96+ // Base directory of current page (e.g., '/guide/intro.html' -> '/guide/')
97+ const baseDir = route .path .endsWith (' /' ) ? route .path : route .path .replace (/ [^ /] + $ / , ' /' )
98+
99+ // Build URL relative to current route (uses window.location only on client)
100+ const origin = typeof window !== ' undefined' ? window .location .origin : ' http://localhost'
101+ const u = new URL (href, origin + baseDir)
102+
103+ let p = u .pathname
104+
105+ // Convert .md to .html for internal docs pages
106+ if (/ \. md$ / i .test (p)) {
107+ p = p .replace (/ \. md$ / i , ' .html' )
108+ }
109+
110+ // If the path looks like a directory (no extension) and doesn’t end with '/', add trailing slash
111+ const hasExt = / \. [a-z0-9 ] + $ / i .test (p)
112+ if (! hasExt && ! p .endsWith (' /' )) {
113+ p = p + ' /'
114+ }
115+
116+ // Prepend site base exactly once; preserve query/hash
117+ const resolved = applyBase (p) + (u .search || ' ' ) + (u .hash || ' ' )
118+ return resolved
119+ }
120+
121+ // Convert Markdown links [label](url) -> HTML <a> with resolved href.
122+ function mdLinksToHtml (s ) {
123+ if (typeof s !== ' string' ) return ' '
124+ return s .replace (/ \[ ([^ \] ] + )\]\( ([^ )\s ] + )\) / g , (_m , label , url ) => {
125+ const href = resolveInternalLink (String (url).trim ())
126+ const external = isExternal (href)
127+ const target = external ? ' target="_blank" rel="noopener noreferrer"' : ' '
128+ return ` <a href="${ href} "${ target} >${ String (label).trim ()} </a>`
129+ })
130+ }
131+
132+ function renderItem (text ) {
133+ const html = mdLinksToHtml (String (text ?? ' ' ))
134+ // Sanitize to avoid XSS; only <a> with href/target/rel is allowed
135+ return typeof window === ' undefined'
136+ ? ' ' // SSR guard (component should be inside <ClientOnly>)
137+ : DOMPurify .sanitize (html, {
138+ ALLOWED_TAGS : [' a' ],
139+ ALLOWED_ATTR : [' href' , ' target' , ' rel' ]
140+ })
141+ }
142+
143+ onMounted (load)
144+ watch (() => props .items , (nv ) => resizeMask (nv .length ))
145+ watch (doneMask, persist, { deep: true })
146+ < / script>
147+
148+ < style scoped>
149+ .checklist { display: block; }
150+ .checklist__head { display: flex; justify- content: space- between; align- items: baseline; margin- bottom: 8px ; }
151+ .checklist__count { font- variant- numeric: tabular- nums; opacity: 0.7 ; }
152+ .checklist__list { list- style: none; padding: 0 ; margin: 0 ; }
153+ .checklist__item { padding: 6px 0 ; }
154+ .checklist__row { display: flex; align- items: center; gap: 10px ; }
155+ .done { text- decoration: line- through; opacity: 0.6 ; }
156+ .checklist__foot { display: flex; justify- content: flex- end; margin- top: 8px ; }
157+ .checklist__btn {
158+ padding: 6px 12px ;
159+ border- radius: 6px ;
160+ border: 1px solid var (-- vp- c- divider);
161+ background- color: var (-- vp- c- bg);
162+ cursor: pointer;
163+ transition: background- color 0 .2s ease, opacity 0 .2s ease;
164+ }
165+ .checklist__btn : hover: not (: disabled ) { background- color: var (-- vp- c- bg- soft); }
166+ .checklist__btn : disabled { opacity: 0.4 ; cursor: not- allowed; }
167+ < / style>
0 commit comments