Skip to content

Commit

Permalink
feat: useBreadcrumbItems()
Browse files Browse the repository at this point in the history
  • Loading branch information
harlan-zw committed Dec 21, 2023
1 parent eb3bc37 commit 1b5d87d
Show file tree
Hide file tree
Showing 3 changed files with 195 additions and 1 deletion.
13 changes: 13 additions & 0 deletions module/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
addServerHandler,
createResolver,
defineNuxtModule,
hasNuxtModule,
installModule,
useLogger,
} from '@nuxt/kit'
Expand Down Expand Up @@ -107,6 +108,18 @@ export default defineNuxtModule<ModuleOptions>({
})
}

if (!hasNuxtModule('@nuxtjs/i18n')) {
addImports({
from: resolve(`./runtime/composables/polyfills`),
name: 'useI18n',
})
}

addImports({
from: resolve(`./runtime/composables/useBreadcrumbItems`),
name: 'useBreadcrumbItems',
})

// if user disables certain modules we need to pollyfill the imports
const polyfills: Record<string, string[]> = {
robots: ['defineRobotMeta'],
Expand Down
15 changes: 14 additions & 1 deletion module/src/runtime/composables/polyfills.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,17 @@
export function defineRobotMeta() {}
import { ref } from 'vue'
import { useSiteConfig } from '#imports'

export function useSchemaOrg() {}
export function defineWebSite() {}
export function defineWebPage() {}

export function useI18n() {
const siteConfig = useSiteConfig()
return {
t: (_: string, fallback: string) => fallback,
te: (_: string) => false,
strategy: 'no_prefix',
defaultLocale: ref(siteConfig.defaultLocale || 'en'),
locale: ref(siteConfig.currentLocale || siteConfig.defaultLocale || 'en'),
}
}
168 changes: 168 additions & 0 deletions module/src/runtime/composables/useBreadcrumbItems.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { hasTrailingSlash, parseURL, stringifyParsedURL, withTrailingSlash, withoutTrailingSlash } from 'ufo'
import type { RouteMeta } from 'vue-router'
import type { MaybeRefOrGetter } from 'vue'
import type { BreadcrumbLink } from '@nuxt/ui/dist/runtime/types'
import {
computed,
defineBreadcrumb,
toValue,
useI18n,
useRoute,
useRouter,
useSchemaOrg,
withSiteTrailingSlash,
} from '#imports'

export interface BreadcrumbProps {
path?: MaybeRefOrGetter<string>
id?: string
schemaOrg?: boolean
/**
* The Aria Label for the breadcrumbs.
* You shouldn't need to change this.
*
* @default 'Breadcrumbs'
*/
ariaLabel?: string
/**
* Should the current breadcrumb item be shown.
*
* @default false
*/
hideCurrent?: MaybeRefOrGetter<boolean>
/**
* Should the root breadcrumb be shown.
*/
hideRoot?: MaybeRefOrGetter<boolean>
}

export interface BreadcrumbItemProps extends BreadcrumbLink {
/** Whether the breadcrumb item represents the aria-current. */
current?: boolean
/**
* The type of current location the breadcrumb item represents, if `isCurrent` is true.
* @default 'page'
*/
ariaCurrent?: 'page' | 'step' | 'location' | 'date' | 'time' | boolean | 'true' | 'false'
/** Whether the breadcrumb item is disabled. */
disabled?: boolean
to: string
ariaLabel?: string
separator?: boolean | string
icon?: string
class?: (string | string[] | undefined)[] | string
/**
* @internal
*/
_props?: {
first: boolean
last: boolean
}
}

function withoutQuery(path: string) {
return path.split('?')[0]
}

function titleCase(s: string) {
return s
.replaceAll('-', ' ')
.replace(/\w\S*/g, w => w.charAt(0).toUpperCase() + w.substr(1).toLowerCase())
}

export function useBreadcrumbItems(options: BreadcrumbProps = {}) {
const router = useRouter()
const routes = router.getRoutes()

const i18n = useI18n()
const items = computed(() => {
let rootNode = '/'
if (i18n) {
if (i18n.strategy === 'prefix' || (i18n.strategy !== 'no_prefix' && i18n.defaultLocale.value !== i18n.locale.value))
rootNode = `/${i18n.defaultLocale.value}`
}
const current = withoutQuery(withoutTrailingSlash(toValue(options.path || useRoute().path) || rootNode))
return pathBreadcrumbSegments(current, rootNode)
.reverse()
.map(path => ({
to: path,
}) as BreadcrumbItemProps)
.map((item) => {
const route = routes.find(r => withoutTrailingSlash(r.path) === withoutTrailingSlash(item.to))
const routeMeta = (route?.meta || {}) as RouteMeta & { title?: string, breadcrumbLabel: string }
const routeName = route ? String(route.name || route.path) : (item.to === '/' ? 'index' : 'unknown')
let [name] = routeName.split('___')
if (name === 'unknown')
name = item.to.split('/').pop() || '' // fallback to last path segment
// merge with the route meta
if (routeMeta.breadcrumb) {
item = {
...item,
...routeMeta.breadcrumb,
}
}
// allow opt-out of label normalise with `false` value
// @ts-expect-error untyped
item.label = item.label || routeMeta.breadcrumbTitle || routeMeta.title
if (typeof item.label === 'undefined') {
// try use i18n
// fetch from i18n
item.label = item.label || i18n.t(`breadcrumb.items.${name}.label`, name === 'index' ? 'Home' : titleCase(name), { missingWarn: false })
item.ariaLabel = item.ariaLabel || i18n.t(`breadcrumb.items.${name}.ariaLabel`, item.label, { missingWarn: false })
}
item.ariaLabel = item.ariaLabel || item.label
// mark the current based on the options
item.current = item.current || item.to === current
if (toValue(options.hideCurrent) && item.current)
return false
return item
})
.map((m) => {
if (m && m.to) {
m.to = withSiteTrailingSlash(m.to).value
if (m.to === rootNode && toValue(options.hideRoot))
return false
}
return m
})
.filter(Boolean) as BreadcrumbItemProps[]
})

if (process.server && options.schemaOrg) {
useSchemaOrg([
defineBreadcrumb(computed(() => {
return {
id: `#${options.id || 'breadcrumb'}`,
itemListElement: items.value.map(item => ({
name: item.label || item.ariaLabel,
item: item.to,
})),
}
})),
])
}
return items
}

export function pathBreadcrumbSegments(path: string, rootNode: string = '/') {
const startNode = parseURL(path)
const appendsTrailingSlash = hasTrailingSlash(startNode.pathname)

const stepNode = (node: ReturnType<typeof parseURL>, nodes: string[] = []) => {
const fullPath = stringifyParsedURL(node)
// the pathname will always be without the trailing slash
const currentPathName = node.pathname || '/'
// when we hit the root the path will be an empty string; we swap it out for a slash
nodes.push(fullPath || '/')
if (currentPathName !== rootNode) {
// strip the last path segment (/my/cool/path -> /my/cool)
node.pathname = currentPathName.substring(0, currentPathName.lastIndexOf('/'))
// if the input was provided with a trailing slash we need to honour that
if (appendsTrailingSlash)
node.pathname = withTrailingSlash(node.pathname.substring(0, node.pathname.lastIndexOf('/')))
stepNode(node, nodes)
}
return nodes
}
return stepNode(startNode)
}

0 comments on commit 1b5d87d

Please sign in to comment.