diff --git a/packages/preset-hugo/index.js b/packages/preset-hugo/index.js index 45ce435ee..438d9b80b 100644 --- a/packages/preset-hugo/index.js +++ b/packages/preset-hugo/index.js @@ -1,6 +1,5 @@ -import camelcaseKeys from "camelcase-keys"; -import TOML from "@iarna/toml"; -import YAML from "yaml"; +import { getPostTemplate } from "./lib/post-template.js"; +import { getPostTypes } from "./lib/post-types.js"; const defaults = { frontMatterFormat: "yaml", @@ -45,224 +44,14 @@ export default class HugoPreset { ]; } - /** - * Get content - * @access private - * @param {object} properties - JF2 properties - * @returns {string} Content - */ - #content(properties) { - if (properties.content) { - const content = - properties.content.text || - properties.content.html || - properties.content; - return `\n${content}\n`; - } else { - return ""; - } - } - - /** - * Get front matter - * @access private - * @param {object} properties - JF2 properties - * @returns {string} Front matter in chosen format - */ - #frontMatter(properties) { - let delimiters; - let frontMatter; - - /* - * Go templates don’t accept hyphens in property names - * and Hugo camelCases its predefined front matter keys - * @see {link: https://gohugo.io/content-management/front-matter/} - */ - properties = camelcaseKeys(properties, { deep: true }); - - /* - * Replace Microformat properties with Hugo equivalents - * @see {link: https://gohugo.io/content-management/front-matter/} - */ - properties = { - date: properties.published, - publishDate: properties.published, - ...(properties.postStatus === "draft" && { draft: true }), - ...(properties.updated && { lastmod: properties.updated }), - ...(properties.deleted && { expiryDate: properties.deleted }), - ...(properties.name && { title: properties.name }), - ...(properties.photo && { - images: properties.photo.map((image) => image.url), - }), - ...properties, - }; - - delete properties.content; // Shown below front matter - delete properties.deleted; // Use `expiryDate` - delete properties.name; // Use `title` - delete properties.postStatus; // Use `draft` - delete properties.published; // Use `date` - delete properties.type; // Not required - delete properties.updated; // Use `lastmod` - delete properties.url; // Not required - - switch (this.options.frontMatterFormat) { - case "json": { - delimiters = ["", "\n"]; - frontMatter = JSON.stringify(properties, undefined, 2); - break; - } - - case "toml": { - delimiters = ["+++\n", "+++\n"]; - frontMatter = TOML.stringify(properties); - break; - } - - default: { - delimiters = ["---\n", "---\n"]; - frontMatter = YAML.stringify(properties, { lineWidth: 0 }); - break; - } - } - - return `${delimiters[0]}${frontMatter}${delimiters[1]}`; - } - - /** - * Post types - * @returns {object} Post types configuration - */ - get postTypes() { - return [ - { - type: "article", - name: "Article", - post: { - path: "content/articles/{slug}.md", - url: "articles/{slug}", - }, - media: { - path: "static/articles/{filename}", - url: "articles/{filename}", - }, - }, - { - type: "note", - name: "Note", - post: { - path: "content/notes/{slug}.md", - url: "notes/{slug}", - }, - }, - { - type: "photo", - name: "Photo", - post: { - path: "content/photos/{slug}.md", - url: "photos/{slug}", - }, - media: { - path: "static/photos/{filename}", - url: "photos/{filename}", - }, - }, - { - type: "video", - name: "Video", - post: { - path: "content/videos/{slug}.md", - url: "videos/{slug}", - }, - media: { - path: "static/videos/{filename}", - url: "videos/{filename}", - }, - }, - { - type: "audio", - name: "Audio", - post: { - path: "content/audio/{slug}.md", - url: "audio/{slug}", - }, - media: { - path: "static/audio/{filename}", - url: "audio/{filename}", - }, - }, - { - type: "bookmark", - name: "Bookmark", - post: { - path: "content/bookmarks/{slug}.md", - url: "bookmarks/{slug}", - }, - }, - { - type: "checkin", - name: "Checkin", - post: { - path: "content/checkins/{slug}.md", - url: "checkins/{slug}", - }, - }, - { - type: "event", - name: "Event", - post: { - path: "content/events/{slug}.md", - url: "events/{slug}", - }, - }, - { - type: "rsvp", - name: "RSVP", - post: { - path: "content/replies/{slug}.md", - url: "replies/{slug}", - }, - }, - { - type: "reply", - name: "Reply", - post: { - path: "content/replies/{slug}.md", - url: "replies/{slug}", - }, - }, - { - type: "repost", - name: "Repost", - post: { - path: "content/reposts/{slug}.md", - url: "reposts/{slug}", - }, - }, - { - type: "like", - name: "Like", - post: { - path: "content/likes/{slug}.md", - url: "likes/{slug}", - }, - }, - ]; - } - - /** - * Post template - * @param {object} properties - JF2 properties - * @returns {string} Rendered template - */ postTemplate(properties) { - const content = this.#content(properties); - const frontMatter = this.#frontMatter(properties); - - return frontMatter + content; + return getPostTemplate(properties, this.options.frontMatterFormat); } init(Indiekit) { + const { publication } = Indiekit.config; + this.postTypes = getPostTypes(publication.postTypes); + Indiekit.addPreset(this); } } diff --git a/packages/preset-hugo/lib/post-template.js b/packages/preset-hugo/lib/post-template.js new file mode 100644 index 000000000..353c3212a --- /dev/null +++ b/packages/preset-hugo/lib/post-template.js @@ -0,0 +1,99 @@ +import camelcaseKeys from "camelcase-keys"; +import TOML from "@iarna/toml"; +import YAML from "yaml"; + +/** + * Get content + * @access private + * @param {object} properties - JF2 properties + * @returns {string} Content + */ +const getContent = (properties) => { + if (properties.content) { + const content = + properties.content.text || properties.content.html || properties.content; + return `\n${content}\n`; + } else { + return ""; + } +}; + +/** + * Get front matter + * @access private + * @param {object} properties - JF2 properties + * @param {string} frontMatterFormat - Front matter format + * @returns {string} Front matter in chosen format + */ +const getFrontMatter = (properties, frontMatterFormat) => { + let delimiters; + let frontMatter; + + /* + * Go templates don’t accept hyphens in property names + * and Hugo camelCases its predefined front matter keys + * @see {link: https://gohugo.io/content-management/front-matter/} + */ + properties = camelcaseKeys(properties, { deep: true }); + + /* + * Replace Microformat properties with Hugo equivalents + * @see {link: https://gohugo.io/content-management/front-matter/} + */ + properties = { + date: properties.published, + publishDate: properties.published, + ...(properties.postStatus === "draft" && { draft: true }), + ...(properties.updated && { lastmod: properties.updated }), + ...(properties.deleted && { expiryDate: properties.deleted }), + ...(properties.name && { title: properties.name }), + ...(properties.photo && { + images: properties.photo.map((image) => image.url), + }), + ...properties, + }; + + delete properties.content; // Shown below front matter + delete properties.deleted; // Use `expiryDate` + delete properties.name; // Use `title` + delete properties.postStatus; // Use `draft` + delete properties.published; // Use `date` + delete properties.type; // Not required + delete properties.updated; // Use `lastmod` + delete properties.url; // Not required + + switch (frontMatterFormat) { + case "json": { + delimiters = ["", "\n"]; + frontMatter = JSON.stringify(properties, undefined, 2); + break; + } + + case "toml": { + delimiters = ["+++\n", "+++\n"]; + frontMatter = TOML.stringify(properties); + break; + } + + default: { + delimiters = ["---\n", "---\n"]; + frontMatter = YAML.stringify(properties, { lineWidth: 0 }); + break; + } + } + + return `${delimiters[0]}${frontMatter}${delimiters[1]}`; +}; + +/** + * Get post template + * @param {object} properties - JF2 properties + * @param {string} [frontMatterFormat] - Front matter format + * @returns {string} Rendered template + */ +export const getPostTemplate = (properties, frontMatterFormat = "yaml") => { + const content = getContent(properties); + const frontMatter = getFrontMatter(properties, frontMatterFormat); + + return frontMatter + content; +}; diff --git a/packages/preset-hugo/lib/post-types.js b/packages/preset-hugo/lib/post-types.js new file mode 100644 index 000000000..e6476fb17 --- /dev/null +++ b/packages/preset-hugo/lib/post-types.js @@ -0,0 +1,34 @@ +import plur from "plur"; + +/** + * Get paths and URLs for configured post types + * @param {object} postTypes - Post type configuration + * @returns {object} Updated post type configuration + */ +export const getPostTypes = (postTypes) => { + const types = []; + + for (const postType of postTypes) { + const { type } = postType; + const section = plur(type); + + /** + * Follow Hugo content management guidelines + * @see {@link https://gohugo.io/content-management/organization/} + * @see {@link https://gohugo.io/content-management/static-files/} + */ + types.push({ + type, + post: { + path: `content/${section}/{slug}.md`, + url: `${section}/{slug}`, + }, + media: { + path: `static/${section}/{filename}`, + url: `${section}/{filename}`, + }, + }); + } + + return types; +}; diff --git a/packages/preset-hugo/package.json b/packages/preset-hugo/package.json index ae4c88ff0..517a617be 100644 --- a/packages/preset-hugo/package.json +++ b/packages/preset-hugo/package.json @@ -22,6 +22,7 @@ "main": "index.js", "files": [ "assets", + "lib", "index.js" ], "bugs": { diff --git a/packages/preset-hugo/test/index.js b/packages/preset-hugo/test/index.js index 4fdc6ef3e..dc48d4f82 100644 --- a/packages/preset-hugo/test/index.js +++ b/packages/preset-hugo/test/index.js @@ -1,13 +1,28 @@ import { strict as assert } from "node:assert"; import { describe, it } from "node:test"; import { Indiekit } from "@indiekit/indiekit"; -import { getFixture } from "@indiekit-test/fixtures"; import HugoPreset from "../index.js"; -describe("preset-hugo", () => { +describe("preset-hugo", async () => { const hugo = new HugoPreset(); + const indiekit = await Indiekit.initialize({ + config: { + publication: { + postTypes: [ + { + type: "puppy", + name: "Puppy posts", + }, + ], + }, + }, + }); - const properties = JSON.parse(getFixture("jf2/post-template-properties.jf2")); + hugo.init(indiekit); + + it("Initiates plug-in", async () => { + assert.equal(indiekit.publication.preset.info.name, "Hugo"); + }); it("Gets plug-in info", () => { assert.equal(hugo.name, "Hugo preset"); @@ -21,227 +36,19 @@ describe("preset-hugo", () => { ); }); - it("Initiates plug-in", async () => { - const indiekit = await Indiekit.initialize({ config: {} }); - hugo.init(indiekit); - - assert.equal(indiekit.publication.preset.info.name, "Hugo"); - }); - it("Gets publication post types", () => { - const result = hugo.postTypes; - - assert.equal(result[0].type, "article"); - }); - - it("Renders post template without content", () => { - const result = hugo.postTemplate({ - published: "2020-02-02", - updated: "2022-12-11", - deleted: "2022-12-12", - name: "What I had for lunch", + assert.deepEqual(hugo.postTypes[0].post, { + path: "content/puppies/{slug}.md", + url: "static/puppies/{filename}", }); - - assert.equal( - result, - `--- -date: 2020-02-02 -publishDate: 2020-02-02 -lastmod: 2022-12-11 -expiryDate: 2022-12-12 -title: What I had for lunch ---- -`, - ); - }); - - it("Renders post template with basic draft content", () => { - const result = hugo.postTemplate({ - published: "2020-02-02", - name: "What I had for lunch", - content: - "I ate a [cheese](https://en.wikipedia.org/wiki/Cheese) sandwich, which was nice.", - "post-status": "draft", - }); - - assert.equal( - result, - `--- -date: 2020-02-02 -publishDate: 2020-02-02 -draft: true -title: What I had for lunch ---- - -I ate a [cheese](https://en.wikipedia.org/wiki/Cheese) sandwich, which was nice. -`, - ); }); - it("Renders post template with HTML content", () => { + it("Renders post template", () => { const result = hugo.postTemplate({ published: "2020-02-02", - name: "What I had for lunch", - content: { - html: '

I ate a cheese sandwich, which was nice.

', - }, + name: "Lunchtime", }); - assert.equal( - result, - `--- -date: 2020-02-02 -publishDate: 2020-02-02 -title: What I had for lunch ---- - -

I ate a cheese sandwich, which was nice.

-`, - ); - }); - - it("Renders post template with JSON front matter", () => { - const hugo = new HugoPreset({ frontMatterFormat: "json" }); - const result = hugo.postTemplate(properties); - - assert.equal( - result, - `{ - "date": "2020-02-02", - "publishDate": "2020-02-02", - "title": "What I had for lunch", - "images": [ - "https://website.example/photo.jpg" - ], - "summary": "A very satisfactory meal.", - "category": [ - "lunch", - "food" - ], - "audio": [ - { - "url": "https://website.example/audio.mp3" - } - ], - "photo": [ - { - "alt": "Alternative text", - "url": "https://website.example/photo.jpg" - } - ], - "video": [ - { - "url": "https://website.example/video.mp4" - } - ], - "start": "2020-02-02", - "end": "2020-02-20", - "rsvp": "Yes", - "location": { - "type": "geo", - "latitude": "37.780080", - "longitude": "-122.420160", - "name": "37° 46′ 48.29″ N 122° 25′ 12.576″ W" - }, - "bookmarkOf": "https://website.example", - "likeOf": "https://website.example", - "repostOf": "https://website.example", - "inReplyTo": "https://website.example", - "visibility": "private", - "syndication": "https://website.example/post/12345" -} - -I ate a [cheese](https://en.wikipedia.org/wiki/Cheese) sandwich, which was nice. -`, - ); - }); - - it("Renders post template with TOML front matter", () => { - const hugo = new HugoPreset({ frontMatterFormat: "toml" }); - const result = hugo.postTemplate(properties); - - assert.equal( - result, - `+++ -date = "2020-02-02" -publishDate = "2020-02-02" -title = "What I had for lunch" -images = [ "https://website.example/photo.jpg" ] -summary = "A very satisfactory meal." -category = [ "lunch", "food" ] -start = "2020-02-02" -end = "2020-02-20" -rsvp = "Yes" -bookmarkOf = "https://website.example" -likeOf = "https://website.example" -repostOf = "https://website.example" -inReplyTo = "https://website.example" -visibility = "private" -syndication = "https://website.example/post/12345" - -[[audio]] -url = "https://website.example/audio.mp3" - -[[photo]] -alt = "Alternative text" -url = "https://website.example/photo.jpg" - -[[video]] -url = "https://website.example/video.mp4" - -[location] -type = "geo" -latitude = "37.780080" -longitude = "-122.420160" -name = "37° 46′ 48.29″ N 122° 25′ 12.576″ W" -+++ - -I ate a [cheese](https://en.wikipedia.org/wiki/Cheese) sandwich, which was nice. -`, - ); - }); - - it("Renders post template with YAML front matter", () => { - const hugo = new HugoPreset({ frontMatterFormat: "yaml" }); - const result = hugo.postTemplate(properties); - - assert.equal( - result, - `--- -date: 2020-02-02 -publishDate: 2020-02-02 -title: What I had for lunch -images: - - https://website.example/photo.jpg -summary: A very satisfactory meal. -category: - - lunch - - food -audio: - - url: https://website.example/audio.mp3 -photo: - - alt: Alternative text - url: https://website.example/photo.jpg -video: - - url: https://website.example/video.mp4 -start: 2020-02-02 -end: 2020-02-20 -rsvp: Yes -location: - type: geo - latitude: "37.780080" - longitude: "-122.420160" - name: 37° 46′ 48.29″ N 122° 25′ 12.576″ W -bookmarkOf: https://website.example -likeOf: https://website.example -repostOf: https://website.example -inReplyTo: https://website.example -visibility: private -syndication: https://website.example/post/12345 ---- - -I ate a [cheese](https://en.wikipedia.org/wiki/Cheese) sandwich, which was nice. -`, - ); + assert.equal(result.includes("date: 2020-02-02"), true); }); }); diff --git a/packages/preset-hugo/test/unit/post-template.js b/packages/preset-hugo/test/unit/post-template.js new file mode 100644 index 000000000..9638090eb --- /dev/null +++ b/packages/preset-hugo/test/unit/post-template.js @@ -0,0 +1,216 @@ +import { strict as assert } from "node:assert"; +import { describe, it } from "node:test"; +import { getFixture } from "@indiekit-test/fixtures"; +import { getPostTemplate } from "../../lib/post-template.js"; + +describe("preset-jekyll/lib/post-template", async () => { + const properties = JSON.parse(getFixture("jf2/post-template-properties.jf2")); + + it("Renders post template without content", () => { + const result = getPostTemplate({ + published: "2020-02-02", + updated: "2022-12-11", + deleted: "2022-12-12", + name: "What I had for lunch", + }); + + assert.equal( + result, + `--- +date: 2020-02-02 +publishDate: 2020-02-02 +lastmod: 2022-12-11 +expiryDate: 2022-12-12 +title: What I had for lunch +--- +`, + ); + }); + + it("Renders post template with basic draft content", () => { + const result = getPostTemplate({ + published: "2020-02-02", + name: "What I had for lunch", + content: + "I ate a [cheese](https://en.wikipedia.org/wiki/Cheese) sandwich, which was nice.", + "post-status": "draft", + }); + + assert.equal( + result, + `--- +date: 2020-02-02 +publishDate: 2020-02-02 +draft: true +title: What I had for lunch +--- + +I ate a [cheese](https://en.wikipedia.org/wiki/Cheese) sandwich, which was nice. +`, + ); + }); + + it("Renders post template with HTML content", () => { + const result = getPostTemplate({ + published: "2020-02-02", + name: "What I had for lunch", + content: { + html: '

I ate a cheese sandwich, which was nice.

', + }, + }); + + assert.equal( + result, + `--- +date: 2020-02-02 +publishDate: 2020-02-02 +title: What I had for lunch +--- + +

I ate a cheese sandwich, which was nice.

+`, + ); + }); + + it("Renders post template with JSON front matter", () => { + const result = getPostTemplate(properties, "json"); + + assert.equal( + result, + `{ + "date": "2020-02-02", + "publishDate": "2020-02-02", + "title": "What I had for lunch", + "images": [ + "https://website.example/photo.jpg" + ], + "summary": "A very satisfactory meal.", + "category": [ + "lunch", + "food" + ], + "audio": [ + { + "url": "https://website.example/audio.mp3" + } + ], + "photo": [ + { + "alt": "Alternative text", + "url": "https://website.example/photo.jpg" + } + ], + "video": [ + { + "url": "https://website.example/video.mp4" + } + ], + "start": "2020-02-02", + "end": "2020-02-20", + "rsvp": "Yes", + "location": { + "type": "geo", + "latitude": "37.780080", + "longitude": "-122.420160", + "name": "37° 46′ 48.29″ N 122° 25′ 12.576″ W" + }, + "bookmarkOf": "https://website.example", + "likeOf": "https://website.example", + "repostOf": "https://website.example", + "inReplyTo": "https://website.example", + "visibility": "private", + "syndication": "https://website.example/post/12345" +} + +I ate a [cheese](https://en.wikipedia.org/wiki/Cheese) sandwich, which was nice. +`, + ); + }); + + it("Renders post template with TOML front matter", () => { + const result = getPostTemplate(properties, "toml"); + + assert.equal( + result, + `+++ +date = "2020-02-02" +publishDate = "2020-02-02" +title = "What I had for lunch" +images = [ "https://website.example/photo.jpg" ] +summary = "A very satisfactory meal." +category = [ "lunch", "food" ] +start = "2020-02-02" +end = "2020-02-20" +rsvp = "Yes" +bookmarkOf = "https://website.example" +likeOf = "https://website.example" +repostOf = "https://website.example" +inReplyTo = "https://website.example" +visibility = "private" +syndication = "https://website.example/post/12345" + +[[audio]] +url = "https://website.example/audio.mp3" + +[[photo]] +alt = "Alternative text" +url = "https://website.example/photo.jpg" + +[[video]] +url = "https://website.example/video.mp4" + +[location] +type = "geo" +latitude = "37.780080" +longitude = "-122.420160" +name = "37° 46′ 48.29″ N 122° 25′ 12.576″ W" ++++ + +I ate a [cheese](https://en.wikipedia.org/wiki/Cheese) sandwich, which was nice. +`, + ); + }); + + it("Renders post template with YAML front matter", () => { + const result = getPostTemplate(properties, "yaml"); + + assert.equal( + result, + `--- +date: 2020-02-02 +publishDate: 2020-02-02 +title: What I had for lunch +images: + - https://website.example/photo.jpg +summary: A very satisfactory meal. +category: + - lunch + - food +audio: + - url: https://website.example/audio.mp3 +photo: + - alt: Alternative text + url: https://website.example/photo.jpg +video: + - url: https://website.example/video.mp4 +start: 2020-02-02 +end: 2020-02-20 +rsvp: Yes +location: + type: geo + latitude: "37.780080" + longitude: "-122.420160" + name: 37° 46′ 48.29″ N 122° 25′ 12.576″ W +bookmarkOf: https://website.example +likeOf: https://website.example +repostOf: https://website.example +inReplyTo: https://website.example +visibility: private +syndication: https://website.example/post/12345 +--- + +I ate a [cheese](https://en.wikipedia.org/wiki/Cheese) sandwich, which was nice. +`, + ); + }); +}); diff --git a/packages/preset-hugo/test/unit/post-types.js b/packages/preset-hugo/test/unit/post-types.js new file mode 100644 index 000000000..dd4b4872f --- /dev/null +++ b/packages/preset-hugo/test/unit/post-types.js @@ -0,0 +1,58 @@ +import { strict as assert } from "node:assert"; +import { describe, it } from "node:test"; +import { getPostTypes } from "../../lib/post-types.js"; + +const postTypes = [ + { + type: "article", + name: "Journal post", + }, + { + type: "note", + name: "Micro post", + }, + { + type: "puppy", + name: "Puppy post", + }, +]; + +describe("preset-hugo/lib/post-types", () => { + it("Gets paths and URLs for configured post types", () => { + assert.deepEqual(getPostTypes(postTypes), [ + { + type: "article", + post: { + path: "content/articles/{slug}.md", + url: "articles/{slug}", + }, + media: { + path: "static/articles/{filename}", + url: "articles/{filename}", + }, + }, + { + type: "note", + post: { + path: "content/notes/{slug}.md", + url: "notes/{slug}", + }, + media: { + path: "static/notes/{filename}", + url: "notes/{filename}", + }, + }, + { + type: "puppy", + post: { + path: "content/puppies/{slug}.md", + url: "puppies/{slug}", + }, + media: { + path: "static/puppies/{filename}", + url: "puppies/{filename}", + }, + }, + ]); + }); +});