Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions app/components/ApiCodeSamples.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
<script setup lang="ts">
import { useClipboard } from '@vueuse/core';
import { getLibraryByLabel, getLibraryByValue, getSampleOptionByLabel } from '~/utils/libraries';
import type { ApiReferenceCodeSample } from '~/types';

const props = defineProps<{
method: string;
path: string;
// Shiki-highlighted HTML for the auto-generated REST sample, precomputed at build time.
restHtml: string;
samples?: ApiReferenceCodeSample[];
}>();

const { library, setLibrary } = useUserPreferences();

interface Sample {
label: string;
html: string;
source: string;
icon?: string;
libraryValue?: string;
}

const samples = computed<Sample[]>(() => {
const customSamples = props.samples ?? [];
const result: Sample[] = [];

const sdk = customSamples.find(s => getLibraryByLabel(s.label)?.label === 'SDK');
if (sdk) {
const lib = getLibraryByLabel(sdk.label);
result.push({ label: sdk.label, html: sdk.html, source: sdk.source, icon: lib?.icon, libraryValue: lib?.value });
}

const restLib = getLibraryByLabel('REST');
result.push({
label: 'REST',
html: props.restHtml,
source: `${props.method.toUpperCase()} ${props.path}`,
icon: restLib?.icon,
libraryValue: restLib?.value,
});

for (const sample of customSamples) {
if (sample === sdk) continue;
const lib = getSampleOptionByLabel(sample.label);
result.push({ label: sample.label, html: sample.html, source: sample.source, icon: lib?.icon, libraryValue: lib?.value });
}

return result;
});

const items = computed(() =>
samples.value.map(s => ({
label: s.label,
value: s.libraryValue ?? s.label,
icon: s.icon,
})),
);

const fallbackValue = computed(() => items.value[0]?.value ?? '');

const localActive = ref(fallbackValue.value);

const active = computed<string>({
get: () => {
const current = library.value;
if (current && items.value.some(i => i.value === current)) return current;
if (items.value.some(i => i.value === localActive.value)) return localActive.value;
return fallbackValue.value;
},
set: (value: string) => {
localActive.value = value;
const lib = getLibraryByValue(value);
if (lib) setLibrary(lib.value);
},
});

const activeSample = computed(() =>
samples.value.find(s => (s.libraryValue ?? s.label) === active.value),
);

const { copy, copied } = useClipboard({ copiedDuring: 2000 });
</script>

<template>
<div class="dark border border-default rounded-md overflow-hidden bg-default">
<div class="flex items-center justify-between gap-2 pr-2 border-b border-default bg-elevated">
<UTabs
v-model="active"
:items="items"
variant="link"
color="primary"
size="sm"
:content="false"
class="min-w-0 grow"
:ui="{ list: 'border-b-0 px-3', trigger: 'text-xs' }"
/>
<UButton
:icon="copied ? 'i-material-symbols-check' : 'i-material-symbols-content-copy-outline'"
color="neutral"
variant="ghost"
size="xs"
:aria-label="copied ? 'Copied' : 'Copy code'"
class="shrink-0"
@click="copy(activeSample?.source ?? '')"
/>
</div>
<template v-for="sample of samples" :key="sample.label">
<div
v-show="active === (sample.libraryValue ?? sample.label)"
class="api-request-pre"
v-html="sample.html"
/>
</template>
</div>
</template>

<style>
/* The code region scrolls on its own if content can't fit, so an overflowing
sample never widens the panel, pushes the copy button away, or grows the page
taller than the viewport. The background lives here (not on the pre) so it
fills the whole scroll area, including space past the wrapped content. */
.api-request-pre {
max-width: 100%;
max-height: 400px;
overflow: auto;
background-color: var(--ui-bg-muted);
scrollbar-width: thin;
scrollbar-color: var(--ui-border-accented) transparent;
}

.api-request-pre::-webkit-scrollbar {
width: 8px;
height: 8px;
}

.api-request-pre::-webkit-scrollbar-thumb {
background-color: var(--ui-border-accented);
border-radius: 9999px;
}

.api-request-pre::-webkit-scrollbar-track {
background: transparent;
}

.api-request-pre pre.shiki {
border: 0 !important;
border-radius: 0 !important;
margin: 0 !important;
padding: 0.75rem 1rem;
font-size: 0.75rem;
line-height: 1.5;
background-color: transparent !important;
/* Reflow long lines at whitespace so they wrap instead of scrolling, but
never split inside a token. A single token too wide to fit scrolls
horizontally (via the wrapper's overflow) rather than breaking mid-word. */
white-space: pre-wrap;
overflow-wrap: normal;
word-break: normal;
}

.api-request-pre pre.shiki span {
color: var(--shiki-light);
background-color: var(--shiki-light-bg);
}

.dark .api-request-pre pre.shiki span {
color: var(--shiki-dark);
background-color: var(--shiki-dark-bg);
}
</style>
19 changes: 10 additions & 9 deletions app/components/ApiEndpoint.vue
Original file line number Diff line number Diff line change
Expand Up @@ -133,16 +133,17 @@ const statusCodeDescriptions: StatusCodeDescriptions = {
</UTabs>
</div>
</div>
<div class="grow sticky top-16 w-full">
<MDC
v-if="operation['x-codeSamples']?.length"
:key="`code-samples-${operation.method}-${operation.path}`"
:value="codeSamplesMd(operation)"
<div class="grow min-w-0 sticky top-16 w-full">
<ApiCodeSamples
:method="operation.method"
:path="operation.path"
:rest-html="operation.restSampleHtml"
:samples="operation['x-codeSamples']"
/>
<MDC
v-if="operation.responseExample"
:key="`response-example-${operation.method}-${operation.path}`"
:value="preMd('json', 'Response Example', operation.responseExample)"
<ApiResponseExample
v-if="operation.responseExampleHtml"
:html="operation.responseExampleHtml"
:source="operation.responseExample"
/>
</div>
</UPageBody>
Expand Down
2 changes: 1 addition & 1 deletion app/components/ApiNav.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ defineProps<{
</script>

<template>
<nav class="grow full block pl-4 pr-6 py-3 rounded-md border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-sm/6 font-mono">
<nav class="grow full block pl-4 pr-6 py-3 rounded-md border border-default bg-muted text-muted text-sm/6 font-mono">
<ULink
v-for="methodObject of operations"
:key="methodObject.path"
Expand Down
94 changes: 94 additions & 0 deletions app/components/ApiResponseExample.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<script setup lang="ts">
import { useClipboard } from '@vueuse/core';

const props = defineProps<{
// Shiki-highlighted HTML for the response example, precomputed at build time.
html: string;
// Raw response example value, used for copy-to-clipboard.
source?: unknown;
}>();

const copyText = computed(() =>
typeof props.source === 'string' ? props.source : JSON.stringify(props.source, null, 2),
);

const { copy, copied } = useClipboard({ copiedDuring: 2000 });
</script>

<template>
<div class="border border-default rounded-md overflow-hidden bg-default mt-4">
<div class="flex items-center justify-between gap-2 pl-3 pr-2 py-1 border-b border-default bg-elevated">
<span class="font-mono text-xs text-muted">Response Example</span>
<UButton
:icon="copied ? 'i-material-symbols-check' : 'i-material-symbols-content-copy-outline'"
color="neutral"
variant="ghost"
size="xs"
:aria-label="copied ? 'Copied' : 'Copy response'"
class="shrink-0"
@click="copy(copyText)"
/>
</div>
<div
class="api-response-pre"
v-html="html"
/>
</div>
</template>

<style>
/* The code region scrolls on its own if content can't fit, so an overflowing
example never widens the panel, pushes the copy button away, or grows the page
taller than the viewport. The background lives here (not on the pre) so it
fills the whole scroll area, including space past the wrapped content. */
.api-response-pre {
max-width: 100%;
max-height: 400px;
overflow: auto;
background-color: var(--ui-bg-muted);
scrollbar-width: thin;
scrollbar-color: var(--ui-border-accented) transparent;
}

.api-response-pre::-webkit-scrollbar {
width: 8px;
height: 8px;
}

.api-response-pre::-webkit-scrollbar-thumb {
background-color: var(--ui-border-accented);
border-radius: 9999px;
}

.api-response-pre::-webkit-scrollbar-track {
background: transparent;
}

.api-response-pre pre.shiki {
border: 0 !important;
border-radius: 0 !important;
margin: 0 !important;
padding: 0.75rem 1rem;
font-size: 0.75rem;
line-height: 1.5;
background-color: transparent !important;
/* Reflow long lines at whitespace so they wrap instead of scrolling, but
never split inside a token. A single token too wide to fit scrolls
horizontally (via the wrapper's overflow) rather than breaking mid-word. */
white-space: pre-wrap;
overflow-wrap: normal;
word-break: normal;
}

/* Response uses the light syntax theme in light mode and the dark theme in
dark mode, so it reads as a distinct surface from the dark request panel. */
.api-response-pre pre.shiki span {
color: var(--shiki-light);
background-color: var(--shiki-light-bg);
}

.dark .api-response-pre pre.shiki span {
color: var(--shiki-dark);
background-color: var(--shiki-dark-bg);
}
</style>
25 changes: 18 additions & 7 deletions app/components/HomePersonalized.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -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();
});

Expand Down Expand Up @@ -199,7 +210,7 @@ function relativeTime(ts?: number): string {
</div>

<div
v-if="favorites.length || restRecents.length"
v-if="favoritesList.length || restRecents.length"
class="grid grid-cols-1 divide-y divide-default border-t border-default @min-[48rem]/docs-pane:grid-cols-2 @min-[48rem]/docs-pane:divide-x @min-[48rem]/docs-pane:divide-y-0"
>
<div class="px-6 pt-5 pb-6">
Expand All @@ -210,11 +221,11 @@ function relativeTime(ts?: number): string {
Favorites
</DocsEyebrow>
<ul
v-if="favorites.length"
v-if="favoritesList.length"
class="flex flex-col gap-0.5"
>
<li
v-for="f in favorites.slice(0, 8)"
v-for="f in favoritesList.slice(0, 8)"
:key="f.path"
class="group flex items-center gap-2"
>
Expand Down
5 changes: 0 additions & 5 deletions app/components/LogoDocs.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
<script setup lang="ts">
const { preference } = useColorMode();
</script>

<template>
<div class="relative h-7 shrink-0">
<!-- Mobile: compact mark+wordmark without "docs" -->
Expand All @@ -15,7 +11,6 @@ const { preference } = useColorMode();
</svg>
<!-- Desktop: full logo with "docs" -->
<svg
:class="preference"
viewBox="0 0 262 40"
xmlns="http://www.w3.org/2000/svg"
class="fill-current text-default dark:text-white h-full w-auto transition-opacity duration-300 opacity-0 sm:opacity-100 pointer-events-none sm:pointer-events-auto absolute sm:static inset-0"
Expand Down
2 changes: 1 addition & 1 deletion app/pages/api/[tag].vue
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ if (!apiReferencePage.value) {

<template>
<UPage>
<div class="lg:flex gap-10 items-center border-b border-gray-200 dark:border-gray-800 py-7">
<div class="lg:flex gap-10 items-center border-b border-default py-7">
<UPageHeader
:title="tag!.name"
:description="tag!.description"
Expand Down
Loading
Loading