1
0
mirror of https://github.com/kamranahmedse/developer-roadmap.git synced 2025-08-28 03:29:48 +02:00
This commit is contained in:
Arik Chakma
2025-08-18 13:07:42 +06:00
parent 805d34df96
commit e8d9a761d6
11 changed files with 740 additions and 157 deletions

View File

@@ -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;
---
<article class='lg:grid lg:max-w-full lg:grid-cols-[1fr_minmax(0,700px)_1fr]'>
{
(showTableOfContent || showRelatedGuides) && (
<div class='sticky top-0 lg:relative bg-linear-to-r from-gray-50 py-0 lg:col-start-3 lg:col-end-4 lg:row-start-1'>
<RelatedGuides
relatedTitle={guideFrontmatter?.relatedTitle}
relatedGuides={guide?.relatedGuides || {}}
client:load
/>
<TableOfContent toc={tableOfContent} client:load />
</div>
)
}
<div
class:list={[
'col-start-2 col-end-3 row-start-1 mx-auto max-w-[700px] py-5 sm:py-10',
{
'lg:border-r': showTableOfContent,
},
]}
>
<MarkdownFile>
<h1 class='mb-3 text-balance text-4xl font-bold'>
{guideFrontmatter.title}
</h1>
<p class='my-0 flex items-center justify-start text-sm text-gray-400'>
<a
href={`/authors/${author.id}`}
class='inline-flex items-center font-medium underline-offset-2 hover:text-gray-600 hover:underline'
>
<img
alt={author.frontmatter.name}
src={author.frontmatter.imageUrl}
class='mb-0 mr-2 inline h-5 w-5 rounded-full'
/>
{author.frontmatter.name}
</a>
<span class='mx-2 hidden sm:inline'>&middot;</span>
<a
class='hidden underline-offset-2 hover:text-gray-600 sm:inline'
href={`https://github.com/kamranahmedse/developer-roadmap/tree/master/src/data/guides/${guide.id}.md`}
target='_blank'
>
Improve this Guide
</a>
</p>
<guide.Content />
</MarkdownFile>
</div>
</article>

View File

@@ -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 (
<article className="lg:grid lg:max-w-full lg:grid-cols-[1fr_minmax(0,700px)_1fr]">
{(showTableOfContent || hasRelatedGuides) && (
<div className="sticky top-0 bg-linear-to-r from-gray-50 py-0 lg:relative lg:col-start-3 lg:col-end-4 lg:row-start-1">
{hasRelatedGuides && (
<RelatedGuides relatedGuides={guide?.relatedGuides || []} />
)}
{showTableOfContent && <TableOfContent toc={tableOfContents} />}
</div>
)}
<div
className={cn(
'col-start-2 col-end-3 row-start-1 mx-auto max-w-[700px] py-5 sm:py-10',
showTableOfContent && 'lg:border-r',
)}
>
<div className="prose prose-xl prose-h2:mb-3 prose-h2:mt-10 prose-h2:scroll-mt-5 prose-h2:text-balance prose-h2:text-3xl prose-h3:mt-2 prose-h4:text-2xl prose-h3:scroll-mt-5 prose-h3:text-balance prose-h4:text-balance prose-h5:text-balance prose-h5:font-medium prose-blockquote:font-normal prose-code:bg-transparent prose-img:mt-1 sm:prose-h2:scroll-mt-10 sm:prose-h3:scroll-mt-10 container">
<h1 className="mb-3 text-4xl font-bold text-balance">
{guide.title}
</h1>
<p className="my-0 flex items-center justify-start text-sm text-gray-400">
<a
href={`/authors/${guide.author?.slug}`}
className="inline-flex items-center font-medium underline-offset-2 hover:text-gray-600 hover:underline"
>
<img
alt={guide.author?.name}
src={guide.author?.avatar}
className="mr-2 mb-0 inline h-5 w-5 rounded-full"
/>
{guide.author?.name}
</a>
</p>
{content}
</div>
</div>
</article>
);
}

View File

@@ -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<string, string>;
relatedGuides: Pick<OfficialGuideDocument, 'title' | 'slug' | 'roadmapId'>[];
};
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) => (
<li key={relatedGuide.url}>
<a
href={relatedGuide.url}
className="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"
onClick={() => {
if (!isOpen) {
return;
}
{relatedGuides.map((relatedGuide) => {
const { roadmapId, slug, title } = relatedGuide;
const href = roadmapId ? `/${roadmapId}/${slug}` : `/guides/${slug}`;
setIsOpen(false);
}}
>
{relatedGuide.title}
</a>
</li>
))}
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 (
<li key={slug}>
<a
href={href}
className={className}
onClick={() => {
if (!isOpen) {
return;
}
setIsOpen(false);
}}
>
{title}
</a>
</li>
);
})}
</ol>
</div>
);

View File

@@ -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) {
</div>
<div className="mx-auto flex max-w-[550px] flex-1 items-center justify-center py-3 sm:py-8">
<p className="px-4 text-xl font-semibold leading-snug! text-black sm:text-3xl">
<p className="px-4 text-xl leading-snug! font-semibold text-black sm:text-3xl">
{question.question}
</p>
</div>
@@ -88,27 +84,15 @@ export function QuestionCard(props: QuestionCardProps) {
<div
ref={answerRef}
className={`absolute left-0 right-0 flex flex-col items-center justify-center rounded-[7px] bg-neutral-100 py-4 text-sm leading-normal text-black transition-all duration-300 sm:py-8 sm:text-xl ${
isAnswerVisible ? 'top-0 min-h-[248px] sm:min-h-[398px]' : 'top-full'
}`}
className={cn(
'absolute right-0 left-0 flex flex-col items-center justify-center rounded-[7px] bg-neutral-100 py-4 text-sm leading-normal text-black transition-all duration-300 sm:py-8 sm:text-xl',
isAnswerVisible ? 'top-0 min-h-[248px] sm:min-h-[398px]' : 'top-full',
)}
>
{!question.isLongAnswer && (
<div
className={`mx-auto flex max-w-[600px] grow flex-col items-center justify-center py-0 px-5 text-center text-base [&>p]:leading-relaxed sm:text-xl`}
dangerouslySetInnerHTML={{
__html: markdownToHtml(question.answer, false),
}}
/>
)}
<div className="qa-answer prose prose-h5:font-semibold prose-h5:mb-2 prose-h5:text-black prose-sm prose-quoteless prose-h1:mb-2.5 prose-h1:mt-7 prose-h2:mb-3 prose-h2:mt-0 prose-h3:mb-[5px] prose-h3:mt-[10px] prose-p:mb-2 prose-p:mt-0 prose-blockquote:font-normal prose-blockquote:not-italic prose-blockquote:text-gray-700 prose-pre:mb-6! prose-pre:w-full prose-ul:my-2 prose-li:m-0 prose-li:mb-0.5 prose-li:[&>p]:mb-0 sm:prose-p:mb-4 mx-auto flex w-full max-w-[600px] grow flex-col items-start justify-center px-4 py-0 text-left text-sm sm:px-5 sm:text-lg">
{guideRenderer.render(question.answer)}
</div>
{question.isLongAnswer && (
<div
className={`qa-answer prose prose-h5:font-semibold prose-h5:mb-2 prose-h5:text-black prose-sm prose-quoteless mx-auto flex w-full max-w-[600px] grow flex-col items-start justify-center py-0 px-4 text-left text-sm prose-h1:mb-2.5 prose-h1:mt-7 prose-h2:mb-3 prose-h2:mt-0 prose-h3:mb-[5px] prose-h3:mt-[10px] prose-p:mb-2 prose-p:mt-0 prose-blockquote:font-normal prose-blockquote:not-italic prose-blockquote:text-gray-700 prose-pre:mb-6! prose-pre:w-full prose-ul:my-2 prose-li:m-0 prose-li:mb-0.5 sm:px-5 sm:text-lg sm:prose-p:mb-4`}
dangerouslySetInnerHTML={{
__html: markdownToHtml(question.answer, false),
}}
/>
)}
<div className="mt-7 text-center">
<button
onClick={() => {

View File

@@ -81,7 +81,7 @@ const showTableOfContent = tableOfContent.length > 0;
---
<article class='lg:grid lg:max-w-full lg:grid-cols-[1fr_minmax(0,700px)_1fr]'>
{
<!-- {
showTableOfContent && (
<div class='bg-linear-to-r from-gray-50 py-0 lg:col-start-3 lg:col-end-4 lg:row-start-1'>
<RelatedGuides
@@ -92,7 +92,7 @@ const showTableOfContent = tableOfContent.length > 0;
<TableOfContent toc={tableOfContent} client:load />
</div>
)
}
} -->
<div
class:list={[
@@ -138,13 +138,13 @@ const showTableOfContent = tableOfContent.length > 0;
You can either use these flashcards or jump to the questions list
section below to see them in a list format.
</p>
<div class='mx-0 sm:-mb-32'>
<!-- <div class='mx-0 sm:-mb-32'>
<QuestionsList
groupId={questionGroup.id}
questions={questionGroup.questions}
client:load
/>
</div>
</div> -->
<h2 id='questions-list'>Questions List</h2>
<p>

View File

@@ -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 (
<div className="mb-0 gap-3 text-center sm:mb-40">
<div className={cn('mb-0 gap-3 text-center sm:mb-40', className)}>
<QuestionsProgress
knowCount={knowCount}
didNotKnowCount={dontKnowCount}
@@ -139,9 +139,10 @@ export function QuestionsList(props: QuestionsListProps) {
</div>
<div
className={`flex flex-col gap-1 transition-opacity duration-300 sm:flex-row sm:gap-3 ${
hasFinished ? 'opacity-0' : 'opacity-100'
}`}
className={cn(
'flex flex-col gap-1 transition-opacity duration-300 sm:flex-row sm:gap-3',
hasFinished ? 'opacity-0' : 'opacity-100',
)}
>
<button
disabled={!currQuestion}
@@ -152,7 +153,7 @@ export function QuestionsList(props: QuestionsListProps) {
}}
className="flex flex-1 items-center rounded-md border border-gray-300 bg-white px-2 py-2 text-sm text-black transition-colors hover:border-black hover:bg-black hover:text-white disabled:pointer-events-none disabled:opacity-50 sm:rounded-lg sm:px-4 sm:py-3 sm:text-base"
>
<CheckCircle className="mr-1 h-4 text-current" />
<CheckCircleIcon className="mr-1 h-4 text-current" />
Already Know that
</button>
<button
@@ -162,7 +163,7 @@ export function QuestionsList(props: QuestionsListProps) {
disabled={!currQuestion}
className="flex flex-1 items-center rounded-md border border-gray-300 bg-white px-2 py-2 text-sm text-black transition-colors hover:border-black hover:bg-black hover:text-white disabled:pointer-events-none disabled:opacity-50 sm:rounded-lg sm:px-4 sm:py-3 sm:text-base"
>
<Sparkles className="mr-1 h-4 text-current" />
<SparklesIcon className="mr-1 h-4 text-current" />
Didn't Know that
</button>
<button
@@ -173,7 +174,7 @@ export function QuestionsList(props: QuestionsListProps) {
data-next-question="skip"
className="flex flex-1 items-center rounded-md border border-red-600 px-2 py-2 text-sm text-red-600 hover:bg-red-600 hover:text-white disabled:pointer-events-none disabled:opacity-50 sm:rounded-lg sm:px-4 sm:py-3 sm:text-base"
>
<SkipForward className="mr-1 h-4" />
<SkipForwardIcon className="mr-1 h-4" />
Skip Question
</button>
</div>

View File

@@ -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 (
<div
className={cn(
'relative min-w-[250px] px-5 pt-0 max-lg:min-w-full max-lg:max-w-full max-lg:border-none max-lg:px-0 lg:pt-5',
'relative min-w-[250px] px-5 pt-0 max-lg:max-w-full max-lg:min-w-full max-lg:border-none max-lg:px-0 lg:pt-5',
{
'top-0 lg:sticky!': totalRows <= 20,
},
@@ -68,7 +68,7 @@ export function TableOfContent(props: TableOfContentProps) {
</a>
{heading.children.length > 0 && (
<ol className="my-0 ml-4 mt-1 space-y-0 max-lg:ml-0 max-lg:mt-0 max-lg:list-none">
<ol className="my-0 mt-1 ml-4 space-y-0 max-lg:mt-0 max-lg:ml-0 max-lg:list-none">
{heading.children.map((children) => {
return (
<li key={children.slug}>

504
src/lib/guide-renderer.tsx Normal file
View File

@@ -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<string, any> | 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 <Fragment key={`${node.type}-${index}`}>{component}</Fragment>;
})
.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<HeadingType> = [];
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 (
<Fragment key={`${childNode.type}-${index}`}>{component}</Fragment>
);
})
.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 || <>&nbsp;</>;
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 <p>{node.content ? this.content(node) : <>&nbsp;</>}</p>;
}
private text(node: JSONContent): JSX.Element {
if (node.marks) {
return this.renderMark(node);
}
const text = node.text;
return text ? <>{text}</> : <>&nbsp;</>;
}
private bold(_: MarkType, text: JSX.Element): JSX.Element {
return <strong>{text}</strong>;
}
private italic(_: MarkType, text: JSX.Element): JSX.Element {
return <em>{text}</em>;
}
private underline(_: MarkType, text: JSX.Element): JSX.Element {
return <u>{text}</u>;
}
private strike(_: MarkType, text: JSX.Element): JSX.Element {
return <s style={{ textDecoration: 'line-through' }}>{text}</s>;
}
private textStyle(mark: MarkType, text: JSX.Element): JSX.Element {
const { attrs } = mark;
const { color = 'inherit' } = attrs || {};
return (
<span
style={{
color,
}}
>
{text}
</span>
);
}
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 (
<a href={href} target="_blank" rel={rel}>
{text}
</a>
);
}
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 <Comp id={slug}>{this.content(node)}</Comp>;
}
private horizontalRule(_: JSONContent): JSX.Element {
return <hr />;
}
private orderedList(node: JSONContent): JSX.Element {
return <ol>{this.content(node)}</ol>;
}
private bulletList(node: JSONContent): JSX.Element {
return <ul>{this.content(node)}</ul>;
}
private listItem(node: JSONContent): JSX.Element {
return <li>{this.content(node)}</li>;
}
private hardBreak(_: JSONContent): JSX.Element {
return <br />;
}
private image(node: JSONContent): JSX.Element {
const { attrs } = node;
const { src, alt } = attrs || {};
return <img alt={alt || 'Image'} src={src} />;
}
private code(_: MarkType, text: JSX.Element): JSX.Element {
return <code>{text}</code>;
}
private codeBlock(node: JSONContent): JSX.Element {
const code = this.getText(node);
const language = node.attrs?.language || 'javascript';
return (
<div className="not-prose my-6 w-full max-w-full overflow-hidden rounded-lg border border-gray-200">
<CodeBlockHeader language={language} code={code} />
<CodeBlockItem key={language} value={language} lineNumbers={false}>
<CodeBlockContent language={language}>{code}</CodeBlockContent>
</CodeBlockItem>
</div>
);
}
private blockquote(node: JSONContent): JSX.Element {
return <blockquote>{this.content(node)}</blockquote>;
}
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<string, QuestionType[]>,
);
const topicsInOrder = [
...new Set(
questions
.map((question) => question.topics)
.flat()
.filter(Boolean),
),
];
return (
<>
<h2 id="test-with-flashcards">Test yourself with Flashcards</h2>
<p>
You can either use these flashcards or jump to the questions list
section below to see them in a list format.
</p>
<div className="mx-0 sm:-mb-32">
<QuestionsList questions={questions} />
</div>
<h2 id="questions-list">Questions List</h2>
<p>
If you prefer to see the questions in a list format, you can find them
below.
</p>
{topicsInOrder.map((questionLevel) => (
<div className="mb-5" key={questionLevel}>
<h3 id={slugify(questionLevel)} className="mb-0 capitalize">
{questionLevel.toLowerCase() === 'beginners'
? 'Beginner Level'
: questionLevel.toLowerCase() === 'intermediate'
? 'Intermediate Level'
: questionLevel.toLowerCase() === 'advanced'
? 'Advanced Level'
: questionLevel}
</h3>
{questionsGroupedByTopics[questionLevel].map((q) => (
<div className="mb-5" key={q.id}>
<h4>{q.question}</h4>
<div>{this.render(q.answer)}</div>
</div>
))}
</div>
))}
</>
);
}
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 (
<table className="[&_p]:m-0">
{hasTableHead && <thead>{this.renderNode(firstRow)}</thead>}
<tbody>
{this.render({
type: 'doc',
content: remainingRows,
})}
</tbody>
</table>
);
}
private tableRow(node: JSONContent): JSX.Element {
return <tr>{this.content(node)}</tr>;
}
private tableHeader(node: JSONContent): JSX.Element {
return <th>{this.content(node)}</th>;
}
private tableCell(node: JSONContent): JSX.Element {
return <td>{this.content(node)}</td>;
}
}
export const guideRenderer = new GuideRenderer();

View File

@@ -43,15 +43,19 @@ export async function httpCall<ResponseType = AppResponse>(
? 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 ?? {}),
});

View File

@@ -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 =
---
<BaseLayout
title={guideData.seo.title}
description={guideData.seo.description}
title={guide?.seo?.metaTitle ?? guide?.title ?? ''}
description={guide?.seo?.metaDescription ?? guide?.description ?? ''}
permalink={`/data-analyst/career-path`}
canonicalUrl={guideData.canonicalUrl}
canonicalUrl={guide?.seo?.canonicalUrl || ''}
ogImageUrl={ogImageUrl}
>
<GuideContent guide={guide!} />
<GuideContent guide={guide!} client:load />
<div slot='changelog-banner'></div>
</BaseLayout>

View File

@@ -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<OfficialGuideDocument[]>(
`/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<OfficialGuideDocument, 'title' | 'slug' | 'roadmapId'>[];
};
export type OfficialGuideResponse = GuideWithAuthor;
export async function getOfficialGuide(slug: string, roadmapId?: string) {
try {
const guide = await httpGet<OfficialGuideResponse>(
`/v1-official-guide/${slug}`,
{
...(roadmapId ? { roadmapId } : {}),
},
);
return guide;
} catch (error) {
if (FetchError.isFetchError(error) && error.status === 404) {
return null;
}
throw error;
}
}