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
112 changes: 112 additions & 0 deletions apps/web/lib/apps/[slug]/__tests__/parseFrontmatter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { describe, expect, it } from "vitest";
import { parseFrontmatter } from "../getStaticProps";

describe("parseFrontmatter", () => {
describe("valid frontmatter parsing", () => {
it("parses frontmatter with items array", () => {
const source = `---
items:
- 1.jpg
- 2.png
---
Some content here`;

const result = parseFrontmatter(source);

expect(result.data).toEqual({ items: ["1.jpg", "2.png"] });
expect(result.content).toBe("Some content here");
});

it("parses frontmatter with description only", () => {
const source = `---
description: A simple description
---
Content`;

const result = parseFrontmatter(source);

expect(result.data).toEqual({ description: "A simple description" });
expect(result.content).toBe("Content");
});

it("parses items with iframe objects", () => {
const source = `---
items:
- iframe: { src: https://youtube.com/embed/abc }
- 1.jpg
---
Content`;

const result = parseFrontmatter(source);

expect(result.data).toEqual({
items: [{ iframe: { src: "https://youtube.com/embed/abc" } }, "1.jpg"],
});
expect(result.content).toBe("Content");
});
});

describe("edge cases", () => {
it("returns empty data when no frontmatter present", () => {
const source = "Just plain text content";

const result = parseFrontmatter(source);

expect(result.data).toEqual({});
expect(result.content).toBe("Just plain text content");
});

it("parses simple frontmatter correctly", () => {
const source = `---
items:
- test.jpg
---
Content`;

const result = parseFrontmatter(source);

expect(result.data).toEqual({ items: ["test.jpg"] });
expect(result.content).toBe("Content");
});

it("preserves blank line after frontmatter", () => {
const source = `---
title: Test
---

Content`;

const result = parseFrontmatter(source);

expect(result.data).toEqual({ title: "Test" });
expect(result.content).toBe("\nContent");
});

it("returns empty data for non-object frontmatter (array root)", () => {
const source = `---
- item1
- item2
---
Content`;

const result = parseFrontmatter(source);

expect(result.data).toEqual({});
expect(result.content).toBe("Content");
});
});

describe("security (JSON_SCHEMA protection)", () => {
it("returns empty data on unsafe YAML types", () => {
const source = `---
date: !!js/date 2024-01-01
---
Content`;

const result = parseFrontmatter(source);

expect(result.data).toEqual({});
expect(result.content).toBe("Content");
});
});
});
49 changes: 44 additions & 5 deletions apps/web/lib/apps/[slug]/getStaticProps.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,51 @@
import fs from "node:fs"
import matter from "gray-matter";
import path from "node:path"
import fs from "node:fs";
import path from "node:path";

import yaml from "js-yaml";
import { z } from "zod";

import { getAppWithMetadata } from "@calcom/app-store/_appRegistry";
import { getAppAssetFullPath } from "@calcom/app-store/getAppAssetFullPath";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import prisma from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
import logger from "@calcom/lib/logger";

const log = logger.getSubLogger({ prefix: ["lib", "parseFrontmatter"] });

const FRONTMATTER_REGEX = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;

function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

/**
* Parses markdown content with YAML frontmatter
* Replaces gray-matter to use js-yaml 4.x directly (yaml.load is safe by default)
*/
export function parseFrontmatter(source: string): { data: Record<string, unknown>; content: string } {
const match = source.match(FRONTMATTER_REGEX);

if (!match) {
return { data: {}, content: source };
}

let data: Record<string, unknown> = {};

try {
const parsed = yaml.load(match[1], { schema: yaml.JSON_SCHEMA });

if (isRecord(parsed)) {
data = parsed;
}
} catch (error) {
log.warn("Invalid YAML frontmatter", { error });
}

return {
data,
content: source.slice(match[0].length),
};
}

export const sourceSchema = z.object({
content: z.string(),
Expand Down Expand Up @@ -65,7 +104,7 @@ export const getStaticProps = async (slug: string) => {
source = appMeta.description;
}

const result = matter(source);
const result = parseFrontmatter(source);
const { content, data } = sourceSchema.parse({ content: result.content, data: result.data });
if (data.items) {
data.items = data.items.map((item) => {
Expand Down
1 change: 0 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,6 @@
"classnames": "2.3.2",
"dompurify": "3.3.1",
"entities": "4.5.0",
"gray-matter": "4.0.3",
"handlebars": "4.7.7",
"i18next": "23.2.3",
"ical.js": "1.5.0",
Expand Down
1 change: 0 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3546,7 +3546,6 @@ __metadata:
env-cmd: "npm:10.1.0"
glob: "npm:10.4.5"
google-auth-library: "npm:9.15.0"
gray-matter: "npm:4.0.3"
handlebars: "npm:4.7.7"
i18next: "npm:23.2.3"
ical.js: "npm:1.5.0"
Expand Down
Loading