diff --git a/module/src/module.ts b/module/src/module.ts index 1df0039f..2457092b 100644 --- a/module/src/module.ts +++ b/module/src/module.ts @@ -4,6 +4,7 @@ import { addServerHandler, createResolver, defineNuxtModule, + hasNuxtModule, installModule, useLogger, } from '@nuxt/kit' @@ -107,6 +108,18 @@ export default defineNuxtModule({ }) } + 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 = { robots: ['defineRobotMeta'], diff --git a/module/src/runtime/composables/polyfills.ts b/module/src/runtime/composables/polyfills.ts index 8c59def5..53e49727 100644 --- a/module/src/runtime/composables/polyfills.ts +++ b/module/src/runtime/composables/polyfills.ts @@ -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'), + } +} diff --git a/module/src/runtime/composables/useBreadcrumbItems.ts b/module/src/runtime/composables/useBreadcrumbItems.ts new file mode 100644 index 00000000..0e6d57ad --- /dev/null +++ b/module/src/runtime/composables/useBreadcrumbItems.ts @@ -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 + 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 + /** + * Should the root breadcrumb be shown. + */ + hideRoot?: MaybeRefOrGetter +} + +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, 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) +}