diff --git a/.github/workflows/create-songbook-post.yml b/.github/workflows/create-songbook-post.yml new file mode 100644 index 0000000..6903b70 --- /dev/null +++ b/.github/workflows/create-songbook-post.yml @@ -0,0 +1,141 @@ +name: Create Songbook Post + +on: + workflow_dispatch: + inputs: + filename: + description: "Filename / Slug for the post (e.g. bukayo-saka)" + required: true + type: string + title: + description: "Post Title (Optional if imageAlt provided)" + required: false + type: string + date: + description: "Post Date in YYYY-MM-DD format (Optional)" + required: false + type: string + image: + description: "Image URL (Required, e.g. https://example.com/image.jpg)" + required: true + type: string + imageAlt: + description: "Image Alt text (Optional if title provided)" + required: false + type: string + imageDimensions: + description: "Image dimensions WxH (Required, e.g. 735x990)" + required: true + type: string + imagePlacement: + description: 'Image placement (Required, e.g. header, body, or JSON: {"all":"header","md":"body"})' + required: true + default: "header" + type: string + imageLink: + description: "Image link URL (Optional)" + required: false + type: string + orientation: + description: "Image orientation (Optional, e.g. portrait, landscape, square, banner)" + required: false + type: string + metaTitle: + description: "Meta Title for SEO (Optional)" + required: false + type: string + additionalStyling: + description: "Additional CSS styling class (Optional)" + required: false + type: string + content: + description: "Post Markdown / Song Lyrics content (Optional)" + required: false + type: string + imageFileName: + description: "Filename to save the downloaded image in public/ (e.g. my-image.jpg)" + required: true + type: string + branch: + description: "The branch to run on" + required: true + default: "main" + type: string + +jobs: + create-songbook-post: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ssh-key: ${{ secrets.DATA_PIPELINE_PR_AUTOMATION_KEY }} + ref: ${{ inputs.branch }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm install + + - name: Download image + run: | + mkdir -p public + IMAGE_PATH="public/${{ inputs.imageFileName }}" + mkdir -p "$(dirname "$IMAGE_PATH")" + curl -L -o "$IMAGE_PATH" ${{ inputs.image }} + + - name: Create Songbook Post + env: + INPUT_FILENAME: ${{ inputs.filename }} + INPUT_TITLE: ${{ inputs.title }} + INPUT_DATE: ${{ inputs.date }} + INPUT_IMAGE: /${{ inputs.imageFileName }} + INPUT_IMAGE_ALT: ${{ inputs.imageAlt }} + INPUT_IMAGE_DIMENSIONS: ${{ inputs.imageDimensions }} + INPUT_IMAGE_PLACEMENT: ${{ inputs.imagePlacement }} + INPUT_IMAGE_LINK: ${{ inputs.imageLink }} + INPUT_ORIENTATION: ${{ inputs.orientation }} + INPUT_META_TITLE: ${{ inputs.metaTitle }} + INPUT_ADDITIONAL_STYLING: ${{ inputs.additionalStyling }} + INPUT_CONTENT: ${{ inputs.content }} + run: npm run create-songbook-post + + - name: Format generated post + run: npm run format + + - name: Run unit tests + run: npm run test:unit + + - name: Log git status + run: git status + + - name: Create Pull Request + id: cpr + uses: peter-evans/create-pull-request@v7 + with: + commit-message: "feat(songbook): add songbook post ${{ inputs.filename }}" + branch: "songbook-post-${{ inputs.filename }}" + base: "main" + add-paths: | + src/content/posts/* + public/${{ inputs.imageFileName }} + title: "New Songbook Post: ${{ inputs.title || inputs.filename }}" + body: | + This is an automated pull request to create a new songbook post using `imagePost` schema inputs. + labels: | + songbook-post + + - name: Enable Pull Request Automerge + if: steps.cpr.outputs.pull-request-number != '' + run: | + gh pr merge --rebase --auto "${{ steps.cpr.outputs.pull-request-number }}" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/bin/create-songbook-post.ts b/bin/create-songbook-post.ts new file mode 100644 index 0000000..184edb3 --- /dev/null +++ b/bin/create-songbook-post.ts @@ -0,0 +1,43 @@ +import { + createSongbookPostFile, + type SongbookPostInput, +} from "@/lib/createSongbookPost"; + +const input: SongbookPostInput = { + filename: process.env.INPUT_FILENAME || process.argv[2] || "", + title: process.env.INPUT_TITLE || undefined, + date: process.env.INPUT_DATE || undefined, + image: process.env.INPUT_IMAGE || process.argv[3] || "", + imageAlt: process.env.INPUT_IMAGE_ALT || undefined, + imageDimensions: process.env.INPUT_IMAGE_DIMENSIONS || process.argv[4] || "", + imagePlacement: + process.env.INPUT_IMAGE_PLACEMENT || process.argv[5] || "header", + imageLink: process.env.INPUT_IMAGE_LINK || undefined, + orientation: process.env.INPUT_ORIENTATION || undefined, + metaTitle: process.env.INPUT_META_TITLE || undefined, + additionalStyling: process.env.INPUT_ADDITIONAL_STYLING || undefined, + content: process.env.INPUT_CONTENT || undefined, +}; + +if (!input.filename) { + console.error("Error: 'filename' input is required."); + process.exit(1); +} + +if (!input.image) { + console.error("Error: 'image' input is required."); + process.exit(1); +} + +if (!input.imageDimensions) { + console.error("Error: 'imageDimensions' input is required."); + process.exit(1); +} + +try { + const result = createSongbookPostFile(input); + console.log(`Successfully created songbook post at: ${result.filePath}`); +} catch (error) { + console.error("Error creating songbook post:", error); + process.exit(1); +} diff --git a/package.json b/package.json index 6d2d87a..149e6a2 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "check": "astro check && prettier --check .", "format": "prettier --write .", "get-data": "tsx bin/get-data.ts", + "create-songbook-post": "tsx bin/create-songbook-post.ts", "test": "vitest run --exclude \"**/*.integration.test.ts\"", "test:unit": "npm run test", "test:integration": "vitest run integration", diff --git a/src/lib/createSongbookPost.test.ts b/src/lib/createSongbookPost.test.ts new file mode 100644 index 0000000..0f03b5a --- /dev/null +++ b/src/lib/createSongbookPost.test.ts @@ -0,0 +1,113 @@ +import fs from "fs"; +import path from "path"; +import os from "os"; +import { describe, expect, test } from "vitest"; +import { + generateSongbookPostMarkdown, + createSongbookPostFile, + parseImagePlacement, +} from "./createSongbookPost"; +import { postSchema } from "@/content.types"; + +describe("createSongbookPost", () => { + describe("parseImagePlacement", () => { + test("parses plain string placement", () => { + expect(parseImagePlacement("header")).toBe("header"); + expect(parseImagePlacement("body")).toBe("body"); + }); + + test("parses valid JSON object placement", () => { + const result = parseImagePlacement('{"all":"header","md":"body"}'); + expect(result).toEqual({ all: "header", md: "body" }); + }); + + test("falls back to raw string when JSON is invalid", () => { + const result = parseImagePlacement("{invalid json}"); + expect(result).toBe("{invalid json}"); + }); + }); + + describe("generateSongbookPostMarkdown", () => { + test("generates markdown compliant with imagePost schema", () => { + const markdown = generateSongbookPostMarkdown({ + filename: "saka-chant", + title: "Bukayo Saka", + image: "/images/saka.jpg", + imageDimensions: "735x990", + imagePlacement: "header", + orientation: "portrait", + content: "We've got Bukayo Saka...", + }); + + expect(markdown).toContain('title: "Bukayo Saka"'); + expect(markdown).toContain('image: "/images/saka.jpg"'); + expect(markdown).toContain('imageDimensions: "735x990"'); + expect(markdown).toContain("imagePlacement: header"); + expect(markdown).toContain("We've got Bukayo Saka..."); + }); + + test("supports imagePlacement as responsive JSON object", () => { + const markdown = generateSongbookPostMarkdown({ + filename: "rice-chant", + title: "Declan Rice", + image: "/images/rice.jpg", + imageDimensions: "1242x828", + imagePlacement: '{"all":"header","md":"body"}', + imageAlt: "Declan Rice celebrating", + content: "Declan Rice...", + }); + + expect(markdown).toContain("imagePlacement:"); + expect(markdown).toContain(" all: header"); + expect(markdown).toContain(" md: body"); + expect(markdown).toContain('imageAlt: "Declan Rice celebrating"'); + }); + + test("throws error if inputs fail imagePost schema validation", () => { + expect(() => { + generateSongbookPostMarkdown({ + filename: "invalid-post", + image: "/images/test.jpg", + imageDimensions: "100x100", + imagePlacement: "header", + // Missing both title and imageAlt + }); + }).toThrowError(/Validation failed for imagePost schema/); + }); + }); + + describe("createSongbookPostFile", () => { + test("creates songbook post file in target directory", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "songbook-test-")); + try { + const result = createSongbookPostFile( + { + filename: "saliba-chant", + title: "William Saliba", + image: "/images/saliba.jpg", + imageDimensions: "500x500", + imagePlacement: "body", + content: "Te-quila!", + }, + tmpDir, + ); + + const expectedPath = path.join( + tmpDir, + "src", + "content", + "posts", + "saliba-chant.md", + ); + expect(result.filePath).toBe(expectedPath); + expect(fs.existsSync(expectedPath)).toBe(true); + + const writtenContent = fs.readFileSync(expectedPath, "utf-8"); + expect(writtenContent).toContain('title: "William Saliba"'); + expect(writtenContent).toContain("Te-quila!"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + }); +}); diff --git a/src/lib/createSongbookPost.ts b/src/lib/createSongbookPost.ts new file mode 100644 index 0000000..7541bb3 --- /dev/null +++ b/src/lib/createSongbookPost.ts @@ -0,0 +1,178 @@ +import fs from "fs"; +import path from "path"; +import { postSchema } from "@/content.types"; + +export interface SongbookPostInput { + filename: string; + title?: string; + date?: string; + image: string; + imageAlt?: string; + imageDimensions: string; + imagePlacement: string; + imageLink?: string; + orientation?: string; + metaTitle?: string; + additionalStyling?: string; + content?: string; +} + +export interface CreatedSongbookPost { + filePath: string; + content: string; +} + +export function parseImagePlacement( + rawPlacement: string, +): string | Record { + const trimmed = rawPlacement.trim(); + if (trimmed.startsWith("{")) { + try { + const parsed = JSON.parse(trimmed) as unknown; + if ( + typeof parsed === "object" && + parsed !== null && + !Array.isArray(parsed) + ) { + return parsed as Record; + } + } catch { + // Fallback to string if JSON parsing fails + } + } + return trimmed; +} + +export function formatFrontmatterValue(value: unknown): string { + if (typeof value === "string") { + return `"${value.replace(/"/g, '\\"')}"`; + } + return String(value); +} + +export function generateSongbookPostMarkdown(input: SongbookPostInput): string { + const imagePlacementParsed = parseImagePlacement(input.imagePlacement); + + const validationObj: Record = { + type: "image", + image: input.image, + imageDimensions: input.imageDimensions, + imagePlacement: imagePlacementParsed, + }; + + if (input.title && input.title.trim() !== "") { + validationObj.title = input.title.trim(); + } + if (input.date && input.date.trim() !== "") { + const parsedDate = new Date(input.date.trim()); + if (!isNaN(parsedDate.getTime())) { + validationObj.date = parsedDate; + } + } + if (input.imageAlt && input.imageAlt.trim() !== "") { + validationObj.imageAlt = input.imageAlt.trim(); + } + if (input.imageLink && input.imageLink.trim() !== "") { + validationObj.imageLink = input.imageLink.trim(); + } + if (input.orientation && input.orientation.trim() !== "") { + validationObj.orientation = input.orientation.trim(); + } + if (input.metaTitle && input.metaTitle.trim() !== "") { + validationObj.metaTitle = input.metaTitle.trim(); + } + if (input.additionalStyling && input.additionalStyling.trim() !== "") { + validationObj.additionalStyling = input.additionalStyling.trim(); + } + + const parseResult = postSchema.safeParse(validationObj); + if (!parseResult.success) { + const errorMessages = parseResult.error.issues + .map((issue) => `${issue.path.join(".")}: ${issue.message}`) + .join("; "); + throw new Error(`Validation failed for imagePost schema: ${errorMessages}`); + } + + const lines: string[] = ["---"]; + + if (validationObj.title) { + lines.push(`title: ${formatFrontmatterValue(validationObj.title)}`); + } + if (input.date && input.date.trim() !== "") { + lines.push(`date: ${input.date.trim()}`); + } + lines.push(`image: ${formatFrontmatterValue(input.image)}`); + if (validationObj.imageAlt) { + lines.push(`imageAlt: ${formatFrontmatterValue(validationObj.imageAlt)}`); + } + lines.push( + `imageDimensions: ${formatFrontmatterValue(input.imageDimensions)}`, + ); + + if ( + typeof imagePlacementParsed === "object" && + imagePlacementParsed !== null + ) { + lines.push("imagePlacement:"); + for (const [key, val] of Object.entries(imagePlacementParsed)) { + lines.push(` ${key}: ${val}`); + } + } else { + lines.push(`imagePlacement: ${imagePlacementParsed}`); + } + + if (validationObj.imageLink) { + lines.push(`imageLink: ${formatFrontmatterValue(validationObj.imageLink)}`); + } + if (validationObj.orientation) { + lines.push( + `orientation: ${formatFrontmatterValue(validationObj.orientation)}`, + ); + } + if (validationObj.metaTitle) { + lines.push(`metaTitle: ${formatFrontmatterValue(validationObj.metaTitle)}`); + } + if (validationObj.additionalStyling) { + lines.push( + `additionalStyling: ${formatFrontmatterValue(validationObj.additionalStyling)}`, + ); + } + + lines.push("---"); + + const bodyContent = input.content ? input.content.trim() : ""; + if (bodyContent.length > 0) { + lines.push(""); + lines.push(bodyContent); + } + lines.push(""); + + return lines.join("\n"); +} + +export function createSongbookPostFile( + input: SongbookPostInput, + baseDir: string = process.cwd(), +): CreatedSongbookPost { + let cleanFilename = input.filename.trim(); + if (cleanFilename.endsWith(".md")) { + cleanFilename = cleanFilename.slice(0, -3); + } + if (cleanFilename.endsWith(".mdx")) { + cleanFilename = cleanFilename.slice(0, -4); + } + + const markdown = generateSongbookPostMarkdown(input); + const targetDir = path.join(baseDir, "src", "content", "posts"); + if (!fs.existsSync(targetDir)) { + fs.mkdirSync(targetDir, { recursive: true }); + } + + const filePath = path.join(targetDir, `${cleanFilename}.md`); + fs.writeFileSync(filePath, markdown, "utf-8"); + + return { + filePath, + content: markdown, + }; +}