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
Binary file modified public/avatar.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions src/app/about/components/social-media.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@ export const SocialMedia = ({
icon: FaSquareXTwitter,
},
{
href: "https://t.me/khaykingleb_blog",
href: "https://t.me/blog_khaykingleb",
platform: "Telegram",
label: "@khaykingleb_blog",
label: "@blog_khaykingleb",
icon: FaTelegram,
},
{
Expand Down
16 changes: 8 additions & 8 deletions src/app/about/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ export default function AboutPage() {
<div className="mt-2 space-y-1 text-base text-pretty">
<ul className="list-disc space-y-1 space-x-0 pl-4">
<li>
Full-stack developer focused on MLOps/DevOps engineering,
passionate about building scalable systems and apps
ML platform engineer working on large-scale ML systems,
infrastructure, and DevOps
</li>
<li>
Currently working on the Model Shaping team at{" "}
Expand All @@ -71,10 +71,11 @@ export default function AboutPage() {
hover:underline hover:opacity-80
`}
>
Together AI
Together.ai
</a>
, building infrastructure and tooling for fine-tuning,
evaluation, and continual improvement of open-source LLMs
reinforcement learning, evaluation, quantization, and
distillation of open-source LLMs
</li>
<li>
Studied{" "}
Expand Down Expand Up @@ -112,11 +113,10 @@ export default function AboutPage() {
`}
>
National Research University &quot;Higher School of
Economics&quot;
Economics &quot;
</a>{" "}
— this dual background equips me with a comprehensive
understanding of both the business and technical aspects of
projects
which equips me with a comprehensive understanding of both
the business and technical aspects of projects
</li>
<li>
Passed{" "}
Expand Down
66 changes: 57 additions & 9 deletions src/app/blog/[slug]/components/notion-page.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,31 @@
import { unstable_cache } from "next/cache";
import { NotionAPI } from "notion-client";
import type { ExtendedRecordMap } from "notion-types";
import { getPageTableOfContents } from "notion-utils";

import NotionRendererClient from "@/app/blog/[slug]/components/notion-renderer"; // ← direct import
import NotionRendererClient from "@/app/blog/[slug]/components/notion-renderer";

/**
* Converts text to a URL-friendly slug.
*
* @param text - The text to convert.
* @returns A URL-friendly slug.
*/
const slugify = (text: string) =>
text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, "")
.replace(/[\s_-]+/g, "-")
.replace(/^-+|-+$/g, "");

/**
* Fetches a Notion page using the provided page ID.
*
* @param page_id - The ID of the Notion page to fetch.
* @returns The record map of the Notion page.
* @param pageId - The ID of the Notion page.
* @returns The Notion page record map.
*/
async function fetchNotionPage(page_id: string): Promise<ExtendedRecordMap> {
async function fetchNotionPage(pageId: string): Promise<ExtendedRecordMap> {
const notion = new NotionAPI({
// Hotfix: https://github.com/NotionX/react-notion-x/issues/659
// Thanks Notion for breaking changes :)
Expand All @@ -19,7 +34,6 @@ async function fetchNotionPage(page_id: string): Promise<ExtendedRecordMap> {
beforeRequest: [
(request, options) => {
const url = request.url.toString();

if (url.includes("/api/v3/syncRecordValues")) {
return new Request(
url.replace(
Expand All @@ -29,21 +43,49 @@ async function fetchNotionPage(page_id: string): Promise<ExtendedRecordMap> {
options,
);
}

return request;
},
],
},
},
});
const recordMap = await notion.getPage(page_id);
return recordMap;
return notion.getPage(pageId);
}

const getNotionPage = unstable_cache(fetchNotionPage, ["notionPage"], {
revalidate: 60,
});

/**
* Builds a mapping from block IDs to readable header slugs.
*
* @param recordMap - The Notion page record map.
* @returns A mapping from block IDs (with and without dashes) to header slugs.
*/
function buildHeaderSlugMap(
recordMap: ExtendedRecordMap,
): Record<string, string> {
const pageId = Object.keys(recordMap.block)[0];
const pageBlock = recordMap.block[pageId]?.value;

if (!pageBlock || pageBlock.type !== "page") {
return {};
}

const toc = getPageTableOfContents(pageBlock, recordMap);
const mapping: Record<string, string> = {};

for (const entry of toc) {
if (entry.text) {
const slug = slugify(entry.text);
mapping[entry.id] = slug;
mapping[entry.id.replace(/-/g, "")] = slug;
}
}

return mapping;
}

/**
* Renders a Notion page using the provided page ID.
*
Expand All @@ -52,5 +94,11 @@ const getNotionPage = unstable_cache(fetchNotionPage, ["notionPage"], {
*/
export default async function NotionPage({ page_id }: { page_id: string }) {
const recordMap = await getNotionPage(page_id);
return <NotionRendererClient recordMap={recordMap} />;

return (
<NotionRendererClient
recordMap={recordMap}
headerSlugMap={buildHeaderSlugMap(recordMap)}
/>
);
}
68 changes: 61 additions & 7 deletions src/app/blog/[slug]/components/notion-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import dynamic from "next/dynamic";
import type { ExtendedRecordMap } from "notion-types";
import { useEffect } from "react";
import { NotionRenderer } from "react-notion-x";

const Code = dynamic(() =>
Expand Down Expand Up @@ -34,28 +35,81 @@ const Equation = dynamic(() =>

const Pdf = dynamic(
() => import("react-notion-x/build/third-party/pdf").then((m) => m.Pdf),
{
ssr: false,
},
{ ssr: false },
);

/**
* Renders the Notion content using the NotionRenderer component.
* Rewrites Notion's block IDs to human-readable slugs in the DOM.
* Also handles initial scroll to hash anchor on page load.
*
* @param recordMap - The record map containing the Notion data.
* @param headerSlugMap - Mapping from block IDs to header slugs.
*/
function useHeaderSlugRewrites(headerSlugMap: Record<string, string>) {
useEffect(() => {
for (const [blockId, slug] of Object.entries(headerSlugMap)) {
const cleanId = blockId.replace(/-/g, "");

// Update anchor element ID
const anchor = document.getElementById(cleanId);
if (anchor?.classList.contains("notion-header-anchor")) {
anchor.id = slug;
}

// Update hash link href
document
.querySelector(`a.notion-hash-link[href="#${cleanId}"]`)
?.setAttribute("href", `#${slug}`);
}

// Update table of contents links
for (const item of document.querySelectorAll(
".notion-table-of-contents-item",
)) {
const href = item.getAttribute("href");
if (href?.startsWith("#")) {
const slug = headerSlugMap[href.substring(1)];
if (slug) item.setAttribute("href", `#${slug}`);
}
}

// Scroll to anchor after DOM updates (for direct link navigation)
const { hash } = window.location;
if (hash) {
setTimeout(() => {
document
.getElementById(hash.substring(1))
?.scrollIntoView({ behavior: "instant" });
}, 50);
}
}, [headerSlugMap]);
}

/**
* Renders Notion content with human-readable anchor slugs.
*
* @param recordMap - The Notion page record map.
* @param headerSlugMap - Mapping from block IDs to header slugs.
* @returns The rendered Notion content.
*/
export default function NotionRendererClient({
recordMap,
headerSlugMap,
}: {
recordMap: ExtendedRecordMap;
headerSlugMap: Record<string, string>;
}) {
useHeaderSlugRewrites(headerSlugMap);

return (
<NotionRenderer
recordMap={recordMap}
fullPage={true}
disableHeader={true}
fullPage
disableHeader
components={{ Code, Collection, Equation, Pdf }}
mapPageUrl={(pageId) => {
const cleanId = pageId.replace(/-/g, "");
return `#${headerSlugMap[pageId] ?? headerSlugMap[cleanId] ?? cleanId}`;
}}
/>
);
}
19 changes: 9 additions & 10 deletions src/app/styles/notion.css
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,21 @@

@reference "./global.css";

/* Light mode colors */
.notion-table-of-contents-item {
color: #5d5d5f !important;
}

/* Dark mode colors */
.notion {
@apply !bg-[hsl(var(--b1))] !text-[hsl(var(--bc))];
}

.notion-table-of-contents-item {
@apply dark:!text-[#b0b0b0];
.notion-table-of-contents-item,
.notion-table-of-contents-item-body,
.notion-table-of-contents a {
color: #5d5d5f !important;
opacity: 1 !important;
}

.notion-table-of-contents a {
@apply dark:text-[hsl(var(--bc))] dark:opacity-70;
:is([data-theme="dark"], .dark) .notion-table-of-contents-item,
:is([data-theme="dark"], .dark) .notion-table-of-contents-item-body,
:is([data-theme="dark"], .dark) .notion-table-of-contents a {
color: #b0b0b0 !important;
}

.notion-asset-caption {
Expand Down