1.1.0: R2 storage, media preconnect/normalization, favicon + SEO structured data, x-default hreflang
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
type Crumb = {
|
||||
/** Visible name of the breadcrumb (e.g. 'Usługi'). */
|
||||
name: string
|
||||
/** Absolute URL of this crumb (e.g. 'https://example.com/pl/uslugi'). */
|
||||
url: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds BreadcrumbList JSON-LD (schema.org) for a page's position in the site
|
||||
* hierarchy. Google can show breadcrumbs in the result (Dom › Usługi › Detailing)
|
||||
* and uses them to understand structure — a signal that helps navigational
|
||||
* results and sitelinks.
|
||||
*
|
||||
* Unlike Organization/WebSite (site-wide, root layout), breadcrumbs are
|
||||
* PER-PAGE — build them from the page's ancestry and emit on that page:
|
||||
*
|
||||
* import { buildBreadcrumbJsonLd } from '@intecion/ipal-kit'
|
||||
* const jsonLd = buildBreadcrumbJsonLd([
|
||||
* { name: 'Strona główna', url: `${base}/pl` },
|
||||
* { name: 'Usługi', url: `${base}/pl/uslugi` },
|
||||
* { name: 'Detailing', url: `${base}/pl/uslugi/detailing` },
|
||||
* ])
|
||||
* <script type="application/ld+json" ... />
|
||||
*
|
||||
* The crumb data comes from the page's real position (parent pages / URL path),
|
||||
* NOT hardcoded. Derive it from the resolved route, not a static list.
|
||||
*
|
||||
* Returns null for an empty/single crumb list — a one-item breadcrumb isn't
|
||||
* meaningful and shouldn't be emitted.
|
||||
*/
|
||||
export function buildBreadcrumbJsonLd(crumbs: Crumb[]) {
|
||||
if (!crumbs || crumbs.length < 2) {return null}
|
||||
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: crumbs.map((crumb, index) => ({
|
||||
name: crumb.name,
|
||||
'@type': 'ListItem',
|
||||
item: crumb.url,
|
||||
position: index + 1,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
type NavItem = {
|
||||
/** Visible label (e.g. 'Usługi'). */
|
||||
name: string
|
||||
/** Absolute URL (e.g. 'https://example.com/pl/uslugi'). */
|
||||
url: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds SiteNavigationElement JSON-LD (schema.org) from the main navigation —
|
||||
* declares the site's primary nav as structured data. A weaker sitelinks signal
|
||||
* than WebSite/breadcrumbs, but cheap: it tells Google which pages are the main
|
||||
* navigation targets.
|
||||
*
|
||||
* Feed it the SAME nav items the header renders (from the panel/nav global), so
|
||||
* the structured data matches the visible menu — not a separate hardcoded list.
|
||||
*
|
||||
* import { buildSiteNavigationJsonLd } from '@intecion/ipal-kit'
|
||||
* const jsonLd = buildSiteNavigationJsonLd(
|
||||
* navItems.map(i => ({ name: i.label, url: `${base}${i.href}` }))
|
||||
* )
|
||||
* <script type="application/ld+json" ... />
|
||||
*
|
||||
* Emit once (site-wide, root layout). Data from the nav source, never hardcoded.
|
||||
* Returns null for empty nav.
|
||||
*/
|
||||
export function buildSiteNavigationJsonLd(items: NavItem[]) {
|
||||
if (!items || items.length === 0) {return null}
|
||||
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
itemListElement: items.map((item, index) => ({
|
||||
name: item.name,
|
||||
'@type': 'SiteNavigationElement',
|
||||
position: index + 1,
|
||||
url: item.url,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
type SearchActionConfig = {
|
||||
/**
|
||||
* URL template for site search, with {search_term_string} placeholder.
|
||||
* e.g. 'https://example.com/szukaj?q={search_term_string}'. Only include if
|
||||
* the site actually HAS a working search page — a SearchAction pointing at a
|
||||
* non-existent search does more harm than good.
|
||||
*/
|
||||
target: string
|
||||
}
|
||||
|
||||
type WebSiteJsonLdArgs = {
|
||||
/** Site name (from panel — siteName). */
|
||||
name: string
|
||||
/**
|
||||
* Optional site search. Enables the "sitelinks searchbox" — a search field
|
||||
* Google may show under the brand result. Only pass when a real search page
|
||||
* exists. Omit entirely otherwise.
|
||||
*/
|
||||
search?: SearchActionConfig
|
||||
/** Absolute site URL (https://…). */
|
||||
url: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds WebSite JSON-LD (schema.org). Two jobs:
|
||||
* - Declares the site + name (helps Google associate brand queries with the site).
|
||||
* - Optionally declares a SearchAction, which is what can produce the "sitelinks
|
||||
* searchbox" (a search field under the brand result in Google).
|
||||
*
|
||||
* IMPORTANT — sitelinks (the sub-links under a result) CANNOT be forced. No
|
||||
* schema guarantees them; Google generates them algorithmically from site
|
||||
* structure, internal links, clear titles, and ranking. This schema is a SIGNAL
|
||||
* that improves the odds and can enable the searchbox — not a switch. Manage
|
||||
* expectations accordingly (see docs/seo.md).
|
||||
*
|
||||
* Emit once in the ROOT layout (site-wide), from panel data:
|
||||
*
|
||||
* import { buildWebSiteJsonLd } from '@intecion/ipal-kit'
|
||||
* const jsonLd = buildWebSiteJsonLd({ name: settings.siteName, url: baseUrl })
|
||||
* <script type="application/ld+json"
|
||||
* dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||
*/
|
||||
export function buildWebSiteJsonLd({ name, search, url }: WebSiteJsonLdArgs) {
|
||||
return {
|
||||
name,
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
url,
|
||||
...(search
|
||||
? {
|
||||
potentialAction: {
|
||||
'@type': 'SearchAction',
|
||||
'query-input': 'required name=search_term_string',
|
||||
target: {
|
||||
'@type': 'EntryPoint',
|
||||
urlTemplate: search.target,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'
|
||||
|
||||
import { buildLocalizedPath, getLocaleCodes } from '../i18n/index.js'
|
||||
import { buildLocalizedPath, getDefaultLocale, getLocaleCodes } from '../i18n/index.js'
|
||||
|
||||
type BuildHreflangArgs = {
|
||||
/** Absolute site origin, e.g. 'https://example.com'. Omit for relative paths. */
|
||||
@@ -19,20 +19,28 @@ type BuildHreflangArgs = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a map of locale → URL for hreflang alternate links, suitable for
|
||||
* Next.js Metadata `alternates.languages`.
|
||||
*
|
||||
* Bridges SEO and i18n: for each configured locale that the document has a
|
||||
* slug in, it produces the locale-aware path (via buildLocalizedPath),
|
||||
* optionally prefixed with an absolute origin.
|
||||
*
|
||||
* Also emits `x-default` pointing at the default locale — the version Google
|
||||
* serves when the user's language/region matches no hreflang, and the fallback
|
||||
* when the root ('/') redirect is ambiguous (Googlebot with no/foreign
|
||||
* Accept-Language).
|
||||
*
|
||||
* @example
|
||||
* buildHreflangAlternates({
|
||||
* slugs: { pl: 'o-nas', en: 'about' },
|
||||
* config,
|
||||
* baseUrl: 'https://example.com',
|
||||
* })
|
||||
* // → { pl: 'https://example.com/pl/o-nas', en: 'https://example.com/en/about' }
|
||||
* // → {
|
||||
* // pl: 'https://example.com/pl/o-nas',
|
||||
* // en: 'https://example.com/en/about',
|
||||
* // 'x-default': 'https://example.com/pl/o-nas',
|
||||
* // }
|
||||
*/
|
||||
export function buildHreflangAlternates({
|
||||
baseUrl,
|
||||
@@ -51,5 +59,15 @@ export function buildHreflangAlternates({
|
||||
}
|
||||
}
|
||||
|
||||
// x-default: the version Google serves when the user's language/region doesn't
|
||||
// match any hreflang — and, crucially here, the fallback when the root ('/')
|
||||
// redirect is ambiguous (Googlebot with no/foreign Accept-Language). Point it
|
||||
// at the default locale (the primary market) so search shows that version by
|
||||
// default instead of guessing. Only set when the default locale has a URL.
|
||||
const defaultLocalePath = alternates[getDefaultLocale(config)]
|
||||
if (defaultLocalePath) {
|
||||
alternates['x-default'] = defaultLocalePath
|
||||
}
|
||||
|
||||
return alternates
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { buildAutoFillMetaHook } from './autoFillMeta.js'
|
||||
export type { AutoFillMapping } from './autoFillMeta.js'
|
||||
export { buildBreadcrumbJsonLd } from './buildBreadcrumbJsonLd.js'
|
||||
export { buildIconsMetadata } from './buildIconsMetadata.js'
|
||||
export { buildMetadata } from './buildMetadata.js'
|
||||
export type { PageMetadata } from './buildMetadata.js'
|
||||
@@ -8,6 +9,8 @@ export { buildRobots } from './buildRobots.js'
|
||||
export type { RobotsRules } from './buildRobots.js'
|
||||
export { buildSitemapEntries } from './buildSitemapEntries.js'
|
||||
export type { SitemapEntry } from './buildSitemapEntries.js'
|
||||
export { buildSiteNavigationJsonLd } from './buildSiteNavigationJsonLd.js'
|
||||
export { buildWebSiteJsonLd } from './buildWebSiteJsonLd.js'
|
||||
export { composeTitle } from './composeTitle.js'
|
||||
export type { TitleOrder } from './composeTitle.js'
|
||||
export { createMetadataGenerator } from './createMetadataGenerator.js'
|
||||
|
||||
Reference in New Issue
Block a user