m9ve website code outsidr

This commit is contained in:
taskylizard 2025-06-27 22:37:01 +00:00
parent 15a56af368
commit df4eecc405
No known key found for this signature in database
GPG key ID: 1820131ED1A24120
87 changed files with 3108 additions and 59 deletions

View file

@ -1,14 +0,0 @@
Copyright (c) taskylizard. Apache License 2.0
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View file

@ -1,3 +0,0 @@
This is the website source code to be used with [VitePress](https://vitepress.dev/).
Licensed under the Apache License v2.0, see [LICENSE](./LICENSE) for more information.

View file

@ -6,6 +6,11 @@ import OptimizeExclude from 'vite-plugin-optimize-exclude'
import Terminal from 'vite-plugin-terminal'
import vueDevtools from 'vite-plugin-vue-devtools'
import { defineConfig } from 'vitepress'
import { generateFeed, generateImages, generateMeta } from '../../website/hooks'
import { defs, emojiRender, movePlugin } from '../../website/markdown/emoji'
import { headersPlugin } from '../../website/markdown/headers'
import { toggleStarredPlugin } from '../../website/markdown/toggle-starred'
import { transformsPlugin } from '../../website/transformer'
import {
commitRef,
feedback,
@ -16,11 +21,6 @@ import {
sidebar,
socialLinks
} from './constants'
import { generateFeed, generateImages, generateMeta } from './hooks'
import { defs, emojiRender, movePlugin } from './markdown/emoji'
import { headersPlugin } from './markdown/headers'
import { toggleStarredPlugin } from './markdown/toggleStarred'
import { transformsPlugin } from './transformer'
// @unocss-include
@ -82,7 +82,7 @@ export default defineConfig({
transformHead: async (context) => generateMeta(context, meta.hostname),
buildEnd: async (context) => {
generateImages(context)
.then(() => generateFeed(context))
.then(() => generateFeed(meta, context))
.finally(() => consola.success('Success!'))
},
vite: {
@ -97,13 +97,16 @@ export default defineConfig({
{
find: /^.*VPSwitchAppearance\.vue$/,
replacement: fileURLToPath(
new URL('./theme/Appearance.vue', import.meta.url)
new URL('../../website/theme/Appearance.vue', import.meta.url)
)
},
{
find: /^.*VPButton\.vue$/,
replacement: fileURLToPath(
new URL('./theme/components/VPButton.vue', import.meta.url)
new URL(
'../../website/theme/components/VPButton.vue',
import.meta.url
)
)
}
]
@ -128,7 +131,7 @@ export default defineConfig({
filepath: './.cache/imports.json'
}
}),
transformsPlugin(),
transformsPlugin(meta),
{
name: 'custom:adjust-order',
configResolved(c) {

View file

@ -16,7 +16,7 @@
import type { DefaultTheme } from 'vitepress'
import consola from 'consola'
import { transform, transformGuide } from './transformer'
import { transform, transformGuide } from '../../website/transformer'
// @unocss-include

Binary file not shown.

View file

@ -1,43 +0,0 @@
<script setup lang="ts">
defineProps<{ title: string; description?: string }>()
</script>
<template>
<span
tw="w-full h-full bg-black flex flex-col"
style="
background-image: linear-gradient(
43deg,
#b794f4 2%,
#b18df2 7.5%,
#ab87ef 13%,
#9f7aea 24%,
#8c6ee2 32%,
#7864d8 40%,
#4c51bf 56%,
#4949ae 60.5%,
#46419b 65%,
#3c366b 74%,
#2f315d 80.5%,
#272d47 87%,
#1a202c 100%
);
"
>
<span
tw="p-10 w-full min-h-0 grow flex flex-col items-center justify-between"
>
<span tw="w-full flex justify-between items-center text-5xl font-medium">
<span tw="flex items-center">
<span tw="text-zinc-100 ml-2 mt-1 font-semibold">
freemediaheckyeah
</span>
</span>
</span>
<span tw="w-full pr-56 flex flex-col items-start justify-end">
<span style="color: #f3f4f6" tw="text-6xl font-bold" v-html="title" />
<span style="color: #c0caf5" tw="mt-2 text-4xl" v-html="description" />
</span>
</span>
</span>
</template>

View file

@ -1,23 +0,0 @@
/**
* Copyright (c) 2025 taskylizard. Apache License 2.0.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Barrel generated using @taskylizard/tasker.
*/
export * from './meta'
export * from './opengraph'
export * from './rss'
export * from './satoriConfig'

View file

@ -1,124 +0,0 @@
/**
* Copyright (c) 2025 taskylizard. Apache License 2.0.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { HeadConfig, TransformContext } from 'vitepress'
export function generateMeta(context: TransformContext, hostname: string) {
const head: HeadConfig[] = []
const { pageData } = context
if (pageData.isNotFound) return head
if (Object.keys(pageData.frontmatter).length === 0) return head
const url = `${hostname}/${pageData.relativePath.replace(/((^|\/)index)?\.md$/, '$2')}`
head.push(
['link', { rel: 'canonical', href: url }],
['meta', { property: 'og:url', content: url }],
['meta', { name: 'twitter:url', content: url }],
['meta', { name: 'twitter:card', content: 'summary_large_image' }],
['meta', { property: 'og:title', content: pageData.frontmatter.title }],
['meta', { name: 'twitter:title', content: pageData.frontmatter.title }]
)
if (pageData.frontmatter.description) {
head.push(
[
'meta',
{
property: 'og:description',
content: pageData.frontmatter.description
}
],
[
'meta',
{
name: 'twitter:description',
content: pageData.frontmatter.description
}
]
)
}
if (pageData.frontmatter.image) {
head.push([
'meta',
{
property: 'og:image',
content: `${hostname}/${pageData.frontmatter.image.replace(/^\//, '')}`
}
])
head.push([
'meta',
{
name: 'twitter:image',
content: `${hostname}/${pageData.frontmatter.image.replace(/^\//, '')}`
}
])
} else {
const url = pageData.filePath.replace('index.md', '').replace('.md', '')
const imageUrl = `${url}/__og_image__/og.png`
.replaceAll('//', '/')
.replace(/^\//, '')
head.push(
['meta', { property: 'og:image', content: `${hostname}/${imageUrl}` }],
['meta', { property: 'og:image:width', content: '1200' }],
['meta', { property: 'og:image:height', content: '628' }],
['meta', { property: 'og:image:type', content: 'image/png' }],
[
'meta',
{ property: 'og:image:alt', content: pageData.frontmatter.title }
],
['meta', { name: 'twitter:image', content: `${hostname}/${imageUrl}` }],
['meta', { name: 'twitter:image:width', content: '1200' }],
['meta', { name: 'twitter:image:height', content: '628' }],
[
'meta',
{ name: 'twitter:image:alt', content: pageData.frontmatter.title }
]
)
}
if (pageData.frontmatter.tag) {
head.push([
'meta',
{ property: 'article:tag', content: pageData.frontmatter.tag }
])
}
if (pageData.frontmatter.date) {
head.push([
'meta',
{
property: 'article:published_time',
content: pageData.frontmatter.date
}
])
}
if (pageData.lastUpdated && pageData.frontmatter.lastUpdated !== false) {
head.push([
'meta',
{
property: 'article:modified_time',
content: new Date(pageData.lastUpdated).toISOString()
}
])
}
return head
}

View file

@ -1,141 +0,0 @@
/**
* Copyright (c) 2025 taskylizard. Apache License 2.0.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ContentData, SiteConfig } from 'vitepress'
import type { SatoriOptions } from 'x-satori/vue'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { renderAsync } from '@resvg/resvg-js'
import consola from 'consola'
import { createContentLoader } from 'vitepress'
import { satoriVue } from 'x-satori/vue'
import { headers } from '../transformer/constants'
const __dirname = dirname(fileURLToPath(import.meta.url))
const __fonts = resolve(__dirname, '../fonts')
export async function generateImages(config: SiteConfig) {
const pages = await createContentLoader('**/*.md', { excerpt: true }).load()
const template = await readFile(resolve(__dirname, './Template.vue'), 'utf-8')
const fonts: SatoriOptions['fonts'] = [
{
name: 'Inter',
data: await readFile(resolve(__fonts, 'Inter-Regular.otf')),
weight: 400,
style: 'normal'
},
{
name: 'Inter',
data: await readFile(resolve(__fonts, 'Inter-Medium.otf')),
weight: 500,
style: 'normal'
},
{
name: 'Inter',
data: await readFile(resolve(__fonts, 'Inter-SemiBold.otf')),
weight: 600,
style: 'normal'
},
{
name: 'Inter',
data: await readFile(resolve(__fonts, 'Inter-Bold.otf')),
weight: 700,
style: 'normal'
}
]
for (const page of pages) {
await generateImage({
page,
template,
outDir: config.outDir,
fonts
})
}
return consola.info('Generated opengraph images.')
}
interface GenerateImagesOptions {
page: ContentData
template: string
outDir: string
fonts: SatoriOptions['fonts']
}
async function generateImage({
page,
template,
outDir,
fonts
}: GenerateImagesOptions) {
const { frontmatter, url } = page
const _page = getPage(url)
const title =
frontmatter.layout === 'home'
? (frontmatter.hero.name ?? frontmatter.title)
: frontmatter.title
? frontmatter.title
: _page?.title
const description =
frontmatter.layout === 'home'
? (frontmatter.hero.tagline ?? frontmatter.description)
: frontmatter.description
? frontmatter.description
: _page?.description
// consola.info(url, title, description)
const options: SatoriOptions = {
width: 1200,
height: 628,
fonts,
props: {
title,
description
}
}
const svg = await satoriVue(options, template)
const render = await renderAsync(svg)
const outputFolder = resolve(outDir, url.slice(1), '__og_image__')
const outputFile = resolve(outputFolder, 'og.png')
await mkdir(outputFolder, { recursive: true })
await writeFile(outputFile, render.asPng())
}
function getPage(page: string) {
// Get the page name
const pageName = `${page}.md`.slice(1).split('/').at(-1)
// Find the header
// TODO: This is a hacky way to find the header
const header = Object.entries(headers).find(([key]) => key === pageName)
if (!header) return
const { title, description } = header[1]
return {
title,
description
}
}

View file

@ -1,62 +0,0 @@
/**
* Copyright (c) 2025 taskylizard. Apache License 2.0.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ContentData, SiteConfig } from 'vitepress'
import { writeFileSync } from 'node:fs'
import path from 'node:path'
import consola from 'consola'
import { Feed } from 'feed'
import { createContentLoader } from 'vitepress'
import { meta } from '../constants'
export async function generateFeed(config: SiteConfig): Promise<void> {
const feed: Feed = new Feed({
id: meta.hostname,
link: meta.hostname,
title: 'FMHY blog',
description: meta.description,
language: 'en-US',
image: 'https://github.com/fmhy.png',
favicon: `${meta.hostname}/favicon.ico`,
copyright: 'Copyright (c) 2023-present FMHY'
})
const posts: ContentData[] = await createContentLoader('posts/*.md', {
excerpt: true,
render: true,
transform: (rawData) => {
return rawData.sort((a, b) => {
return (
Number(new Date(b.frontmatter.date)) -
Number(new Date(a.frontmatter.date))
)
})
}
}).load()
for (const { url, frontmatter, html } of posts) {
feed.addItem({
title: frontmatter.title as string,
id: `${meta.hostname}${url.replace(/\/\d+\./, '/')}`,
link: `${meta.hostname}${url.replace(/\/\d+\./, '/')}`,
date: frontmatter.date,
content: html?.replaceAll('&ZeroWidthSpace;', '')
})
}
writeFileSync(path.join(config.outDir, 'feed.rss'), feed.rss2())
return consola.info('Generated rss feed.')
}

View file

@ -1,63 +0,0 @@
/**
* Copyright (c) 2025 taskylizard. Apache License 2.0.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { SatoriOptions } from 'x-satori/vue'
import { readFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineSatoriConfig } from 'x-satori/vue'
const __dirname = dirname(fileURLToPath(import.meta.url))
const __fonts = resolve(__dirname, '../fonts')
const fonts: SatoriOptions['fonts'] = [
{
name: 'Inter',
data: await readFile(resolve(__fonts, 'Inter-Regular.otf')),
weight: 400,
style: 'normal'
},
{
name: 'Inter',
data: await readFile(resolve(__fonts, 'Inter-Medium.otf')),
weight: 500,
style: 'normal'
},
{
name: 'Inter',
data: await readFile(resolve(__fonts, 'Inter-SemiBold.otf')),
weight: 600,
style: 'normal'
},
{
name: 'Inter',
data: await readFile(resolve(__fonts, 'Inter-Bold.otf')),
weight: 700,
style: 'normal'
}
]
export default defineSatoriConfig({
width: 1200,
height: 628,
fonts,
props: {
title: 'Title',
description:
'Lorem ipsum dolor sit amet, qui minim labore adipisicing minim sint cillum sint consectetur cupidatat.',
dir: '/j'
}
})

View file

@ -1,44 +0,0 @@
/**
* Copyright (c) 2025 taskylizard. Apache License 2.0.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { MarkdownRenderer } from 'vitepress'
// FIXME: tasky: possibly write less horror jank?
export function base64DecodePlugin(md: MarkdownRenderer) {
const decode = (str: string): string =>
Buffer.from(str, 'base64').toString('binary')
// Save the original rule for backticks
const defaultRender =
md.renderer.rules.code_inline ||
function (tokens, idx, options, _env, self) {
return self.renderToken(tokens, idx, options)
}
md.renderer.rules.code_inline = function (tokens, idx, options, env, self) {
if (
!env.frontmatter.title ||
(env.frontmatter.title && env.frontmatter.title !== 'base64')
) {
return defaultRender(tokens, idx, options, env, self)
}
const token = tokens[idx]
const content = token.content
return `<button class='base64' onclick="(function(btn){ const codeEl = btn.querySelector('code'); navigator.clipboard.writeText('${decode(
content
)}').then(() => { const originalText = codeEl.textContent; codeEl.textContent = 'Copied'; setTimeout(() => codeEl.textContent = originalText, 3000); }).catch(console.error); })(this)"><code>${content}</code></button>`
}
}

View file

@ -1,58 +0,0 @@
/**
* Copyright (c) 2025 taskylizard. Apache License 2.0.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { MarkdownRenderer } from 'vitepress'
import { icons as twemoji } from '@iconify-json/twemoji'
export const defs = {
...Object.fromEntries(
Object.entries(twemoji.icons).map(([key]) => {
return [key, '']
})
)
}
export function emojiRender(md: MarkdownRenderer) {
md.renderer.rules.emoji = (tokens, idx) => {
if (tokens[idx].markup.startsWith('star')) {
return `<span class="i-twemoji-${tokens[idx].markup} starred"></span>`
}
return `<span class="i-twemoji-${tokens[idx].markup}"></span>`
}
}
export function movePlugin(
plugins: { name: string }[],
pluginAName: string,
order: 'before' | 'after',
pluginBName: string
) {
const pluginBIndex = plugins.findIndex((p) => p.name === pluginBName)
if (pluginBIndex === -1) return
const pluginAIndex = plugins.findIndex((p) => p.name === pluginAName)
if (pluginAIndex === -1) return
if (order === 'before' && pluginAIndex > pluginBIndex) {
const pluginA = plugins.splice(pluginAIndex, 1)[0]
plugins.splice(pluginBIndex, 0, pluginA)
}
if (order === 'after' && pluginAIndex < pluginBIndex) {
const pluginA = plugins.splice(pluginAIndex, 1)[0]
plugins.splice(pluginBIndex, 0, pluginA)
}
}

View file

@ -1,84 +0,0 @@
/**
* Copyright (c) 2025 taskylizard. Apache License 2.0.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Copyright (c) 2024 taskylizard
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { MarkdownRenderer } from 'vitepress'
import { headers } from '../transformer/constants'
const titles = Object.keys(headers).map((key) => headers[key].title)
export const headersPlugin = (md: MarkdownRenderer) => {
// Add the Feedback component in the heading, before the link.
//
// Adding it after the link is closed prevents vitepress from properly
// indexing the file's content.
md.renderer.rules.heading_open = (tokens, idx, options, env, self) => {
const result = self.renderToken(tokens, idx, options)
const idxClose =
idx +
tokens.slice(idx).findIndex((token) => token.type === 'heading_close')
if (idxClose <= idx) return result
const level = tokens[idx].tag.slice(1)
if (!titles.includes(env.frontmatter.title) || level !== '2') return result
// Find the token for the link.
//
// The token after `heading_open` contains the link as a child token.
const children = tokens[idx + 1].children || []
const linkOpenToken = children.findLast((c) => c.type === 'link_open')
if (!linkOpenToken) return result
const heading = tokens[idxClose - 1]
linkOpenToken.meta = linkOpenToken.meta || {}
linkOpenToken.meta.feedback = {
heading: heading.content
}
return result
}
const defaultRender = md.renderer.rules.link_open
md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
const result = defaultRender!!!!!!!!!!(tokens, idx, options, env, self)
const meta = tokens[idx].meta
if (!meta || !meta.feedback) return result
const heading = meta.feedback.heading || ''
if (!heading) return result
return `<Feedback heading="${heading}" />${result}`
}
}

View file

@ -1,39 +0,0 @@
/**
* Copyright (c) 2025 taskylizard. Apache License 2.0.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { MarkdownRenderer } from 'vitepress'
const excluded = ['Beginners Guide']
export function toggleStarredPlugin(md: MarkdownRenderer) {
md.renderer.rules.list_item_open = (tokens, index, options, env, self) => {
const contentToken = tokens[index + 2]
// Ensure the token exists
if (contentToken) {
const content = contentToken.content
if (
!excluded.includes(env.frontmatter.title) &&
(content.includes(':star:') || content.includes(':glowing-star:'))
) {
return `<li class="starred">`
}
}
return self.renderToken(tokens, index, options)
}
}

View file

@ -1,72 +0,0 @@
<script setup lang="ts">
import VPIconMoon from 'vitepress/dist/client/theme-default/components/icons/VPIconMoon.vue'
import VPIconSun from 'vitepress/dist/client/theme-default/components/icons/VPIconSun.vue'
const { isDark } = useData()
const toggleAppearance = inject('toggle-appearance', () => {
isDark.value = !isDark.value
})
const supportsViewTransition = ref(false)
onMounted(() => {
supportsViewTransition.value =
'startViewTransition' in document &&
window.matchMedia('(prefers-reduced-motion: no-preference)').matches
})
</script>
<template>
<button
type="button"
role="switch"
title="VPSwitchAppearance"
class="VPSwitchAppearance"
:aria-checked="isDark"
:data-view-transition="supportsViewTransition"
@click="toggleAppearance"
>
<ClientOnly>
<Transition name="fade" mode="out-in">
<div v-if="!isDark" class="sun text-xl i-ph-sun-duotone" />
<div v-else class="moon text-xl i-ph-moon-duotone" />
</Transition>
</ClientOnly>
</button>
</template>
<style lang="scss" scoped>
.VPSwitchAppearance {
display: flex;
justify-content: center;
align-items: center;
width: 36px;
height: 36px;
color: var(--vp-c-text-2);
transition: color 0.5s;
&:hover {
color: var(--vp-c-text-1);
transition: color 0.25s;
}
& > :deep(svg) {
width: 20px;
height: 20px;
fill: currentColor;
}
&[data-view-transition='false'] {
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.1s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
}
}
</style>

View file

@ -1,108 +0,0 @@
<script setup lang="ts">
import { BProgress } from '@bprogress/core'
import DefaultTheme from 'vitepress/theme'
import Announcement from './components/Announcement.vue'
import Sidebar from './components/SidebarCard.vue'
import '@bprogress/core/css'
const { isDark } = useData()
const router = useRouter()
BProgress.configure({
showSpinner: false,
easing: 'ease'
})
router.onBeforeRouteChange = () => {
BProgress.start()
}
router.onAfterRouteChange = () => {
BProgress.done()
}
const enableTransitions = () =>
'startViewTransition' in document &&
window.matchMedia('(prefers-reduced-motion: no-preference)').matches
provide('toggle-appearance', async ({ clientX: x, clientY: y }: MouseEvent) => {
if (!enableTransitions()) {
isDark.value = !isDark.value
return
}
const clipPath = [
`circle(0px at ${x}px ${y}px)`,
`circle(${Math.hypot(
Math.max(x, innerWidth - x),
Math.max(y, innerHeight - y)
)}px at ${x}px ${y}px)`
]
await document.startViewTransition(async () => {
isDark.value = !isDark.value
await nextTick()
}).ready
document.documentElement.animate(
{ clipPath: isDark.value ? clipPath.reverse() : clipPath },
{
duration: 300,
easing: 'ease-in',
pseudoElement: `::view-transition-${isDark.value ? 'old' : 'new'}(root)`
}
)
})
const { Layout } = DefaultTheme
</script>
<template>
<Layout>
<template #sidebar-nav-after>
<Sidebar />
</template>
<template #home-hero-info-before>
<Announcement />
</template>
<template #home-features-before>
<p class="text-center text-lg text-text-2 mb-2">
Or browse these pages
<span class="inline-block i-twemoji:sparkles" />
</p>
</template>
<Content />
</Layout>
</template>
<style>
:root {
--bprogress-color: var(--vp-c-brand);
}
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-old(root),
.dark::view-transition-new(root) {
z-index: 1;
}
::view-transition-new(root),
.dark::view-transition-old(root) {
z-index: 9999;
}
.VPSwitchAppearance {
width: 22px !important;
}
.VPSwitchAppearance .check {
transform: none !important;
}
</style>

View file

@ -1,24 +0,0 @@
<script setup lang="ts">
import Authors from './components/Authors.vue'
const props = defineProps<{
authors: string
}>()
const formatDate = (raw: string): string => {
const date = new Date(raw)
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric'
})
}
const { frontmatter } = useData()
const authors = computed(() => props.authors.split(','))
</script>
<template>
<h1>{{ frontmatter.title }}</h1>
<div>{{ frontmatter.description }} {{ formatDate(frontmatter.date) }}</div>
<Authors :authors="authors" />
</template>

View file

@ -1,67 +0,0 @@
<!-- eslint-disable vue/require-v-for-key -->
<script setup lang="ts">
import { data as posts } from './posts.data'
const formatDate = (raw: string): string => {
const date = new Date(raw)
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric'
})
}
</script>
<template>
<div>
<section>
<h1 class="flex items-center gap-2">Posts</h1>
<p>Everything from Monthly Updates to fmhy updates.</p>
We also have a
<a href="/feed.rss" target="_blank" title="RSS feed">
<div class="i-carbon-rss vertical-top" />
RSS feed.
</a>
</section>
<template v-for="year in Object.keys(posts).reverse()" :key="year">
<h2>{{ year }}</h2>
<ul>
<li v-for="post of posts[year]" :key="post.url">
<article>
<a :href="post.url" class="border-none">{{ post.title }}</a>
-
<dl class="m-0 inline">
<dt class="sr-only">Published on</dt>
<dd class="m-0 inline">
<time :datetime="post.date" class="font-bold">
{{ formatDate(post.date) }}
</time>
</dd>
</dl>
</article>
</li>
</ul>
</template>
</div>
</template>
<style scoped>
.VPBadge {
border: 1px solid transparent;
border-radius: 8px;
display: inline-flex;
margin-left: 2px;
padding: 0 10px;
line-height: 22px;
font-size: 12px;
font-weight: 500;
transform: translateY(-2px);
align-items: center;
gap: 0.2rem;
padding-right: 10px;
vertical-align: middle;
color: var(--vp-badge-tip-text);
background-color: transparent;
border-color: var(--vp-custom-block-tip-outline);
}
</style>

View file

@ -1,13 +0,0 @@
<script setup lang="ts">
const { frontmatter } = useData()
</script>
<template>
<a
v-if="frontmatter.hero.announcement"
:href="frontmatter.hero.announcement.link"
class="mb-3 inline-flex items-center rounded-lg bg-[var(--vp-c-default-soft)] px-4 py-1 text-sm font-semibold"
>
{{ frontmatter.hero.announcement.title }}
</a>
</template>

View file

@ -1,49 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
authors: string[]
}>()
interface Author {
name: string
github: string
}
const data = [
{
name: 'nbats',
github: 'https://github.com/nbats'
},
{
name: 'Kai',
github: 'https://github.com/Kai-FMHY'
},
{
name: 'taskylizard',
github: 'https://github.com/taskylizard'
},
{
name: 'zinklog',
github: 'https://github.com/zinklog2'
},
{
name: 'Q',
github: 'https://github.com/qiracy'
}
] satisfies Author[]
const authors = computed(() =>
data.filter((author) => props.authors.includes(author.name))
)
</script>
<template>
<div class="flex flex-wrap gap-4 pt-2">
<div v-for="(c, index) of authors" class="flex items-center gap-2">
<img :src="`${c.github}.png`" class="h-8 w-8 rounded-full" />
<a :href="c.github">{{ c.name }}</a>
<span v-if="index < authors.length - 1"></span>
</div>
</div>
</template>

View file

@ -1,16 +0,0 @@
<script setup lang="ts">
defineProps<{
icon: string
}>()
</script>
<template>
<div class="g-[12px] mb-[8px] flex items-center">
<span class="flex items-center">
<div class="text-2xl" :class="icon" />
<div class="ml-2 text-sm text-[var(--vp-c-text-2)]">
<slot />
</div>
</span>
</div>
</template>

View file

@ -1,114 +0,0 @@
<script setup lang="ts">
import { colors } from '@fmhy/colors'
import { useStorage, useStyleTag } from '@vueuse/core'
import {
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectPortal,
SelectRoot,
SelectTrigger,
SelectValue,
SelectViewport
} from 'reka-ui'
import { watch } from 'vue'
const colorScales = [
'50',
'100',
'200',
'300',
'400',
'500',
'600',
'700',
'800',
'900',
'950'
] as const
type ColorNames = keyof typeof colors
const selectedColor = useStorage<ColorNames>('preferred-color', 'swarm')
const colorOptions = Object.keys(colors).filter(
(key) => typeof colors[key as keyof typeof colors] === 'object'
)
const { css } = useStyleTag('', { id: 'brand-color' })
const updateThemeColor = (colorName: ColorNames) => {
const colorSet = colors[colorName]
const cssVars = colorScales
.map((scale) => `--vp-c-brand-${scale}: ${colorSet[scale]};`)
.join('\n ')
css.value = `
:root {
${cssVars}
--vp-c-brand-1: ${colorSet[500]};
--vp-c-brand-2: ${colorSet[600]};
--vp-c-brand-3: ${colorSet[800]};
--vp-c-brand-soft: ${colorSet[400]};
}
.dark {
${cssVars}
--vp-c-brand-1: ${colorSet[400]};
--vp-c-brand-2: ${colorSet[500]};
--vp-c-brand-3: ${colorSet[700]};
--vp-c-brand-soft: ${colorSet[300]};
}
`
}
// Initialize theme color
updateThemeColor(selectedColor.value)
watch(selectedColor, updateThemeColor)
const normalizeColorName = (colorName: string) =>
colorName.replaceAll(/-/g, ' ').charAt(0).toUpperCase() +
colorName.slice(1).replaceAll(/-/g, ' ')
const handleColorChange = (value: string) => {
selectedColor.value = value as ColorNames
}
</script>
<template>
<div class="color-picker">
<SelectRoot :model-value="selectedColor" @update:model-value="handleColorChange">
<SelectTrigger
class="inline-flex items-center justify-between px-3 py-2 text-sm bg-bg-alt border border-div rounded-md hover:bg-bg-elv transition-colors min-w-[180px] mx-auto align-left"
aria-label="Select theme color">
<div class="flex items-center gap-2">
<span class="inline-block w-4 h-4 rounded-full border border-div"
:style="{ backgroundColor: colors[selectedColor][500] }" />
<SelectValue :placeholder="normalizeColorName(selectedColor)" />
</div>
<span class="i-lucide:chevron-down w-4 h-4 text-text-2" />
</SelectTrigger>
<SelectPortal>
<SelectContent class="bg-bg-elv border border-div rounded-md shadow-lg z-50 max-h-60 overflow-hidden z-9999"
:side-offset="4">
<SelectViewport class="p-1">
<SelectItem v-for="color in colorOptions" :key="color" :value="color"
class="relative flex items-center gap-2 px-3 py-2 text-sm cursor-pointer hover:bg-bg-alt rounded-sm outline-none data-[highlighted]:bg-bg-alt data-[state=checked]:bg-bg-alt data-[state=checked]:text-text">
<span class="inline-block w-4 h-4 rounded-full border border-div"
:style="{ backgroundColor: colors[color][500] }" />
<SelectItemText>
{{ normalizeColorName(color) }}
</SelectItemText>
<SelectItemIndicator class="absolute right-2">
<span class="i-lucide:check w-4 h-4 text-text-2" />
</SelectItemIndicator>
</SelectItem>
</SelectViewport>
</SelectContent>
</SelectPortal>
</SelectRoot>
</div>
</template>

View file

@ -1,363 +0,0 @@
<script setup lang="ts">
import type { FeedbackType } from '../../types/Feedback'
import { useRouter } from 'vitepress'
import { computed, reactive, ref } from 'vue'
import { feedbackOptions, getFeedbackOption } from '../../types/Feedback'
const props = defineProps<{
heading?: string
}>()
const prompts = [
'Make it count!',
'Leave some feedback for us!',
`We're all ears 🐰`,
'Tell us what is missing in FMHY',
'Your thoughts matter to us 💡',
'Feedback is a gift 🎁',
'What do you think?',
'We appreciate your support 🙏',
'Help us make FMHY better 🤝',
'We need your help 👋',
'Your feedback is valuable 💯',
'So... what do you think?',
"I guess you don't need to say anything 😉",
'Spill the beans 💣',
"We're always looking for ways to improve!",
'Your feedback is valuable and helps us make FMHY better.',
'aliens are watching you 👽',
'tasky was here 👀',
'The internet is full of crap 😱'
]
function getPrompt() {
return prompts[Math.floor(Math.random() * prompts.length)]
}
const messages = {
suggestion: [
"We're glad you want to share your ideas!",
'Nix the fluff and just tell us what you think!',
"We'll be happy to read your thoughts and incorporate them into our wiki.",
"Hello! We're glad you want to share your ideas!"
],
appreciation: [
'We appreciate your support!',
"We're always looking for ways to improve!.",
'Your feedback is valuable and helps us make FMHY better.'
],
other: [
"We're always looking for ways to improve!",
'Your feedback is valuable and helps us make FMHY better.'
]
}
function getMessage(type: FeedbackType['type']) {
return messages[type][Math.floor(Math.random() * messages[type].length)]
}
const loading = ref<boolean>(false)
const error = ref<unknown>(null)
const success = ref<boolean>(false)
const isDisabled = computed(() => {
return (
!feedback.message.length ||
feedback.message.length < 5 ||
feedback.message.length > 1000
)
})
const router = useRouter()
const feedback = reactive<{
message: string
page: string
type?: FeedbackType['type']
}>({
page: router.route.path,
message: ''
})
const selectedOption = ref(feedbackOptions[0])
async function handleSubmit(type?: FeedbackType['type']) {
if (type) {
feedback.type = type
selectedOption.value = getFeedbackOption(type)!
}
loading.value = true
const body: FeedbackType = {
message: feedback.message,
type: feedback.type!,
page: feedback.page,
...(props.heading && { heading: props.heading })
}
try {
const response = await fetch('https://api.fmhy.net/feedback', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
})
const data = await response.json()
if (data.error) {
error.value = data.error
return
}
if (data.status === 'ok') {
success.value = true
}
} catch (err) {
error.value = err
} finally {
loading.value = false
}
}
const isCardShown = ref<boolean>(false)
const helpfulText = props.heading
? 'What do you think about this section?'
: 'What do you think about this page?'
const helpfulDescription = props.heading
? 'Let us know how helpful this section is.'
: 'Let us know how helpful this page is.'
const prompt = computed(() => getPrompt())
const message = computed(() => getMessage(feedback.type!))
const toggleCard = () => (isCardShown.value = !isCardShown.value)
</script>
<template>
<template v-if="props.heading">
<button
@click="toggleCard()"
class="bg-$vp-c-default-soft text-primary border-$vp-c-default-soft hover:border-primary ml-3 inline-flex h-7 items-center justify-center whitespace-nowrap rounded-md border-2 border-solid px-1.5 py-3.5 text-sm font-medium transition-all duration-300 sm:h-6"
>
<span
:class="isCardShown === false ? `i-lucide:mail` : `i-lucide:mail-x`"
/>
</button>
</template>
<template v-else>
<div
class="mt-2 p-4 border-2 border-solid bg-brand-50 border-brand-300 dark:bg-brand-950 dark:border-brand-800 rounded-xl col-span-3 transition-colors duration-250"
>
<div class="flex items-start md:items-center gap-3">
<div class="pt-1 md:pt-0">
<div
class="w-10 h-10 rounded-full flex items-center justify-center bg-brand-500 dark:bg-brand-400"
>
<span
:class="
isCardShown === false
? `i-lucide:mail w-6 h-6 text-white dark:text-brand-950`
: `i-lucide:mail-x w-6 h-6 text-white dark:text-brand-950`
"
/>
</div>
</div>
<div
class="flex-grow flex items-start md:items-center gap-3 flex-col md:flex-row"
>
<div class="flex-grow">
<div class="font-semibold text-brand-950 dark:text-brand-50">
Got feedback?
</div>
<div class="text-sm text-brand-800 dark:text-brand-100">
We'd love to know what you think about this page.
</div>
</div>
<div>
<button
class="inline-block text-center rounded-full px-4 py-2.5 text-sm font-medium border-2 border-solid text-brand-700 border-brand-300 dark:text-brand-100 dark:border-brand-800"
@click="toggleCard()"
>
Share Feedback
</button>
</div>
</div>
</div>
</div>
</template>
<Transition name="fade" mode="out-in">
<div
v-if="isCardShown"
class="border-$vp-c-divider bg-$vp-c-bg-alt b-rd-4 m-[2rem 0] mt-4 border-2 border-solid p-6"
>
<Transition name="fade" mode="out-in">
<div v-if="!feedback.type">
<p class="heading">
{{ helpfulText }}
</p>
<div class="flex flex-wrap gap-2">
<button
v-for="item in feedbackOptions"
:key="item.value"
class="bg-bg border-$vp-c-default-soft hover:border-primary mt-2 select-none rounded border-2 border-solid font-bold transition-all duration-250 rounded-lg text-[14px] font-500 leading-normal m-0 px-3 py-1.5 text-center align-middle whitespace-nowrap"
@click="handleSubmit(item.value)"
>
<span>{{ item.label }}</span>
</button>
</div>
</div>
<div v-else-if="feedback.type && !success">
<div>
<p class="desc">{{ helpfulDescription }} - {{ prompt }}</p>
<span>{{ getFeedbackOption(feedback.type)?.label }}</span>
</div>
<p class="heading" v-text="message"></p>
<div v-if="feedback.type === 'suggestion'" class="mb-2 text-sm">
<details>
<summary>
<span class="ii-lucide-shield-x bg-cerise-400 mb-1 ml-1" />
Do not submit any of the following:
</summary>
<strong>🕹 Emulators</strong>
<p class="desc">
They're already on the
<a
class="text-primary text-underline font-bold"
href="https://emulation.gametechwiki.com/index.php/Main_Page"
>
Game Tech Wiki.
</a>
</p>
<strong>🔻 Leeches</strong>
<p class="desc">
They're already on the
<a
class="text-primary text-underline font-bold"
href="https://filehostlist.miraheze.org/wiki/Free_Premium_Leeches"
>
File Hosting Wiki.
</a>
</p>
<strong>🐧 Distros</strong>
<p class="desc">
They're already on
<a
class="text-primary text-underline font-bold"
href="https://distrowatch.com/"
>
DistroWatch.
</a>
</p>
<strong>🎲 Mining / Betting Sites</strong>
<p class="desc">
Don't post anything related to betting, mining, BINs, CCs, etc.
</p>
<strong>🎮 Multiplayer Game Hacks</strong>
<p class="desc">
Don't post any hacks/exploits that give unfair advantages in
multiplayer games.
</p>
</details>
</div>
<textarea
v-model="feedback.message"
autofocus
class="bg-$vp-c-bg-alt text-$vp-c-text-2 w-full h-[100px] border border-$vp-c-divider rounded px-3 py-1.5 border-$vp-c-divider bg-$vp-c-bg-alt b-rd-4 border-2 border-solid"
placeholder="What a lovely wiki!"
/>
<p class="desc mb-2">
Add your Discord handle if you would like a response, or if we need
more information from you, otherwise join our
<a
class="text-primary text-underline font-semibold"
href="https://rentry.co/FMHY-Invite/"
>
Discord.
</a>
</p>
<div class="flex flex-row gap-2">
<button
class="bg-$vp-c-default-soft text-primary border-$vp-c-default-soft inline-flex h-7 items-center justify-center whitespace-nowrap rounded-md border-2 border-solid px-1.5 py-3.5 text-sm font-medium transition-all duration-300 sm:h-6"
@click="feedback.type = undefined"
>
<span class="i-lucide:panel-left-close">close</span>
</button>
<button
type="submit"
class="border border-div rounded-lg transition-colors duration-250 inline-block text-14px font-500 leading-1.5 px-3 py-3 text-center align-middle whitespace-nowrap disabled:opacity-50 text-text-2 bg-brand-100 dark:bg-brand-700 border-brand-800 dark:border-brand-700 disabled:bg-brand-100 dark:disabled:bg-brand-900 hover:border-brand-900 dark:hover:border-brand-800 hover:bg-brand-200 dark:hover:bg-brand-800"
:disabled="isDisabled"
@click="handleSubmit()"
>
Send Feedback 📩
</button>
</div>
</div>
<div v-else>
<p class="heading">Thanks for your feedback!</p>
</div>
</Transition>
</div>
</Transition>
</template>
<style scoped lang="css">
.btn {
border: 1px solid var(--vp-c-divider);
background-color: var(--vp-c-bg);
border-radius: 8px;
transition:
border-color 0.25s,
background-color 0.25s;
display: inline-block;
font-size: 14px;
font-weight: 500;
line-height: 1.5;
margin: 0;
padding: 0.375rem 0.75rem;
text-align: center;
vertical-align: middle;
white-space: nowrap;
}
.btn:disabled {
opacity: 0.5;
}
.btn:hover {
border-color: var(--vp-c-brand);
}
.btn-primary {
color: #fff;
background-color: var(--vp-c-brand);
border-color: var(--vp-c-brand);
}
.btn-primary:hover {
background-color: var(--vp-c-brand-darker);
border-color: var(--vp-c-brand-darker);
}
.heading {
font-size: 1.2rem;
font-weight: 700;
}
.desc {
display: block;
line-height: 20px;
font-size: 12px;
font-weight: 500;
color: var(--vp-c-text-2);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.25s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View file

@ -1,47 +0,0 @@
<script setup lang="ts">
defineProps<{
label: string
id: string
}>()
</script>
<template>
<div class="input-field">
<div v-if="label" class="input-label">
<label :for="id" class="pane-label">
{{ label }}
</label>
<div class="display-value">
<slot name="display" />
</div>
</div>
<slot />
</div>
</template>
<style scoped>
.pane-label {
line-height: 20px;
font-size: 13px;
font-weight: 600;
color: var(--vt-c-text-1);
display: block;
}
.input-field:not(:last-child) {
margin-bottom: 16px;
}
.display-value {
font-size: 13px;
color: var(--vp-c-text-2);
}
.input-label {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
gap: 12px;
}
</style>

View file

@ -1,30 +0,0 @@
<script setup lang="ts">
import Field from './CardField.vue'
import ColorPicker from './ColorPicker.vue'
import InputField from './InputField.vue'
import ToggleStarred from './ToggleStarred.vue'
</script>
<template>
<div
class="bg-$vp-c-bg border-$vp-c-default-soft hover:border-primary transition-border relative z-0 rounded-lg border-2 border-solid p-5 duration-500">
<div class="align-center mb-4 flex justify-between">
<div class="text-$vp-c-text-1 lh-relaxed text-sm font-bold">
Emoji Legend
</div>
</div>
<Field icon="i-twemoji-globe-with-meridians">Indexes</Field>
<Field icon="i-twemoji-repeat-button">Storage Links</Field>
<Field icon="i-twemoji-star">Recommendations</Field>
<div class="align-center mb-4 mt-4 flex justify-between">
<div class="text-$vp-c-text-1 lh-relaxed text-sm font-bold">Options</div>
</div>
<InputField id="toggle-starred" label="Toggle Starred">
<template #display>
<ToggleStarred />
</template>
</InputField>
<ColorPicker />
</div>
</template>

View file

@ -1,52 +0,0 @@
<script setup>
import { Switch } from '@headlessui/vue'
import { ref } from 'vue'
const enabled = ref(false)
</script>
<template>
<Switch v-model="enabled" class="switch" :class="{ enabled }">
<span class="thumb" />
</Switch>
</template>
<style>
.switch {
display: inline-flex;
position: relative;
width: 40px;
height: 22px;
flex-shrink: 0;
border: 1px solid var(--vp-input-border-color);
background-color: var(--vp-input-switch-bg-color);
transition:
border-color 0.25s,
background-color 0.4s ease;
border-radius: 11px;
}
.switch.enabled {
background-color: var(--vp-c-brand);
}
</style>
<style scoped>
.switch:hover {
border-color: var(--vp-input-hover-border-color);
}
.thumb {
display: inline-block;
background-color: #fff;
transition: transform 0.25s;
width: 20px;
height: 20px;
border-radius: 50%;
box-shadow: var(--vp-shadow-1);
}
.switch.enabled .thumb {
transform: translateX(18px);
}
</style>

View file

@ -1,16 +0,0 @@
<script setup lang="ts">
import Switch from './Switch.vue'
const toggleStarred = () =>
document.documentElement.classList.toggle('starred-only')
</script>
<template>
<Switch @click="toggleStarred()" />
</template>
<style>
.starred-only li:not(.starred) {
display: none;
}
</style>

View file

@ -1,346 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
const KNOWN_EXTENSIONS = new Set()
function treatAsHtml(filename: string): boolean {
if (KNOWN_EXTENSIONS.size === 0) {
const extraExts =
(typeof process === 'object' && process.env?.VITE_EXTRA_EXTENSIONS) ||
(import.meta as any).env?.VITE_EXTRA_EXTENSIONS ||
''
// md, html? are intentionally omitted
; (
'3g2,3gp,aac,ai,apng,au,avif,bin,bmp,cer,class,conf,crl,css,csv,dll,' +
'doc,eps,epub,exe,gif,gz,ics,ief,jar,jpe,jpeg,jpg,js,json,jsonld,m4a,' +
'man,mid,midi,mjs,mov,mp2,mp3,mp4,mpe,mpeg,mpg,mpp,oga,ogg,ogv,ogx,' +
'opus,otf,p10,p7c,p7m,p7s,pdf,png,ps,qt,roff,rtf,rtx,ser,svg,t,tif,' +
'tiff,tr,ts,tsv,ttf,txt,vtt,wav,weba,webm,webp,woff,woff2,xhtml,xml,' +
'yaml,yml,zip' +
(extraExts && typeof extraExts === 'string' ? ',' + extraExts : '')
)
.split(',')
.forEach((ext) => KNOWN_EXTENSIONS.add(ext))
}
const ext = filename.split('.').pop()
return ext == null || !KNOWN_EXTENSIONS.has(ext.toLowerCase())
}
const EXTERNAL_URL_RE = /^(?:[a-z]+:|\/\/)/i
function normalizeLink(url: string): string {
const { pathname, search, hash, protocol } = new URL(url, 'http://a.com')
function isExternal(path: string): boolean {
return EXTERNAL_URL_RE.test(path)
}
if (
isExternal(url) ||
url.startsWith('#') ||
!protocol.startsWith('http') ||
!treatAsHtml(pathname)
)
return url
const { site } = useData()
const normalizedPath =
pathname.endsWith('/') || pathname.endsWith('.html')
? url
: url.replace(
/(?:(^\.+)\/)?.*$/,
`$1${pathname.replace(
/(\.md)?$/,
site.value.cleanUrls ? '' : '.html'
)}${search}${hash}`
)
return withBase(normalizedPath)
}
interface Props {
tag?: string
size?: 'medium' | 'big'
theme?: 'brand' | 'alt' | 'sponsor'
text?: string
href?: string
target?: string
rel?: string
}
const props = withDefaults(defineProps<Props>(), {
size: 'medium',
theme: 'brand'
})
const isExternal = computed(
() => props.href && EXTERNAL_URL_RE.test(props.href)
)
const component = computed(() => {
return props.tag || (props.href ? 'a' : 'button')
})
// Dropdown functionality
const isDropdownOpen = ref(false)
const dropdownRef = ref<HTMLElement>()
const toggleDropdown = (e: Event) => {
e.preventDefault()
e.stopPropagation()
isDropdownOpen.value = !isDropdownOpen.value
}
const closeDropdown = () => {
isDropdownOpen.value = false
}
const handleClickOutside = (event: Event) => {
if (dropdownRef.value && !dropdownRef.value.contains(event.target as Node)) {
closeDropdown()
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
const processedItems = computed(() => {
return FMHY_HOMEPAGE_ITEMS.map((item: any) => {
if ('items' in item && item.items) {
return {
...item,
items: item.items.map((subItem: any) => ({
...subItem,
displayText: subItem.text || ''
}))
}
}
return {
...item,
displayText: item.text || ''
}
})
})
</script>
<template>
<div v-if="theme === 'brand'" class="VPButtonDropdown" ref="dropdownRef">
<div class="VPButtonWrapper">
<component :is="component" class="VPButton VPButtonMain" :class="[size, theme]"
:href="href ? normalizeLink(href) : undefined" :target="props.target ?? (isExternal ? '_blank' : undefined)"
:rel="props.rel ?? (isExternal ? 'noreferrer' : undefined)">
<slot>{{ text }}</slot>
</component>
<button
class="bg-$vp-c-default-soft text-text border-$vp-c-default-soft hover:border-primary ml-3 inline-flex h-8 items-center justify-center whitespace-nowrap rounded-md border-2 border-solid px-2.5 py-4.5 text-md font-medium transition-all duration-300 sm:h-7 VPButtonTrigger"
@click="toggleDropdown" :aria-expanded="isDropdownOpen" aria-label="Toggle dropdown menu">
<span class="i-lucide:menu"></span>
</button>
</div>
<div v-if="isDropdownOpen" class="VPButtonDropdownMenu">
<div class="VPButtonDropdownContent">
<template v-for="item in processedItems" :key="item.text">
<div v-if="'items' in item" class="VPButtonDropdownSection">
<div class="VPButtonDropdownSectionTitle" v-html="item.text"></div>
<a v-for="subItem in item.items" :key="subItem.text" :href="subItem.link" class="VPButtonDropdownItem"
@click="closeDropdown">
<span v-html="subItem.displayText"></span>
</a>
</div>
<a v-else :href="item.link" class="VPButtonDropdownItem" @click="closeDropdown">
<span v-html="item.displayText"></span>
</a>
</template>
</div>
</div>
</div>
<component v-else :is="component" class="VPButton" :class="[size, theme]"
:href="href ? normalizeLink(href) : undefined" :target="props.target ?? (isExternal ? '_blank' : undefined)"
:rel="props.rel ?? (isExternal ? 'noreferrer' : undefined)">
<slot>{{ text }}</slot>
</component>
</template>
<style scoped>
.VPButton {
display: inline-block;
border: 1px solid transparent;
text-align: center;
font-weight: 600;
white-space: nowrap;
transition: color 0.25s, border-color 0.25s, background-color 0.25s;
}
.VPButton:active {
transition: color 0.1s, border-color 0.1s, background-color 0.1s;
}
.VPButton.medium {
border-radius: 20px;
padding: 0 20px;
line-height: 38px;
font-size: 14px;
}
.VPButton.big {
border-radius: 24px;
padding: 0 24px;
line-height: 46px;
font-size: 16px;
}
.VPButton.brand {
border-color: var(--vp-button-brand-border);
color: var(--vp-button-brand-text);
background-color: var(--vp-button-brand-bg);
}
.VPButton.brand:hover {
border-color: var(--vp-button-brand-hover-border);
color: var(--vp-button-brand-hover-text);
background-color: var(--vp-button-brand-hover-bg);
}
.VPButton.brand:active {
border-color: var(--vp-button-brand-active-border);
color: var(--vp-button-brand-active-text);
background-color: var(--vp-button-brand-active-bg);
}
.VPButton.alt {
border-color: var(--vp-button-alt-border);
color: var(--vp-button-alt-text);
background-color: var(--vp-button-alt-bg);
}
.VPButton.alt:hover {
border-color: var(--vp-button-alt-hover-border);
color: var(--vp-button-alt-hover-text);
background-color: var(--vp-button-alt-hover-bg);
}
.VPButton.alt:active {
border-color: var(--vp-button-alt-active-border);
color: var(--vp-button-alt-active-text);
background-color: var(--vp-button-alt-active-bg);
}
.VPButton.sponsor {
border-color: var(--vp-button-sponsor-border);
color: var(--vp-button-sponsor-text);
background-color: var(--vp-button-sponsor-bg);
}
.VPButton.sponsor:hover {
border-color: var(--vp-button-sponsor-hover-border);
color: var(--vp-button-sponsor-hover-text);
background-color: var(--vp-button-sponsor-hover-bg);
}
.VPButton.sponsor:active {
border-color: var(--vp-button-sponsor-active-border);
color: var(--vp-button-sponsor-active-text);
background-color: var(--vp-button-sponsor-active-bg);
}
/* Dropdown styles */
.VPButtonDropdown {
position: relative;
display: inline-block;
}
.VPButtonWrapper {
display: flex;
align-items: stretch;
}
.VPButtonMain {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-right: none;
}
.VPButtonDropdownMenu {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 50;
margin-top: 4px;
background-color: var(--vp-c-bg-elv);
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.1), 0 2px 6px rgba(0, 0, 0, 0.08);
min-width: 280px;
max-height: 400px;
overflow-y: auto;
}
.VPButtonDropdownContent {
padding: 8px;
}
.VPButtonDropdownSection {
margin-bottom: 8px;
}
.VPButtonDropdownSection:last-child {
margin-bottom: 0;
}
.VPButtonDropdownSectionTitle {
font-size: 12px;
font-weight: 600;
color: var(--vp-c-text-2);
padding: 8px 12px 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.VPButtonDropdownItem {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 6px;
color: var(--vp-c-text-1);
text-decoration: none;
font-size: 14px;
transition: background-color 0.2s ease;
}
.VPButtonDropdownItem:hover {
background-color: var(--vp-c-bg-alt);
color: var(--vp-c-brand-1);
}
.VPButtonDropdownIcon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.VPButtonDropdownItem span :deep([class*="i-"]) {
width: 16px;
height: 16px;
flex-shrink: 0;
margin-right: 8px;
}
.VPButtonDropdownSectionTitle :deep([class*="i-"]) {
width: 14px;
height: 14px;
flex-shrink: 0;
margin-right: 6px;
}
</style>

View file

@ -1,37 +0,0 @@
<script setup lang="ts">
defineProps<{
title: string
description: string
mobile: string
desktop: string
}>()
</script>
<template>
<div class="bg-bg-elv text-text w-full max-w-md rounded-lg border shadow-sm">
<div class="flex flex-col space-y-1.5 p-6 pb-4">
<h4
class="whitespace-nowrap text-2xl font-semibold leading-none tracking-tight"
>
{{ title }}
</h4>
<p class="text-text-2 text-sm font-medium leading-none tracking-tight">
{{ description }}
</p>
</div>
<div class="p-6">
<img
:src="desktop"
:alt="title"
class="aspect-[3/2] rounded-md object-cover"
width="1080"
height="1920"
/>
</div>
<div class="flex items-center gap-2 p-6">
<a :href="mobile" target="_blank">Mobile</a>
<span class="text-text-2"></span>
<a :href="desktop" target="_blank">Desktop</a>
</div>
</div>
</template>

View file

@ -1,733 +0,0 @@
<script setup lang="ts">
import {
DialogClose,
DialogContent,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
DialogTrigger
} from 'reka-ui'
import { useRouter } from 'vitepress'
import { onMounted, onUnmounted, ref, watch } from 'vue'
const router = useRouter()
export type BookmarkType = {
name: string
chord: string
url: string
icon?: string // e.g. 'i-logos:github'
color?: string
isCustom?: boolean // Track if it's a custom bookmark
customSvg?: string // Store custom SVG icon
}
const props = defineProps<{ isInputGloballyFocused: boolean }>()
const currentChordInput = ref('')
const activePossibleChords = ref<BookmarkType[]>([])
const customBookmarks = ref<BookmarkType[]>([])
const allBookmarks = ref<BookmarkType[]>([])
// Dialog states
const isAddDialogOpen = ref(false)
const isEditDialogOpen = ref(false)
const isDeleteDialogOpen = ref(false)
// Form states
const formData = ref<BookmarkType>({
name: '',
chord: '',
url: '',
icon: '',
color: '',
isCustom: true,
customSvg: ''
})
const editingBookmark = ref<BookmarkType | null>(null)
const deletingBookmark = ref<BookmarkType | null>(null)
let chordTimeout: NodeJS.Timeout | null = null
const initialBookmarksData: BookmarkType[] = [
{
name: 'Hacker News',
chord: 'HN',
url: 'https://news.ycombinator.com/',
icon: 'i-logos:ycombinator'
},
{
name: 'GitHub',
chord: 'GH',
url: 'https://github.com/',
icon: 'i-simple-icons:github'
},
{
name: 'Reddit',
chord: 'RD',
url: 'https://reddit.com/',
icon: 'i-logos:reddit-icon'
},
{
name: 'Twitter',
chord: 'TW',
url: 'https://twitter.com/',
icon: 'i-logos:twitter'
},
{
name: 'YouTube',
chord: 'YT',
url: 'https://youtube.com/',
icon: 'i-logos:youtube-icon'
},
{
name: 'Wikipedia',
chord: 'WK',
url: 'https://wikipedia.org/',
icon: 'i-simple-icons:wikipedia'
},
{
name: "Beginner's Guide",
chord: 'BG',
url: '/beginners-guide',
icon: 'i-lucide:book-open-text'
},
{
name: 'Wotaku',
chord: 'WT',
url: 'https://wotaku.wiki/',
icon: 'i-twemoji:flag-japan'
},
{
name: 'privateersclub',
chord: 'PC',
url: 'https://megathread.pages.dev/',
icon: 'i-custom:privateersclub'
}
]
// Load custom bookmarks from localStorage
const loadCustomBookmarks = () => {
try {
const stored = localStorage.getItem('customBookmarks')
if (stored) {
customBookmarks.value = JSON.parse(stored)
}
} catch (error) {
console.error('Error loading custom bookmarks:', error)
}
}
// Save custom bookmarks to localStorage
const saveCustomBookmarks = () => {
try {
localStorage.setItem(
'customBookmarks',
JSON.stringify(customBookmarks.value)
)
} catch (error) {
console.error('Error saving custom bookmarks:', error)
}
}
// Update all bookmarks when custom bookmarks change
const updateAllBookmarks = () => {
allBookmarks.value = [...initialBookmarksData, ...customBookmarks.value]
}
// Watch for changes in custom bookmarks
watch(
customBookmarks,
() => {
updateAllBookmarks()
saveCustomBookmarks()
},
{ deep: true }
)
const resetChord = () => {
currentChordInput.value = ''
activePossibleChords.value = []
if (chordTimeout) clearTimeout(chordTimeout)
chordTimeout = null
}
const handleBookmarkClick = (bookmark: BookmarkType) => {
if (bookmark.url.startsWith('/')) {
router.go(bookmark.url)
} else {
window.open(bookmark.url, '_self')
}
}
// Form validation
const isFormValid = () => {
return (
formData.value.name.trim() &&
formData.value.chord.trim() &&
formData.value.url.trim() &&
!isChordTaken(formData.value.chord, editingBookmark.value?.chord)
)
}
const isChordTaken = (chord: string, excludeChord?: string) => {
return allBookmarks.value.some(
(b) =>
b.chord.toUpperCase() === chord.toUpperCase() && b.chord !== excludeChord
)
}
// Reset form
const resetForm = () => {
formData.value = {
name: '',
chord: '',
url: '',
icon: '',
color: '',
isCustom: true,
customSvg: ''
}
}
// Add bookmark
const handleAddBookmark = () => {
if (!isFormValid()) return
const newBookmark: BookmarkType = {
...formData.value,
chord: formData.value.chord.toUpperCase(),
isCustom: true
}
// If no icon and no custom SVG, use default website icon
if (!newBookmark.icon && !newBookmark.customSvg) {
newBookmark.icon = 'i-lucide:globe'
}
customBookmarks.value.push(newBookmark)
isAddDialogOpen.value = false
resetForm()
}
// Edit bookmark
const openEditDialog = (bookmark: BookmarkType) => {
editingBookmark.value = bookmark
formData.value = { ...bookmark }
isEditDialogOpen.value = true
}
const handleEditBookmark = () => {
if (!isFormValid() || !editingBookmark.value) return
const index = customBookmarks.value.findIndex(
(b) => b === editingBookmark.value
)
if (index !== -1) {
customBookmarks.value[index] = {
...formData.value,
chord: formData.value.chord.toUpperCase(),
isCustom: true
}
// If no icon and no custom SVG, use default website icon
if (
!customBookmarks.value[index].icon &&
!customBookmarks.value[index].customSvg
) {
customBookmarks.value[index].icon = 'i-lucide:globe'
}
}
isEditDialogOpen.value = false
editingBookmark.value = null
resetForm()
}
// Delete bookmark
const openDeleteDialog = (bookmark: BookmarkType) => {
deletingBookmark.value = bookmark
isDeleteDialogOpen.value = true
}
const handleDeleteBookmark = () => {
if (!deletingBookmark.value) return
const index = customBookmarks.value.findIndex(
(b) => b === deletingBookmark.value
)
if (index !== -1) {
customBookmarks.value.splice(index, 1)
}
isDeleteDialogOpen.value = false
deletingBookmark.value = null
}
// Handle SVG input
const handleSvgInput = (event: Event) => {
const target = event.target as HTMLTextAreaElement
formData.value.customSvg = target.value
}
const handleKeyDown = (e: KeyboardEvent) => {
if (
props.isInputGloballyFocused ||
e.altKey ||
e.metaKey ||
e.ctrlKey ||
e.shiftKey
)
return
const active = document.activeElement as HTMLElement | null
if (
active?.tagName === 'INPUT' ||
active?.tagName === 'TEXTAREA' ||
active?.isContentEditable
)
return
const key = e.key.toUpperCase()
if (chordTimeout) clearTimeout(chordTimeout)
if (!currentChordInput.value) {
const matches = allBookmarks.value.filter((b) => b.chord.startsWith(key))
if (matches.length) {
e.preventDefault()
currentChordInput.value = key
activePossibleChords.value = matches
chordTimeout = setTimeout(resetChord, 2000)
}
} else {
const next = currentChordInput.value + key
const match = activePossibleChords.value.find((b) => b.chord === next)
if (match) {
if (match.url.startsWith('/')) {
router.go(match.url)
} else {
window.open(match.url, '_self')
}
resetChord()
} else {
const filtered = allBookmarks.value.filter((b) =>
b.chord.startsWith(next)
)
if (filtered.length) {
currentChordInput.value = next
activePossibleChords.value = filtered
chordTimeout = setTimeout(resetChord, 2000)
} else {
resetChord()
}
}
}
}
onMounted(() => {
loadCustomBookmarks()
updateAllBookmarks()
document.addEventListener('keydown', handleKeyDown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeyDown)
if (chordTimeout) clearTimeout(chordTimeout)
})
</script>
<template>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 text-text-2">
<i class="i-lucide:bookmark w-5 h-5" />
<h2 class="text-xl">Bookmarks</h2>
</div>
<div class="flex items-center gap-2">
<div
v-if="currentChordInput"
class="px-3 py-1 rounded-md text-sm font-medium bg-yellow-200/20 text-yellow-600"
>
Chord: {{ currentChordInput }}...
</div>
<!-- Add Bookmark Button -->
<DialogRoot v-model:open="isAddDialogOpen">
<DialogTrigger as-child>
<button
class="flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium bg-bg-alt text-white hover:opacity-80 transition-opacity"
>
<i class="i-lucide:plus w-4 h-4" />
Add Bookmark
</button>
</DialogTrigger>
<DialogPortal>
<DialogOverlay class="fixed inset-0 bg-black/50 z-50" />
<DialogContent
description="Add New Bookmark"
class="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-bg border border-div rounded-lg p-6 w-full max-w-md z-50 max-h-[90vh] overflow-y-auto"
>
<DialogTitle class="text-lg font-semibold text-text mb-4">
Add New Bookmark
</DialogTitle>
<DialogDescription class="text-text-2 mb-6">
Add a new bookmark
</DialogDescription>
<form @submit.prevent="handleAddBookmark" class="space-y-4">
<div>
<label class="block text-sm font-medium text-text-2 mb-1">
Name
</label>
<input
v-model="formData.name"
type="text"
required
class="w-full px-3 py-2 border border-div rounded-md bg-bg-alt text-text focus:border-primary outline-none"
placeholder="Bookmark name"
/>
</div>
<div>
<label class="block text-sm font-medium text-text-2 mb-1">
Chord (2 letters)
</label>
<input
v-model="formData.chord"
type="text"
required
maxlength="2"
class="w-full px-3 py-2 border border-div rounded-md bg-bg-alt text-text focus:border-primary outline-none uppercase"
placeholder="AB"
@input="
(e) =>
(formData.chord = (
e.target as HTMLInputElement
).value.toUpperCase())
"
/>
<p
v-if="isChordTaken(formData.chord)"
class="text-red-500 text-xs mt-1"
>
This chord is already taken
</p>
</div>
<div>
<label class="block text-sm font-medium text-text-2 mb-1">
URL
</label>
<input
v-model="formData.url"
type="url"
required
class="w-full px-3 py-2 border border-div rounded-md bg-bg-alt text-text focus:border-primary outline-none"
placeholder="https://example.com"
/>
</div>
<div>
<label class="block text-sm font-medium text-text-2 mb-1">
Icon (UnoCSS class)
</label>
<input
v-model="formData.icon"
type="text"
class="w-full px-3 py-2 border border-div rounded-md bg-bg-alt text-text focus:border-primary outline-none"
placeholder="i-lucide:globe (optional)"
/>
</div>
<div>
<label class="block text-sm font-medium text-text-2 mb-1">
Custom SVG Icon
</label>
<textarea
v-model="formData.customSvg"
class="w-full px-3 py-2 border border-div rounded-md bg-bg-alt text-text focus:border-primary outline-none h-20 resize-none"
placeholder="Paste SVG code here (optional)"
/>
<p class="text-xs text-text-2 mt-1">
If provided, this will override the icon class
</p>
</div>
<div>
<label class="block text-sm font-medium text-text-2 mb-1">
Color
</label>
<input
v-model="formData.color"
type="text"
class="w-full px-3 py-2 border border-div rounded-md bg-bg-alt text-text focus:border-primary outline-none"
placeholder="#3B82F6 (optional)"
/>
</div>
<div class="flex gap-3 pt-4">
<button
type="submit"
:disabled="!isFormValid()"
class="flex-1 bg-primary text-white py-2 px-4 rounded-md font-medium hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed transition-opacity"
>
Add Bookmark
</button>
<DialogClose as-child>
<button
type="button"
class="flex-1 bg-bg-alt text-text py-2 px-4 rounded-md font-medium border border-div hover:bg-bg-elv transition-colors"
>
Cancel
</button>
</DialogClose>
</div>
</form>
</DialogContent>
</DialogPortal>
</DialogRoot>
</div>
</div>
<div
class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6 gap-2"
>
<div
v-for="bookmark in allBookmarks"
:key="bookmark.name"
class="relative group"
>
<button
:class="[
'w-full rounded-md border border-div bg-bg-alt px-3 py-2 text-left transition-opacity duration-150',
activePossibleChords.some((ab) => ab.chord === bookmark.chord)
? bookmark.chord === currentChordInput
? 'opacity-100 ring-2 ring-primary ring-offset-2 ring-offset-bg'
: 'opacity-75'
: currentChordInput
? 'opacity-30'
: 'opacity-100'
]"
@click="handleBookmarkClick(bookmark)"
>
<div
class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 sm:gap-3 w-full"
>
<div class="flex items-center gap-2 truncate">
<!-- Custom SVG Icon -->
<div
v-if="bookmark.customSvg"
class="shrink-0 w-4 h-4"
v-html="bookmark.customSvg"
/>
<!-- Regular Icon -->
<i
v-else-if="bookmark.icon"
:class="`shrink-0 w-4 h-4 ${bookmark.icon}`"
:style="bookmark.color ? { color: bookmark.color } : {}"
/>
<!-- Fallback Icon -->
<i v-else class="shrink-0 w-4 h-4 i-lucide:globe" />
<span class="truncate font-medium">{{ bookmark.name }}</span>
</div>
<div class="hidden sm:flex text-xs items-center gap-1 text-text-2">
<kbd
v-for="(char, i) in bookmark.chord.split('')"
:key="i"
class="bg-bg border border-div px-1 py-0.5 rounded text-sm font-semibold"
>
{{ char }}
</kbd>
</div>
</div>
</button>
<!-- Edit/Delete buttons for custom bookmarks -->
<div
v-if="bookmark.isCustom"
class="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity flex gap-1"
>
<button
@click.stop="openEditDialog(bookmark)"
class="p-1 bg-bg-elv border border-div rounded hover:bg-bg-alt transition-colors"
title="Edit bookmark"
>
<i class="i-lucide:edit-2 w-3 h-3 text-text-2" />
</button>
<button
@click.stop="openDeleteDialog(bookmark)"
class="p-1 bg-bg-elv border border-div rounded hover:bg-red-100 hover:text-red-600 transition-colors"
title="Delete bookmark"
>
<i class="i-lucide:trash-2 w-3 h-3 text-text-2" />
</button>
</div>
</div>
</div>
<!-- Edit Dialog -->
<DialogRoot v-model:open="isEditDialogOpen">
<DialogPortal>
<DialogOverlay class="fixed inset-0 bg-black/50 z-50" />
<DialogContent
class="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-bg border border-div rounded-lg p-6 w-full max-w-md z-50 max-h-[90vh] overflow-y-auto"
>
<DialogTitle class="text-lg font-semibold text-text mb-4">
Edit Bookmark
</DialogTitle>
<DialogDescription class="text-text-2 mb-6">
Editing "{{ editingBookmark?.name }}"
</DialogDescription>
<form @submit.prevent="handleEditBookmark" class="space-y-4">
<div>
<label class="block text-sm font-medium text-text-2 mb-1">
Name
</label>
<input
v-model="formData.name"
type="text"
required
class="w-full px-3 py-2 border border-div rounded-md bg-bg-alt text-text focus:border-primary outline-none"
placeholder="Bookmark name"
/>
</div>
<div>
<label class="block text-sm font-medium text-text-2 mb-1">
Chord (2 letters)
</label>
<input
v-model="formData.chord"
type="text"
required
maxlength="2"
class="w-full px-3 py-2 border border-div rounded-md bg-bg-alt text-text focus:border-primary outline-none uppercase"
placeholder="AB"
@input="
(e) =>
(formData.chord = (
e.target as HTMLInputElement
).value.toUpperCase())
"
/>
<p
v-if="isChordTaken(formData.chord, editingBookmark?.chord)"
class="text-red-500 text-xs mt-1"
>
This chord is already taken
</p>
</div>
<div>
<label class="block text-sm font-medium text-text-2 mb-1">
URL
</label>
<input
v-model="formData.url"
type="url"
required
class="w-full px-3 py-2 border border-div rounded-md bg-bg-alt text-text focus:border-primary outline-none"
placeholder="https://example.com"
/>
</div>
<div>
<label class="block text-sm font-medium text-text-2 mb-1">
Icon (UnoCSS class)
</label>
<input
v-model="formData.icon"
type="text"
class="w-full px-3 py-2 border border-div rounded-md bg-bg-alt text-text focus:border-primary outline-none"
placeholder="i-lucide:globe (optional)"
/>
</div>
<div>
<label class="block text-sm font-medium text-text-2 mb-1">
Custom SVG Icon
</label>
<textarea
v-model="formData.customSvg"
class="w-full px-3 py-2 border border-div rounded-md bg-bg-alt text-text focus:border-primary outline-none h-20 resize-none"
placeholder="Paste SVG code here (optional)"
/>
<p class="text-xs text-text-2 mt-1">
If provided, this will override the icon class
</p>
</div>
<div>
<label class="block text-sm font-medium text-text-2 mb-1">
Color
</label>
<input
v-model="formData.color"
type="text"
class="w-full px-3 py-2 border border-div rounded-md bg-bg-alt text-text focus:border-primary outline-none"
placeholder="#3B82F6 (optional)"
/>
</div>
<div class="flex gap-3 pt-4">
<button
type="submit"
:disabled="!isFormValid()"
class="flex-1 bg-primary text-white py-2 px-4 rounded-md font-medium hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed transition-opacity"
>
Save Changes
</button>
<DialogClose as-child>
<button
type="button"
class="flex-1 bg-bg-alt text-text py-2 px-4 rounded-md font-medium border border-div hover:bg-bg-elv transition-colors"
>
Cancel
</button>
</DialogClose>
</div>
</form>
</DialogContent>
</DialogPortal>
</DialogRoot>
<!-- Delete Confirmation Dialog -->
<DialogRoot v-model:open="isDeleteDialogOpen">
<DialogPortal>
<DialogOverlay class="fixed inset-0 bg-black/50 z-50" />
<DialogContent
class="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-bg border border-div rounded-lg p-6 w-full max-w-md z-50"
>
<DialogTitle class="text-lg font-semibold text-text mb-2">
Delete Bookmark
</DialogTitle>
<DialogDescription class="text-text-2 mb-6">
Are you sure you want to delete "{{ deletingBookmark?.name }}"? This
action cannot be undone.
</DialogDescription>
<div class="flex gap-3">
<button
@click="handleDeleteBookmark"
class="flex-1 bg-red-600 text-white py-2 px-4 rounded-md font-medium hover:bg-red-700 transition-colors"
>
Delete
</button>
<DialogClose as-child>
<button
type="button"
class="flex-1 bg-bg-alt text-text py-2 px-4 rounded-md font-medium border border-div hover:bg-bg-elv transition-colors"
>
Cancel
</button>
</DialogClose>
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</div>
</template>

View file

@ -1,29 +0,0 @@
<template>
<div class="text-6xl font-bold text-text">
{{ timeString }}
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
const time = ref<Date | null>(null)
function updateTime() {
time.value = new Date()
}
onMounted(() => {
updateTime()
const interval = setInterval(updateTime, 1000)
onUnmounted(() => clearInterval(interval))
})
const timeString = computed(() => {
if (!time.value) return '--:--:--'
const h = String(time.value.getHours()).padStart(2, '0')
const m = String(time.value.getMinutes()).padStart(2, '0')
const s = String(time.value.getSeconds()).padStart(2, '0')
return `${h}:${m}:${s}`
})
</script>

View file

@ -1,194 +0,0 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import Clock from './Clock.vue'
export interface PlatformType {
name: string
key: string
url: string
icon?: string
color?: string
}
const props = defineProps<{
onFocusChange: (focused: boolean) => void
initialQuery?: string
}>()
const platforms: PlatformType[] = [
{
name: 'SearXNG',
key: 'a',
url: 'https://searx.fmhy.net/search?q=',
icon: 'i-simple-icons:searxng'
},
{
name: 'ChatGPT',
key: 's',
url: 'https://chat.openai.com/?q=',
icon: 'i-simple-icons:openai'
},
{
name: 'Claude',
key: 'd',
url: 'https://claude.ai/chat/',
icon: 'i-logos:claude-icon'
},
{
name: 'Perplexity',
key: 'f',
url: 'https://www.perplexity.ai/search?q=',
icon: 'i-logos:perplexity-icon'
}
]
const inputRef = ref<HTMLInputElement | null>(null)
const query = ref(props.initialQuery ?? '')
const isInputFocused = ref(false)
const showShortcuts = ref(false)
function handleInputFocus() {
isInputFocused.value = true
props.onFocusChange(true)
}
function handleInputBlur() {
isInputFocused.value = false
props.onFocusChange(false)
}
function handleSubmit() {
if (!query.value.trim()) return
const google = platforms.find((p) => p.name === 'SearX') || platforms[0]
if (google)
window.open(google.url + encodeURIComponent(query.value.trim()), '_self')
}
function handlePlatformClick(platform: PlatformType) {
if (!query.value.trim()) return
window.open(platform.url + encodeURIComponent(query.value.trim()), '_self')
}
function platformClass() {
const base =
'widget-card group relative widget-button rounded-md bg-bg-elv p-2 border transition-transform'
const disabled = !query.value.trim()
const highlight = showShortcuts.value && isInputFocused.value
return [
base,
disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer',
highlight ? 'border-2 border-primary scale-105' : 'border-div'
].join(' ')
}
onMounted(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const active = document.activeElement
const isSearchFocused = inputRef.value === active
if (e.key === '/' && !isSearchFocused) {
const typingInInput =
active &&
(active.tagName === 'INPUT' ||
active.tagName === 'TEXTAREA' ||
(active instanceof HTMLElement && active.isContentEditable))
if (!typingInInput) {
e.preventDefault()
inputRef.value?.focus()
}
return
}
if (isInputFocused.value && e.altKey) {
const key = e.key.toLowerCase()
let platform = platforms.find((p) => p.key === key)
if (!platform && e.code.startsWith('Key') && e.code.length === 4) {
const codeKey = e.code.slice(3).toLowerCase()
platform = platforms.find((p) => p.key === codeKey)
}
if (platform && query.value.trim()) {
e.preventDefault()
window.open(
platform.url + encodeURIComponent(query.value.trim()),
'_self'
)
}
}
if (e.altKey) showShortcuts.value = true
}
const handleKeyUp = (e: KeyboardEvent) => {
if (!e.altKey) showShortcuts.value = false
}
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
})
})
</script>
<template>
<div class="flex flex-col items-start w-full space-y-4 antialiased">
<Clock />
<form @submit.prevent="handleSubmit" class="relative w-full">
<div class="relative">
<i
class="i-lucide-search absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-text-2"
/>
<input
ref="inputRef"
v-model="query"
@focus="handleInputFocus"
@blur="handleInputBlur"
placeholder="What would you like to search for?"
class="w-full pl-10 pr-3 py-3 text-lg rounded-md shadow-sm transition-colors bg-bg-elv text-text border-2 outline-none border-div hover:border-primary"
/>
</div>
</form>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4 w-full">
<button
v-for="platform in platforms"
:key="platform.name"
:disabled="!query.trim()"
@click="handlePlatformClick(platform)"
:class="platformClass()"
:style="platform.color ? { borderColor: platform.color } : {}"
>
<div class="flex items-center gap-2">
<i
v-if="platform.icon"
:class="`w-5 h-5 ${platform.icon}`"
:style="platform.color ? { color: platform.color } : {}"
/>
<div class="text-left flex-grow">
<h3 class="font-semibold truncate">{{ platform.name }}</h3>
</div>
<div
class="hidden sm:flex items-center gap-1 text-xs ml-auto text-white"
>
<kbd
class="bg-bg border border-div px-1 py-0.5 rounded text-sm font-semibold"
>
Alt
</kbd>
<span class="text-white font-semibold">+</span>
<kbd
class="bg-bg border border-div px-1 py-0.5 rounded text-sm font-semibold"
>
{{ platform.key.toUpperCase() }}
</kbd>
</div>
</div>
</button>
</div>
</div>
</template>

View file

@ -1,49 +0,0 @@
<script setup lang="ts">
import Bookmarks from './Bookmarks.vue'
import Searchbar from './SearchBar.vue'
const isFocused = ref(false)
const handleFocusChange = (focused: boolean) => {
isFocused.value = focused
}
</script>
<template>
<div class="min-h-screen flex pt-48 justify-center p-4 transition-colors">
<div class="w-full max-w-7xl space-y-8">
<Searchbar @focus-change="handleFocusChange" />
<Bookmarks :is-input-globally-focused="isFocused" />
<div class="hidden sm:block space-y-2 text-sm text-text-2">
<p>
Press
<kbd class="kbd">/</kbd>
anywhere to focus the search box
</p>
<p>
Use
<kbd class="kbd">Alt + a/s/d/f</kbd>
to search different platforms
</p>
<p>
Type bookmark chords (like
<kbd class="kbd px-1.5 py-0.5">H</kbd>
<kbd class="kbd px-1.5 py-0.5">N</kbd>
for Hacker News) when search is not focused
</p>
<p>
Press
<kbd class="kbd">Enter</kbd>
to search SearXNG (hosted by us) by default
</p>
</div>
</div>
</div>
</template>
<style>
kbd {
--uno: px-1.5 py-0.5 rounded-sm font-sans font-semibold text-xs bg-bg-alt
text-text-2 border-2 border-div nowrap;
}
</style>

View file

@ -1,42 +0,0 @@
import type { NProgress } from 'nprogress'
import type { EnhanceAppContext } from 'vitepress'
import nprogress from 'nprogress'
/**
* Copyright (c) 2025 taskylizard. Apache License 2.0.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export function loadProgress(
router: EnhanceAppContext['router']
): NProgress | undefined {
if (typeof window === 'undefined') return
setTimeout(() => {
nprogress.configure({ showSpinner: false })
const cacheBeforeRouteChange = router.onBeforeRouteChange
const cacheAfterRouteChange = router.onAfterRouteChanged
router.onBeforeRouteChange = (to) => {
nprogress.start()
cacheBeforeRouteChange?.(to)
}
router.onAfterRouteChanged = (to) => {
nprogress.done()
cacheAfterRouteChange?.(to)
}
})
return nprogress
}

View file

@ -1,36 +1,3 @@
/**
* Copyright (c) 2025 taskylizard. Apache License 2.0.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import Theme from '../../../website/theme/index'
import type { Theme } from 'vitepress'
import Components from '@fmhy/components'
import DefaultTheme from 'vitepress/theme'
import Layout from './Layout.vue'
import Post from './PostLayout.vue'
import './style.scss'
import 'virtual:uno.css'
import Feedback from './components/Feedback.vue'
export default {
extends: DefaultTheme,
Layout,
enhanceApp({ router, app }) {
app.use(Components)
app.component('Post', Post)
app.component('Feedback', Feedback)
}
} satisfies Theme
export default Theme

View file

@ -1,47 +0,0 @@
/**
* Copyright (c) 2025 taskylizard. Apache License 2.0.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ContentData } from 'vitepress'
import { createContentLoader } from 'vitepress'
import { groupBy } from '../utils'
interface Post {
title: string
url: string
date: string
}
type Dictionary = ReturnType<typeof createContentLoader>
declare const data: Dictionary
export { data }
function transformRawPosts(rawPosts: ContentData[]): Record<string, Post[]> {
const posts: Post[] = rawPosts
.map(({ url, frontmatter }) => ({
title: frontmatter.title,
url,
date: (frontmatter.date as Date).toISOString().slice(0, 10)
}))
.sort((a, b) => b.date.localeCompare(a.date))
return groupBy(posts, (post) => post.date.slice(0, 4))
}
export default createContentLoader('posts/*.md', {
includeSrc: true,
transform: (raw) => transformRawPosts(raw)
})

View file

@ -1,304 +0,0 @@
:root {
--vp-c-bg: #fefbff;
--vp-c-bg-alt: #f2f0f4;
--vp-c-bg-elv: #f2f0f4;
--vp-c-bg-soft: #f2f0f4;
}
.dark {
--vp-c-bg: #121316;
--vp-c-bg-alt: #0d0e11;
--vp-c-bg-elv: #1b1b1f;
--vp-c-bg-soft: #1b1b1f;
}
:root {
--vp-c-border: #c2c2c4;
--vp-c-divider: #c4c6d0;
--vp-c-gutter: #e2e2e3;
}
.dark {
--vp-c-border: #77777a;
--vp-c-divider: #222429;
--vp-c-gutter: #000000;
}
:root {
--vp-c-text-1: #1b1b1f;
--vp-c-text-2: #2f3033;
--vp-c-text-3: #46464a;
}
.dark {
--vp-c-text-1: #e3e2e6;
--vp-c-text-2: #ababaf;
--vp-c-text-3: #919094;
}
:root {
/* Colors: Brand */
--vp-c-brand-1: theme('colors.swarm.500');
--vp-c-brand-2: theme('colors.swarm.600');
--vp-c-brand-3: theme('colors.swarm.800');
--vp-c-brand-soft: theme('colors.swarm.400');
/* Colors: Button */
--vp-button-brand-bg: var(--vp-c-brand-1);
--vp-button-brand-border: var(--vp-c-brand-soft);
--vp-button-brand-text: rgba(42, 40, 47);
--vp-button-brand-hover-border: var(--vp-c-brand-soft);
--vp-button-brand-hover-text: rgba(42, 40, 47);
--vp-button-brand-hover-bg: var(--vp-c-brand-soft);
--vp-button-brand-active-border: var(--vp-c-brand-soft);
--vp-button-brand-active-text: rgba(42, 40, 47);
--vp-button-brand-active-bg: var(--vp-button-brand-bg);
--vp-button-alt-bg: #484848;
--vp-button-alt-text: #f0eeee;
--vp-button-alt-hover-bg: #484848;
--vp-button-alt-hover-text: #f0eeee;
/* Colors: Custom Block */
/** Info */
--vp-custom-block-info-bg: theme('colors.swarm.100');
--vp-custom-block-info-border: theme('colors.swarm.800');
--vp-custom-block-info-text: theme('colors.swarm.800');
--vp-custom-block-info-text-deep: theme('colors.swarm.900');
/** Tip */
--vp-custom-block-tip-bg: theme('colors.meadow.100');
--vp-custom-block-tip-border: theme('colors.meadow.800');
--vp-custom-block-tip-text: theme('colors.meadow.800');
--vp-custom-block-tip-text-deep: theme('colors.meadow.900');
/** Warning */
--vp-custom-block-warning-bg: theme('colors.merlin.100');
--vp-custom-block-warning-border: theme('colors.merlin.800');
--vp-custom-block-warning-text: theme('colors.merlin.800');
--vp-custom-block-warning-text-deep: theme('colors.merlin.900');
/** Danger */
--vp-custom-block-danger-bg: theme('colors.carnation.100');
--vp-custom-block-danger-border: theme('colors.carnation.800');
--vp-custom-block-danger-text: theme('colors.carnation.800');
--vp-custom-block-danger-text-deep: theme('colors.carnation.900');
}
.dark {
/* Colors: Brand */
--vp-c-brand-1: theme('colors.swarm.400');
--vp-c-brand-2: theme('colors.swarm.500');
--vp-c-brand-3: theme('colors.swarm.700');
--vp-c-brand-soft: theme('colors.swarm.300');
/* Colors: Custom Block */
/** Info */
--vp-custom-block-info-bg: theme('colors.swarm.950');
--vp-custom-block-info-border: theme('colors.swarm.800');
--vp-custom-block-info-text: theme('colors.swarm.200');
--vp-custom-block-info-text-deep: theme('colors.swarm.200');
/** Tip */
--vp-custom-block-tip-bg: theme('colors.meadow.950');
--vp-custom-block-tip-border: theme('colors.meadow.800');
--vp-custom-block-tip-text: theme('colors.meadow.200');
--vp-custom-block-tip-text-deep: theme('colors.meadow.200');
/** Warning */
--vp-custom-block-warning-bg: theme('colors.merlin.950');
--vp-custom-block-warning-border: theme('colors.merlin.800');
--vp-custom-block-warning-text: theme('colors.merlin.200');
--vp-custom-block-warning-text-deep: theme('colors.merlin.200');
/** Danger */
--vp-custom-block-danger-bg: theme('colors.carnation.950');
--vp-custom-block-danger-border: theme('colors.carnation.800');
--vp-custom-block-danger-text: theme('colors.carnation.200');
--vp-custom-block-danger-text-deep: theme('colors.carnation.200');
}
.vp-doc a {
color: var(--vp-c-brand-1);
text-decoration: underline;
text-underline-offset: 4px;
text-decoration-style: solid;
text-decoration-color: transparent;
-webkit-text-decoration-color: transparent;
transition: text-decoration-color 0.25s;
&:hover {
color: var(--vp-c-brand-1);
text-decoration-color: var(--vp-c-brand-1);
-webkit-text-decoration-color: var(--vp-c-brand-1);
}
}
.vp-doc .custom-block a {
text-decoration: underline;
text-underline-offset: 4px;
text-decoration-style: solid;
}
::selection {
background-color: var(--vp-c-brand-400, #5586a6);
.dark & {
background-color: var(--vp-c-brand-800, #0f2c47);
}
}
.VPFooter a {
text-decoration-line: underline;
text-decoration-style: dashed;
text-underline-offset: 5px;
transition: 0.3s;
&:hover {
color: var(--vp-c-text-1);
text-decoration-line: underline;
text-decoration-style: dashed;
text-underline-offset: 5px;
}
}
/* Custom scrollbar */
.VPSidebar::-webkit-scrollbar {
block-size: 4px;
border-end-end-radius: 14px;
border-start-end-radius: 14px;
inline-size: 4px;
}
/**
* Component: Home
*/
:root {
--vp-home-hero-name-color: transparent;
--vp-home-hero-name-background: -webkit-linear-gradient(
120deg,
#c4b5fd 30%,
#7bc5e4
);
--vp-home-hero-image-background-image: linear-gradient(
-45deg,
#c4b5fd 50%,
#47caff 50%
);
--vp-home-hero-image-filter: blur(44px);
}
@media (min-width: 640px) {
:root {
--vp-home-hero-image-filter: blur(56px);
}
}
@media (min-width: 960px) {
:root {
--vp-home-hero-image-filter: blur(68px);
}
}
#VPContent strong > a {
font-weight: bold;
}
.info.custom-block a {
color: var(--vp-custom-block-info-text);
font-weight: 500;
text-decoration: underline;
text-underline-offset: 2px;
transition: opacity 0.25s;
}
.info.custom-block a:hover {
opacity: 0.7;
color: var(--vp-custom-block-info-text-deep);
}
.note.custom-block a {
color: var(--vp-custom-block-info-text);
font-weight: 500;
text-decoration: underline;
text-underline-offset: 2px;
transition: opacity 0.25s;
}
.note.custom-block a:hover {
opacity: 0.7;
color: var(--vp-custom-block-note-text-deep);
}
.tip.custom-block a {
color: var(--vp-custom-block-tip-text);
font-weight: 500;
text-decoration: underline;
text-underline-offset: 2px;
transition: opacity 0.25s;
}
.tip.custom-block a:hover {
opacity: 0.7;
color: var(--vp-custom-block-tip-text-deep);
}
.warning.custom-block a {
color: var(--vp-custom-block-warning-text);
font-weight: 500;
text-decoration: underline;
text-underline-offset: 2px;
transition: opacity 0.25s;
}
.warning.custom-block a:hover {
opacity: 0.7;
color: var(--vp-custom-block-warning-text-deep);
}
.danger.custom-block a {
color: var(--vp-custom-block-danger-text);
font-weight: 500;
text-decoration: underline;
text-underline-offset: 2px;
transition: opacity 0.25s;
}
.danger.custom-block a:hover {
opacity: 0.7;
color: var(--vp-custom-block-danger-text-deep);
}
.info.custom-block {
--icon: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWluZm8iPjxjaXJjbGUgY3g9IjEyIiBjeT0iMTIiIHI9IjEwIi8+PHBhdGggZD0iTTEyIDE2di00Ii8+PHBhdGggZD0iTTEyIDhoLjAxIi8+PC9zdmc+');
}
.note.custom-block {
--icon: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWluZm8iPjxjaXJjbGUgY3g9IjEyIiBjeT0iMTIiIHI9IjEwIi8+PHBhdGggZD0iTTEyIDE2di00Ii8+PHBhdGggZD0iTTEyIDhoLjAxIi8+PC9zdmc+');
}
.tip.custom-block {
--icon: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWxpZ2h0YnVsYiI+PHBhdGggZD0iTTE1IDE0Yy4yLTEgLjctMS43IDEuNS0yLjUgMS0uOSAxLjUtMi4yIDEuNS0zLjVBNiA2IDAgMCAwIDYgOGMwIDEgLjIgMi4yIDEuNSAzLjUuNy43IDEuMyAxLjUgMS41IDIuNSIvPjxwYXRoIGQ9Ik05IDE4aDYiLz48cGF0aCBkPSJNMTAgMjJoNCIvPjwvc3ZnPg==');
}
.warning.custom-block {
--icon: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWFsZXJ0LXRyaWFuZ2xlIj48cGF0aCBkPSJtMjEuNzMgMTgtOC0xNGEyIDIgMCAwIDAtMy40OCAwbC04IDE0QTIgMiAwIDAgMCA0IDIxaDE2YTIgMiAwIDAgMCAxLjczLTNaIi8+PHBhdGggZD0iTTEyIDl2NCIvPjxwYXRoIGQ9Ik0xMiAxN2guMDEiLz48L3N2Zz4=');
}
.danger.custom-block {
--icon: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLXNrdWxsIj48Y2lyY2xlIGN4PSI5IiBjeT0iMTIiIHI9IjEiLz48Y2lyY2xlIGN4PSIxNSIgY3k9IjEyIiByPSIxIi8+PHBhdGggZD0iTTggMjB2Mmg4di0yIi8+PHBhdGggZD0ibTEyLjUgMTctLjUtMS0uNSAxaDF6Ii8+PHBhdGggZD0iTTE2IDIwYTIgMiAwIDAgMCAxLjU2LTMuMjUgOCA4IDAgMSAwLTExLjEyIDBBMiAyIDAgMCAwIDggMjAiLz48L3N2Zz4=');
}
.custom-block-title {
display: inline-flex;
align-items: center;
gap: 8px;
}
.custom-block-title::before {
content: '';
width: 16px;
height: 16px;
-webkit-mask: var(--icon) no-repeat;
mask: var(--icon) no-repeat;
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
background-color: currentColor;
color: inherit;
}

View file

@ -1,349 +0,0 @@
/**
* Copyright (c) 2025 taskylizard. Apache License 2.0.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Plugin } from 'vitepress'
import { basename } from 'pathe'
import { excluded, getHeader } from './transformer/constants'
import { replaceUnderscore, transformer } from './transformer/core'
export function transformsPlugin(): Plugin {
return {
name: 'custom:transform-content',
enforce: 'pre',
transform(code, id) {
const _id = basename(id)
if (
id.endsWith('.md') &&
!excluded.includes(_id) &&
// check if it's a post
!id.includes('posts') &&
!id.includes('other')
) {
const header = getHeader(_id)
const contents = transform(code)
if (_id === 'beginners-guide.md') {
const _contents = transformGuide(contents)
return header + _contents
}
if (_id === 'storage.md') return header + contents
return header + transformLinks(contents)
}
}
}
}
export const transformGuide = (text: string): string =>
transformer(text)
.transform('Beginners Guide', [
{
name: 'TOC',
find: /\[TOC\]\n/gm,
replace: ''
},
{
name: 'TOC2',
find: /\*\*Table of Contents\*\*\n\[TOC2\]\n/gm,
replace: ''
},
{
name: 'Beginners Guide',
find: /# -> \*\*\*Beginners Guide to Piracy\*\*\* <-\n/gm,
replace: ''
},
{
name: 'Note',
find: /!!!note\s(.+?)\n/gm,
replace: '\n:::info\n$1\n:::\n'
},
{
name: 'Info',
find: /!!!info\s(.+?)\n/gm,
replace: '\n:::info\n$1\n:::\n'
},
{
name: 'Warning',
find: /!!!warning\s(.+?)\n/gm,
replace: ':::warning\n$1\n:::\n'
},
{
name: 'Quote',
find: />\s(.+?)\n/gm,
replace: '> $1\n\n'
},
{
name: 'Back to Top',
find: /\*\*\[\^ Back to Top\]\(#beginners-guide-to-piracy\)\*\*/gm,
replace: ''
},
{
name: 'Back to Top',
find: /\*\*\[\^ Back to Top\]\(#beginners-guide-to-piracy\)\*\*/gm,
replace: ''
}
])
.getText()
export function transform(text: string): string {
let _text = text
// Transform reddit index links
.replace(
/\*\*\[◄◄ Back to Wiki Index\]\(https:\/\/www\.reddit\.com\/r\/FREEMEDIAHECKYEAH\/wiki\/index\)\*\*\n/gm,
''
)
.replace(
/\*\*\[◄◄ Back to Wiki Index\]\(https:\/\/www\.reddit\.com\/r\/FREEMEDIAHECKYEAH\/wiki\/tools-index\)\*\*\n/gm,
''
)
.replace(
/\*\*\[Table of Contents\]\(https?:\/\/.*?ibb\.co.*\)\*\* - For mobile users\n/gm,
''
)
// Remove extra lines
.replace(/\*\*\*\n\*\*\*\n\*\*\*\n\*\*\*\n\n\n\*\*\*\n\*\*\*\n\n/gm, '')
.replace(/\*\*\*\n\*\*\*\n\*\*\*\n\*\*\*\n\n\n\*\*\*\n\*\*\* \n\n/gm, '')
.replace(/\*\*\*\n\*\*\*\n\*\*\*\n\n\n\*\*\*\n\*\*\*\n\n/gm, '')
.replace(/\*\*\*\n\*\*\*\n\*\*\*\n\*\*\*\n\n\n\*\*\*\n\n/gm, '')
.replace(/\*\*\*\n\*\*\*\n\n\n\*\*\*\n\n/gm, '')
// Transform reddit links
.replace(/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/ai/g, '/ai')
.replace(
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/adblock-vpn-privacy/g,
'/adblockvpnguide'
)
.replace(
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/android/g,
'/android-iosguide'
)
.replace(
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/games/g,
'/gamingpiracyguide'
)
.replace(
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/reading/g,
'/readingpiracyguide'
)
.replace(
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/download/g,
'/downloadpiracyguide'
)
.replace(
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/torrent/g,
'/torrentpiracyguide'
)
.replace(
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/edu/g,
'/edupiracyguide'
)
.replace(
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/system-tools/g,
'/system-tools'
)
.replace(
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/file-tools/g,
'/file-tools'
)
.replace(
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/internet-tools/g,
'/internet-tools'
)
.replace(
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/social-media/g,
'/social-media-tools'
)
.replace(
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/text-tools/g,
'/text-tools'
)
.replace(
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/video-tools/g,
'/video-tools'
)
.replace(
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/audio-tools/g,
'/audio-tools'
)
.replace(
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/game-tools/g,
'/gaming-tools'
)
.replace(
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/video/g,
'/videopiracyguide'
)
.replace(
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/audio/g,
'/audiopiracyguide'
)
.replace(
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/linux/g,
'/linuxguide'
)
.replace(
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/non-eng/g,
'/non-english'
)
.replace(
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/misc/g,
'/miscguide'
)
.replace(
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/storage/g,
'/storage'
)
.replace(
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/dev-tools/g,
'/devtools'
)
.replace(
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/img-tools/g,
'/img-tools'
)
// Remove extra characters
.replace(/\/#wiki_/g, '/#')
.replace(/#wiki_/g, '/#')
.replace(/.25BA_/g, '')
.replace(/.25B7_/g, '')
.replace(/_.2F_/g, '-')
_text = replaceUnderscore(_text)
.replace(/\/#(\d)/g, '/#_$1') // Prefix headings starting with numbers
.replace(/#(\d)/g, '#_$1') // Prefix headings starting with numbers
.replace(/(\]\(\s*)\/\s*(\#[^)\s]*?\s*\))/g, '$1$2')
.replace(/\*\*\*\n\n/gm, '')
.replace(/\*\*\*\n/gm, '')
.replace(/# ►/g, '##')
.replace(/## ▷/g, '###')
.replace(/####/g, '###')
// Replace emojis
.replace(/🌟/g, ':glowing-star:')
.replace(/⭐/g, ':star:')
.replace(/🌐/g, ':globe-with-meridians:')
.replace(/↪/g, ':repeat-button:')
// Replace note/warning/tip
.replace(/^\*\*Note\*\* - (.+)$/gm, ':::tip\n$1\n:::')
.replace(/^\* \*\*Note\*\* - (.+)$/gm, ':::tip\n$1\n:::')
.replace(/^Note - (.+)$/gm, ':::tip\n$1\n:::')
.replace(/^\*\*Warning\*\* - (.+)$/gm, ':::warning\n$1\n:::')
.replace(/^\* \*\*Warning\*\* - (.+)$/gm, ':::warning\n$1\n:::')
.replace(/^\*\s([^*])/gm, '- $1')
// Replace links
.replace(
/\/storage\/#encode--decode_urls/g,
'/storage/#encode--decode-urls'
)
.replace(/\/devtools\/#machine-learning2/g, '/devtools/#machine-learning-1')
.replace(/\/linuxguide#software-sites2/g, '/linuxguide#software-sites-1')
.replace(/\/linuxguide#software_sites/g, '/linuxguide#software-sites')
.replace(/\/non-english#reading10/g, '/non-english#reading-9')
.replace(
/\/storage#satellite-.26amp.3B_street_view_maps/g,
'/storage#satellite-street-view-maps'
)
.replace(
/(.+?) site or extension\.\n/gm,
'Click on the texts to copy them decoded.\n'
)
return _text
}
const transformLinks = (text: string): string =>
transformer(text)
.transform('Links to Icons', [
{
name: 'Discord',
find: /\[Discord\]\(([^\)]*?)\)/gm,
replace:
'<a target="_blank" href="$1"><div alt="Discord" class="i-carbon:logo-discord" /></a>'
},
{
name: 'GitHub',
find: /\[GitHub\]\(([^\)]*?)\)/gm,
replace:
'<a target="_blank" href="$1"><div alt="GitHub" class="i-carbon:logo-github" /></a>'
},
{
name: 'GitLab',
find: /\[GitLab\]\(([^\)]*?)\)/gm,
replace:
'<a target="_blank" href="$1"><div alt="GitLab" class="i-carbon:logo-gitlab" /></a>'
},
{
name: 'Source Code',
find: /\[Source Code\]\(([^\)]*?)\)/gm,
replace:
'<a target="_blank" href="$1"><div alt="Source Code" class="i-gravity-ui:code" /></a>'
},
{
name: 'Telegram',
find: /\[Telegram\]\(([^\)]*?)\)/gm,
replace:
'<a target="_blank" href="$1"><div alt="Telegram" class="i-mdi:telegram" /></a>'
},
{
name: 'Subreddit',
find: /\[Subreddit\]\(([^\)]*?)\)/gm,
replace:
'<a target="_blank" href="$1"><div alt="Reddit" class="i-mdi:reddit" /></a>'
},
{
name: 'X',
find: /\[X\]\(([^\)]*?)\)/gm,
replace:
'<a target="_blank" href="$1"><div alt="X" class="i-carbon:logo-x" /></a>'
},
{
name: 'Tor',
find: /\[.onion\]\(([^\)]*?)\)/gm,
replace:
'<a target="_blank" href="$1"><div alt=".onion" class="i-simple-icons:torbrowser w-1em h-1em" /></a>'
},
// Platform indicators
{
name: 'Windows',
find: /(?<=\/ (\/>|[^/\r\n])*)(,\s)?(?<![a-z]\s)Windows(?=,|[ \t]\/|$)/gm,
replace: ' <div alt="Windows" class="i-qlementine-icons:windows-24" /> '
},
{
name: 'Mac',
find: /(?<=\/ (\/>|[^/\r\n])*)(,\s)?(?<![a-z]\s)Mac(?=,|[ \t]\/|$)/gm,
replace: ' <div alt="Mac" class="i-qlementine-icons:mac-fill-16" /> '
},
{
name: 'Linux',
find: /(?<=\/ (\/>|[^/\r\n])*)(,\s)?(?<![a-z]\s)Linux(?=,|[ \t]\/|$)/gm,
replace: ' <div alt="Linux" class="i-fluent-mdl2:linux-logo-32" /> '
},
{
name: 'Android',
find: /(?<=\/ (\/>|[^/\r\n])*)(,\s)?(?<![a-z]\s)Android(?=,|[ \t]\/|$)/gm,
replace: ' <div alt="Android" class="i-material-symbols:android" /> '
},
{
name: 'iOS',
find: /(?<=\/ (\/>|[^/\r\n])*)(,\s)?(?<![a-z]\s)iOS(?=,|[ \t]\/|$)/gm,
replace: ' <div alt="iOS" class="i-simple-icons:ios" /> '
},
{
name: 'Web',
find: /(?<=\/ (\/>|[^/\r\n])*)(,\s)?(?<![a-z]\s)Web(?=,|[ \t]\/|$)/gm,
replace: ' <div alt="Web" class="i-fluent:globe-32-filled" /> '
}
])
.getText()

View file

@ -1,162 +0,0 @@
import { meta } from '../constants'
/**
* Copyright (c) 2025 taskylizard. Apache License 2.0.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
interface Header {
[file: string]: { title: string; description: string }
}
export const headers: Header = {
'adblockvpnguide.md': {
title: 'Adblocking / Privacy',
description: 'Adblocking, Privacy, VPNs, Proxies, Antiviruses'
},
'ai.md': {
title: 'Artificial Intelligence',
description: 'Chatbots, Text Generators, Image Generators, ChatGPT Tools'
},
'android-iosguide.md': {
title: 'Android / iOS',
description: 'Apps, Jailbreaking, Android Emulators'
},
'audiopiracyguide.md': {
title: 'Music / Podcasts / Radio',
description: 'Stream Audio, Download Audio, Torrent Audio'
},
'beginners-guide.md': {
title: 'Beginners Guide',
description: 'A Guide for Beginners to Piracy'
},
'downloadpiracyguide.md': {
title: 'Downloading',
description: 'Download Sites, Software Sites, Open Directories'
},
'edupiracyguide.md': {
title: 'Educational',
description: 'Courses, Documentaries, Learning Resources'
},
'gamingpiracyguide.md': {
title: 'Gaming / Emulation',
description: 'Download Games, ROMs, Gaming Tools'
},
'linuxguide.md': {
title: 'Linux / macOS',
description: 'Apps, Software Sites, Gaming'
},
'miscguide.md': {
title: 'Miscellaneous',
description: 'Extensions, Indexes, News, Health, Food, Fun'
},
'nsfwpiracy.md': {
title: 'NSFW',
description: 'NSFW Indexes, Streaming, Downloading'
},
'non-english.md': {
title: 'Non-English',
description: 'International Piracy Sites'
},
'readingpiracyguide.md': {
title: 'Books / Comics / Manga',
description: 'Books, Comics, Magazines, Newspapers'
},
'gaming-tools.md': {
title: 'Gaming Tools',
description: 'Gaming Optimization, Game Launchers, Multiplayer'
},
'devtools.md': {
title: 'Developer Tools',
description: 'Git, Hosting, App Dev, Software Dev'
},
'img-tools.md': {
title: 'Image Tools',
description: 'Image Editors, Generators, Compress'
},
'audio-tools.md': {
title: 'Audio Tools',
description: 'Audio Players, Audio Editors, Audio Downloaders'
},
'system-tools.md': {
title: 'System Tools',
description: 'System Tools, Hardware Tools, Windows ISOs, Customization'
},
'file-tools.md': {
title: 'File Tools',
description: 'Download Managers, File Hosting, File Archivers'
},
'video-tools.md': {
title: 'Video Tools',
description: 'Video Players, Video Editors, Live Streaming, Animation'
},
'text-tools.md': {
title: 'Text Tools',
description: 'Text Editors, Pastebins, Fonts, Translators'
},
'internet-tools.md': {
title: 'Internet Tools',
description: 'Browsers, Extensions, Search Engines'
},
'social-media-tools.md': {
title: 'Social Media Tools',
description: 'Discord Tools, Reddit Tools, YouTube Tools'
},
'storage.md': {
title: 'Storage',
description: 'Sections too big to fit on main pages'
},
'torrentpiracyguide.md': {
title: 'Torrenting',
description: 'Torrent Clients, Torrent Sites, Trackers'
},
'videopiracyguide.md': {
title: 'Movies / TV / Anime',
description: 'Stream Videos, Download Videos, Torrent Videos'
},
'base64.md': {
title: 'Base64',
description: 'Base64 storage'
},
'unsafesites.md': {
title: 'Unsafe Sites',
description: 'Unsafe/harmful sites to avoid.'
}
} as const
export const excluded = [
'readme.md',
'single-page',
'feedback.md',
'index.md',
'sandbox.md',
'startpage.md'
]
export function getHeader(id: string) {
const title =
'<div class="space-y-2 not-prose"><h1 class="text-4xl font-extrabold tracking-tight text-primary underline lg:text-5xl lg:leading-[3.5rem]">'
const description = '<p class="text-black dark:text-text-2">'
const feedback = meta.build.api ? '<Feedback />' : ''
const data = headers[id]
let header = '---\n'
header += `title: "${data.title}"\n`
header += `description: ${data.description}\n`
header += '---\n'
header += `${title}${data.title}</h1>\n`
header += `${description}${data.description}</p></div>\n\n${feedback}\n\n`
return header
}

View file

@ -1,69 +0,0 @@
/**
* Copyright (c) 2025 taskylizard. Apache License 2.0.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import consola from 'consola'
type Transform = {
name: string
find: string | RegExp
replace: string | ((match: string) => string)
}
type TransformerFunc = (name: string, transforms: Transform[]) => Replacer
interface Replacer {
transform: TransformerFunc
getText(): string
}
export const transformer = (text: string) => {
const handler: ProxyHandler<{ text: string }> = {
get(target: { text: string }, prop: string | symbol) {
if (prop === 'transform') {
return (name: string, transforms: Transform[]): Replacer => {
consola.debug(`Starting transform ${name} with ${transforms}`)
transforms.forEach(({ name, find, replace }) => {
consola.debug(`Transforming ${name} with ${find}`)
target.text = target.text.replace(find, replace as any)
})
// @ts-expect-error - Proxy is not typed
return proxy
}
}
if (prop === 'getText') {
return () => target.text
}
return Reflect.get(target, prop)
}
}
const target = { text }
const proxy = new Proxy(target, handler)
return proxy as unknown as Replacer
}
export function replaceUnderscore(text: string): string {
const pattern = /\/#[\w\-]+(?:_[\w]+)*/g
const matches = text.match(pattern) || []
let _text = text
for (const match of matches) {
const replacement = match.replace(/_/g, '-')
_text = _text.replace(match, replacement)
}
return _text
}

View file

@ -1,50 +0,0 @@
/**
* Copyright (c) 2025 taskylizard. Apache License 2.0.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import z from 'zod'
export const FeedbackSchema = z.object({
message: z.string().min(5).max(1000),
type: z.enum(['suggestion', 'appreciation', 'other']),
page: z.string().min(3).max(20),
// For heading based feedback
heading: z.string().min(3).max(20).optional()
})
export interface Option {
label: string
value: FeedbackType['type']
}
export const feedbackOptions: Option[] = [
{
label: '💡 I have a suggestion',
value: 'suggestion'
},
{
label: '👍 I appreciate the work',
value: 'appreciation'
},
{ label: '📂 Something else', value: 'other' }
]
export function getFeedbackOption(
value: FeedbackType['type']
): Option | undefined {
return feedbackOptions.find((option) => option.value === value)
}
export type FeedbackType = z.infer<typeof FeedbackSchema>

View file

@ -1,28 +0,0 @@
/**
* Copyright (c) 2025 taskylizard. Apache License 2.0.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export function groupBy<T, K extends keyof any>(
arr: T[],
key: (i: T) => K
): Record<K, T[]> {
return arr.reduce(
(groups, item) => {
// biome-ignore lint/suspicious/noAssignInExpressions: <explanation>
;(groups[key(item)] ||= []).push(item)
return groups
},
{} as Record<K, T[]>
)
}

View file

@ -1,22 +0,0 @@
/**
* Copyright (c) 2025 taskylizard. Apache License 2.0.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable ts/consistent-type-imports */
declare module '*.vue' {
const component: import('vue').Component
export default component
}
declare const FMHY_HOMEPAGE_ITEMS: typeof import('./constants').wikiSidebar

View file

@ -6,7 +6,7 @@ prev: false
---
<script setup>
import WallpaperCard from '../.vitepress/theme/components/WallpaperCard.vue'
import WallpaperCard from '../../website/theme/components/WallpaperCard.vue'
</script>
# Wallpapers

View file

@ -9,7 +9,7 @@ sidebar: true
---
<script setup>
import Index from './.vitepress/theme/Posts.vue'
import Index from '../website/theme/Posts.vue'
</script>
<Index/>

View file

@ -5,7 +5,7 @@ pageClass: startpage-custom-styling
---
<script setup>
import StartPage from './.vitepress/theme/components/startpage/Startpage.vue'
import StartPage from '../website/theme/components/startpage/Startpage.vue'
</script>
<StartPage />