Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,10 @@ jobs:
env:
NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}

- name: Audit blog author avatars
run: npm run authors:audit
env:
NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}

- name: Check bundle size
run: npm run bundle:check
151 changes: 138 additions & 13 deletions lib/notion-blog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export interface BlogContent {

type NotionProperties = PageObjectResponse["properties"];
type NotionProperty = NotionProperties[string];
type NotionPropertyEntry = { key: string; property: NotionProperty };

// =============================================================================
// Helper Functions for Property Extraction
Expand Down Expand Up @@ -164,6 +165,118 @@ function getPerson(property: NotionProperty | undefined): string | null {
return null;
}

function normalizePropertyKey(key: string): string {
return key.toLowerCase().replace(/[^a-z0-9]/g, "");
}

function findPropertyByExactNames(
properties: NotionProperties,
names: string[],
allowedTypes?: NotionProperty["type"][]
): NotionPropertyEntry | null {
const normalized = new Set(names.map(normalizePropertyKey));

for (const [key, property] of Object.entries(properties)) {
if (normalized.has(normalizePropertyKey(key))) {
if (!allowedTypes || allowedTypes.includes(property.type)) {
return { key, property };
}
}
}

return null;
}

function findPropertyByPattern(
properties: NotionProperties,
pattern: RegExp,
allowedTypes?: NotionProperty["type"][]
): NotionPropertyEntry | null {
for (const [key, property] of Object.entries(properties)) {
if (pattern.test(key)) {
if (!allowedTypes || allowedTypes.includes(property.type)) {
return { key, property };
}
}
}

return null;
}

function getAvatarUrlFromPeople(property: NotionProperty | undefined): string | null {
if (!property || property.type !== "people" || property.people.length === 0) {
return null;
}

const person = property.people[0] as { avatar_url?: string | null };
if (person.avatar_url && person.avatar_url.trim().length > 0) {
return person.avatar_url;
}

return null;
}

function getRichTextUrl(property: NotionProperty | undefined): string | null {
if (!property || property.type !== "rich_text" || property.rich_text.length === 0) {
return null;
}

for (const segment of property.rich_text) {
if (segment.href && /^https?:\/\//i.test(segment.href)) {
return segment.href;
}
if (/^https?:\/\//i.test(segment.plain_text)) {
return segment.plain_text;
}
}

return null;
}

function getAuthorPeopleProperty(properties: NotionProperties): NotionPropertyEntry | null {
return (
findPropertyByExactNames(properties, ["Author"], ["people"]) ||
findPropertyByPattern(properties, /author/i, ["people"])
);
}

function getAuthorImageFromProperties(
properties: NotionProperties
): { url: string; source: string } | null {
const exactMatch = findPropertyByExactNames(
properties,
["Author image", "Author Image", "Author Photo", "Author Avatar", "Profile Photo", "Profile Image"],
["files", "url", "rich_text"]
);

const fuzzyMatch =
exactMatch ||
findPropertyByPattern(
properties,
/(author|profile).*(image|photo|avatar)|(image|photo|avatar).*(author|profile)/i,
["files", "url", "rich_text"]
);

if (!fuzzyMatch) {
return null;
}

const { key, property } = fuzzyMatch;
const url =
getFiles(property) ||
getUrl(property) ||
getRichTextUrl(property);

if (!url) {
return null;
}

return {
url,
source: `${key} (${property.type})`,
};
}


// =============================================================================
// Category Mapping
Expand Down Expand Up @@ -314,7 +427,9 @@ async function transformNotionPageToBlogPost(
getRichText(props["topic"]) ||
getSelect(props["Category"]) ||
getSelect(props["category"]);
const authorPeopleProperty = getAuthorPeopleProperty(props);
const authorName =
getPerson(authorPeopleProperty?.property) ||
getPerson(props["Author"]) ||
getRichText(props["Author"]) ||
getSelect(props["Author"]);
Expand Down Expand Up @@ -348,18 +463,14 @@ async function transformNotionPageToBlogPost(
const readTime = getNumber(props["Read Time"]) || 5;
// Slug property (renamed from URL) - use rich text Slug as primary
const customSlug = getRichText(props["Slug"]) || getUrl(props["URL"]);
// Author photo from Notion files property
const authorImageUrl =
getFiles(props["Author image"]) ||
getFiles(props["Author Image"]) ||
getFiles(props["Author Photo"]);
if (!authorImageUrl && authorName) {
// Log available file-type properties to help diagnose missing author photos
const fileProps = Object.entries(props)
.filter(([, v]) => v.type === "files")
.map(([k, v]) => `${k}: ${(v as { files?: unknown[] }).files?.length ?? 0} file(s)`);
console.warn(`[author-photo] No image found for "${authorName}". File properties: [${fileProps.join(", ")}]`);
}
const explicitAuthorImage = getAuthorImageFromProperties(props);
const peopleAvatarUrl = getAvatarUrlFromPeople(authorPeopleProperty?.property);
const authorImageUrl = explicitAuthorImage?.url || peopleAvatarUrl || null;
const authorImageSource =
explicitAuthorImage?.source ||
(peopleAvatarUrl
? `${authorPeopleProperty?.key || "Author"} (people.avatar_url)`
: null);

// Cover image from Notion files property (primary) with fallbacks
const featuredImage =
Expand All @@ -382,10 +493,24 @@ async function transformNotionPageToBlogPost(
slug = generateSlug(title);
}

if (!authorImageUrl && authorName) {
// Log available candidate properties so missing photos can be fixed quickly in Notion.
const imageLikeProps = Object.entries(props)
.filter(([k]) => /(author|profile|image|photo|avatar)/i.test(k))
.map(([k, v]) => `${k} (${v.type})`);
console.warn(
`[author-photo] Missing author photo for slug="${slug}", author="${authorName}". Candidate properties: [${imageLikeProps.join(", ")}]`
);
}

// Map category and author
const category = mapCategory(categoryName);
const authorId = authorName?.toLowerCase().replace(/\s+/g, "-") || "procedure-team";
const cachedAuthorPhoto = await cacheAuthorPhoto(authorImageUrl, authorId);
const cachedAuthorPhoto = await cacheAuthorPhoto(authorImageUrl, authorId, {
authorName: authorName || undefined,
slug,
source: authorImageSource || undefined,
});
const author = mapAuthor(authorName, authorBio, authorTitle, cachedAuthorPhoto);

// Cache cover image to public folder (downloads from Notion and saves locally)
Expand Down
43 changes: 38 additions & 5 deletions lib/notion-image-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,17 @@ function isNotionUrl(url: string): boolean {
}

// Download and cache a single image, converting non-browser formats to JPEG
async function downloadImage(url: string, localPath: string): Promise<boolean> {
async function downloadImage(
url: string,
localPath: string,
context?: string
): Promise<boolean> {
try {
const response = await fetch(url);
if (!response.ok) {
console.warn(`Failed to download image: ${url} (${response.status})`);
console.warn(
`[image-cache] Failed to download image${context ? ` (${context})` : ""}: ${url} (${response.status})`
);
return false;
}

Expand Down Expand Up @@ -159,7 +165,10 @@ async function downloadImage(url: string, localPath: string): Promise<boolean> {
writeFileSync(localPath, converted);
return true;
} catch (error) {
console.warn(`Error downloading image ${url}:`, error);
console.warn(
`[image-cache] Error downloading image${context ? ` (${context})` : ""}: ${url}`,
error
);
return false;
}
}
Expand Down Expand Up @@ -321,7 +330,12 @@ export async function cacheBlogContentImage(
*/
export async function cacheAuthorPhoto(
url: string | null,
authorId: string
authorId: string,
context?: {
authorName?: string;
slug?: string;
source?: string;
}
): Promise<string | null> {
if (!url) return null;

Expand All @@ -346,7 +360,26 @@ export async function cacheAuthorPhoto(
return `/content/cache/authors/${legacyFilename}`;
}

const success = await downloadImage(url, localPath);
const contextLabel = [
context?.slug ? `slug=${context.slug}` : null,
context?.authorName ? `author=${context.authorName}` : null,
context?.source ? `source=${context.source}` : null,
]
.filter(Boolean)
.join(", ");

const success = await downloadImage(
url,
localPath,
contextLabel || `authorId=${authorId}`
);

if (!success) {
console.warn(
`[author-photo] Failed to cache author image (${contextLabel || `authorId=${authorId}`}) from URL: ${url}`
);
}

return success ? publicPath : null;
}

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"typecheck": "tsc --noEmit",
"test:e2e": "playwright test",
"seo:check": "node scripts/seo-check.mjs",
"authors:audit": "node scripts/audit-author-avatars.mjs",
"lighthouse": "lhci autorun",
"bundle:check": "node scripts/bundle-budget.mjs",
"bundle:analyze": "ANALYZE=true next build",
Expand Down
Loading
Loading