chore(docs): eslint migration + extractor updates
Migrate docs to eslint flat config (build-script console override); doc extractor points at configs/eslint.
This commit is contained in:
+104
-23
@@ -1,5 +1,37 @@
|
||||
import metadata from '#docs/metadata';
|
||||
import type { DocsMetadata, PackageMeta, CategoryMeta, ItemMeta } from '../../modules/extractor/types';
|
||||
import type {
|
||||
CategoryMeta,
|
||||
ComponentMeta,
|
||||
DocsMetadata,
|
||||
GuideSection,
|
||||
ItemMeta,
|
||||
PackageGroup,
|
||||
PackageMeta,
|
||||
} from '../../modules/extractor/types';
|
||||
|
||||
/** A unified, normalised entry for any documented leaf, regardless of kind. */
|
||||
export type DocEntry
|
||||
= | { kind: 'api'; pkg: PackageMeta; category: CategoryMeta; item: ItemMeta }
|
||||
| { kind: 'components'; pkg: PackageMeta; component: ComponentMeta }
|
||||
| { kind: 'guide'; pkg: PackageMeta; section: GuideSection };
|
||||
|
||||
export interface SearchResult {
|
||||
pkg: PackageMeta;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
/** Display kind for the badge */
|
||||
badge: string;
|
||||
}
|
||||
|
||||
const GROUP_LABELS: Record<PackageGroup, string> = {
|
||||
core: 'Core',
|
||||
vue: 'Vue',
|
||||
configs: 'Configs',
|
||||
infra: 'Infra',
|
||||
};
|
||||
|
||||
const GROUP_ORDER: PackageGroup[] = ['core', 'vue', 'configs', 'infra'];
|
||||
|
||||
export function useDocs() {
|
||||
const data = metadata as unknown as DocsMetadata;
|
||||
@@ -8,35 +40,85 @@ export function useDocs() {
|
||||
return data.packages;
|
||||
}
|
||||
|
||||
/** Packages grouped & ordered for sidebar / landing. */
|
||||
function getGroupedPackages(): Array<{ group: PackageGroup; label: string; packages: PackageMeta[] }> {
|
||||
return GROUP_ORDER
|
||||
.map(group => ({
|
||||
group,
|
||||
label: GROUP_LABELS[group],
|
||||
packages: data.packages.filter(p => p.group === group),
|
||||
}))
|
||||
.filter(g => g.packages.length > 0);
|
||||
}
|
||||
|
||||
function getPackage(slug: string): PackageMeta | undefined {
|
||||
return data.packages.find(p => p.slug === slug);
|
||||
}
|
||||
|
||||
function getItem(packageSlug: string, itemSlug: string): { pkg: PackageMeta; category: CategoryMeta; item: ItemMeta } | undefined {
|
||||
/** Number of documented leaves in a package, whatever its kind. */
|
||||
function countEntries(pkg: PackageMeta): number {
|
||||
if (pkg.kind === 'api') return pkg.categories.reduce((s, c) => s + c.items.length, 0);
|
||||
if (pkg.kind === 'components') return pkg.components.length;
|
||||
return pkg.sections.length;
|
||||
}
|
||||
|
||||
/** Resolve any `/:package/:slug` route to a normalised entry. */
|
||||
function resolveEntry(packageSlug: string, slug: string): DocEntry | undefined {
|
||||
const pkg = getPackage(packageSlug);
|
||||
if (!pkg) return undefined;
|
||||
|
||||
for (const category of pkg.categories) {
|
||||
const item = category.items.find(i => i.slug === itemSlug);
|
||||
if (item) return { pkg, category, item };
|
||||
if (pkg.kind === 'api') {
|
||||
for (const category of pkg.categories) {
|
||||
const item = category.items.find(i => i.slug === slug);
|
||||
if (item) return { kind: 'api', pkg, category, item };
|
||||
}
|
||||
}
|
||||
else if (pkg.kind === 'components') {
|
||||
const component = pkg.components.find(c => c.slug === slug);
|
||||
if (component) return { kind: 'components', pkg, component };
|
||||
}
|
||||
else {
|
||||
const section = pkg.sections.find(s => s.slug === slug);
|
||||
if (section) return { kind: 'guide', pkg, section };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function searchItems(query: string): Array<{ pkg: PackageMeta; item: ItemMeta }> {
|
||||
if (!query.trim()) return [];
|
||||
const q = query.toLowerCase();
|
||||
const results: Array<{ pkg: PackageMeta; item: ItemMeta }> = [];
|
||||
/** The default entry to open when landing on a package, if any. */
|
||||
function firstEntrySlug(pkg: PackageMeta): string | undefined {
|
||||
if (pkg.kind === 'api') return pkg.categories[0]?.items[0]?.slug;
|
||||
if (pkg.kind === 'components') return pkg.components[0]?.slug;
|
||||
return pkg.sections[0]?.slug;
|
||||
}
|
||||
|
||||
function search(query: string): SearchResult[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
|
||||
const results: SearchResult[] = [];
|
||||
|
||||
for (const pkg of data.packages) {
|
||||
for (const category of pkg.categories) {
|
||||
for (const item of category.items) {
|
||||
if (
|
||||
item.name.toLowerCase().includes(q)
|
||||
|| item.description.toLowerCase().includes(q)
|
||||
) {
|
||||
results.push({ pkg, item });
|
||||
if (pkg.kind === 'api') {
|
||||
for (const category of pkg.categories) {
|
||||
for (const item of category.items) {
|
||||
if (item.name.toLowerCase().includes(q) || item.description.toLowerCase().includes(q)) {
|
||||
results.push({ pkg, slug: item.slug, name: item.name, description: item.description, badge: item.kind });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (pkg.kind === 'components') {
|
||||
for (const c of pkg.components) {
|
||||
if (c.name.toLowerCase().includes(q) || c.description.toLowerCase().includes(q)) {
|
||||
results.push({ pkg, slug: c.slug, name: c.name, description: c.description || `${c.parts.length} parts`, badge: 'component' });
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
for (const s of pkg.sections) {
|
||||
if (s.title.toLowerCase().includes(q) || s.markdown.toLowerCase().includes(q)) {
|
||||
results.push({ pkg, slug: s.slug, name: s.title, description: pkg.name, badge: 'guide' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,19 +128,18 @@ export function useDocs() {
|
||||
}
|
||||
|
||||
function getTotalItems(): number {
|
||||
return data.packages.reduce(
|
||||
(sum, pkg) => sum + pkg.categories.reduce(
|
||||
(catSum, cat) => catSum + cat.items.length, 0,
|
||||
), 0,
|
||||
);
|
||||
return data.packages.reduce((sum, pkg) => sum + countEntries(pkg), 0);
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
getPackages,
|
||||
getGroupedPackages,
|
||||
getPackage,
|
||||
getItem,
|
||||
searchItems,
|
||||
countEntries,
|
||||
resolveEntry,
|
||||
firstEntrySlug,
|
||||
search,
|
||||
getTotalItems,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { marked } from 'marked';
|
||||
|
||||
export interface Heading {
|
||||
depth: number;
|
||||
text: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export function slugHeading(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/`/g, '')
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.trim()
|
||||
.replace(/\s+/g, '-');
|
||||
}
|
||||
|
||||
/** Collect h2/h3 headings for the table of contents. */
|
||||
export function extractHeadings(markdown: string): Heading[] {
|
||||
const headings: Heading[] = [];
|
||||
const seen = new Map<string, number>();
|
||||
let inFence = false;
|
||||
|
||||
for (const line of markdown.split('\n')) {
|
||||
if (/^\s*```/.test(line)) {
|
||||
inFence = !inFence;
|
||||
continue;
|
||||
}
|
||||
if (inFence) continue;
|
||||
|
||||
const m = line.match(/^(#{2,3})\s+(.+?)\s*#*$/);
|
||||
if (!m) continue;
|
||||
|
||||
const depth = m[1]!.length;
|
||||
const text = m[2]!.replace(/`/g, '').trim();
|
||||
let id = slugHeading(text);
|
||||
const count = seen.get(id) ?? 0;
|
||||
seen.set(id, count + 1);
|
||||
if (count > 0) id = `${id}-${count}`;
|
||||
|
||||
headings.push({ depth, text, id });
|
||||
}
|
||||
|
||||
return headings;
|
||||
}
|
||||
|
||||
/** Render markdown to HTML with stable heading ids (matching extractHeadings). */
|
||||
export function renderMarkdown(markdown: string): string {
|
||||
const seen = new Map<string, number>();
|
||||
|
||||
const renderer = new marked.Renderer();
|
||||
renderer.heading = function ({ tokens, depth }) {
|
||||
const inner = this.parser.parseInline(tokens);
|
||||
const plain = inner.replace(/<[^>]+>/g, '');
|
||||
let id = slugHeading(plain);
|
||||
const count = seen.get(id) ?? 0;
|
||||
seen.set(id, count + 1);
|
||||
if (count > 0) id = `${id}-${count}`;
|
||||
return `<h${depth} id="${id}">${inner}</h${depth}>\n`;
|
||||
};
|
||||
|
||||
return marked.parse(markdown, { renderer, async: false }) as string;
|
||||
}
|
||||
@@ -7,7 +7,7 @@ function getHighlighter(): Promise<Highlighter> {
|
||||
if (!highlighterPromise) {
|
||||
highlighterPromise = createHighlighter({
|
||||
themes: ['github-light', 'github-dark'],
|
||||
langs: ['typescript', 'vue', 'json', 'bash'],
|
||||
langs: ['typescript', 'javascript', 'vue', 'json', 'bash'],
|
||||
});
|
||||
}
|
||||
return highlighterPromise;
|
||||
@@ -17,7 +17,7 @@ export function useShiki() {
|
||||
const highlighted = ref<string>('');
|
||||
const isReady = ref(false);
|
||||
|
||||
async function highlight(code: string, lang: string = 'typescript'): Promise<string> {
|
||||
async function highlight(code: string, lang = 'typescript'): Promise<string> {
|
||||
const highlighter = await getHighlighter();
|
||||
return highlighter.codeToHtml(code, {
|
||||
lang,
|
||||
@@ -28,7 +28,7 @@ export function useShiki() {
|
||||
});
|
||||
}
|
||||
|
||||
async function highlightReactive(code: string, lang: string = 'typescript'): Promise<void> {
|
||||
async function highlightReactive(code: string, lang = 'typescript'): Promise<void> {
|
||||
highlighted.value = await highlight(code, lang);
|
||||
isReady.value = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
export type ThemePreference = 'light' | 'dark' | 'system';
|
||||
|
||||
const STORAGE_KEY = 'docs-theme';
|
||||
|
||||
/**
|
||||
* Theme controller. The actual `.dark` class is set as early as possible by the
|
||||
* inline head script (see nuxt.config) to avoid a flash; this composable keeps a
|
||||
* reactive preference, persists it, and re-applies the resolved theme on change.
|
||||
*/
|
||||
export function useTheme() {
|
||||
const preference = useState<ThemePreference>('theme-preference', () => 'system');
|
||||
|
||||
function resolve(pref: ThemePreference): 'light' | 'dark' {
|
||||
if (pref === 'system') {
|
||||
return import.meta.client && globalThis.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
}
|
||||
return pref;
|
||||
}
|
||||
|
||||
function apply(pref: ThemePreference) {
|
||||
if (!import.meta.client) return;
|
||||
const resolved = resolve(pref);
|
||||
document.documentElement.classList.toggle('dark', resolved === 'dark');
|
||||
}
|
||||
|
||||
function setTheme(pref: ThemePreference) {
|
||||
preference.value = pref;
|
||||
if (import.meta.client) {
|
||||
if (pref === 'system') localStorage.removeItem(STORAGE_KEY);
|
||||
else localStorage.setItem(STORAGE_KEY, pref);
|
||||
apply(pref);
|
||||
}
|
||||
}
|
||||
|
||||
function cycle() {
|
||||
const order: ThemePreference[] = ['light', 'dark', 'system'];
|
||||
const next = order[(order.indexOf(preference.value) + 1) % order.length]!;
|
||||
setTheme(next);
|
||||
}
|
||||
|
||||
// Initialise reactive preference from storage on the client.
|
||||
if (import.meta.client) {
|
||||
onMounted(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY) as ThemePreference | null;
|
||||
preference.value = stored ?? 'system';
|
||||
|
||||
// Track OS changes while in `system` mode.
|
||||
const mq = globalThis.matchMedia('(prefers-color-scheme: dark)');
|
||||
const onChange = () => {
|
||||
if (preference.value === 'system') apply('system');
|
||||
};
|
||||
mq.addEventListener('change', onChange);
|
||||
onUnmounted(() => mq.removeEventListener('change', onChange));
|
||||
});
|
||||
}
|
||||
|
||||
const resolved = computed(() => resolve(preference.value));
|
||||
|
||||
return { preference, resolved, setTheme, cycle };
|
||||
}
|
||||
Reference in New Issue
Block a user