diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 683f61981..5d6be04f3 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -94,6 +94,12 @@ export default defineConfig({ replacement: fileURLToPath( new URL('./theme/Appearance.vue', import.meta.url) ) + }, + { + find: /^.*VPLocalSearchBox.vue$/, + replacement: fileURLToPath( + new URL('./theme/components/Search.vue', import.meta.url) + ) } ] }, diff --git a/docs/.vitepress/constants.ts b/docs/.vitepress/constants.ts index faece2094..0942867de 100644 --- a/docs/.vitepress/constants.ts +++ b/docs/.vitepress/constants.ts @@ -16,6 +16,7 @@ import type { DefaultTheme } from 'vitepress' import consola from 'consola' +import { customTokenize, customTokenProcessor } from './search' import { transform, transformGuide } from './transformer' // @unocss-include @@ -65,44 +66,13 @@ export const search: DefaultTheme.Config['search'] = { }, miniSearch: { options: { - tokenize: (text) => text.split(/[\n\r #%*,=/:;?[\]{}()&]+/u), // simplified charset: removed [-_.@] and non-english chars (diacritics etc.) - processTerm: (term, fieldName) => { - // biome-ignore lint/style/noParameterAssign: h - term = term - .trim() - .toLowerCase() - .replace(/^\.+/, '') - .replace(/\.+$/, '') - const stopWords = [ - 'frontmatter', - '$frontmatter.synopsis', - 'and', - 'about', - 'but', - 'now', - 'the', - 'with', - 'you' - ] - if (term.length < 2 || stopWords.includes(term)) return false - - if (fieldName === 'text') { - const parts = term.split('.') - if (parts.length > 1) { - const newTerms = [term, ...parts] - .filter((t) => t.length >= 2) - .filter((t) => !stopWords.includes(t)) - return newTerms - } - } - return term - } + tokenize: customTokenize, + processTerm: customTokenProcessor }, searchOptions: { - combineWith: 'AND', - fuzzy: true, // @ts-ignore boostDocument: (documentId, term, storedFields: Record) => { + console.log(storedFields.titles) const titles = (storedFields?.titles as string[]) .filter((t) => Boolean(t)) .map((t) => t.toLowerCase()) @@ -153,7 +123,10 @@ export const nav: DefaultTheme.NavItem[] = [ { text: '🔖 Bookmarks', link: 'https://github.com/fmhy/bookmarks' }, { text: '✅ SafeGuard', link: 'https://github.com/fmhy/FMHY-SafeGuard' }, { text: '📋 snowbin', link: 'https://pastes.fmhy.net' }, - { text: '®️ Redlib', link: 'https://redlib.fmhy.net/r/FREEMEDIAHECKYEAH/wiki/index' }, + { + text: '®️ Redlib', + link: 'https://redlib.fmhy.net/r/FREEMEDIAHECKYEAH/wiki/index' + }, { text: '🔎 SearXNG', link: 'https://searx.fmhy.net/' }, { text: '💡 Site Hunting', diff --git a/docs/.vitepress/search.ts b/docs/.vitepress/search.ts new file mode 100644 index 000000000..1a2428303 --- /dev/null +++ b/docs/.vitepress/search.ts @@ -0,0 +1,360 @@ +export const customTokenProcessor = (token: string): string | null => { + // Remove dots and normalize case before processing + const normalizedToken = token.replace(/\./g, '').toLowerCase() + + const step2list: Record = { + ational: 'ate', + tional: 'tion', + enci: 'ence', + anci: 'ance', + izer: 'ize', + bli: 'ble', + alli: 'al', + entli: 'ent', + eli: 'e', + ousli: 'ous', + ization: 'ize', + ation: 'ate', + ator: 'ate', + alism: 'al', + iveness: 'ive', + fulness: 'ful', + ousness: 'ous', + aliti: 'al', + iviti: 'ive', + biliti: 'ble', + logi: 'log' + } + + const step3list: Record = { + icate: 'ic', + ative: '', + alize: 'al', + iciti: 'ic', + ical: 'ic', + ful: '', + ness: '' + } + + const consonant = '[^aeiou]' + const vowel = '[aeiouy]' + const consonants = '(' + consonant + '[^aeiouy]*)' + const vowels = '(' + vowel + '[aeiou]*)' + + const gt0 = new RegExp('^' + consonants + '?' + vowels + consonants) + const eq1 = new RegExp( + '^' + consonants + '?' + vowels + consonants + vowels + '?$' + ) + const gt1 = new RegExp( + '^' + consonants + '?(' + vowels + consonants + '){2,}' + ) + const vowelInStem = new RegExp('^' + consonants + '?' + vowel) + const consonantLike = new RegExp('^' + consonants + vowel + '[^aeiouwxy]$') + + const sfxLl = /ll$/ + const sfxE = /^(.+?)e$/ + const sfxY = /^(.+?)y$/ + const sfxIon = /^(.+?(s|t))(ion)$/ + const sfxEdOrIng = /^(.+?)(ed|ing)$/ + const sfxAtOrBlOrIz = /(at|bl|iz)$/ + const sfxEED = /^(.+?)eed$/ + const sfxS = /^.+?[^s]s$/ + const sfxSsesOrIes = /^.+?(ss|i)es$/ + const sfxMultiConsonantLike = /([^aeiouylsz])\1$/ + const step2 = + /^(.+?)(ational|tional|enci|anci|izer|bli|alli|entli|eli|ousli|ization|ation|ator|alism|iveness|fulness|ousness|aliti|iviti|biliti|logi)$/ + const step3 = /^(.+?)(icate|ative|alize|iciti|ical|ful|ness)$/ + const step4 = + /^(.+?)(al|ance|ence|er|ic|able|ible|ant|ement|ment|ent|ou|ism|ate|iti|ous|ive|ize)$/ + + function stemmer(value: string) { + let result = String(value).toLowerCase() + + // Exit early. + if (result.length < 3) { + return result + } + + /** @type {boolean} */ + let firstCharacterWasLowerCaseY = false + + // Detect initial `y`, make sure it never matches. + if ( + result.codePointAt(0) === 121 // Lowercase Y + ) { + firstCharacterWasLowerCaseY = true + result = 'Y' + result.slice(1) + } + + // Step 1a. + if (sfxSsesOrIes.test(result)) { + // Remove last two characters. + result = result.slice(0, -2) + } else if (sfxS.test(result)) { + // Remove last character. + result = result.slice(0, -1) + } + + /** @type {RegExpMatchArray|null} */ + let match + + // Step 1b. + if ((match = sfxEED.exec(result))) { + if (gt0.test(match[1])) { + // Remove last character. + result = result.slice(0, -1) + } + } else if ( + (match = sfxEdOrIng.exec(result)) && + vowelInStem.test(match[1]) + ) { + result = match[1] + + if (sfxAtOrBlOrIz.test(result)) { + // Append `e`. + result += 'e' + } else if (sfxMultiConsonantLike.test(result)) { + // Remove last character. + result = result.slice(0, -1) + } else if (consonantLike.test(result)) { + // Append `e`. + result += 'e' + } + } + + // Step 1c. + if ((match = sfxY.exec(result)) && vowelInStem.test(match[1])) { + // Remove suffixing `y` and append `i`. + result = match[1] + 'i' + } + + // Step 2. + if ((match = step2.exec(result)) && gt0.test(match[1])) { + result = match[1] + step2list[match[2]] + } + + // Step 3. + if ((match = step3.exec(result)) && gt0.test(match[1])) { + result = match[1] + step3list[match[2]] + } + + // Step 4. + if ((match = step4.exec(result))) { + if (gt1.test(match[1])) { + result = match[1] + } + } else if ((match = sfxIon.exec(result)) && gt1.test(match[1])) { + result = match[1] + } + + // Step 5. + if ( + (match = sfxE.exec(result)) && + (gt1.test(match[1]) || + (eq1.test(match[1]) && !consonantLike.test(match[1]))) + ) { + result = match[1] + } + + if (sfxLl.test(result) && gt1.test(result)) { + result = result.slice(0, -1) + } + + // Turn initial `Y` back to `y`. + if (firstCharacterWasLowerCaseY) { + result = 'y' + result.slice(1) + } + + return result + } + // adapted from these two sources + // https://gist.github.com/sebleier/554280 + // https://meta.wikimedia.org/wiki/Stop_word_list/google_stop_word_list + const stopWords = new Set([ + 'a', + 'about', + 'above', + 'after', + 'again', + 'against', + 'all', + 'am', + 'an', + 'and', + 'any', + 'are', + 'aren', + 'as', + 'at', + 'be', + 'because', + 'been', + 'before', + 'being', + 'below', + 'between', + 'both', + 'but', + 'by', + 'can', + 'cannot', + 'com', + 'could', + 'couldn', + 'did', + 'didn', + 'do', + 'does', + 'doesn', + 'doing', + 'down', + 'during', + 'each', + 'few', + 'for', + 'from', + 'further', + 'had', + 'hadn', + 'has', + 'hasn', + 'have', + 'haven', + 'having', + 'he', + 'her', + 'here', + 'hers', + 'herself', + 'him', + 'himself', + 'his', + 'how', + 'i', + 'if', + 'in', + 'into', + 'is', + 'isn', + 'it', + 'its', + 'itself', + 'just', + 'let', + 'll', + 'me', + 'more', + 'most', + 'mustn', + 'my', + 'myself', + 'no', + 'nor', + 'not', + 'now', + 'of', + 'off', + 'on', + 'once', + 'only', + 'or', + 'other', + 'ought', + 'our', + 'ours', + 'ourselves', + 'out', + 'over', + 'own', + 're', + 's', + 'same', + 'shan', + 'she', + 'should', + 'shouldn', + 'so', + 'some', + 'such', + 't', + 'than', + 'that', + 'the', + 'their', + 'theirs', + 'them', + 'themselves', + 'then', + 'there', + 'these', + 'they', + 'this', + 'those', + 'through', + 'to', + 'too', + 'under', + 'until', + 'up', + 've', + 'very', + 'was', + 'wasn', + 'we', + 'were', + 'weren', + 'what', + 'when', + 'where', + 'which', + 'while', + 'who', + 'whom', + 'why', + 'will', + 'with', + 'won', + 'would', + 'wouldn', + 'you', + 'your', + 'yours', + 'yourself', + 'yourselves' + ]) + + return stopWords.has(normalizedToken) ? null : stemmer(normalizedToken) +} + +export const customTokenize = (text: string): string[] => { + // Pre-process the text to handle dots in special cases + // This will help with cases like "V.R" to match with "vr" by removing dots + const preprocessedText = text.replace(/([A-Za-z])\.([A-Za-z])/g, '$1$2') // Remove dots between letters (like V.R -> VR) + + // This regular expression matches any Unicode space or punctuation character + // Copied from https://github.com/lucaong/minisearch + // which adapted from https://unicode.org/cldr/utility/list-unicodeset.jsp?a=%5Cp%7BZ%7D%5Cp%7BP%7D&abb=on&c=on&esc=on + const SPACE_OR_PUNCTUATION = + /[\n\r -#%-*,-/:;?@[-\]_{}\u00A0\u00A1\u00A7\u00AB\u00B6\u00B7\u00BB\u00BF\u037E\u0387\u055A-\u055F\u0589\u058A\u05BE\u05C0\u05C3\u05C6\u05F3\u05F4\u0609\u060A\u060C\u060D\u061B\u061E\u061F\u066A-\u066D\u06D4\u0700-\u070D\u07F7-\u07F9\u0830-\u083E\u085E\u0964\u0965\u0970\u09FD\u0A76\u0AF0\u0C77\u0C84\u0DF4\u0E4F\u0E5A\u0E5B\u0F04-\u0F12\u0F14\u0F3A-\u0F3D\u0F85\u0FD0-\u0FD4\u0FD9\u0FDA\u104A-\u104F\u10FB\u1360-\u1368\u1400\u166E\u1680\u169B\u169C\u16EB-\u16ED\u1735\u1736\u17D4-\u17D6\u17D8-\u17DA\u1800-\u180A\u1944\u1945\u1A1E\u1A1F\u1AA0-\u1AA6\u1AA8-\u1AAD\u1B5A-\u1B60\u1BFC-\u1BFF\u1C3B-\u1C3F\u1C7E\u1C7F\u1CC0-\u1CC7\u1CD3\u2000-\u200A\u2010-\u2029\u202F-\u2043\u2045-\u2051\u2053-\u205F\u207D\u207E\u208D\u208E\u2308-\u230B\u2329\u232A\u2768-\u2775\u27C5\u27C6\u27E6-\u27EF\u2983-\u2998\u29D8-\u29DB\u29FC\u29FD\u2CF9-\u2CFC\u2CFE\u2CFF\u2D70\u2E00-\u2E2E\u2E30-\u2E4F\u3000-\u3003\u3008-\u3011\u3014-\u301F\u3030\u303D\u30A0\u30FB\uA4FE\uA4FF\uA60D-\uA60F\uA673\uA67E\uA6F2-\uA6F7\uA874-\uA877\uA8CE\uA8CF\uA8F8-\uA8FA\uA8FC\uA92E\uA92F\uA95F\uA9C1-\uA9CD\uA9DE\uA9DF\uAA5C-\uAA5F\uAADE\uAADF\uAAF0\uAAF1\uABEB\uFD3E\uFD3F\uFE10-\uFE19\uFE30-\uFE52\uFE54-\uFE61\uFE63\uFE68\uFE6A\uFE6B\uFF01-\uFF03\uFF05-\uFF0A\uFF0C-\uFF0F\uFF1A\uFF1B\uFF1F\uFF20\uFF3B-\uFF3D\uFF3F\uFF5B\uFF5D\uFF5F-\uFF65]+/u + + // Split on any space or punctuation; same as minisearch default tokenizer + // except i've corrected for the possibility for returning empty string + const tokens = preprocessedText.split(SPACE_OR_PUNCTUATION).filter(Boolean) + + // Handle cases with capital letters in the middle (like "xManager" -> "x Manager") + const expandedTokens: string[] = [] + + for (const token of tokens) { + expandedTokens.push(token) + + // If token has a capital letter in the middle, add a version with space before it + // This helps with cases like "xManager" to match with "x Manager" + const splitOnCapitals = token.replace(/([a-z])([A-Z])/g, '$1 $2') + if (splitOnCapitals !== token) { + const additionalTokens = splitOnCapitals.split(' ').filter(Boolean) + expandedTokens.push(...additionalTokens) + } + } + + return expandedTokens +} diff --git a/docs/.vitepress/theme/components/Feedback.vue b/docs/.vitepress/theme/components/Feedback.vue index 996b29456..cd4f93d7d 100644 --- a/docs/.vitepress/theme/components/Feedback.vue +++ b/docs/.vitepress/theme/components/Feedback.vue @@ -266,7 +266,8 @@ const toggleCard = () => (isCardShown.value = !isCardShown.value) placeholder="What a lovely wiki!" />

- Add your Discord handle if you would like a response, or if we need more information from you, otherwise join our + Add your Discord handle if you would like a response, or if we need + more information from you, otherwise join our +import type { SearchResult } from 'minisearch' +import type { DefaultTheme } from 'vitepress/theme' +import type { Ref } from 'vue' +import localSearchIndex from '@localSearchIndex' +import { + computedAsync, + debouncedWatch, + onKeyStroke, + useEventListener, + useLocalStorage, + useScrollLock, + useSessionStorage +} from '@vueuse/core' +import { useFocusTrap } from '@vueuse/integrations/useFocusTrap' +import Mark from 'mark.js/src/vanilla.js' +import MiniSearch from 'minisearch' +import { + dataSymbol, + inBrowser, + useData as useData$, + useRouter +} from 'vitepress' +import { + computed, + createApp, + markRaw, + nextTick, + onBeforeUnmount, + onMounted, + ref, + shallowRef, + watch, + watchEffect +} from 'vue' + +const useData: typeof useData$ = useData$ + +interface ModalTranslations { + displayDetails?: string + resetButtonTitle?: string + backButtonTitle?: string + noResultsText?: string + exactMatchTitle?: string + footer?: FooterTranslations +} + +interface FooterTranslations { + selectText?: string + selectKeyAriaLabel?: string + navigateText?: string + navigateUpKeyAriaLabel?: string + navigateDownKeyAriaLabel?: string + closeText?: string + closeKeyAriaLabel?: string +} + +const INVALID_CHAR_REGEX = /[\u0000-\u001F"#$&*+,:;<=>?[\]^`{|}\u007F]/g +const DRIVE_LETTER_REGEX = /^[a-z]:/i + +// https://github.com/sindresorhus/escape-string-regexp/blob/ba9a4473850cb367936417e97f1f2191b7cc67dd/index.js +function escapeRegExp(str: string) { + return str.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&').replace(/-/g, '\\x2d') +} + +function sanitizeFileName(name: string): string { + const match = DRIVE_LETTER_REGEX.exec(name) + const driveLetter = match ? match[0] : '' + + return ( + driveLetter + + name + .slice(driveLetter.length) + .replace(INVALID_CHAR_REGEX, '_') + .replace(/(^|\/)_+(?=[^/]*$)/, '$1') + ) +} + +/** + * Converts a url path to the corresponding js chunk filename. + */ +function pathToFile(path: string) { + let pagePath = path.replace(/\.html$/, '') + pagePath = decodeURIComponent(pagePath) + pagePath = pagePath.replace(/\/$/, '/index') // /foo/ -> /foo/ + if (import.meta.env.DEV) { + // always force re-fetch content in dev + pagePath += `.md?t=${Date.now()}` + } else { + // in production, each .md file is built into a .md.js file following + // the path conversion scheme. + // /foo/bar.html -> ./foo_bar.md + if (inBrowser) { + const base = import.meta.env.BASE_URL + pagePath = + sanitizeFileName( + pagePath.slice(base.length).replace(/\//g, '_') || 'index' + ) + '.md' + // client production build needs to account for page hash, which is + // injected directly in the page's html + let pageHash = __VP_HASH_MAP__[pagePath.toLowerCase()] + if (!pageHash) { + pagePath = pagePath.endsWith('_index.md') + ? pagePath.slice(0, -9) + '.md' + : pagePath.slice(0, -3) + '_index.md' + pageHash = __VP_HASH_MAP__[pagePath.toLowerCase()] + } + if (!pageHash) return null + pagePath = `${base}${__ASSETS_DIR__}/${pagePath}.${pageHash}.js` + } else { + // ssr build uses much simpler name mapping + pagePath = `./${sanitizeFileName( + pagePath.slice(1).replace(/\//g, '_') + )}.md.js` + } + } + + return pagePath +} +class LRUCache { + private max: number + private cache: Map + + constructor(max: number = 10) { + this.max = max + this.cache = new Map() + } + + get(key: K): V | undefined { + let item = this.cache.get(key) + if (item !== undefined) { + // refresh key + this.cache.delete(key) + this.cache.set(key, item) + } + return item + } + + set(key: K, val: V): void { + // refresh key + if (this.cache.has(key)) this.cache.delete(key) + // evict oldest + else if (this.cache.size === this.max) this.cache.delete(this.first()!) + this.cache.set(key, val) + } + + first(): K | undefined { + return this.cache.keys().next().value + } + + clear(): void { + this.cache.clear() + } +} +function createSearchTranslate( + defaultTranslations: Record +): (key: string) => string { + const { localeIndex, theme } = useData() + + function translate(key: string): string { + const keyPath = key.split('.') + const themeObject = theme.value.search?.options + + const isObject = themeObject && typeof themeObject === 'object' + const locales = + (isObject && themeObject.locales?.[localeIndex.value]?.translations) || + null + const translations = (isObject && themeObject.translations) || null + + let localeResult: Record | null = locales + let translationResult: Record | null = translations + let defaultResult: Record | null = defaultTranslations + + const lastKey = keyPath.pop()! + for (const k of keyPath) { + let fallbackResult: Record | null = null + const foundInFallback: any = defaultResult?.[k] + if (foundInFallback) { + fallbackResult = defaultResult = foundInFallback + } + const foundInTranslation: any = translationResult?.[k] + if (foundInTranslation) { + fallbackResult = translationResult = foundInTranslation + } + const foundInLocale: any = localeResult?.[k] + if (foundInLocale) { + fallbackResult = localeResult = foundInLocale + } + // Put fallback into unresolved results + if (!foundInFallback) { + defaultResult = fallbackResult + } + if (!foundInTranslation) { + translationResult = fallbackResult + } + if (!foundInLocale) { + localeResult = fallbackResult + } + } + return ( + localeResult?.[lastKey] ?? + translationResult?.[lastKey] ?? + defaultResult?.[lastKey] ?? + '' + ) + } + + return translate +} + +const emit = defineEmits<{ + (e: 'close'): void +}>() + +const el = shallowRef() +const resultsEl = shallowRef() + +/* Search */ +const searchIndexData = shallowRef(localSearchIndex) + +// hmr +if (import.meta.hot) { + import.meta.hot.accept('@localSearchIndex', (m) => { + if (m) { + searchIndexData.value = m.default + } + }) +} + +interface Result { + title: string + titles: string[] + text?: string +} + +const vitePressData = useData() +const { activate } = useFocusTrap(el, { + immediate: true, + allowOutsideClick: true, + clickOutsideDeactivates: true, + escapeDeactivates: true +}) +const { localeIndex, theme } = vitePressData +const searchIndex = computedAsync(async () => + markRaw( + MiniSearch.loadJSON( + (await searchIndexData.value[localeIndex.value]?.())?.default, + { + fields: ['title', 'titles', 'text'], + storeFields: ['title', 'titles'], + searchOptions: { + // words of >=8 characters get an allowable edit distance of 1 + fuzzy: 0.07, + // perform prefix search (i.e. "monk" matches "monkfish") only with terms greater than 3 chars + prefix: (term) => term.length > 3, + // max edit distance of 1 no matter length of an individual term + maxFuzzy: 1, + boost: { title: 4, text: 2, titles: 1 }, + ...(theme.value.search?.provider === 'local' && + theme.value.search.options?.miniSearch?.searchOptions) + }, + ...(theme.value.search?.provider === 'local' && + theme.value.search.options?.miniSearch?.options) + } + ) + ) +) + +const disableQueryPersistence = computed(() => { + return ( + theme.value.search?.provider === 'local' && + theme.value.search.options?.disableQueryPersistence === true + ) +}) + +const filterText = disableQueryPersistence.value + ? ref('') + : useSessionStorage('vitepress:local-search-filter', '') + +const showDetailedList = useLocalStorage( + 'vitepress:local-search-detailed-list', + theme.value.search?.provider === 'local' && + theme.value.search.options?.detailedView === true +) + +const useExactMatch = useLocalStorage( + 'vitepress:local-search-exact-match', + false +) + +const disableDetailedView = computed(() => { + return ( + theme.value.search?.provider === 'local' && + (theme.value.search.options?.disableDetailedView === true || + theme.value.search.options?.detailedView === false) + ) +}) + +const buttonText = computed(() => { + const options = theme.value.search?.options ?? theme.value.algolia + + return ( + options?.locales?.[localeIndex.value]?.translations?.button?.buttonText || + options?.translations?.button?.buttonText || + 'Search' + ) +}) + +watchEffect(() => { + if (disableDetailedView.value) { + showDetailedList.value = false + } +}) + +const results: Ref<(SearchResult & Result)[]> = shallowRef([]) + +const enableNoResults = ref(false) + +watch(filterText, () => { + enableNoResults.value = false +}) + +const mark = computedAsync(async () => { + if (!resultsEl.value) return + return markRaw(new Mark(resultsEl.value)) +}, null) + +const cache = new LRUCache>(16) // 16 files + +debouncedWatch( + () => + [ + searchIndex.value, + filterText.value, + showDetailedList.value, + useExactMatch.value + ] as const, + async ( + [index, filterTextValue, showDetailedListValue, useExactMatchValue], + old, + onCleanup + ) => { + if (old?.[0] !== index) { + // in case of hmr + cache.clear() + } + + let canceled = false + onCleanup(() => { + canceled = true + }) + + if (!index) return + + // Search + if (useExactMatchValue && filterTextValue.trim()) { + // Perform exact match search + results.value = index + .search(filterTextValue, { + fuzzy: 0, // Disable fuzzy matching + prefix: false, // Disable prefix matching + combineWith: 'AND', // Require all terms to match + ...(theme.value.search?.provider === 'local' && + theme.value.search.options?.miniSearch?.searchOptions) + }) + .slice(0, 16) as (SearchResult & Result)[] + } else { + // Use default search behavior + results.value = index + .search(filterTextValue) + .slice(0, 16) as (SearchResult & Result)[] + } + enableNoResults.value = true + + // Highlighting + const mods = showDetailedListValue + ? await Promise.all(results.value.map((r) => fetchExcerpt(r.id))) + : [] + if (canceled) return + for (const { id, mod } of mods) { + const mapId = id.slice(0, id.indexOf('#')) + let map = cache.get(mapId) + if (map) continue + map = new Map() + cache.set(mapId, map) + const comp = mod.default ?? mod + if (comp?.render || comp?.setup) { + const app = createApp(comp) + // Silence warnings about missing components + app.config.warnHandler = () => {} + app.provide(dataSymbol, vitePressData) + Object.defineProperties(app.config.globalProperties, { + $frontmatter: { + get() { + return vitePressData.frontmatter.value + } + }, + $params: { + get() { + return vitePressData.page.value.params + } + } + }) + const div = document.createElement('div') + app.mount(div) + const headings = div.querySelectorAll('h1, h2, h3, h4, h5, h6') + headings.forEach((el) => { + const href = el.querySelector('a')?.getAttribute('href') + const anchor = href?.startsWith('#') && href.slice(1) + if (!anchor) return + let html = '' + while ((el = el.nextElementSibling!) && !/^h[1-6]$/i.test(el.tagName)) + html += el.outerHTML + map!.set(anchor, html) + }) + app.unmount() + } + if (canceled) return + } + + const terms = new Set() + + results.value = results.value.map((r) => { + const [id, anchor] = r.id.split('#') + const map = cache.get(id) + const text = map?.get(anchor) ?? '' + for (const term in r.match) { + terms.add(term) + } + return { ...r, text } + }) + + await nextTick() + if (canceled) return + + await new Promise((r) => { + mark.value?.unmark({ + done: () => { + mark.value?.markRegExp(formMarkRegex(terms), { done: r }) + } + }) + }) + + const excerpts = Array.from( + resultsEl.value?.querySelectorAll('.result .excerpt') ?? [] + ) + for (const excerpt of excerpts) { + excerpt + .querySelector('mark[data-markjs="true"]') + ?.scrollIntoView({ block: 'center' }) + } + // FIXME: without this whole page scrolls to the bottom + resultsEl.value?.firstElementChild?.scrollIntoView({ block: 'start' }) + }, + { debounce: 200, immediate: true } +) + +async function fetchExcerpt(id: string) { + const file = pathToFile(id.slice(0, id.indexOf('#'))) + try { + if (!file) throw new Error(`Cannot find file for id: ${id}`) + return { id, mod: await import(/*@vite-ignore*/ file) } + } catch (e) { + console.error(e) + return { id, mod: {} } + } +} + +/* Search input focus */ + +const searchInput = ref() +const disableReset = computed(() => { + return filterText.value?.length <= 0 +}) +function focusSearchInput(select = true) { + searchInput.value?.focus() + select && searchInput.value?.select() +} + +onMounted(() => { + focusSearchInput() +}) + +function onSearchBarClick(event: PointerEvent) { + if (event.pointerType === 'mouse') { + focusSearchInput() + } +} + +/* Search keyboard selection */ + +const selectedIndex = ref(-1) +const disableMouseOver = ref(true) + +watch(results, (r) => { + selectedIndex.value = r.length ? 0 : -1 + scrollToSelectedResult() +}) + +function scrollToSelectedResult() { + nextTick(() => { + const selectedEl = document.querySelector('.result.selected') + selectedEl?.scrollIntoView({ block: 'nearest' }) + }) +} + +onKeyStroke('ArrowUp', (event) => { + event.preventDefault() + selectedIndex.value-- + if (selectedIndex.value < 0) { + selectedIndex.value = results.value.length - 1 + } + disableMouseOver.value = true + scrollToSelectedResult() +}) + +onKeyStroke('ArrowDown', (event) => { + event.preventDefault() + selectedIndex.value++ + if (selectedIndex.value >= results.value.length) { + selectedIndex.value = 0 + } + disableMouseOver.value = true + scrollToSelectedResult() +}) + +const router = useRouter() + +onKeyStroke('Enter', (e) => { + if (e.isComposing) return + + if (e.target instanceof HTMLButtonElement && e.target.type !== 'submit') + return + + const selectedPackage = results.value[selectedIndex.value] + if (e.target instanceof HTMLInputElement && !selectedPackage) { + e.preventDefault() + return + } + + if (selectedPackage) { + router.go(selectedPackage.id) + emit('close') + } +}) + +onKeyStroke('Escape', () => { + emit('close') +}) + +// Translations +const defaultTranslations: { modal: ModalTranslations } = { + modal: { + displayDetails: 'Display detailed list', + resetButtonTitle: 'Reset search', + backButtonTitle: 'Close search', + noResultsText: 'No results for', + exactMatchTitle: 'Toggle exact match', + footer: { + selectText: 'to select', + selectKeyAriaLabel: 'enter', + navigateText: 'to navigate', + navigateUpKeyAriaLabel: 'up arrow', + navigateDownKeyAriaLabel: 'down arrow', + closeText: 'to close', + closeKeyAriaLabel: 'escape' + } + } +} + +const translate = createSearchTranslate(defaultTranslations) + +// Back + +onMounted(() => { + // Prevents going to previous site + window.history.pushState(null, '', null) +}) + +useEventListener('popstate', (event) => { + event.preventDefault() + emit('close') +}) + +/** Lock body */ +const isLocked = useScrollLock(inBrowser ? document.body : null) + +onMounted(() => { + nextTick(() => { + isLocked.value = true + nextTick().then(() => activate()) + }) +}) + +onBeforeUnmount(() => { + isLocked.value = false +}) + +function resetSearch() { + filterText.value = '' + nextTick().then(() => focusSearchInput(false)) +} + +function formMarkRegex(terms: Set) { + return new RegExp( + [...terms] + .sort((a, b) => b.length - a.length) + .map((term) => `(${escapeRegExp(term)})`) + .join('|'), + 'gi' + ) +} + +function onMouseMove(e: MouseEvent) { + if (!disableMouseOver.value) return + const el = (e.target as HTMLElement)?.closest('.result') + const index = Number.parseInt(el?.dataset.index!) + if (index >= 0 && index !== selectedIndex.value) { + selectedIndex.value = index + } + disableMouseOver.value = false +} + + + + + diff --git a/docs/.vitepress/transformer/constants.ts b/docs/.vitepress/transformer/constants.ts index 0baf1b97b..8e8e3bf05 100644 --- a/docs/.vitepress/transformer/constants.ts +++ b/docs/.vitepress/transformer/constants.ts @@ -22,7 +22,7 @@ interface Header { export const headers: Header = { 'adblockvpnguide.md': { title: 'Adblocking / Privacy', - description: "Adblocking, Privacy, VPNs, Proxies, Antiviruses" + description: 'Adblocking, Privacy, VPNs, Proxies, Antiviruses' }, 'ai.md': { title: 'Artificial Intelligence', diff --git a/docs/.vitepress/vue-shim.d.ts b/docs/.vitepress/vue-shim.d.ts index 844f88324..77273f5fb 100644 --- a/docs/.vitepress/vue-shim.d.ts +++ b/docs/.vitepress/vue-shim.d.ts @@ -13,8 +13,38 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -/* eslint-disable ts/consistent-type-imports */ +declare const __VP_HASH_MAP__: Record +declare const __VP_LOCAL_SEARCH__: boolean +declare const __ALGOLIA__: boolean +declare const __CARBON__: boolean +declare const __VUE_PROD_DEVTOOLS__: boolean +declare const __ASSETS_DIR__: string + declare module '*.vue' { - const component: import('vue').Component + import type { DefineComponent } from 'vue' + const component: DefineComponent export default component } + +declare module '@siteData' { + import type { SiteData } from 'vitepress' + const data: SiteData + export default data +} + +declare module '@theme/index' { + import type { Theme } from 'vitepress' + const theme: Theme + export default theme +} + +declare module '@localSearchIndex' { + const data: Record Promise<{ default: string }>> + export default data +} + +declare module 'mark.js/src/vanilla.js' { + import type Mark from 'mark.js' + const mark: typeof Mark + export default mark +} diff --git a/package.json b/package.json index 50d366f33..a3091515a 100644 --- a/package.json +++ b/package.json @@ -25,14 +25,19 @@ "@headlessui/vue": "^1.7.23", "@resvg/resvg-js": "^2.6.2", "@vueuse/core": "^13.0.0", + "@vueuse/integrations": "^13.1.0", "consola": "^3.2.3", "feed": "^4.2.2", "itty-fetcher": "^0.9.4", + "mark.js": "^8.11.1", + "markdown-it": "^14.1.0", + "minisearch": "^7.1.2", "nitro-cors": "^0.7.1", "nitropack": "^2.11.6", "nprogress": "^0.2.0", "pathe": "^2.0.1", "reka-ui": "^2.1.1", + "stemmer": "^2.0.1", "unocss": "66.1.0-beta.3", "vitepress": "^1.6.3", "vue": "^3.5.13", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 195c6da8f..1097275ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@vueuse/core': specifier: ^13.0.0 version: 13.0.0(vue@3.5.13(typescript@5.8.2)) + '@vueuse/integrations': + specifier: ^13.1.0 + version: 13.1.0(change-case@5.4.4)(focus-trap@7.6.4)(nprogress@0.2.0)(vue@3.5.13(typescript@5.8.2)) consola: specifier: ^3.2.3 version: 3.2.3 @@ -32,6 +35,15 @@ importers: itty-fetcher: specifier: ^0.9.4 version: 0.9.4 + mark.js: + specifier: ^8.11.1 + version: 8.11.1 + markdown-it: + specifier: ^14.1.0 + version: 14.1.0 + minisearch: + specifier: ^7.1.2 + version: 7.1.2 nitro-cors: specifier: ^0.7.1 version: 0.7.1 @@ -47,6 +59,9 @@ importers: reka-ui: specifier: ^2.1.1 version: 2.1.1(typescript@5.8.2)(vue@3.5.13(typescript@5.8.2)) + stemmer: + specifier: ^2.0.1 + version: 2.0.1 unocss: specifier: 66.1.0-beta.3 version: 66.1.0-beta.3(vite@5.4.14(@types/node@20.16.12)(sass@1.85.1)(terser@5.39.0))(vue@3.5.13(typescript@5.8.2)) @@ -1869,6 +1884,11 @@ packages: peerDependencies: vue: ^3.5.0 + '@vueuse/core@13.1.0': + resolution: {integrity: sha512-PAauvdRXZvTWXtGLg8cPUFjiZEddTqmogdwYpnn60t08AA5a8Q4hZokBnpTOnVNqySlFlTcRYIC8OqreV4hv3Q==} + peerDependencies: + vue: ^3.5.0 + '@vueuse/integrations@12.8.2': resolution: {integrity: sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==} peerDependencies: @@ -1910,12 +1930,57 @@ packages: universal-cookie: optional: true + '@vueuse/integrations@13.1.0': + resolution: {integrity: sha512-wJ6aANdUs4SOpVabChQK+uLIwxRTUAEmn1DJnflGG7Wq6yaipiRmp6as/Md201FjJnquQt8MecIPbFv8HSBeDA==} + peerDependencies: + async-validator: ^4 + axios: ^1 + change-case: ^5 + drauu: ^0.4 + focus-trap: ^7 + fuse.js: ^7 + idb-keyval: ^6 + jwt-decode: ^4 + nprogress: ^0.2 + qrcode: ^1.5 + sortablejs: ^1 + universal-cookie: ^7 + vue: ^3.5.0 + peerDependenciesMeta: + async-validator: + optional: true + axios: + optional: true + change-case: + optional: true + drauu: + optional: true + focus-trap: + optional: true + fuse.js: + optional: true + idb-keyval: + optional: true + jwt-decode: + optional: true + nprogress: + optional: true + qrcode: + optional: true + sortablejs: + optional: true + universal-cookie: + optional: true + '@vueuse/metadata@12.8.2': resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==} '@vueuse/metadata@13.0.0': resolution: {integrity: sha512-TRNksqmvtvqsuHf7bbgH9OSXEV2b6+M3BSN4LR5oxWKykOFT9gV78+C2/0++Pq9KCp9KQ1OQDPvGlWNQpOb2Mw==} + '@vueuse/metadata@13.1.0': + resolution: {integrity: sha512-+TDd7/a78jale5YbHX9KHW3cEDav1lz1JptwDvep2zSG8XjCsVE+9mHIzjTOaPbHUAk5XiE4jXLz51/tS+aKQw==} + '@vueuse/shared@12.8.2': resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} @@ -1924,6 +1989,11 @@ packages: peerDependencies: vue: ^3.5.0 + '@vueuse/shared@13.1.0': + resolution: {integrity: sha512-IVS/qRRjhPTZ6C2/AM3jieqXACGwFZwWTdw5sNTSKk2m/ZpkuuN+ri+WCVUP8TqaKwJYt/KuMwmXspMAw8E6ew==} + peerDependencies: + vue: ^3.5.0 + abbrev@3.0.0: resolution: {integrity: sha512-+/kfrslGQ7TNV2ecmQwMJj/B65g5KVq1/L3SGVZ3tCYGqlzFuFCGBZJtMP99wH3NpEUyAjn0zPdPUg0D+DwrOA==} engines: {node: ^18.17.0 || >=20.5.0} @@ -2307,15 +2377,6 @@ packages: supports-color: optional: true - debug@4.3.4: - resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.3.6: resolution: {integrity: sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==} engines: {node: '>=6.0'} @@ -2862,6 +2923,9 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + listhen@1.9.0: resolution: {integrity: sha512-I8oW2+QL5KJo8zXNWX046M134WchxsXC7SawLPvRQpogCbkyQIaFxPE89A2HiwR7vAK2Dm2ERBAmyjTYGYEpBg==} hasBin: true @@ -2911,12 +2975,19 @@ packages: mark.js@8.11.1: resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} + markdown-it@14.1.0: + resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} + hasBin: true + mdast-util-to-hast@13.2.0: resolution: {integrity: sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==} mdn-data@2.12.2: resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -3331,6 +3402,10 @@ packages: property-information@7.0.0: resolution: {integrity: sha512-7D/qOz/+Y4X/rzSB6jKxKUsQnphO046ei8qxG59mtM3RG3DHgTK81HrxrmoDVINJb8NKT5ZsRbwHvQ6B68Iyhg==} + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + quansync@0.2.8: resolution: {integrity: sha512-4+saucphJMazjt7iOM27mbFCk+D9dd/zmgMDCzRZ8MEoBfYp7lAvoN38et/phRQF6wOPMy/OROBGgoWeSKyluA==} @@ -3590,6 +3665,10 @@ packages: std-env@3.8.1: resolution: {integrity: sha512-vj5lIj3Mwf9D79hBkltk5qmkFI+biIKWS2IBxEyEU3AX1tUf7AoL8nSazCOiiqQsGKIq01SClsKEzweu34uwvA==} + stemmer@2.0.1: + resolution: {integrity: sha512-bkWvSX2JR4nSZFfs113kd4C6X13bBBrg4fBKv2pVdzpdQI2LA5pZcWzTFNdkYsiUNl13E4EzymSRjZ0D55jBYg==} + hasBin: true + stoppable@1.1.0: resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==} engines: {node: '>=4', npm: '>=6'} @@ -3721,6 +3800,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + ufo@1.3.2: resolution: {integrity: sha512-o+ORpgGwaYQXgqGDwd+hkS4PuZ3QnmqMMxRuajK/a38L6fTpcE5GPIfrf+L/KemFzfUpeUQc1rRS1iDBozvnFA==} @@ -4268,7 +4350,7 @@ snapshots: '@babel/traverse': 7.24.8 '@babel/types': 7.24.9 convert-source-map: 2.0.0 - debug: 4.3.4 + debug: 4.4.0(supports-color@9.4.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -4378,7 +4460,7 @@ snapshots: '@babel/helper-split-export-declaration': 7.24.7 '@babel/parser': 7.24.8 '@babel/types': 7.24.9 - debug: 4.3.4 + debug: 4.4.0(supports-color@9.4.0) globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -5686,6 +5768,13 @@ snapshots: '@vueuse/shared': 13.0.0(vue@3.5.13(typescript@5.8.2)) vue: 3.5.13(typescript@5.8.2) + '@vueuse/core@13.1.0(vue@3.5.13(typescript@5.8.2))': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 13.1.0 + '@vueuse/shared': 13.1.0(vue@3.5.13(typescript@5.8.2)) + vue: 3.5.13(typescript@5.8.2) + '@vueuse/integrations@12.8.2(change-case@5.4.4)(focus-trap@7.6.4)(nprogress@0.2.0)(typescript@5.8.2)': dependencies: '@vueuse/core': 12.8.2(typescript@5.8.2) @@ -5698,10 +5787,22 @@ snapshots: transitivePeerDependencies: - typescript + '@vueuse/integrations@13.1.0(change-case@5.4.4)(focus-trap@7.6.4)(nprogress@0.2.0)(vue@3.5.13(typescript@5.8.2))': + dependencies: + '@vueuse/core': 13.1.0(vue@3.5.13(typescript@5.8.2)) + '@vueuse/shared': 13.1.0(vue@3.5.13(typescript@5.8.2)) + vue: 3.5.13(typescript@5.8.2) + optionalDependencies: + change-case: 5.4.4 + focus-trap: 7.6.4 + nprogress: 0.2.0 + '@vueuse/metadata@12.8.2': {} '@vueuse/metadata@13.0.0': {} + '@vueuse/metadata@13.1.0': {} + '@vueuse/shared@12.8.2(typescript@5.8.2)': dependencies: vue: 3.5.13(typescript@5.8.2) @@ -5712,6 +5813,10 @@ snapshots: dependencies: vue: 3.5.13(typescript@5.8.2) + '@vueuse/shared@13.1.0(vue@3.5.13(typescript@5.8.2))': + dependencies: + vue: 3.5.13(typescript@5.8.2) + abbrev@3.0.0: {} abort-controller@3.0.0: @@ -6060,10 +6165,6 @@ snapshots: dependencies: ms: 2.0.0 - debug@4.3.4: - dependencies: - ms: 2.1.2 - debug@4.3.6: dependencies: ms: 2.1.2 @@ -6645,6 +6746,10 @@ snapshots: lines-and-columns@1.2.4: {} + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + listhen@1.9.0: dependencies: '@parcel/watcher': 2.5.1 @@ -6717,6 +6822,15 @@ snapshots: mark.js@8.11.1: {} + markdown-it@14.1.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + mdast-util-to-hast@13.2.0: dependencies: '@types/hast': 3.0.4 @@ -6731,6 +6845,8 @@ snapshots: mdn-data@2.12.2: {} + mdurl@2.0.0: {} + merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -7170,6 +7286,8 @@ snapshots: property-information@7.0.0: {} + punycode.js@2.3.1: {} + quansync@0.2.8: {} queue-microtask@1.2.3: {} @@ -7499,6 +7617,8 @@ snapshots: std-env@3.8.1: {} + stemmer@2.0.1: {} + stoppable@1.1.0: {} streamx@2.22.0: @@ -7626,6 +7746,8 @@ snapshots: typescript@5.8.2: {} + uc.micro@2.1.0: {} + ufo@1.3.2: {} ufo@1.5.4: {} diff --git a/tsconfig.json b/tsconfig.json index 504dc7c9b..23b59056d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,7 @@ "noUnusedLocals": true, "strictNullChecks": true, "forceConsistentCasingInFileNames": true, - "types": ["vitepress"] + "types": ["vitepress", "vitepress/client"] }, "exclude": ["node_modules"], "include": [