From e8d9a761d687354a0bbf68ce4f70ed83a67146df Mon Sep 17 00:00:00 2001 From: Arik Chakma Date: Mon, 18 Aug 2025 13:07:42 +0600 Subject: [PATCH] wip --- src/components/Guide/GuideContent.astro | 72 --- src/components/Guide/GuideContent.tsx | 60 +++ src/components/Guide/RelatedGuides.tsx | 53 +- src/components/Questions/QuestionCard.tsx | 36 +- src/components/Questions/QuestionGuide.astro | 8 +- src/components/Questions/QuestionsList.tsx | 27 +- .../TableOfContent/TableOfContent.tsx | 6 +- src/lib/guide-renderer.tsx | 504 ++++++++++++++++++ src/lib/query-http.ts | 10 +- src/pages/data-analyst/career-path.astro | 20 +- src/queries/official-guide.ts | 101 ++++ 11 files changed, 740 insertions(+), 157 deletions(-) delete mode 100644 src/components/Guide/GuideContent.astro create mode 100644 src/components/Guide/GuideContent.tsx create mode 100644 src/lib/guide-renderer.tsx create mode 100644 src/queries/official-guide.ts diff --git a/src/components/Guide/GuideContent.astro b/src/components/Guide/GuideContent.astro deleted file mode 100644 index 61ce3b047..000000000 --- a/src/components/Guide/GuideContent.astro +++ /dev/null @@ -1,72 +0,0 @@ ---- -import { getGuideTableOfContent, type GuideFileType } from '../../lib/guide'; -import MarkdownFile from '../MarkdownFile.astro'; -import { TableOfContent } from '../TableOfContent/TableOfContent'; -import { RelatedGuides } from './RelatedGuides'; - -interface Props { - guide: GuideFileType; -} - -const { guide } = Astro.props; - -const allHeadings = guide.getHeadings(); -const tableOfContent = getGuideTableOfContent(allHeadings); - -const showTableOfContent = tableOfContent.length > 0; -const showRelatedGuides = - guide?.relatedGuides && Object.keys(guide?.relatedGuides).length > 0; -const { frontmatter: guideFrontmatter, author } = guide; ---- - -
- { - (showTableOfContent || showRelatedGuides) && ( -
- - -
- ) - } - -
- -

- {guideFrontmatter.title} -

-

- - {author.frontmatter.name} - {author.frontmatter.name} - - - -

- -
-
-
diff --git a/src/components/Guide/GuideContent.tsx b/src/components/Guide/GuideContent.tsx new file mode 100644 index 000000000..f119a177f --- /dev/null +++ b/src/components/Guide/GuideContent.tsx @@ -0,0 +1,60 @@ +import { cn } from '../../lib/classname'; +import { guideRenderer } from '../../lib/guide-renderer'; +import type { OfficialGuideResponse } from '../../queries/official-guide'; +import { TableOfContent } from '../TableOfContent/TableOfContent'; +import { RelatedGuides } from './RelatedGuides'; + +type GuideContentProps = { + guide: OfficialGuideResponse; +}; + +export function GuideContent(props: GuideContentProps) { + const { guide } = props; + const content = guideRenderer.render(guide.content); + const tableOfContents = guideRenderer.tableOfContents(guide.content); + const showTableOfContent = tableOfContents.length > 0; + const hasRelatedGuides = + guide.relatedGuides && guide.relatedGuides.length > 0; + + return ( +
+ {(showTableOfContent || hasRelatedGuides) && ( +
+ {hasRelatedGuides && ( + + )} + + {showTableOfContent && } +
+ )} + +
+
+

+ {guide.title} +

+

+ + {guide.author?.name} + {guide.author?.name} + +

+ + {content} +
+
+
+ ); +} diff --git a/src/components/Guide/RelatedGuides.tsx b/src/components/Guide/RelatedGuides.tsx index fd30870bf..879bc8c74 100644 --- a/src/components/Guide/RelatedGuides.tsx +++ b/src/components/Guide/RelatedGuides.tsx @@ -1,10 +1,11 @@ import { ChevronDown } from 'lucide-react'; import { useState } from 'react'; +import type { OfficialGuideDocument } from '../../queries/official-guide'; import { cn } from '../../lib/classname'; type RelatedGuidesProps = { relatedTitle?: string; - relatedGuides: Record; + relatedGuides: Pick[]; }; export function RelatedGuides(props: RelatedGuidesProps) { @@ -12,14 +13,7 @@ export function RelatedGuides(props: RelatedGuidesProps) { const [isOpen, setIsOpen] = useState(false); - const relatedGuidesArray = Object.entries(relatedGuides).map( - ([title, url]) => ({ - title, - url, - }), - ); - - if (relatedGuidesArray.length === 0) { + if (relatedGuides.length === 0) { return null; } @@ -47,23 +41,32 @@ export function RelatedGuides(props: RelatedGuidesProps) { isOpen && 'block', )} > - {relatedGuidesArray.map((relatedGuide) => ( -
  • - { - if (!isOpen) { - return; - } + {relatedGuides.map((relatedGuide) => { + const { roadmapId, slug, title } = relatedGuide; + const href = roadmapId ? `/${roadmapId}/${slug}` : `/guides/${slug}`; - setIsOpen(false); - }} - > - {relatedGuide.title} - -
  • - ))} + const className = cn( + 'text-sm text-gray-500 no-underline hover:text-black max-lg:block max-lg:border-b max-lg:px-3 max-lg:py-1', + ); + + return ( +
  • + { + if (!isOpen) { + return; + } + + setIsOpen(false); + }} + > + {title} + +
  • + ); + })} ); diff --git a/src/components/Questions/QuestionCard.tsx b/src/components/Questions/QuestionCard.tsx index 0087ba3d3..5ab38dfc0 100644 --- a/src/components/Questions/QuestionCard.tsx +++ b/src/components/Questions/QuestionCard.tsx @@ -1,8 +1,6 @@ import { Fragment, useEffect, useRef, useState } from 'react'; -import type { QuestionType } from '../../lib/question-group'; -import { markdownToHtml } from '../../lib/markdown'; -import Prism from 'prismjs'; -import './PrismAtom.css'; +import { guideRenderer, type QuestionType } from '../../lib/guide-renderer'; +import { cn } from '../../lib/classname'; type QuestionCardProps = { question: QuestionType; @@ -20,8 +18,6 @@ export function QuestionCard(props: QuestionCardProps) { // width if the answer is visible and the question height is less than // the answer height if (isAnswerVisible) { - Prism.highlightAll(); - const answerHeight = answerRef.current?.clientHeight || 0; const questionHeight = questionRef.current?.clientHeight || 0; @@ -69,7 +65,7 @@ export function QuestionCard(props: QuestionCardProps) {
    -

    +

    {question.question}

    @@ -88,27 +84,15 @@ export function QuestionCard(props: QuestionCardProps) {
    - {!question.isLongAnswer && ( -
    p]:leading-relaxed sm:text-xl`} - dangerouslySetInnerHTML={{ - __html: markdownToHtml(question.answer, false), - }} - /> - )} +
    + {guideRenderer.render(question.answer)} +
    - {question.isLongAnswer && ( -
    - )}
    diff --git a/src/components/TableOfContent/TableOfContent.tsx b/src/components/TableOfContent/TableOfContent.tsx index a71d01bb8..2ce89d578 100644 --- a/src/components/TableOfContent/TableOfContent.tsx +++ b/src/components/TableOfContent/TableOfContent.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import type { HeadingGroupType } from '../../lib/guide'; +import type { HeadingGroupType } from '../../lib/guide-renderer'; import { ChevronDown } from 'lucide-react'; import { cn } from '../../lib/classname'; @@ -23,7 +23,7 @@ export function TableOfContent(props: TableOfContentProps) { return (
    {heading.children.length > 0 && ( -
      +
        {heading.children.map((children) => { return (
      1. diff --git a/src/lib/guide-renderer.tsx b/src/lib/guide-renderer.tsx new file mode 100644 index 000000000..1318f4e39 --- /dev/null +++ b/src/lib/guide-renderer.tsx @@ -0,0 +1,504 @@ +import { Fragment } from 'react'; +import type { JSX } from 'react/jsx-runtime'; +import type { JSONContent } from '@tiptap/core'; +import { slugify } from './slugger'; +import { + CodeBlockContent, + CodeBlockHeader, + CodeBlockItem, +} from '../components/Global/CodeBlock'; +import { QuestionsList } from '../components/Questions/QuestionsList'; + +export type HeadingType = { + level: number; + text: string; + slug: string; +}; + +export type HeadingGroupType = HeadingType & { children: HeadingType[] }; + +export type QuestionType = { + id: string; + question: string; + answer: JSONContent; + topics: string[]; +}; + +export interface MarkType { + [key: string]: any; + type: string; + attrs?: Record | undefined; +} + +export class GuideRenderer { + private marksOrder = ['underline', 'bold', 'italic', 'textStyle', 'link']; + + render(content: JSONContent): JSX.Element[] { + const nodes = content.content || []; + const jsxNodes = nodes + .map((node, index) => { + const component = this.renderNode(node); + if (!component) { + return null; + } + + return {component}; + }) + .filter(Boolean) as JSX.Element[]; + + return jsxNodes; + } + + tableOfContents(node: JSONContent) { + const headlines = this.headlines(node); + let toc: HeadingGroupType[] = []; + let currentGroup: HeadingGroupType | null = null; + + const hasQASection = node.content?.some( + (node) => node.type === 'qaSection', + ); + + headlines + .filter((heading) => heading.level !== 1) + .forEach((heading) => { + if (heading.level === 2) { + currentGroup = { ...heading, children: [] }; + toc.push(currentGroup); + } else if (currentGroup && heading.level === 3) { + currentGroup.children.push({ ...heading, text: heading.text }); + } + }); + + if (toc.length > 5) { + toc.forEach((group) => { + group.children = []; + }); + } + + const qaSection = node.content?.find((node) => node.type === 'qaSection'); + if (hasQASection && qaSection) { + toc.push({ + level: 2, + text: 'Test yourself with Flashcards', + slug: 'test-with-flashcards', + children: [], + }); + + const questions = this.questions(qaSection); + const topicsInOrder = [ + ...new Set( + questions + .map((question) => question.topics) + .flat() + .filter(Boolean), + ), + ]; + + toc.push({ + level: 2, + text: 'Questions List', + slug: 'questions-list', + children: topicsInOrder.map((topic) => { + let topicText = topic; + let topicSlug = slugify(topic); + if (topic.toLowerCase() === 'beginners') { + topicText = 'Beginner Level'; + topicSlug = 'beginner-level'; + } else if (topic.toLowerCase() === 'intermediate') { + topicText = 'Intermediate Level'; + topicSlug = 'intermediate-level'; + } else if (topic.toLowerCase() === 'advanced') { + topicText = 'Advanced Level'; + topicSlug = 'advanced-level'; + } + + return { + level: 2, + children: [], + slug: topicSlug, + text: topicText, + }; + }), + }); + } + + return toc; + } + + headlines(node: JSONContent) { + const nodes = node.content || []; + const headlines: Array = []; + + const extractHeadlines = (node: JSONContent) => { + if (node.type === 'qaSection') { + return; + } + + if (node.type === 'heading') { + const text = this.getText(node); + headlines.push({ + level: node.attrs?.level || 1, + text, + slug: slugify(text), + }); + } + + if (node.content) { + node.content.forEach((childNode) => { + extractHeadlines(childNode); + }); + } + }; + + nodes.forEach((childNode) => { + extractHeadlines(childNode); + }); + + return headlines; + } + + private getText(node: JSONContent): string { + if (node.type === 'text') { + return node.text || ''; + } + + if (node.content) { + return node.content.map((childNode) => this.getText(childNode)).join(''); + } + + return ''; + } + + // `content` will call corresponding node type + // and return text content + private content(node: JSONContent): JSX.Element[] { + const allNodes = node.content || []; + return allNodes + .map((childNode, index) => { + const component = this.renderNode(childNode); + if (!component) { + return null; + } + + return ( + {component} + ); + }) + .filter(Boolean) as JSX.Element[]; + } + + // `renderNode` will call the method of the corresponding node type + private renderNode(node: JSONContent): JSX.Element | null { + const type = node.type || ''; + + if (type in this) { + // @ts-expect-error - `this` is not assignable to type 'never' + return this[type]?.(node) as JSX.Element; + } + + console.log(`Node type "${type}" is not supported.`); + return null; + } + + // `renderMark` will call the method of the corresponding mark type + private renderMark(node: JSONContent): JSX.Element { + // It will wrap the text with the corresponding mark type + const text = node?.text || <> ; + let marks = node?.marks || []; + // sort the marks by uderline, bold, italic, textStyle, link + // so that the text will be wrapped in the correct order + marks.sort((a, b) => { + return this.marksOrder.indexOf(a.type) - this.marksOrder.indexOf(b.type); + }); + + return marks.reduce( + (acc, mark) => { + const type = mark.type; + if (type in this) { + // @ts-expect-error - `this` is not assignable to type 'never' + return this[type]?.(mark, acc) as JSX.Element; + } + + throw new Error(`Mark type "${type}" is not supported.`); + }, + <>{text}, + ); + } + + private paragraph(node: JSONContent): JSX.Element { + return

        {node.content ? this.content(node) : <> }

        ; + } + + private text(node: JSONContent): JSX.Element { + if (node.marks) { + return this.renderMark(node); + } + + const text = node.text; + return text ? <>{text} : <> ; + } + + private bold(_: MarkType, text: JSX.Element): JSX.Element { + return {text}; + } + + private italic(_: MarkType, text: JSX.Element): JSX.Element { + return {text}; + } + + private underline(_: MarkType, text: JSX.Element): JSX.Element { + return {text}; + } + + private strike(_: MarkType, text: JSX.Element): JSX.Element { + return {text}; + } + + private textStyle(mark: MarkType, text: JSX.Element): JSX.Element { + const { attrs } = mark; + const { color = 'inherit' } = attrs || {}; + + return ( + + {text} + + ); + } + + private link(mark: MarkType, text: JSX.Element): JSX.Element { + const { attrs } = mark; + const { href } = attrs || {}; + + const isExternal = href?.startsWith('http'); + const isRoadmapUrl = href?.startsWith('https://roadmap.sh/'); + + const rel = isExternal && !isRoadmapUrl ? 'noopener noreferrer' : undefined; + + return ( + + {text} + + ); + } + + private heading(node: JSONContent): JSX.Element { + const { attrs } = node; + const { level } = attrs || {}; + + const text = this.getText(node); + const slug = slugify(text); + + let Comp: keyof JSX.IntrinsicElements = 'h1'; + if (level === 2) { + Comp = 'h2'; + } else if (level === 3) { + Comp = 'h3'; + } else if (level === 4) { + Comp = 'h4'; + } else if (level === 5) { + Comp = 'h5'; + } else if (level === 6) { + Comp = 'h6'; + } + + return {this.content(node)}; + } + + private horizontalRule(_: JSONContent): JSX.Element { + return
        ; + } + + private orderedList(node: JSONContent): JSX.Element { + return
          {this.content(node)}
        ; + } + + private bulletList(node: JSONContent): JSX.Element { + return
          {this.content(node)}
        ; + } + + private listItem(node: JSONContent): JSX.Element { + return
      2. {this.content(node)}
      3. ; + } + + private hardBreak(_: JSONContent): JSX.Element { + return
        ; + } + + private image(node: JSONContent): JSX.Element { + const { attrs } = node; + const { src, alt } = attrs || {}; + + return {alt; + } + + private code(_: MarkType, text: JSX.Element): JSX.Element { + return {text}; + } + + private codeBlock(node: JSONContent): JSX.Element { + const code = this.getText(node); + const language = node.attrs?.language || 'javascript'; + + return ( +
        + + + + {code} + +
        + ); + } + + private blockquote(node: JSONContent): JSX.Element { + return
        {this.content(node)}
        ; + } + + private questions(node: JSONContent) { + const content = node.content || []; + const questions: QuestionType[] = []; + let currentTopic: string | null = null; + let currentQuestion: QuestionType | null = null; + + for (const childNode of content) { + switch (childNode.type) { + case 'heading': + // if level is 2, it's a topic + if (childNode.attrs?.level === 2) { + currentTopic = this.getText(childNode); + // if level is 3, it's a question + } else if (childNode.attrs?.level === 3) { + if (currentTopic) { + const questionText = this.getText(childNode); + currentQuestion = { + id: slugify(questionText), + question: questionText, + answer: { + type: 'doc', + content: [], + }, + topics: [currentTopic], + }; + questions.push(currentQuestion); + } + } + break; + // anything else is an answer + default: + if (!currentQuestion || !currentQuestion.answer.content) { + console.warn('No current question found'); + continue; + } + + currentQuestion.answer.content.push(childNode); + break; + } + } + + return questions; + } + + private qaSection(node: JSONContent): JSX.Element { + const questions = this.questions(node); + + const questionsGroupedByTopics = questions.reduce( + (acc, question) => { + question.topics?.forEach((topic) => { + acc[topic] = [...(acc[topic] || []), question]; + }); + return acc; + }, + {} as Record, + ); + + const topicsInOrder = [ + ...new Set( + questions + .map((question) => question.topics) + .flat() + .filter(Boolean), + ), + ]; + + return ( + <> +

        Test yourself with Flashcards

        +

        + You can either use these flashcards or jump to the questions list + section below to see them in a list format. +

        + +
        + +
        + +

        Questions List

        +

        + If you prefer to see the questions in a list format, you can find them + below. +

        + + {topicsInOrder.map((questionLevel) => ( +
        +

        + {questionLevel.toLowerCase() === 'beginners' + ? 'Beginner Level' + : questionLevel.toLowerCase() === 'intermediate' + ? 'Intermediate Level' + : questionLevel.toLowerCase() === 'advanced' + ? 'Advanced Level' + : questionLevel} +

        + {questionsGroupedByTopics[questionLevel].map((q) => ( +
        +

        {q.question}

        +
        {this.render(q.answer)}
        +
        + ))} +
        + ))} + + ); + } + + private table(node: JSONContent): JSX.Element { + const content = node.content || []; + const rows = content.filter((node) => node.type === 'tableRow'); + const firstRow = rows?.[0]; + const hasTableHead = firstRow?.content?.some( + (node) => node.type === 'tableHeader', + ); + + const remainingRows = rows.slice(hasTableHead ? 1 : 0); + + return ( + + {hasTableHead && {this.renderNode(firstRow)}} + + {this.render({ + type: 'doc', + content: remainingRows, + })} + +
        + ); + } + + private tableRow(node: JSONContent): JSX.Element { + return {this.content(node)}; + } + + private tableHeader(node: JSONContent): JSX.Element { + return {this.content(node)}; + } + + private tableCell(node: JSONContent): JSX.Element { + return {this.content(node)}; + } +} + +export const guideRenderer = new GuideRenderer(); diff --git a/src/lib/query-http.ts b/src/lib/query-http.ts index 70e8cec75..a37fe1a5c 100644 --- a/src/lib/query-http.ts +++ b/src/lib/query-http.ts @@ -43,15 +43,19 @@ export async function httpCall( ? url : `${import.meta.env.PUBLIC_API_URL}${url}`; try { - const fingerprintPromise = await fp.load(); - const fingerprint = await fingerprintPromise.get(); + let visitorId = ''; + if (typeof window !== 'undefined') { + const fingerprintPromise = await fp.load(); + const fingerprint = await fingerprintPromise.get(); + visitorId = fingerprint.visitorId; + } const isMultiPartFormData = options?.body instanceof FormData; const headers = new Headers({ Accept: 'application/json', Authorization: `Bearer ${Cookies.get(TOKEN_COOKIE_NAME)}`, - fp: fingerprint.visitorId, + ...(visitorId ? { fp: visitorId } : {}), ...(options?.headers ?? {}), }); diff --git a/src/pages/data-analyst/career-path.astro b/src/pages/data-analyst/career-path.astro index c4f48dbf5..a14f15e8a 100644 --- a/src/pages/data-analyst/career-path.astro +++ b/src/pages/data-analyst/career-path.astro @@ -1,16 +1,14 @@ --- -import GuideContent from '../../components/Guide/GuideContent.astro'; +import { GuideContent } from '../../components/Guide/GuideContent'; import BaseLayout from '../../layouts/BaseLayout.astro'; -import { getGuideById } from '../../lib/guide'; import { getOpenGraphImageUrl } from '../../lib/open-graph'; +import { getOfficialGuide } from '../../queries/official-guide'; -const guideId = 'data-analyst-career-path'; -const guide = await getGuideById(guideId); - -const { frontmatter: guideData } = guide!; +const guideId = 'career-path'; +const guide = await getOfficialGuide(guideId, 'data-analyst'); const ogImageUrl = - guideData.seo.ogImageUrl || + guide?.seo?.ogImageUrl || getOpenGraphImageUrl({ group: 'guide', resourceId: guideId, @@ -18,12 +16,12 @@ const ogImageUrl = --- - +
        diff --git a/src/queries/official-guide.ts b/src/queries/official-guide.ts new file mode 100644 index 000000000..d30136123 --- /dev/null +++ b/src/queries/official-guide.ts @@ -0,0 +1,101 @@ +import { FetchError, httpGet } from '../lib/query-http'; + +export const allowedOfficialGuideStatus = ['draft', 'published'] as const; +export type AllowedOfficialGuideStatus = + (typeof allowedOfficialGuideStatus)[number]; + +export interface OfficialGuideDocument { + _id: string; + title: string; + slug: string; + description?: string; + content: any; + authorId: string; + roadmapId?: string; + featuredImage?: string; + status: AllowedOfficialGuideStatus; + publishedAt?: Date; + seo?: { + ogImageUrl?: string; + canonicalUrl?: string; + metaTitle?: string; + metaDescription?: string; + keywords?: string[]; + }; + tags?: string[]; + viewCount?: number; + createdAt: Date; + updatedAt: Date; +} + +type ListOfficialGuidesQuery = { + authorSlug?: string; +}; + +export async function listOfficialGuides(query: ListOfficialGuidesQuery = {}) { + try { + const guides = await httpGet( + `/v1-list-official-guides`, + query, + ); + + return guides.sort((a, b) => { + const aDate = new Date(a.createdAt); + const bDate = new Date(b.createdAt); + + return bDate.getTime() - aDate.getTime(); + }); + } catch (error) { + if (FetchError.isFetchError(error) && error.status === 404) { + return []; + } + + throw error; + } +} + +export interface OfficialAuthorDocument { + _id: string; + name: string; + slug: string; + bio?: string; + avatar?: string; + socialLinks?: { + twitter?: string; + linkedin?: string; + github?: string; + website?: string; + }; + isActive: boolean; + createdAt: Date; + updatedAt: Date; +} + +type GuideWithAuthor = OfficialGuideDocument & { + author?: Pick< + OfficialAuthorDocument, + 'name' | 'slug' | 'avatar' | 'bio' | 'socialLinks' + >; + relatedGuides?: Pick[]; +}; + +export type OfficialGuideResponse = GuideWithAuthor; + +export async function getOfficialGuide(slug: string, roadmapId?: string) { + try { + const guide = await httpGet( + `/v1-official-guide/${slug}`, + { + ...(roadmapId ? { roadmapId } : {}), + }, + ); + + return guide; + } catch (error) { + if (FetchError.isFetchError(error) && error.status === 404) { + return null; + } + + throw error; + } +}