docs framework: tocs, contents, highlighter

This commit is contained in:
Timothy Yin 2024-11-19 20:53:54 +08:00
parent ab9fe5f242
commit a6ee301d5b
13 changed files with 271 additions and 98 deletions

View File

@ -1,4 +1,7 @@
<script>
<script setup lang="ts">
const { data: navigation } = await useAsyncData('navigation', () => fetchContentNavigation())
provide('navigation', navigation)
</script>
<template>
@ -8,3 +11,9 @@
</NuxtLayout>
</RayMessageProvider>
</template>
<style>
html {
scroll-behavior: smooth;
}
</style>

View File

@ -3,12 +3,13 @@ import FileTypeVue from "./icon/VscodeIconsFileTypeVue.vue"
import FileTypeTypescript from "./icon/VscodeIconsFileTypeTypescriptOfficial.vue"
import FileTypeJavascript from "./icon/VscodeIconsFileTypeJsOfficial.vue"
import TablerTerminal from "./icon/TablerTerminal.vue";
import { camelCase, kebabCase, upperFirst } from "scule";
const hightlighter = useShikiHighlighter();
const route = useRoute();
const highlighter = useShikiHighlighter();
const slots = defineSlots<{
default?: () => VNode[];
code?: () => VNode[];
}>();
const IconComponents = {
@ -19,33 +20,19 @@ const IconComponents = {
'js': FileTypeJavascript,
}
const codeSlotContent = computed(() => {
if (slots.code) {
const slotContent = slots.code();
let contentLines = slotContent
.map(vnode => vnode.children || '')
.join('')
.replace('\n', '') // remove first line break
.split('\n');
// calculate the minimum indent
const minIndent = contentLines.reduce((min, line) => {
const match = line.match(/^(\s*)\S/);
if (match) {
return Math.min(min, match[1].length);
}
return min;
}, Infinity);
// remove the minimum indent from each line
const stringContent = contentLines.map(line => line.slice(minIndent)).join('\n');
return stringContent;
}
return '';
})
const props = defineProps({
slug: {
type: String,
default: '',
},
props: {
type: Object,
default: () => ({}),
},
slots: {
type: Object,
default: () => ({}),
},
filename: {
type: String,
default: '',
@ -54,13 +41,27 @@ const props = defineProps({
type: String as PropType<keyof typeof IconComponents>,
default: '',
},
code: {
type: String,
default: '',
}
})
const { data: ast } = await useAsyncData(`${'name'}-ast-${JSON.stringify({ slots: props.slots, code: props.code })}`, async () => {
const componentName = props.slug || `Ray${upperFirst(camelCase(route.params.slug[route.params.slug.length - 1]))}`
const componentProps = reactive({ ...props.props })
const code = computed(() => {
let code = `\`\`\`html
<template>
<${componentName}`
for (const [k, v] of Object.entries(componentProps)) {
code += ` ${typeof v === 'boolean' || typeof v === 'number' || typeof v === 'object' ? ':' : ''}${kebabCase(k)}="${typeof v === 'object' ? renderObject(v) : v}"`
}
code += `/>\n</template>
\`\`\`
`
return code;
})
const { data: codeRender } = await useAsyncData(`${componentName}-renderer-${JSON.stringify({ slots: slots, code: code.value })}`, async () => {
let formatted = ''
try {
// @ts-ignore
@ -70,24 +71,25 @@ const { data: ast } = await useAsyncData(`${'name'}-ast-${JSON.stringify({ slots
singleQuote: true
})
} catch {
formatted = props.code
formatted = code.value
}
return parseMarkdown(formatted, {
highlight: {
highlighter,
theme: {
light: 'material-theme-lighter',
default: 'material-theme',
dark: 'material-theme-palenight'
light: 'light-plus',
dark: 'dark-plus'
}
}
})
}, {
watch: [code]
})
</script>
<template>
<div class="border border-neutral-200 dark:border-neutral-700 rounded-lg">
<div class="border border-neutral-200 dark:border-neutral-700 rounded-lg not-prose my-2 overflow-hidden">
<div v-if="filename" class="p-4 py-2 border-b border-neutral-200 dark:border-neutral-700">
<span class="flex items-center gap-1">
<component v-if="lang" :is="IconComponents[lang]" class="inline" />
@ -95,16 +97,15 @@ const { data: ast } = await useAsyncData(`${'name'}-ast-${JSON.stringify({ slots
</span>
</div>
<template v-if="slots.default">
<div :class="['p-4 overflow-auto', $slots.code ? 'border-b border-neutral-200 dark:border-neutral-700' : '']">
<div :class="['p-4 overflow-auto', !!codeRender ? 'border-b border-neutral-200 dark:border-neutral-700' : '']">
<component :is="componentName" v-bind="componentProps">
<slot></slot>
</div>
</template>
</component>
</div>
<template v-if="slots.code">
<div class="p-4 overflow-auto">
<!-- <LazyShiki class="text-sm" :lang="lang" :code="codeSlotContent" /> -->
<ContentRenderer :value="ast" v-if="ast"/>
<template v-if="codeRender">
<div class="overflow-auto">
<ContentRenderer :value="codeRender" v-if="codeRender" class="p-4 bg-neutral-50 dark:bg-neutral-800/50" />
</div>
</template>
</div>

View File

@ -4,11 +4,12 @@
</script>
<template>
<header class="w-full flex justify-between items-center py-2 border-b border-b-neutral-100 dark:border-b-neutral-800">
<div class="text-neutral-900 dark:text-neutral-100">
<header
class="w-full flex justify-between items-center py-2 border-b border-b-neutral-100 dark:border-b-neutral-800 h-16 z-50 sticky top-0 bg-white/90 dark:bg-neutral-900">
<NuxtLink to="/" class="text-neutral-900 dark:text-neutral-100">
<h1 class="font-medium text-xl">RayineSoft<sup class="text-sm"> &copy;</sup></h1>
<h2 class="font-normal text-xs">Common Components</h2>
</div>
</NuxtLink>
<div class="flex items-center gap-4">
<NuxtLink to="/" class="text-neutral-400 dark:text-neutral-500"
active-class="!text-neutral-700 dark:!text-neutral-300">Docs</NuxtLink>

91
docs/components/Toc.vue Normal file
View File

@ -0,0 +1,91 @@
<script setup lang="ts">
import { ref, computed } from "vue";
interface TocItem {
id: string;
depth: number;
text: string;
children?: TocItem[];
}
const props = defineProps<{
toc: TocItem[];
maxDepth?: number;
}>();
const maxDepth = props.maxDepth ?? 3;
const filteredToc = computed(() => {
const filterByDepth = (items: TocItem[]): TocItem[] => {
return items
.filter(item => item.depth <= maxDepth)
.map(item => ({
...item,
children: item.children ? filterByDepth(item.children) : undefined,
}));
};
return filterByDepth(props.toc);
});
const activeLinks = ref<string[]>([]);
onMounted(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
activeLinks.value.push(entry.target.id);
} else {
activeLinks.value = activeLinks.value.filter(link => link !== entry.target.id);
}
});
},
// { rootMargin: '0px 0px -80% 0px' }
);
filteredToc.value.forEach(item => {
const element = document.getElementById(item.id);
if (element) {
observer.observe(element);
}
item.children?.forEach(child => {
const childElement = document.getElementById(child.id);
if (childElement) {
observer.observe(childElement);
}
});
});
});
</script>
<template>
<ul>
<template v-for="item in filteredToc" :key="item.id">
<li>
<NuxtLink :href="'#' + item.id" class="link" :class="{ 'active': activeLinks.includes(item.id) }">
{{ item.text }}
</NuxtLink>
<ul v-if="item.children && item.children.length" class="ml-4">
<template v-for="child in item.children" :key="child.id">
<li>
<NuxtLink :href="'#' + child.id" class="link" :class="{ 'active': activeLinks.includes(child.id) }">
{{ child.text }}
</NuxtLink>
</li>
</template>
</ul>
</li>
</template>
</ul>
</template>
<style scoped>
.link {
@apply text-xs text-neutral-400 dark:text-neutral-500 font-medium;
}
.link.active {
@apply text-primary;
}
</style>

View File

@ -1,7 +1,8 @@
import { createShikiHighlighter } from "@nuxtjs/mdc/runtime/highlighter/shiki";
import MaterialTheme from "shiki/themes/material-theme.mjs";
import MaterialThemeLighter from "shiki/themes/material-theme-lighter.mjs";
import MaterialThemePalenight from "shiki/themes/material-theme-palenight.mjs";
import LightPlus from "shiki/themes/light-plus.mjs";
import DarkPlus from "shiki/themes/dark-plus.mjs";
import HtmlLang from "shiki/langs/html.mjs";
import MdcLang from "shiki/langs/mdc.mjs";
import VueLang from "shiki/langs/vue.mjs";
@ -14,9 +15,10 @@ export const useShikiHighlighter = () => {
if (!highlighter) {
highlighter = createShikiHighlighter({
bundledThemes: {
"material-theme": MaterialTheme,
"material-theme-lighter": MaterialThemeLighter,
"material-theme-palenight": MaterialThemePalenight,
"material-theme": MaterialTheme,
"light-plus": LightPlus,
"dark-plus": DarkPlus,
},
bundledLangs: {
html: HtmlLang,

View File

@ -1,25 +1,31 @@
# Button
Buttons are used to trigger an action or event, such as submitting a form, opening a dialog, canceling an action, or performing a delete operation.
---
description: Create a button component with different variants and colors
---
## Usage
```html
<RayButton>Click me</RayButton>
```
Default button style
---
::DocExampleBlock
test
#code
console.log('Hello Rayine')
::ComponentPreview
Button
::
::RayButton
### Variants
::ComponentPreview
---
color: red
props:
variant: soft
---
Hello Rayine
Button
::
### Colors
::ComponentPreview
---
props:
color: violet
---
Button
::

View File

@ -1,18 +1 @@
::DocContentBlock
---
title: Button
accent-title: true
---
::
::DocContentBlock
---
title: Variants
---
::
::DocExampleBlock
---
lang: vue
---
::
# index

View File

@ -18,11 +18,13 @@ body {
@apply bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100;
}
.shiki,
/* .shiki,
.shiki span {
background-color: rgba(0, 0, 0, 0) !important;
}
} */
/*
@media (prefers-color-scheme: dark) {
.shiki,
@ -34,5 +36,5 @@ body {
font-weight: var(--shiki-dark-font-weight) !important;
text-decoration: var(--shiki-dark-text-decoration) !important;
}
}
} */
</style>

View File

@ -4,15 +4,15 @@ import defaultTheme from "tailwindcss/defaultTheme";
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: "2024-04-03",
modules: ["@nuxt/content", "@nuxt/fonts", module],
modules: ["@nuxt/content", "@nuxt/fonts", "@nuxtjs/color-mode", module],
devtools: { enabled: true },
rayui: {
// @ts-ignore
globalComponents: true,
safeColors: ["amber", "emerald", "red", "sky", "violet", "cyan"],
},
tailwindcss: {
config: {
darkMode: "media",
theme: {
extend: {
fontFamily: {
@ -22,6 +22,10 @@ export default defineNuxtConfig({
},
},
},
colorMode: {
preference: "system",
classSuffix: "",
},
components: [
{
path: "~/components",
@ -33,6 +37,11 @@ export default defineNuxtConfig({
langs: ["postcss", "mdc", "html", "vue", "ts", "js"],
},
},
mdc: {
highlight: {
themes: ["material-theme-lighter", "material-theme", "light-plus", "dark-plus"],
},
},
typescript: {
includeWorkspace: true,
},

View File

@ -10,6 +10,7 @@
},
"dependencies": {
"@nuxt/content": "^2.13.4",
"@nuxtjs/color-mode": "^3.5.2",
"@nuxtjs/mdc": "^0.9.2",
"nuxt": "^3.14.159",
"nuxt-shiki": "^0.3.0",

View File

@ -1,4 +1,6 @@
<script lang="ts" setup>
import type { NavItem } from '@nuxt/content';
const route = useRoute()
const { data: page } = await useAsyncData(route.path, () => queryContent(route.path).findOne())
@ -8,17 +10,41 @@ if (!page.value) {
statusCode: 404, statusMessage: 'Page not found', fatal: true
})
}
const nav = inject<Ref<NavItem[]>>('navigation')
const navigation = computed(() => nav?.value)
console.log(navigation.value);
const hasToc = computed(() => page.value?.body?.toc && page.value?.body?.toc?.links.length !== 0)
</script>
<template>
<div>
<ContentRenderer v-if="page.body" :value="page" />
<div class="grid grid-cols-12 gap-4 pb-10">
<div class="col-span-12" :class="{ 'md:col-span-9': hasToc }">
<div>
<h1 class="text-3xl text-primary font-medium">{{ page?.title || 'untitled' }}</h1>
<p v-if="page?.description" class="text-lg text-neutral-500 mt-2">{{ page.description }}</p>
</div>
<hr class="my-4 dark:border-neutral-700" />
<div class="doc-body">
<ContentRenderer v-if="page?.body" :value="page" />
</div>
</div>
<div v-if="hasToc" class="hidden" :class="{ 'col-span-3 md:block': hasToc }">
<div class="bg-neutral-50 dark:bg-neutral-800/50 rounded-lg px-4 py-3 overflow-hidden overflow-y-auto sticky top-[calc(64px+16px)]">
<span class="text-xs text-neutral-600 dark:text-neutral-300 font-medium inline-block mb-2">
Table of contents
</span>
<Toc :toc="page!.body!.toc!.links" />
</div>
</div>
</div>
</template>
<style>
.doc-body {
@apply prose dark:prose-invert max-w-none;
@apply prose prose-neutral dark:prose-invert max-w-none prose-headings:no-underline;
hr {
@apply my-8 border-t border-neutral-200 dark:border-neutral-700;
@ -27,9 +53,17 @@ if (!page.value) {
h1 {
@apply text-3xl text-primary font-bold my-4 first:mt-0;
}
& > p {
@apply text-base text-neutral-900 dark:text-neutral-100;
h2 a,
h3 a,
h4 a,
h5 a,
h6 a {
text-decoration: none;
}
&>p {
@apply text-base text-justify text-neutral-900 dark:text-neutral-100;
}
}
</style>

17
docs/utils/index.ts Normal file
View File

@ -0,0 +1,17 @@
export const renderObject = (obj: any): string => {
if (Array.isArray(obj)) {
return `[${obj.map(renderObject).join(", ")}]`;
}
if (typeof obj === "object") {
return `{ ${Object.entries(obj)
.map(([key, value]) => `${key}: ${renderObject(value)}`)
.join(", ")} }`;
}
if (typeof obj === "string") {
return `'${obj}'`;
}
return obj;
};

17
pnpm-lock.yaml generated
View File

@ -78,6 +78,9 @@ importers:
'@nuxt/content':
specifier: ^2.13.4
version: 2.13.4(ioredis@5.4.1)(magicast@0.3.5)(nuxt@3.14.159(@parcel/watcher@2.5.0)(@types/node@22.9.0)(eslint@9.15.0(jiti@2.4.0))(ioredis@5.4.1)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.27.2)(terser@5.36.0)(typescript@5.6.3)(vite@5.4.11(@types/node@22.9.0)(terser@5.36.0))(vue-tsc@2.1.10(typescript@5.6.3)))(rollup@4.27.2)(vue@3.5.13(typescript@5.6.3))
'@nuxtjs/color-mode':
specifier: ^3.5.2
version: 3.5.2(magicast@0.3.5)(rollup@4.27.2)
'@nuxtjs/mdc':
specifier: ^0.9.2
version: 0.9.2(magicast@0.3.5)(rollup@4.27.2)
@ -950,6 +953,9 @@ packages:
peerDependencies:
vue: ^3.3.4
'@nuxtjs/color-mode@3.5.2':
resolution: {integrity: sha512-cC6RfgZh3guHBMLLjrBB2Uti5eUoGM9KyauOaYS9ETmxNWBMTvpgjvSiSJp1OFljIXPIqVTJ3xtJpSNZiO3ZaA==}
'@nuxtjs/mdc@0.9.2':
resolution: {integrity: sha512-dozIPTPjEYu8jChHNCICZP3mN0sFC6l3aLxTkgv/DAr1EI8jqqqoSZKevzuiHUWGNTguS70+fLcztCwrzWdoYA==}
@ -6276,6 +6282,17 @@ snapshots:
- vti
- vue-tsc
'@nuxtjs/color-mode@3.5.2(magicast@0.3.5)(rollup@4.27.2)':
dependencies:
'@nuxt/kit': 3.14.159(magicast@0.3.5)(rollup@4.27.2)
pathe: 1.1.2
pkg-types: 1.2.1
semver: 7.6.3
transitivePeerDependencies:
- magicast
- rollup
- supports-color
'@nuxtjs/mdc@0.9.2(magicast@0.3.5)(rollup@4.27.2)':
dependencies:
'@nuxt/kit': 3.14.159(magicast@0.3.5)(rollup@4.27.2)