Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,6 @@ pnpm-debug.log*

# jetbrains setting folder
.idea/

# Local configuration overrides
cvfolio.config.local.json
20 changes: 20 additions & 0 deletions cvfolio.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"layout": {
"header": true,
"footer": true,
"themeSwitcher": true
},
"sections": {
"homepage": {
"author": true,
"about": true,
"contact": true,
"workExperience": true,
"speaking": true
},
"writing": {
"author": true,
"latestPosts": true
}
}
}
30 changes: 29 additions & 1 deletion docs/customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,32 @@ Here’s an overview of folders relevant to customization:
| `src/styles/global.css` | Global CSS and utility styles |
| `src/lib/` | Utility functions and constants |

> You are free to preserve the labels of `Made by CVFolio` from the footer and the floating badge.
> You are free to preserve the labels of `Made by CVFolio` from the footer and the floating badge.
## Toggle sections and elements

You can hide built-in sections and UI elements without touching the templates by editing `cvfolio.config.json` at the project root. The defaults look like this:

```json
{
"layout": {
"header": true,
"footer": true,
"themeSwitcher": true
},
"sections": {
"homepage": {
"author": true,
"about": true,
"contact": true,
"workExperience": true,
"speaking": true
},
"writing": {
"author": true,
"latestPosts": true
}
}
}
```

Set any flag to `false` to remove that section from the rendered page while keeping the source code intact.
8 changes: 5 additions & 3 deletions src/layouts/BaseLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,24 @@ import Header from '@/components/partials/Header.astro';
import Footer from '@/components/partials/Footer.astro';
import Head from '@/components/partials/Head.astro';
import SwitchTheme from '@/components/SwitchTheme.tsx';
import { loadSiteConfig } from '@/lib/site-config';

interface Props {
seo?: Seo
}

const { seo } = Astro.props;
const { layout } = await loadSiteConfig();
---

<html lang="en">
<Head seo={seo} />
<body class="bg-background text-foreground pb-12">
<Header />
{layout.header && <Header />}
<main class="pt-24">
Copy link

Copilot AI Oct 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] pt-24 appears to account for the header; when layout.header is false, this leaves unnecessary top padding. Consider making the padding conditional, e.g., <main class={layout.header ? 'pt-24' : ''}>.

Suggested change
<main class="pt-24">
<main class={layout.header ? "pt-24" : ""}>

Copilot uses AI. Check for mistakes.

<slot />
</main>
<Footer />
<SwitchTheme client:only="react" />
{layout.footer && <Footer />}
{layout.themeSwitcher && <SwitchTheme client:only="react" />}
</body>
</html>
153 changes: 153 additions & 0 deletions src/lib/site-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { z } from 'astro/zod';

type LayoutConfig = {
header: boolean;
footer: boolean;
themeSwitcher: boolean;
};

type HomepageSections = {
author: boolean;
about: boolean;
contact: boolean;
workExperience: boolean;
speaking: boolean;
};

type WritingSections = {
author: boolean;
latestPosts: boolean;
};

type SectionsConfig = {
homepage: HomepageSections;
writing: WritingSections;
};

export type SiteConfig = {
layout: LayoutConfig;
sections: SectionsConfig;
};

const layoutSchema = z
.object({
header: z.boolean().optional(),
footer: z.boolean().optional(),
themeSwitcher: z.boolean().optional(),
})
.optional();

const homepageSectionsSchema = z
.object({
author: z.boolean().optional(),
about: z.boolean().optional(),
contact: z.boolean().optional(),
workExperience: z.boolean().optional(),
speaking: z.boolean().optional(),
})
.optional();

const writingSectionsSchema = z
.object({
author: z.boolean().optional(),
latestPosts: z.boolean().optional(),
})
.optional();

const sectionsSchema = z
.object({
homepage: homepageSectionsSchema,
writing: writingSectionsSchema,
})
.optional();

const siteConfigSchema = z.object({
layout: layoutSchema,
sections: sectionsSchema,
});

type RawSiteConfig = z.infer<typeof siteConfigSchema>;

type FlagRecord = Record<string, boolean>;

const DEFAULT_SITE_CONFIG: SiteConfig = {
layout: {
header: true,
footer: true,
themeSwitcher: true,
},
sections: {
homepage: {
author: true,
about: true,
contact: true,
workExperience: true,
speaking: true,
},
writing: {
author: true,
latestPosts: true,
},
},
};

function createDefaultSiteConfig(): SiteConfig {
return {
layout: { ...DEFAULT_SITE_CONFIG.layout },
sections: {
homepage: { ...DEFAULT_SITE_CONFIG.sections.homepage },
writing: { ...DEFAULT_SITE_CONFIG.sections.writing },
},
};
}

function mergeFlags<T extends FlagRecord>(defaults: T, overrides?: Partial<T>): T {
return { ...defaults, ...(overrides ?? {}) } as T;
}

function formatValidationIssues(error: z.ZodError): string {
return error.issues
.map((issue) => {
const path = issue.path.join('.') || 'root';
return ` - ${path}: ${issue.message}`;
})
.join('\n');
}

function validateSiteConfig(raw: unknown): RawSiteConfig | undefined {
const result = siteConfigSchema.safeParse(raw);

if (result.success) {
return result.data;
}

console.warn('[cvfolio] Invalid cvfolio.config.json detected. Falling back to defaults.');
console.warn(formatValidationIssues(result.error));
return undefined;
}

export async function loadSiteConfig(): Promise<SiteConfig> {
const defaults = createDefaultSiteConfig();

try {
const mod = await import('../../cvfolio.config.json?raw');
const rawContent = typeof mod.default === 'string' ? mod.default : mod;
const parsedContent = JSON.parse(rawContent);
const parsed = validateSiteConfig(parsedContent);

const effective = parsed ?? {};

const layout = mergeFlags(defaults.layout, effective.layout);
const sections = {
homepage: mergeFlags(defaults.sections.homepage, effective.sections?.homepage),
writing: mergeFlags(defaults.sections.writing, effective.sections?.writing),
Copy link

Copilot AI Oct 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TypeScript compile-time issue: effective is inferred as RawSiteConfig | {}, so property access like effective.layout is not safe and will error. Use parsed directly with optional chaining or assert the type. For example: const layout = mergeFlags(defaults.layout, parsed?.layout); const sections = { homepage: mergeFlags(defaults.sections.homepage, parsed?.sections?.homepage), writing: mergeFlags(defaults.sections.writing, parsed?.sections?.writing) };

Suggested change
const effective = parsed ?? {};
const layout = mergeFlags(defaults.layout, effective.layout);
const sections = {
homepage: mergeFlags(defaults.sections.homepage, effective.sections?.homepage),
writing: mergeFlags(defaults.sections.writing, effective.sections?.writing),
// Use parsed directly with optional chaining to ensure type safety
const layout = mergeFlags(defaults.layout, parsed?.layout);
const sections = {
homepage: mergeFlags(defaults.sections.homepage, parsed?.sections?.homepage),
writing: mergeFlags(defaults.sections.writing, parsed?.sections?.writing),

Copilot uses AI. Check for mistakes.

};

return { layout, sections };
} catch (error) {
console.warn('[cvfolio] Unable to load cvfolio.config.json. Falling back to defaults.', error);
return defaults;
}
}

export { DEFAULT_SITE_CONFIG };
43 changes: 27 additions & 16 deletions src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import Author from '@/components/ui/Author.astro';
import { DEFAULT_CONFIGURATION } from '@/lib/constants';
import WorkExperience from '@/components/ui/WorkExperience.astro';
import Talk from '@/components/ui/Talk.astro';
import { loadSiteConfig } from '@/lib/site-config';
import { sortJobsByDate } from '@/lib/utils';

const entry = await getEntry('pages', 'homepage');
Expand All @@ -20,25 +21,35 @@ const links = await getCollection('links');
const jobs = await getCollection('jobs');
const sortedJobs = sortJobsByDate(jobs);
const talks = await getCollection('talks');
const { sections } = await loadSiteConfig();
Copy link

Copilot AI Oct 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This calls loadSiteConfig in the page while BaseLayout.astro also calls it (line 16 there), causing duplicate JSON import/parse per render. Consider memoizing loadSiteConfig (cache a module-level Promise) or loading once in the page and passing layout down to BaseLayout as a prop to avoid redundant work.

Copilot uses AI. Check for mistakes.

const homepageSections = sections.homepage;
---

<BaseLayout seo={entry.data.seo}>
<Container as="section" class="py-6">
<Author {...DEFAULT_CONFIGURATION.author} />
</Container>
{
homepageSections.author && (
<Container as="section" class="py-6">
<Author {...DEFAULT_CONFIGURATION.author} />
</Container>
)
}

<Container as="section" class="py-6">
<div class="flex flex-col gap-6">
<div class="flex items-center">
<span class="text-headings">About</span>
</div>
<div class="prose dark:prose-invert">
<Content />
</div>
</div>
</Container>
{
links.length > 0 && (
homepageSections.about && (
<Container as="section" class="py-6">
<div class="flex flex-col gap-6">
<div class="flex items-center">
<span class="text-headings">About</span>
</div>
<div class="prose dark:prose-invert">
<Content />
</div>
</div>
</Container>
)
}
{
homepageSections.contact && links.length > 0 && (
<Container as="section" class="py-8">
<div class="flex flex-col gap-5">
<span class="text-headings">Contact</span>
Expand Down Expand Up @@ -66,7 +77,7 @@ const talks = await getCollection('talks');
)
}
{
sortedJobs.length > 0 && (
homepageSections.workExperience && sortedJobs.length > 0 && (
<Container as="section" class="py-6">
<div class="flex flex-col gap-5">
<span class="text-headings">Work Experience</span>
Expand All @@ -80,7 +91,7 @@ const talks = await getCollection('talks');
)
}
{
talks.length > 0 && (
homepageSections.speaking && talks.length > 0 && (
<Container as="section" class="py-6">
<div class="flex flex-col gap-5">
<span class="text-headings">Speaking</span>
Expand Down
33 changes: 22 additions & 11 deletions src/pages/writing/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,32 @@ import Container from '@/components/Container.astro';
import BaseLayout from '@/layouts/BaseLayout.astro';
import { DEFAULT_CONFIGURATION } from '@/lib/constants';
import PostPreview from '@/components/ui/PostPreview.astro';
import { loadSiteConfig } from '@/lib/site-config';

const entry = await getEntry('pages', 'writing');
const posts = await getCollection('posts');
const { sections } = await loadSiteConfig();
const writingSections = sections.writing;
---

<BaseLayout seo={entry.data.seo}>
<Container as='section' class='py-6'>
<Author {...DEFAULT_CONFIGURATION.author} />
</Container>
<Container as='section' class='py-6'>
<div class="flex flex-col gap-6">
<span class="text-headings">Latest posts</span>
<ul class="flex flex-col gap-3">
{posts.map((post) => <PostPreview entry={post} />)}
</ul>
</div>
</Container>
{
writingSections.author && (
<Container as='section' class='py-6'>
<Author {...DEFAULT_CONFIGURATION.author} />
</Container>
)
}
{
writingSections.latestPosts && posts.length > 0 && (
<Container as='section' class='py-6'>
<div class="flex flex-col gap-6">
<span class="text-headings">Latest posts</span>
<ul class="flex flex-col gap-3">
{posts.map((post) => <PostPreview entry={post} />)}
</ul>
</div>
</Container>
)
}
</BaseLayout>