0;
You can either use these flashcards or jump to the questions list
section below to see them in a list format.
-
+
Questions List
diff --git a/src/components/Questions/QuestionsList.tsx b/src/components/Questions/QuestionsList.tsx
index f7fcd5485..e1d42af21 100644
--- a/src/components/Questions/QuestionsList.tsx
+++ b/src/components/Questions/QuestionsList.tsx
@@ -1,10 +1,10 @@
import { useRef, useState } from 'react';
+import type { QuestionType } from '../../lib/guide-renderer';
import { QuestionsProgress } from './QuestionsProgress';
-import { CheckCircle, SkipForward, Sparkles } from 'lucide-react';
-import { QuestionCard } from './QuestionCard';
-import { isLoggedIn } from '../../lib/jwt';
-import type { QuestionType } from '../../lib/question-group';
+import { cn } from '../../lib/classname';
import { QuestionFinished } from './QuestionFinished';
+import { QuestionCard } from './QuestionCard';
+import { CheckCircleIcon, SkipForwardIcon, SparklesIcon } from 'lucide-react';
import { Confetti } from '../Confetti';
type UserQuestionProgress = {
@@ -16,12 +16,12 @@ type UserQuestionProgress = {
export type QuestionProgressType = keyof UserQuestionProgress;
type QuestionsListProps = {
- groupId: string;
questions: QuestionType[];
+ className?: string;
};
export function QuestionsList(props: QuestionsListProps) {
- const { questions } = props;
+ const { questions, className } = props;
const [showConfetti, setShowConfetti] = useState(false);
const [currQuestionIndex, setCurrQuestionIndex] = useState(0);
@@ -73,7 +73,7 @@ export function QuestionsList(props: QuestionsListProps) {
const hasFinished = hasProgress && currQuestionIndex === -1;
return (
-
+
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 (
-
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 ;
+ }
+
+ private listItem(node: JSONContent): JSX.Element {
+ return - {this.content(node)}
;
+ }
+
+ private hardBreak(_: JSONContent): JSX.Element {
+ return
;
+ }
+
+ private image(node: JSONContent): JSX.Element {
+ const { attrs } = node;
+ const { src, alt } = attrs || {};
+
+ return
;
+ }
+
+ 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;
+ }
+}