diff --git a/build/blog.ts b/build/blog.ts index 25aba298740c..1f70ec89141e 100644 --- a/build/blog.ts +++ b/build/blog.ts @@ -12,8 +12,11 @@ import { BLOG_ROOT, BUILD_OUT_ROOT, BASE_URL } from "../libs/env/index.js"; import { BlogPostData, BlogPostFrontmatter, - BlogPostFrontmatterLinks, - BlogPostLimitedFrontmatter, + BlogPostMetadataLinks, + BlogPostLimitedMetadata, + BlogPostMetadata, + AuthorFrontmatter, + AuthorMetadata, } from "../libs/types/blog.js"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore @@ -47,40 +50,82 @@ function calculateReadTime(copy: string): number { ); } -async function getLinks(slug: string): Promise { +async function getLinks(slug: string): Promise { const posts = await allPostFrontmatter(); const index = posts.findIndex((post) => post.slug === slug); const filterFrontmatter = ( f?: BlogPostFrontmatter - ): BlogPostLimitedFrontmatter => f && { title: f.title, slug: f.slug }; + ): BlogPostLimitedMetadata => f && { title: f.title, slug: f.slug }; return { previous: filterFrontmatter(posts[index + 1]), next: filterFrontmatter(posts[index - 1]), }; } +async function getAuthor( + slug: string | AuthorFrontmatter +): Promise { + if (typeof slug === "string") { + const filePath = path.join(BLOG_ROOT, "..", "authors", slug, "index.md"); + const attributes = await readAuthor(filePath); + return parseAuthor(slug, attributes); + } + return slug; +} + +async function readAuthor(filePath: string): Promise { + try { + const raw = await fs.readFile(filePath, "utf-8"); + const { attributes } = frontmatter(raw); + return attributes; + } catch (e: any) { + if (e.code === "ENOENT") { + console.warn("Couldn't find author metadata:", filePath); + return {}; + } + throw e; + } +} + +function parseAuthor( + slug: string, + { name, link, avatar }: AuthorFrontmatter +): AuthorMetadata { + return { + name, + link, + avatar_url: avatar + ? `/${DEFAULT_LOCALE}/blog/author/${slug}/${avatar}` + : undefined, + }; +} + async function readPost( file: string, options?: { readTime?: boolean; previousNext?: boolean; } -): Promise<{ blogMeta: BlogPostFrontmatter; body: string }> { +): Promise<{ blogMeta: BlogPostMetadata; body: string }> { const raw = await fs.readFile(file, "utf-8"); const { attributes, body } = frontmatter(raw); + const blogMeta: BlogPostMetadata = { + ...attributes, + author: await getAuthor(attributes.author), + }; const { readTime = true, previousNext = true } = options || {}; if (readTime) { - attributes.readTime = calculateReadTime(body); + blogMeta.readTime = calculateReadTime(body); } if (previousNext) { - attributes.links = await getLinks(attributes.slug); + blogMeta.links = await getLinks(blogMeta.slug); } - return { blogMeta: attributes, body }; + return { blogMeta, body }; } export function findPostPathBySlug(slug: string): string | null { @@ -145,6 +190,23 @@ async function allPostFiles(): Promise { return await api.withPromise(); } +async function allAuthorFiles(): Promise { + try { + return await new fdir() + .withFullPaths() + .withErrors() + .filter((filePath) => filePath.endsWith("index.md")) + .crawl(path.join(BLOG_ROOT, "..", "authors")) + .withPromise(); + } catch (e: any) { + if (e.code === "ENOENT") { + console.warn("Warning: blog authors directory doesn't exist"); + return []; + } + throw e; + } +} + export const allPostFrontmatter = memoize( async ({ includeUnpublished, @@ -396,3 +458,28 @@ export async function buildBlogFeed(options: { verbose?: boolean }) { console.log("Wrote", filePath); } } + +export async function buildAuthors(options: { verbose?: boolean }) { + for (const filePath of await allAuthorFiles()) { + const dirname = path.dirname(filePath); + const slug = path.basename(dirname); + const outPath = path.join( + BUILD_OUT_ROOT, + DEFAULT_LOCALE.toLowerCase(), + "blog", + "author", + slug + ); + await fs.mkdir(outPath, { recursive: true }); + + const { avatar } = await readAuthor(filePath); + if (avatar) { + const from = path.join(dirname, avatar); + const to = path.join(outPath, avatar); + await fs.copyFile(from, to); + if (options.verbose) { + console.log("Copied", from, "to", to); + } + } + } +} diff --git a/build/build-blog.ts b/build/build-blog.ts index b5a9d4cdc113..4369480c22fb 100644 --- a/build/build-blog.ts +++ b/build/build-blog.ts @@ -1,5 +1,11 @@ -import { buildBlogFeed, buildBlogIndex, buildBlogPosts } from "./blog.js"; +import { + buildAuthors, + buildBlogFeed, + buildBlogIndex, + buildBlogPosts, +} from "./blog.js"; await buildBlogIndex({ verbose: true }); await buildBlogPosts({ verbose: true }); +await buildAuthors({ verbose: true }); await buildBlogFeed({ verbose: true }); diff --git a/client/src/blog/index.scss b/client/src/blog/index.scss index d6a65db3e10d..e4d991f2883c 100644 --- a/client/src/blog/index.scss +++ b/client/src/blog/index.scss @@ -59,9 +59,10 @@ flex-direction: column; figure.blog-image { - margin-bottom: 0.5rem; + margin-bottom: 0; img { + margin-bottom: 0; max-height: 200px; width: auto; } @@ -71,13 +72,20 @@ font: var(--type-heading-h3); font-size: 1.75rem; font-weight: 400; - margin-top: var(--sponsored-height); + margin-bottom: 2.25rem; + margin-top: 1.5rem; } .sponsored ~ h2:first-of-type { margin-top: 0; } } + + p { + margin-bottom: 2.25rem; + margin-top: 1.5rem; + } + @media screen and (min-width: 50rem) { width: max(40vw, 20rem); } diff --git a/client/src/blog/index.tsx b/client/src/blog/index.tsx index 9b56da5a8a07..aa7896e1550e 100644 --- a/client/src/blog/index.tsx +++ b/client/src/blog/index.tsx @@ -6,7 +6,7 @@ import { WRITER_MODE } from "../env"; import { Route, Routes } from "react-router-dom"; import { HydrationData } from "../../../libs/types/hydration"; import { BlogPost, AuthorDateReadTime } from "./post"; -import { BlogImage, BlogPostFrontmatter } from "../../../libs/types/blog.js"; +import { BlogImage, BlogPostMetadata } from "../../../libs/types/blog.js"; import "./index.scss"; import "./post.scss"; @@ -14,7 +14,7 @@ import { Button } from "../ui/atoms/button"; import { SignUpSection as NewsletterSignUp } from "../newsletter"; interface BlogIndexData { - posts: BlogPostFrontmatter[]; + posts: BlogPostMetadata[]; } export function Blog(appProps: HydrationData) { @@ -46,7 +46,7 @@ export function BlogIndexImageFigure({ ); } -function PostPreview({ fm }: { fm: BlogPostFrontmatter }) { +function PostPreview({ fm }: { fm: BlogPostMetadata }) { return (
diff --git a/client/src/blog/post.scss b/client/src/blog/post.scss index 518c731ad36b..e0471f1abeac 100644 --- a/client/src/blog/post.scss +++ b/client/src/blog/post.scss @@ -10,8 +10,28 @@ } .blog-container { + .date-author, + .author { + --date-author-gap: 1rem; + align-items: center; + display: flex; + flex-wrap: wrap; + gap: var(--date-author-gap); + } + .author { font-weight: var(--font-body-strong-weight); + + &::after { + margin-left: calc(4px - var(--date-author-gap)); + } + + img { + border: none !important; + border-radius: 3rem; + height: 3rem; + margin: 0; + } } .read-time { diff --git a/client/src/blog/post.tsx b/client/src/blog/post.tsx index 8d1fd953ac80..767e7770f874 100644 --- a/client/src/blog/post.tsx +++ b/client/src/blog/post.tsx @@ -9,25 +9,33 @@ import "./post.scss"; import { BlogImage, BlogPostData, - BlogPostFrontmatter, - BlogPostFrontmatterLinks, - BlogPostLimitedFrontmatter, + BlogPostMetadata, + BlogPostMetadataLinks, + BlogPostLimitedMetadata, + AuthorMetadata, } from "../../../libs/types/blog"; import { useCopyExamplesToClipboard, useRunSample } from "../document/hooks"; import { DEFAULT_LOCALE } from "../../../libs/constants"; import { SignUpSection as NewsletterSignUp } from "../newsletter"; -function MaybeLink({ link, children }) { +function MaybeLink({ className = "", link, children }) { return link ? ( link.startsWith("https://") ? ( - + {children} ) : ( - {children} + + {children} + ) ) : ( - <>{children} + {children} ); } @@ -48,10 +56,14 @@ export function PublishDate({ date }: { date: string }) { ); } -export function Author({ metadata }: { metadata: BlogPostFrontmatter }) { +export function Author({ metadata }: { metadata: AuthorMetadata | undefined }) { return ( - - {metadata?.author?.name || "The MDN Team"} + + Author avatar + {metadata?.name || "The MDN Team"} ); } @@ -59,15 +71,14 @@ export function Author({ metadata }: { metadata: BlogPostFrontmatter }) { export function AuthorDateReadTime({ metadata, }: { - metadata: BlogPostFrontmatter; + metadata: BlogPostMetadata; }) { return ( - - -
- {" "} +
+ + - +
); } @@ -113,7 +124,7 @@ function BlogImageFigure({ function PreviousNext({ links: { previous, next }, }: { - links: BlogPostFrontmatterLinks; + links: BlogPostMetadataLinks; }) { return (
@@ -130,7 +141,7 @@ function PreviousNextLink({ metadata: { slug, title }, }: { direction: "Previous" | "Next"; - metadata: BlogPostLimitedFrontmatter; + metadata: BlogPostLimitedMetadata; }) { return ( { hyData?: T; doc?: any; - blogMeta?: BlogPostFrontmatter | null; + blogMeta?: BlogPostMetadata | null; pageNotFound?: boolean; pageTitle?: any; possibleLocales?: any; diff --git a/server/index.ts b/server/index.ts index 1342fa5ed887..508c152ae7c0 100644 --- a/server/index.ts +++ b/server/index.ts @@ -10,6 +10,7 @@ import { createProxyMiddleware } from "http-proxy-middleware"; import cookieParser from "cookie-parser"; import openEditor from "open-editor"; import { getBCDDataForPath } from "@mdn/bcd-utils-api"; +import sanitizeFilename from "sanitize-filename"; import { buildDocument, @@ -32,6 +33,7 @@ import { OFFLINE_CONTENT, CONTENT_ROOT, CONTENT_TRANSLATED_ROOT, + BLOG_ROOT, } from "../libs/env/index.js"; import documentRouter from "./document.js"; @@ -240,6 +242,19 @@ app.get("/:locale/blog/index.json", async (_, res) => { ); return res.json({ hyData: { posts } }); }); +app.get("/:locale/blog/author/:slug/:asset", async (req, res) => { + const { slug, asset } = req.params; + return send( + req, + path.resolve( + BLOG_ROOT, + "..", + "authors", + sanitizeFilename(slug), + sanitizeFilename(asset) + ) + ).pipe(res); +}); app.get("/:locale/blog/:slug/index.json", async (req, res) => { const { slug } = req.params; const data = await findPostBySlug(slug); @@ -269,7 +284,9 @@ app.get("/:locale/blog/:slug/:asset", async (req, res) => { const { slug, asset } = req.params; const p = findPostPathBySlug(slug); if (p) { - return send(req, path.resolve(path.join(p, asset))).pipe(res); + return send(req, path.resolve(path.join(p, sanitizeFilename(asset)))).pipe( + res + ); } return res.status(404).send("Nothing here 🤷‍♂️"); });