fix(site): store detail state in url

This commit is contained in:
Jakob Guddas
2026-09-18 23:50:08 +02:00
parent ba6751ac45
commit 15fc44e532
12 changed files with 183 additions and 356 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
import { PageData } from 'vitepress';
export default async function getStructuredData(iconName: string, pageData: PageData) {
const url = `https://lucide.dev/icons/${iconName}`;
const url = `https://lucide.dev/icons/${iconName}/details`;
return {
'@context': 'https://schema.org',
@@ -87,8 +87,8 @@ const Expand = createLucideIcon('Expand', expand);
@click="
go(
icon.externalLibrary
? `/icons/${icon.externalLibrary}/${icon.name}`
: `/icons/${icon.name}`,
? `/icons/${icon.externalLibrary}/${icon.name}/details`
: `/icons/${icon.name}/details`,
)
"
>
@@ -33,12 +33,21 @@ const deprecatedTitle = computed(() => {
});
});
const isDetailsPage = computed(() => {
const relativePath = page.value?.relativePath ?? '';
const name = props.icon.name;
if (props.icon.externalLibrary) {
return relativePath === `icons/${props.icon.externalLibrary}/${name}/details.md`;
}
return relativePath === `icons/${name}/details.md`;
});
const iconPath = computed(() => {
if (props.icon.externalLibrary) {
return `icons/${props.icon.externalLibrary}/${props.icon.name}`;
return `/icons/${props.icon.externalLibrary}/${props.icon.name}/details`;
}
return `icons/${props.icon.name}`;
return `/icons/${props.icon.name}/details`;
});
</script>
@@ -79,7 +88,7 @@ const iconPath = computed(() => {
<div class="group buttons">
<VPButton
v-if="!page?.relativePath?.startsWith?.(iconPath)"
v-if="!isDetailsPage"
:href="iconPath"
text="See in action"
@click="go(iconPath)"
@@ -34,9 +34,17 @@ const icon = computed(() => {
return createLucideIcon(props.name, props.iconNode);
});
const href = computed(() =>
props.externalLibrary ? `/icons/${props.externalLibrary}/${props.name}` : `/icons/${props.name}`,
);
const href = computed(() => {
if (props.overlayMode) {
return props.externalLibrary
? `/icons/${props.externalLibrary}/${props.name}`
: `/icons/${props.name}`;
}
return props.externalLibrary
? `/icons/${props.externalLibrary}/${props.name}/details`
: `/icons/${props.name}/details`;
});
async function navigateToIcon(event) {
if (event.shiftKey) {
@@ -55,24 +63,14 @@ async function navigateToIcon(event) {
if (props.overlayMode && showOverlay.value) {
event.preventDefault();
window.history.pushState(
{},
'',
props.externalLibrary
? `/icons/${props.externalLibrary}/${props.name}`
: `/icons/${props.name}`,
);
window.history.pushState({}, '', href.value);
emit(
'setActiveIcon',
props.externalLibrary ? `${props.externalLibrary}:${props.name}` : props.name,
);
} else {
event.preventDefault();
go(
props.externalLibrary
? `/icons/${props.externalLibrary}/${props.name}`
: `/icons/${props.name}`,
);
go(href.value);
}
}
@@ -137,9 +137,28 @@ useScrollToCategory({
searchQueryDebounced,
});
function onPopState() {
if (typeof window === 'undefined') return;
const pathname = window.location.pathname;
const match = pathname.match(/^\/icons\/(?:(lab)\/)?([^/]+)\/?$/);
if (match) {
const [, lib, name] = match;
if (name && name !== 'categories' && name !== 'lab') {
activeIconName.value = lib ? `${lib}:${name}` : name;
return;
}
}
activeIconName.value = null;
}
onMounted(() => {
containerProps.ref.value = document.documentElement;
useEventListener(window, 'scroll', containerProps.onScroll);
useEventListener(window, 'popstate', onPopState);
if (!activeIconName.value && typeof window !== 'undefined') {
onPopState();
}
});
function loadSearchMetadata() {
@@ -46,11 +46,21 @@ const initialGridItems = computed(() => {
const props = defineProps<{
icons: IconEntity[];
activeIcon?: string | null;
}>();
const activeIconName = ref(null);
const activeIconName = ref<string | null>(props.activeIcon ?? null);
const selectedSort = ref(SORTING[0])
watch(
() => props.activeIcon,
(newVal) => {
if (newVal !== undefined) {
activeIconName.value = newVal;
}
},
);
const { execute: fetchTags, data: tags, isFetching: isFetchingTags } = useFetchTags();
const {
execute: fetchCategories,
@@ -132,9 +142,28 @@ const { list, containerProps, wrapperProps, scrollTo } = useVirtualList(chunkedI
overscan: 10,
});
function onPopState() {
if (typeof window === 'undefined') return;
const pathname = window.location.pathname;
const match = pathname.match(/^\/icons\/(?:(lab)\/)?([^/]+)\/?$/);
if (match) {
const [, lib, name] = match;
if (name && name !== 'categories' && name !== 'lab') {
activeIconName.value = lib ? `${lib}:${name}` : name;
return;
}
}
activeIconName.value = null;
}
onMounted(() => {
containerProps.ref.value = document.documentElement;
useEventListener(window, 'scroll', containerProps.onScroll);
useEventListener(window, 'popstate', onPopState);
if (!activeIconName.value && typeof window !== 'undefined') {
onPopState();
}
// Check if we should focus the search input from URL parameter
const route = useRoute();
+42 -35
View File
@@ -10,47 +10,54 @@ export async function transformPageData(pageData: PageData) {
pageData.params?.name
) {
const iconName = pageData.params.name;
pageData.title = `${iconName} icon details`;
const isDetailsPage = pageData.relativePath.includes('details');
const taggedAs = pageData.params?.tags?.length
? `Tagged as: ${pageData.params.tags.join(', ')}.`
: '';
const categorizedIn = pageData.params?.category?.length
? `Categorized in: ${pageData.params.category.join(', ')}.`
: '';
if (isDetailsPage) {
pageData.title = `${iconName} icon details`;
pageData.description =
`Details and related icons for ${iconName} icon. ${taggedAs} ${categorizedIn}`.trim();
const taggedAs = pageData.params?.tags?.length
? `Tagged as: ${pageData.params.tags.join(', ')}.`
: '';
const categorizedIn = pageData.params?.category?.length
? `Categorized in: ${pageData.params.category.join(', ')}.`
: '';
const structuredData = await getStructuredData(iconName, pageData);
pageData.description =
`Details and related icons for ${iconName} icon. ${taggedAs} ${categorizedIn}`.trim();
const ogPath = await createIconOGImage(iconName, pageData.params?.tags || []);
const structuredData = await getStructuredData(iconName, pageData);
if (ogPath) {
const content = `https://lucide.dev${ogPath}`;
pageData.frontmatter.head.push(
[
'meta',
{
property: 'og:image',
content,
},
],
[
'meta',
{
property: 'twitter:image',
content,
},
],
);
const ogPath = await createIconOGImage(iconName, pageData.params?.tags || []);
if (ogPath) {
const content = `https://lucide.dev${ogPath}`;
pageData.frontmatter.head.push(
[
'meta',
{
property: 'og:image',
content,
},
],
[
'meta',
{
property: 'twitter:image',
content,
},
],
);
}
pageData.frontmatter.head.push([
'script',
{ type: 'application/ld+json' },
JSON.stringify(structuredData),
]);
} else {
pageData.title = `${iconName} icon`;
pageData.description = `Browse and customize the ${iconName} icon.`;
}
pageData.frontmatter.head.push([
'script',
{ type: 'application/ld+json' },
JSON.stringify(structuredData),
]);
}
if (pageData.relativePath.startsWith('guide/')) {
+14 -250
View File
@@ -1,260 +1,24 @@
---
layout: doc
footer: false
aside: false
editLink: false
next: false
prev: false
title: Icons
description: Browse all Lucide icons.
layout: page
outline: 2
outlineTitle: Categories
sidebar: true
---
<script setup>
import { computed } from 'vue'
import { useData } from 'vitepress'
import IconPreview from '~/.vitepress/theme/components/icons/IconPreview.vue'
import IconPreviewSmall from '~/.vitepress/theme/components/icons/IconPreviewSmall.vue'
import EditInStudioButton from '~/.vitepress/theme/components/icons/EditInStudioButton.vue'
import IconInfo from '~/.vitepress/theme/components/icons/IconInfo.vue'
import IconContributors from '~/.vitepress/theme/components/icons/IconContributors.vue'
import IconShowcase from '~/.vitepress/theme/components/icons/IconShowcase.vue'
import RelatedIcons from '~/.vitepress/theme/components/icons/RelatedIcons.vue'
import CodeGroup from '~/.vitepress/theme/components/base/CodeGroup.vue'
import Badge from '~/.vitepress/theme/components/base/Badge.vue'
import Label from '~/.vitepress/theme/components/base/Label.vue'
import PageTabs from '~/.vitepress/theme/components/base/PageTabs.vue'
import { data } from './codeExamples.data'
import { toCamelCase, toPascalCase } from '@lucide/shared'
import { satisfies } from 'semver'
import CarbonAdOverlay from '~/.vitepress/theme/components/icons/CarbonAdOverlay.vue';
import { data } from './icons.data.ts'
import IconsOverview from '~/.vitepress/theme/components/icons/IconsOverview.vue'
import PageContainer from '~/.vitepress/theme/components/PageContainer.vue'
import useIconsWithExternalLibs from '~/.vitepress/theme/composables/useIconsWithExternalLibs'
const { params } = useData()
const tabs = computed(() => data.codeExamples?.map(
(codeExample) => codeExample.title) ?? []
)
const codeExample = computed(() => data.codeExamples?.map(
(codeExample) => {
const pascalCaseName = toPascalCase( params.value.name)
const camelCaseName = toCamelCase(params.value.name)
return codeExample.code
.replace(/\$(?:<[^>]+>)*PascalCase/g, pascalCaseName)
.replace(/\$CamelCase/g, camelCaseName)
.replace(/\$Name/g, params.value.name)
}
).join('') ?? []
)
function releaseTagLink(version) {
const shouldAddV = satisfies(version, `<0.266.0`)
return `https://github.com/lucide-icons/lucide/releases/tag/${shouldAddV ? 'v' : ''}${version}`
}
const icons = useIconsWithExternalLibs(data.icons)
</script>
<div class="layout">
<div class="actions">
<EditInStudioButton :icon="params" />
</div>
<div class="iconPreviews">
<IconPreview
id="previewer"
:name="params.name"
:iconNode="params.iconNode"
class="preview"
/>
</div>
<div>
<div class="info">
<IconInfo :icon="params" />
<div class="meta">
<div
v-if="params.awaitingRelease"
class="version"
>
<Label>Created:</Label>
<Badge class="awaiting-release">Unreleased</Badge>
</div>
<div
v-else-if="params.createdRelease?.version"
class="version"
>
<Label>Created:</Label>
<Badge
:href="releaseTagLink(params.createdRelease.version)"
>
{{params.createdRelease.version}}
</Badge>
</div>
<div
v-if="!params.awaitingRelease && params.changedRelease?.version"
class="version"
>
<Label>Last changed:</Label>
<Badge
:href="releaseTagLink(params.changedRelease.version)"
>
{{params.changedRelease.version}}
</Badge>
</div>
<IconContributors :icon="params" class="contributors"/>
</div>
</div>
</div>
<div class="VPDoc content">
<PageContainer>
<IconsOverview :icons="icons" :activeIcon="params?.name" />
</PageContainer>
</div>
<PageTabs
:tabs="['More like this', 'Code examples', 'See in action']"
>
<template #tab-0>
<RelatedIcons
v-if="params.relatedIcons"
:icons="params.relatedIcons"
/>
</template>
<template #tab-1>
<CodeGroup
:groups="tabs"
groupName="icon-code-example"
class="code"
>
<div
class="blocks"
v-html="codeExample"
/>
</CodeGroup>
</template>
<template #tab-2>
<IconShowcase
:name="params.name"
:iconNode="params.iconNode"
/>
</template>
</PageTabs>
<CarbonAdOverlay :drawerOpen="false" />
<style scoped>
.preview {
grid-area: preview;
margin-bottom: 24px;
max-width: 240px;
width: 240px;
flex-shrink: 0;
}
.layout {
align-items: flex-start;
}
.actions {
text-align: right;
display: flex;
justify-content: flex-end;
margin: -12px -12px 24px 0;
}
.meta {
display: flex;
gap: 24px;
align-items: flex-start;
}
.info {
--tags-gradient-background: var(--vp-c-bg);
}
.icon-info {
text-align: center;
}
.version .label {
margin-top: 12px;
}
.badge {
margin: 12px 0;
}
.version, .contributors {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
margin-bottom: 0px;
justify-content: flex-start;
gap: 4px;
}
.version .awaiting-release {
color: var(--vp-c-danger-3);
}
.iconPreviews {
display: flex;
justify-content: center;
gap: 24px;
}
.smallPreview {
flex-shrink: 2;
flex-direction: column;
align-items: flex-start;
}
@media (min-width: 640px) {
.layout {
align-items: flex-start;
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
gap: 24px;
}
.preview {
margin: 0 auto;
}
.iconPreviews {
flex-direction: column;
justify-content: flex-start;
}
.actions {
position: absolute;
right: 0;
margin: 0;
}
.smallPreview {
flex-direction: row;
align-items: center;
}
}
</style>
<style>
.icon-page-sections h2.title {
text-align: center;
font-weight: 500;
margin-block-end: 64px;
padding-top: 32px;
}
.tab-list {
margin: 24px -24px 24px;
}
@media (min-width: 640px) {
.tab-list {
margin: 24px 0 24px;
}
.iconPreviews {
justify-content: flex-start;
}
}
</style>
+5 -16
View File
@@ -1,22 +1,11 @@
import relatedIcons from '../.vitepress/data/relatedIcons.json';
import iconNodes from '../.vitepress/data/iconNodes';
import * as iconDetails from '../.vitepress/data/iconDetails';
import { IconEntity } from '../.vitepress/theme/types';
export default {
paths: async () => {
return (Object.values(iconDetails) as unknown as IconEntity[]).map((iconEntity) => {
const params = {
...iconEntity,
relatedIcons: relatedIcons[iconEntity.name].map((name: string) => ({
name,
iconNode: iconNodes[name],
})),
};
return {
params,
};
});
return Object.keys(iconNodes).map((name) => ({
params: {
name,
},
}));
},
};
+22 -7
View File
@@ -1,10 +1,25 @@
---
layout: doc
footer: false
aside: false
editLink: false
next: false
prev: false
title: Icons
description: Browse all Lucide icons.
layout: page
outline: 2
outlineTitle: Categories
sidebar: true
---
<!--@include: ../[name].md -->
<script setup>
import { useData } from 'vitepress'
import { data } from '../icons.data.ts'
import IconsOverview from '~/.vitepress/theme/components/icons/IconsOverview.vue'
import PageContainer from '~/.vitepress/theme/components/PageContainer.vue'
import useIconsWithExternalLibs from '~/.vitepress/theme/composables/useIconsWithExternalLibs'
const { params } = useData()
const icons = useIconsWithExternalLibs(data.icons)
</script>
<div class="VPDoc content">
<PageContainer>
<IconsOverview :icons="icons" :activeIcon="params?.name ? `lab:${params.name}` : undefined" />
</PageContainer>
</div>
+4 -18
View File
@@ -1,25 +1,11 @@
import iconNodes from '../../.vitepress/data/lab/iconNodes';
import iconPopularity from '../../.vitepress/data/lab/iconPopularity';
import iconMetaData from '../../.vitepress/data/lab/iconMetaData';
export default {
paths: async () => {
return Object.entries(iconNodes).map(([name, iconNode]) => {
const iconDetails = iconMetaData[name];
delete iconDetails['$schema'];
const params = {
return Object.keys(iconNodes).map((name) => ({
params: {
name,
iconNode,
externalLibrary: 'lab',
popularity: iconPopularity[name]?.count ?? 0,
...iconDetails,
};
return {
params,
};
});
},
}));
},
};
+19 -8
View File
@@ -10,16 +10,27 @@ const iconMetaData = await getIconMetaData(path.resolve(scriptDir, '../../icons'
const iconAliasesRedirectRoutes = Object.entries(iconMetaData)
.filter(([, { aliases }]) => aliases?.length)
.map(([iconName, { aliases }]) => {
const aliasRouteMatches = aliases.map((alias) => typeof alias === 'string' ? alias : alias?.name).join('|');
.flatMap(([iconName, { aliases }]) => {
const aliasRouteMatches = aliases
.map((alias) => (typeof alias === 'string' ? alias : alias?.name))
.join('|');
return {
src: `^/icons/(${aliasRouteMatches})$`,
status: 308,
headers: {
Location: `/icons/${iconName}`,
return [
{
src: `^/icons/(${aliasRouteMatches})$`,
status: 308,
headers: {
Location: `/icons/${iconName}`,
},
},
};
{
src: `^/icons/(${aliasRouteMatches})/details$`,
status: 308,
headers: {
Location: `/icons/${iconName}/details`,
},
},
];
});
const vercelOutputJSON = path.resolve(currentDir, '.vercel/output/config.json');