From 1b77f663f6bc691427f6d70fbf6256ea3757731d Mon Sep 17 00:00:00 2001 From: bryantgillespie Date: Tue, 2 Jun 2026 10:45:00 -0400 Subject: [PATCH 1/3] Pre-render API reference code highlighting --- app/components/ApiCodeSamples.vue | 141 ++++++++++++++++++++++++++ app/components/ApiEndpoint.vue | 16 +-- app/components/ApiResponseExample.vue | 40 ++++++++ app/types.ts | 6 ++ app/utils/codeSamplesMd.ts | 28 ----- app/utils/linguistToShiki.ts | 16 --- app/utils/preMd.ts | 9 -- package.json | 6 +- pnpm-lock.yaml | 3 + scripts/generate-api-reference.ts | 56 +++++++++- 10 files changed, 255 insertions(+), 66 deletions(-) create mode 100644 app/components/ApiCodeSamples.vue create mode 100644 app/components/ApiResponseExample.vue delete mode 100644 app/utils/codeSamplesMd.ts delete mode 100644 app/utils/linguistToShiki.ts delete mode 100644 app/utils/preMd.ts diff --git a/app/components/ApiCodeSamples.vue b/app/components/ApiCodeSamples.vue new file mode 100644 index 00000000..9a1eafff --- /dev/null +++ b/app/components/ApiCodeSamples.vue @@ -0,0 +1,141 @@ + + + + + + + diff --git a/app/components/ApiEndpoint.vue b/app/components/ApiEndpoint.vue index 60487fc0..4483cccf 100644 --- a/app/components/ApiEndpoint.vue +++ b/app/components/ApiEndpoint.vue @@ -134,15 +134,15 @@ const statusCodeDescriptions: StatusCodeDescriptions = {
- -
diff --git a/app/components/ApiResponseExample.vue b/app/components/ApiResponseExample.vue new file mode 100644 index 00000000..0f7fab0a --- /dev/null +++ b/app/components/ApiResponseExample.vue @@ -0,0 +1,40 @@ + + + + + diff --git a/app/types.ts b/app/types.ts index 31916965..cb04aac1 100644 --- a/app/types.ts +++ b/app/types.ts @@ -10,6 +10,8 @@ export interface ApiReferenceCodeSample { label: string; lang: string; source: string; + // Shiki-highlighted HTML for `source`, precomputed at build time. + html: string; } export interface ApiReferenceParameter { @@ -40,6 +42,10 @@ export interface ApiReferenceOperation { requestBody: ApiReferenceRequestBody | null; responses: ApiReferenceResponse[]; responseExample: unknown | null; + // Shiki-highlighted HTML for the response example, precomputed at build time. + responseExampleHtml: string | null; + // Shiki-highlighted HTML for the auto-generated REST sample (`METHOD /path`). + restSampleHtml: string; 'x-codeSamples'?: ApiReferenceCodeSample[]; } diff --git a/app/utils/codeSamplesMd.ts b/app/utils/codeSamplesMd.ts deleted file mode 100644 index 708efd9c..00000000 --- a/app/utils/codeSamplesMd.ts +++ /dev/null @@ -1,28 +0,0 @@ -import preMd from '~/utils/preMd'; -import type { ApiReferenceCodeSample } from '~/types'; - -/** - * Convert our @directus/openapi x-codeSamples to valid markdown codeblocks. - * This allows them to be rendered with MDC - */ -export default function codeSamplesMd(operation: { method: string; path: string; 'x-codeSamples'?: ApiReferenceCodeSample[] }) { - let md = '::code-group{sync="api-consumer"}'; - - const { method, path } = operation; - - md += ` -\`\`\`http [REST] -${method.toUpperCase()} ${path} -\`\`\` -`; - - const samples = operation['x-codeSamples'] ?? []; - - for (const { lang, source, label } of samples) { - md += preMd(lang, label, source); - } - - md += '::'; - - return md; -} diff --git a/app/utils/linguistToShiki.ts b/app/utils/linguistToShiki.ts deleted file mode 100644 index f12ea79e..00000000 --- a/app/utils/linguistToShiki.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Transform GitHub's Linguist language name to a valid shiki highlighter name. - * - * @directus/openapi uses the redocly spec for x-codeSamples, which uses linguist. This util can be used to - * render code blocks with highlighting from those names - * - * @see https://github.com/github-linguist/linguist/blob/main/lib/linguist/popular.yml - * @see https://github.com/shikijs/textmate-grammars-themes/tree/main/packages/tm-grammars/grammars - */ -export default function linguistToShiki(linguistLang: string): string { - switch (linguistLang) { - case 'JavaScript': return 'js'; - case 'GraphQL': return 'graphql'; - default: return linguistLang; - } -} diff --git a/app/utils/preMd.ts b/app/utils/preMd.ts deleted file mode 100644 index 830b25e0..00000000 --- a/app/utils/preMd.ts +++ /dev/null @@ -1,9 +0,0 @@ -import linguistToShiki from '~/utils/linguistToShiki'; - -export default function (lang: string, label: string, source: unknown) { - return ` -\`\`\`${linguistToShiki(lang)} [${label}] -${typeof source === 'string' ? source : JSON.stringify(source, null, 2)} -\`\`\` -`; -} diff --git a/package.json b/package.json index 542c5903..fdda7476 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "posthog-js": "1.371.2", "posthog-node": "5.29.7", "sharp": "^0.34.5", + "shiki": "4.0.2", "tailwindcss": "^4.2.4", "typesense": "^3.0.6", "ufo": "1.6.3", @@ -71,7 +72,10 @@ "overrides": { "vite": "npm:rolldown-vite@latest", "h3": "1.15.11" - } + }, + "onlyBuiltDependencies": [ + "better-sqlite3" + ] }, "packageManager": "pnpm@10.29.2+sha512.bef43fa759d91fd2da4b319a5a0d13ef7a45bb985a3d7342058470f9d2051a3ba8674e629672654686ef9443ad13a82da2beb9eeb3e0221c87b8154fff9d74b8", "engines": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10b72eb1..9838c95e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,9 @@ importers: sharp: specifier: ^0.34.5 version: 0.34.5 + shiki: + specifier: 4.0.2 + version: 4.0.2 tailwindcss: specifier: ^4.2.4 version: 4.2.4 diff --git a/scripts/generate-api-reference.ts b/scripts/generate-api-reference.ts index 8c6ef852..380569d2 100644 --- a/scripts/generate-api-reference.ts +++ b/scripts/generate-api-reference.ts @@ -1,9 +1,10 @@ -import { existsSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { spec as openapi } from '@directus/openapi'; import { get } from 'lodash-es'; +import { createHighlighter } from 'shiki'; import type { OpenAPIObject, OperationObject, @@ -27,6 +28,43 @@ const root = join(dirname(fileURLToPath(import.meta.url)), '..'); const outputDir = join(root, 'app/generated/api-reference'); const tagsDir = join(outputDir, 'tags'); +// Reuse the same Directus themes @nuxt/content uses for the rest of the docs. +const directusLight = JSON.parse(readFileSync(join(root, 'app/assets/shiki/directus-light.json'), 'utf8')); +const directusDark = JSON.parse(readFileSync(join(root, 'app/assets/shiki/directus-dark.json'), 'utf8')); + +// Linguist-style labels from `x-codeSamples` mapped to Shiki language ids. +const langAliases: Record = { + javascript: 'js', + js: 'js', + graphql: 'graphql', + http: 'http', + json: 'json', +}; + +const shikiLangs = [...new Set(Object.values(langAliases))]; + +function toShikiLang(lang: string): string { + return langAliases[lang.toLowerCase()] ?? 'text'; +} + +const highlighter = await createHighlighter({ + themes: [directusLight, directusDark], + langs: shikiLangs, +}); + +const lightTheme = directusLight.name; +const darkTheme = directusDark.name; + +// Render code to dual-theme HTML once at build time. CSS vars (--shiki-dark*) +// let the components swap colors for dark mode without re-highlighting. +function highlight(code: string, lang: string): string { + return highlighter.codeToHtml(code, { + lang: toShikiLang(lang), + themes: { light: lightTheme, dark: darkTheme }, + defaultColor: false, + }); +} + const methods: (keyof PathItemObject)[] = [ 'get', 'put', @@ -209,7 +247,15 @@ function responseExample(operation: OperationObject): unknown | null { } function apiOperation(path: string, method: keyof PathItemObject, operation: OperationObject): ApiReferenceOperation { - const codeSamples = (operation as OperationObject & { 'x-codeSamples'?: ApiReferenceCodeSample[] })['x-codeSamples']; + type RawCodeSample = Omit; + const codeSamples = (operation as OperationObject & { 'x-codeSamples'?: RawCodeSample[] })['x-codeSamples']; + const highlightedSamples = codeSamples?.map(sample => ({ ...sample, html: highlight(sample.source, sample.lang) })); + + const restSource = `${method.toUpperCase()} ${path}`; + const example = responseExample(operation); + const responseExampleHtml = example === null + ? null + : highlight(typeof example === 'string' ? example : JSON.stringify(example, null, 2), 'json'); return { method, @@ -222,8 +268,10 @@ function apiOperation(path: string, method: keyof PathItemObject, operation: Ope .map(apiParameter), requestBody: requestBodyData(operation), responses: responseData(operation), - responseExample: responseExample(operation), - ...(codeSamples?.length ? { 'x-codeSamples': codeSamples } : {}), + responseExample: example, + responseExampleHtml, + restSampleHtml: highlight(restSource, 'http'), + ...(highlightedSamples?.length ? { 'x-codeSamples': highlightedSamples } : {}), }; } From 68cbe1553f3e63651ee4754c104650ae7310ca29 Mon Sep 17 00:00:00 2001 From: bryantgillespie Date: Tue, 2 Jun 2026 11:55:25 -0400 Subject: [PATCH 2/3] Fix SSR hydration mismatches in logo and home panel LogoDocs bound :class to useColorMode().preference, which resolves differently on server vs client. No CSS targeted those classes, so remove it. HomePersonalized rendered localStorage-backed state (recents, favorites, instance URL) during setup, diverging from SSR output. Gate that UI behind a mounted flag so server and first client render share the stable default branch. --- app/components/HomePersonalized.vue | 25 ++++++++++++++++++------- app/components/LogoDocs.vue | 5 ----- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/app/components/HomePersonalized.vue b/app/components/HomePersonalized.vue index 93604da0..3f3f8f4e 100644 --- a/app/components/HomePersonalized.vue +++ b/app/components/HomePersonalized.vue @@ -11,13 +11,21 @@ const { primary: primaryInstanceUrl } = useInstanceUrls(); const settings = useSettingsOverlay(); function openDrawer() { settings.open(); } +// localStorage-backed state (recents, favorites, instance URLs) is only +// populated on the client, so gate any UI that depends on it until after +// mount. Server and first client render share the stable default branch, +// avoiding hydration mismatches. +const mounted = ref(false); + const showInline = computed(() => onboardingState.value === 'active'); const showIdleCta = computed(() => onboardingState.value === 'idle'); -const resume = computed(() => recents.value[0] ?? null); -const restRecents = computed(() => recents.value.slice(1, 7)); +const resume = computed(() => mounted.value ? recents.value[0] ?? null : null); +const restRecents = computed(() => mounted.value ? recents.value.slice(1, 7) : []); +const favoritesList = computed(() => mounted.value ? favorites.value : []); const chips = computed(() => { + if (!mounted.value) return []; const out: { icon: string; label: string }[] = []; if (framework.value) out.push({ icon: framework.value.icon, label: framework.value.label }); if (experience.value) out.push({ icon: experience.value.icon, label: experience.value.label }); @@ -33,14 +41,17 @@ const chips = computed(() => { return out; }); +// Cookie-backed prefs only — SSR-safe. The instance URL is localStorage-backed +// and gated separately via the mounted flag. const hasAnyPref = computed(() => - Boolean(framework.value || useCase.value || deployment.value || role.value || experience.value || primaryInstanceUrl.value)); + Boolean(framework.value || useCase.value || deployment.value || role.value || experience.value)); const showPanel = computed(() => onboardingState.value === 'onboarded' - && (hasAnyPref.value || recents.value.length > 0 || favorites.value.length > 0)); + && (hasAnyPref.value || (mounted.value && (recents.value.length > 0 || favorites.value.length > 0)))); onMounted(() => { + mounted.value = true; touchCookie(); }); @@ -199,7 +210,7 @@ function relativeTime(ts?: number): string {
@@ -210,11 +221,11 @@ function relativeTime(ts?: number): string { Favorites
  • diff --git a/app/components/LogoDocs.vue b/app/components/LogoDocs.vue index e18c55a8..9c5f1d1b 100644 --- a/app/components/LogoDocs.vue +++ b/app/components/LogoDocs.vue @@ -1,7 +1,3 @@ - -