diff --git a/src/components/overrides/Head.astro b/src/components/overrides/Head.astro index 0021e2de4a9e887..695c6142e67d782 100644 --- a/src/components/overrides/Head.astro +++ b/src/components/overrides/Head.astro @@ -5,40 +5,41 @@ import { differenceInCalendarDays } from "date-fns"; import "tippy.js/dist/tippy.css"; import { getEntry } from "astro:content"; +import { getOgImage } from "~/util/og"; +import type { CollectionEntry } from "astro:content"; // grab the current top-level folder. Remove . characters for 1.1.1.1 URL const currentSection = Astro.url.pathname.split("/")[1].replaceAll(".", ""); +const head = Astro.props.entry.data.head; if (currentSection) { const product = await getEntry("products", currentSection); if (product) { if (product.data.meta.title) { - const titleIdx = Astro.props.entry.data.head.findIndex( - (x) => x.tag === "title", - ); + const titleIdx = head.findIndex((x) => x.tag === "title"); let title: string; if (titleIdx !== -1) { - const existingTitle = Astro.props.entry.data.head[titleIdx].content; + const existingTitle = head[titleIdx].content; title = `${existingTitle} · ${product.data.meta.title}`; - Astro.props.entry.data.head[titleIdx] = { + head[titleIdx] = { tag: "title", attrs: {}, content: title, }; } else { title = `${Astro.props.entry.data.title} · ${product.data.meta.title}`; - Astro.props.entry.data.head.push({ + head.push({ tag: "title", attrs: {}, content: title, }); } - Astro.props.entry.data.head.push({ + head.push({ tag: "meta", attrs: { property: "og:title", content: title }, content: "", @@ -47,7 +48,7 @@ if (currentSection) { if (product.data.product.title) { const productName = product.data.product.title; - Astro.props.entry.data.head.push({ + head.push({ tag: "meta", attrs: { name: "pcx_product", @@ -55,7 +56,7 @@ if (currentSection) { }, content: "", }); - Astro.props.entry.data.head.push({ + head.push({ tag: "meta", attrs: { name: "algolia_product_filter", @@ -66,58 +67,7 @@ if (currentSection) { } if (product.data.product.group) { - const group = product.data.product.group.toLowerCase(); - - let ogImage = "https://developers.cloudflare.com/cf-twitter-card.png"; - - const images: Record = { - "cloudflare essentials": - "https://developers.cloudflare.com/core-services-preview.png", - "cloudflare one": "https://developers.cloudflare.com/zt-preview.png", - "developer platform": - "https://developers.cloudflare.com/dev-products-preview.png", - "network security": - "https://developers.cloudflare.com/core-services-preview.png", - "application performance": - "https://developers.cloudflare.com/core-services-preview.png", - "application security": - "https://developers.cloudflare.com/core-services-preview.png", - }; - - if (images[group]) { - ogImage = images[group]; - } - - const tags = [ - { - tag: "meta", - attrs: { - name: "image", - content: ogImage, - }, - content: "", - }, - { - tag: "meta", - attrs: { - name: "og:image", - content: ogImage, - }, - content: "", - }, - { - tag: "meta", - attrs: { - name: "twitter:image", - content: ogImage, - }, - content: "", - }, - ] as const; - - Astro.props.entry.data.head.push(...tags); - - Astro.props.entry.data.head.push({ + head.push({ tag: "meta", attrs: { name: "pcx_content_group", @@ -129,7 +79,7 @@ if (currentSection) { } if (currentSection === "style-guide") { - Astro.props.entry.data.head.push({ + head.push({ tag: "meta", attrs: { name: "robots", @@ -138,46 +88,12 @@ if (currentSection) { content: "", }); } - - if (currentSection === "changelog") { - let changelogImage = - "https://developers.cloudflare.com/changelog-preview.png"; - - const tags = [ - { - tag: "meta", - attrs: { - name: "image", - content: changelogImage, - }, - content: "", - }, - { - tag: "meta", - attrs: { - name: "og:image", - content: changelogImage, - }, - content: "", - }, - { - tag: "meta", - attrs: { - name: "twitter:image", - content: changelogImage, - }, - content: "", - }, - ] as const; - - Astro.props.entry.data.head.push(...tags); - } } // Add noindex tag if present in frontmatter if (Astro.props.entry.data.noindex) { - Astro.props.entry.data.head.push({ + head.push({ tag: "meta", attrs: { name: "robots", @@ -191,7 +107,7 @@ if (Astro.props.entry.data.noindex) { // content type if (Astro.props.entry.data.pcx_content_type) { const contentType = Astro.props.entry.data.pcx_content_type; - Astro.props.entry.data.head.push({ + head.push({ tag: "meta", attrs: { name: "pcx_content_type", @@ -199,7 +115,7 @@ if (Astro.props.entry.data.pcx_content_type) { }, content: "", }); - Astro.props.entry.data.head.push({ + head.push({ tag: "meta", attrs: { name: "algolia_content_type", @@ -212,7 +128,7 @@ if (Astro.props.entry.data.pcx_content_type) { // other products if (Astro.props.entry.data.products) { const additionalProducts = Astro.props.entry.data.products; - Astro.props.entry.data.head.push({ + head.push({ tag: "meta", attrs: { name: "pcx_additional_products", @@ -225,7 +141,7 @@ if (Astro.props.entry.data.products) { // other products if (Astro.props.entry.data.tags) { const pageTags = Astro.props.entry.data.tags; - Astro.props.entry.data.head.push({ + head.push({ tag: "meta", attrs: { name: "pcx_tags", @@ -240,7 +156,7 @@ if (Astro.props.entry.data.updated) { new Date(), Astro.props.entry.data.updated, ); - Astro.props.entry.data.head.push({ + head.push({ tag: "meta", attrs: { name: "pcx_last_reviewed", @@ -256,7 +172,7 @@ if (Astro.props.entry.data.pcx_content_type === "changelog") { const href = new URL(Astro.site ?? ""); href.pathname = Astro.props.entry.slug.concat("/index.xml"); - Astro.props.entry.data.head.push({ + head.push({ tag: "link", attrs: { rel: "alternate", @@ -268,7 +184,7 @@ if (Astro.props.entry.data.pcx_content_type === "changelog") { } if (Astro.props.entry.data.external_link) { - Astro.props.entry.data.head.push({ + head.push({ tag: "meta", attrs: { content: "noindex", @@ -276,7 +192,7 @@ if (Astro.props.entry.data.external_link) { }, content: "", }); - Astro.props.entry.data.head.push({ + head.push({ tag: "meta", attrs: { content: `0; url=${Astro.props.entry.data.external_link}`, @@ -285,6 +201,41 @@ if (Astro.props.entry.data.external_link) { content: "", }); } + +const ogImagePath = await getOgImage( + (Astro.props.originalEntry as CollectionEntry<"changelog">) ?? + Astro.props.entry, +); +const ogImageUrl = new URL(ogImagePath, Astro.url.origin).toString(); + +const ogImageTags = [ + { + tag: "meta", + attrs: { + name: "image", + content: ogImageUrl, + }, + content: "", + }, + { + tag: "meta", + attrs: { + name: "og:image", + content: ogImageUrl, + }, + content: "", + }, + { + tag: "meta", + attrs: { + name: "twitter:image", + content: ogImageUrl, + }, + content: "", + }, +] as const; + +head.push(...ogImageTags); --- diff --git a/src/content/docs/style-guide/frontmatter/custom-properties.mdx b/src/content/docs/style-guide/frontmatter/custom-properties.mdx new file mode 100644 index 000000000000000..c1534710d9286f7 --- /dev/null +++ b/src/content/docs/style-guide/frontmatter/custom-properties.mdx @@ -0,0 +1,17 @@ +--- +title: Custom properties +sidebar: + order: 4 +--- + +import { Type, MetaInfo } from "~/components"; + +We have added specific custom [frontmatter](/style-guide/frontmatter/) to meet specific needs. + +- `difficulty` : Difficulty is displayed as a column in the [ListTutorials component](/style-guide/components/list-tutorials/). +- `external_link` : Path to another page in our docs or elsewhere. Used to add a crosslink entry to the lefthand navigation sidebar. +- `pcx_content_type` : The purpose of the page, and defined through specific pages in [Content strategy](/style-guide/documentation-content-strategy/content-types/). +- `preview_image` : An `src` path to the image that you want to use as a custom preview image for social sharing. +- `products` : The names of related products, which show on some grids for Examples, [Tutorials](/style-guide/documentation-content-strategy/content-types/tutorial/), and [Reference Architectures](/style-guide/documentation-content-strategy/content-types/reference-architecture/) +- `tags` : A group of related keywords relating to the purpose of the page. +- `updated` : This is used to automatically add the [LastReviewed component](/style-guide/components/last-reviewed/). diff --git a/src/pages/changelog/[...slug].astro b/src/pages/changelog/[...slug].astro index 7f9211d33590388..2e2e1f1987c7744 100644 --- a/src/pages/changelog/[...slug].astro +++ b/src/pages/changelog/[...slug].astro @@ -36,6 +36,7 @@ const props = { headings, hideTitle: true, hideBreadcrumbs: true, + originalEntry: note, } as StarlightPageProps; --- diff --git a/src/schemas/base.ts b/src/schemas/base.ts index 3a31a6ce3829d11..50e0ba150a46757 100644 --- a/src/schemas/base.ts +++ b/src/schemas/base.ts @@ -1,5 +1,6 @@ import { z } from "astro:schema"; import { BadgeConfigSchema } from "./types/badge"; +import type { SchemaContext } from "astro:content"; const spotlightAuthorDetails = z .object({ @@ -12,95 +13,97 @@ const spotlightAuthorDetails = z "These are used to automatically add the SpotlightAuthorDetails component to the page. Refer to https://developers.cloudflare.com/style-guide/components/spotlight-author-details/.", ); -export const baseSchema = z.object({ - pcx_content_type: z - .union([ - z.literal("overview"), - z.literal("get-started"), - z.literal("how-to"), - z.literal("concept"), - z.literal("reference"), - z.literal("reference-architecture"), - z.literal("reference-architecture-diagram"), - z.literal("tutorial"), - z.literal("api"), - z.literal("troubleshooting"), - z.literal("faq"), - z.literal("integration-guide"), - z.literal("changelog"), - z.literal("configuration"), - z.literal("navigation"), - z.literal("example"), - z.literal("learning-unit"), - z.literal("design-guide"), - z.literal("video"), - ]) - .catch((ctx) => ctx.input) - .optional() - .describe( - "Refer to https://developers.cloudflare.com/style-guide/documentation-content-strategy/content-types/.", - ), - content_type: z.string().optional(), - tags: z.string().array().optional(), - external_link: z - .string() - .optional() - .describe( - "Links to this page (i.e sidebar, directory listing) will instead appear as the provided link.", - ), - difficulty: z - .union([ - z.literal("Beginner"), - z.literal("Intermediate"), - z.literal("Advanced"), - ]) - .catch((ctx) => ctx.input) - .optional() - .describe( - "Difficulty is displayed as a column in the ListTutorials component.", - ), - updated: z - .date() - .optional() - .describe( - "This is used to automatically add the LastReviewed component to a page. Refer to https://developers.cloudflare.com/style-guide/components/last-reviewed/.", - ), - spotlight: spotlightAuthorDetails, - release_notes_file_name: z.string().array().optional(), - release_notes_product_area_name: z.string().optional(), - products: z.string().array().optional(), - languages: z.string().array().optional(), - summary: z.string().optional(), - goal: z.string().array().optional(), - operation: z.string().array().optional(), - noindex: z.boolean().optional(), - sidebar: z - .object({ - order: z.number().optional(), - label: z.string().optional(), - group: z - .object({ - label: z - .string() - .optional() - .describe( - "Overrides the default 'Overview' label for index pages in the sidebar. Refer to https://developers.cloudflare.com/style-guide/frontmatter/sidebar/.", - ), - hideIndex: z - .boolean() - .default(false) - .describe( - "Hides the index page from the sidebar. Refer to https://developers.cloudflare.com/style-guide/frontmatter/sidebar/.", - ), - badge: BadgeConfigSchema(), - }) - .optional(), - }) - .optional(), - hideChildren: z - .boolean() - .optional() - .describe( - "Renders this group as a single link on the sidebar, to the index page. Refer to https://developers.cloudflare.com/style-guide/frontmatter/sidebar/.", - ), -}); +export const baseSchema = ({ image }: SchemaContext) => + z.object({ + preview_image: image().optional(), + pcx_content_type: z + .union([ + z.literal("overview"), + z.literal("get-started"), + z.literal("how-to"), + z.literal("concept"), + z.literal("reference"), + z.literal("reference-architecture"), + z.literal("reference-architecture-diagram"), + z.literal("tutorial"), + z.literal("api"), + z.literal("troubleshooting"), + z.literal("faq"), + z.literal("integration-guide"), + z.literal("changelog"), + z.literal("configuration"), + z.literal("navigation"), + z.literal("example"), + z.literal("learning-unit"), + z.literal("design-guide"), + z.literal("video"), + ]) + .catch((ctx) => ctx.input) + .optional() + .describe( + "Refer to https://developers.cloudflare.com/style-guide/documentation-content-strategy/content-types/.", + ), + content_type: z.string().optional(), + tags: z.string().array().optional(), + external_link: z + .string() + .optional() + .describe( + "Links to this page (i.e sidebar, directory listing) will instead appear as the provided link.", + ), + difficulty: z + .union([ + z.literal("Beginner"), + z.literal("Intermediate"), + z.literal("Advanced"), + ]) + .catch((ctx) => ctx.input) + .optional() + .describe( + "Difficulty is displayed as a column in the ListTutorials component.", + ), + updated: z + .date() + .optional() + .describe( + "This is used to automatically add the LastReviewed component to a page. Refer to https://developers.cloudflare.com/style-guide/components/last-reviewed/.", + ), + spotlight: spotlightAuthorDetails, + release_notes_file_name: z.string().array().optional(), + release_notes_product_area_name: z.string().optional(), + products: z.string().array().optional(), + languages: z.string().array().optional(), + summary: z.string().optional(), + goal: z.string().array().optional(), + operation: z.string().array().optional(), + noindex: z.boolean().optional(), + sidebar: z + .object({ + order: z.number().optional(), + label: z.string().optional(), + group: z + .object({ + label: z + .string() + .optional() + .describe( + "Overrides the default 'Overview' label for index pages in the sidebar. Refer to https://developers.cloudflare.com/style-guide/frontmatter/sidebar/.", + ), + hideIndex: z + .boolean() + .default(false) + .describe( + "Hides the index page from the sidebar. Refer to https://developers.cloudflare.com/style-guide/frontmatter/sidebar/.", + ), + badge: BadgeConfigSchema(), + }) + .optional(), + }) + .optional(), + hideChildren: z + .boolean() + .optional() + .describe( + "Renders this group as a single link on the sidebar, to the index page. Refer to https://developers.cloudflare.com/style-guide/frontmatter/sidebar/.", + ), + }); diff --git a/src/schemas/changelog.ts b/src/schemas/changelog.ts index 7c705e715b09bc2..a5f366aff1f9468 100644 --- a/src/schemas/changelog.ts +++ b/src/schemas/changelog.ts @@ -1,14 +1,16 @@ -import { reference } from "astro:content"; +import { reference, type SchemaContext } from "astro:content"; import { z } from "astro:schema"; -export const changelogSchema = z.object({ - title: z.string(), - description: z.string(), - date: z.coerce.date(), - products: z - .array(reference("products")) - .default([]) - .describe( - "An array of products to associate this changelog entry with. You may omit the product named after the folder this entry is in.", - ), -}); +export const changelogSchema = ({ image }: SchemaContext) => + z.object({ + title: z.string(), + description: z.string(), + date: z.coerce.date(), + products: z + .array(reference("products")) + .default([]) + .describe( + "An array of products to associate this changelog entry with. You may omit the product named after the folder this entry is in.", + ), + preview_image: image().optional(), + }); diff --git a/src/util/og.ts b/src/util/og.ts new file mode 100644 index 000000000000000..260d166b9fb7b0a --- /dev/null +++ b/src/util/og.ts @@ -0,0 +1,55 @@ +import { getImage } from "astro:assets"; +import { type CollectionEntry, getEntry } from "astro:content"; + +const DEFAULT_OG_IMAGE = "/cf-twitter-card.png"; + +const CHANGELOG_OG_IMAGE = "/changelog-preview.png"; + +const PRODUCT_AREA_OG_IMAGES: Record = { + "cloudflare essentials": "/core-services-preview.png", + "cloudflare one": "/zt-preview.png", + "developer platform": "/dev-products-preview.png", + "network security": "/core-services-preview.png", + "application performance": "/core-services-preview.png", + "application security": "/core-services-preview.png", +}; + +export async function getOgImage(entry: CollectionEntry<"docs" | "changelog">) { + if (entry.data.preview_image) { + if (!entry.data.preview_image.src) { + throw new Error( + `${entry.id} has a preview_image property in frontmatter that is not a valid image path`, + ); + } + + const image = await getImage({ + src: entry.data.preview_image, + format: "png", + }); + + return image.src; + } + + if (entry.collection === "changelog") { + return CHANGELOG_OG_IMAGE; + } + + const section = entry.id.split("/").filter(Boolean).at(0); + + if (!section) { + return DEFAULT_OG_IMAGE; + } + + const product = await getEntry("products", section); + + if (product && product.data.product.group) { + const image = + PRODUCT_AREA_OG_IMAGES[product.data.product.group.toLowerCase()]; + + if (image) { + return image; + } + } + + return DEFAULT_OG_IMAGE; +}