diff --git a/CHANGELOG.md b/CHANGELOG.md index bd36a36c..0ec266ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project try to adheres to [Semantic Versioning](https://semver.org/). Go to the `v1` branch to see the changelog of Lume 1. +## [Unreleased] +### Added +- New plugin: `json_ld` for generating JSON-LD tags in the output [#453] +- New plugin: `purgecss` to remove unused CSS. [#693] + ## [2.4.3] - 2024-12-11 ### Added - New option `finalHandler` to `Server` class. @@ -538,6 +543,7 @@ Go to the `v1` branch to see the changelog of Lume 1. [#376]: https://github.com/lumeland/lume/issues/376 [#430]: https://github.com/lumeland/lume/issues/430 [#447]: https://github.com/lumeland/lume/issues/447 +[#453]: https://github.com/lumeland/lume/issues/453 [#494]: https://github.com/lumeland/lume/issues/494 [#501]: https://github.com/lumeland/lume/issues/501 [#517]: https://github.com/lumeland/lume/issues/517 @@ -623,8 +629,10 @@ Go to the `v1` branch to see the changelog of Lume 1. [#685]: https://github.com/lumeland/lume/issues/685 [#686]: https://github.com/lumeland/lume/issues/686 [#689]: https://github.com/lumeland/lume/issues/689 +[#693]: https://github.com/lumeland/lume/issues/693 [#704]: https://github.com/lumeland/lume/issues/704 +[Unreleased]: https://github.com/lumeland/lume/compare/v2.4.3...HEAD [2.4.3]: https://github.com/lumeland/lume/compare/v2.4.2...v2.4.3 [2.4.2]: https://github.com/lumeland/lume/compare/v2.4.1...v2.4.2 [2.4.1]: https://github.com/lumeland/lume/compare/v2.4.0...v2.4.1 diff --git a/core/utils/lume_config.ts b/core/utils/lume_config.ts index 9a96b2a8..c553a935 100644 --- a/core/utils/lume_config.ts +++ b/core/utils/lume_config.ts @@ -16,6 +16,7 @@ export const pluginNames = [ "google_fonts", "gzip", "inline", + "json_ld", "jsx", "jsx_preact", "katex", @@ -36,6 +37,7 @@ export const pluginNames = [ "postcss", "prism", "pug", + "purgecss", "reading_info", "redirects", "relations", diff --git a/deps/purgecss.ts b/deps/purgecss.ts new file mode 100644 index 00000000..b60cca4d --- /dev/null +++ b/deps/purgecss.ts @@ -0,0 +1,2 @@ +export * from "npm:purgecss@6.0.0"; +export { default as purgeHtml } from "npm:purgecss-from-html@6.0.0/lib/purgecss-from-html.esm.js"; diff --git a/plugins/json_ld.ts b/plugins/json_ld.ts new file mode 100644 index 00000000..94c7756f --- /dev/null +++ b/plugins/json_ld.ts @@ -0,0 +1,266 @@ +import { isPlainObject, merge } from "../core/utils/object.ts"; +import { getDataValue } from "../core/utils/data_values.ts"; + +import type Site from "../core/site.ts"; +import type { Page } from "../core/file.ts"; +import { Graph, Thing } from "npm:schema-dts@1.1.2"; +export interface Options { + /** The list extensions this plugin applies to */ + extensions?: string[]; + + /** The key name for the transformations definitions */ + name?: string; +} + +const defaults: Options = { + extensions: [".html"], + name: "jsonLd", +}; + +export type JsonldData = Graph | Thing; + +/** + * This variable is the result of running + * JSON.stringify(Array.from(document.querySelectorAll('th.prop-nam a')).map(a => a.textContent)) + * on https://schema.org/URL and add '@id'. + */ +const urlKeys = [ + "@id", + "acceptsReservations", + "acquireLicensePage", + "actionPlatform", + "actionableFeedbackPolicy", + "additionalType", + "afterMedia", + "applicationCategory", + "applicationSubCategory", + "archivedAt", + "artMedium", + "artform", + "artworkSurface", + "asin", + "associatedDisease", + "bankAccountType", + "beforeMedia", + "benefitsSummaryUrl", + "bodyType", + "category", + "childTaxon", + "codeRepository", + "colleague", + "colorSwatch", + "competencyRequired", + "constraintProperty", + "contentUrl", + "correction", + "correctionsPolicy", + "courseMode", + "credentialCategory", + "discussionUrl", + "diseasePreventionInfo", + "diseaseSpreadStatistics", + "diversityPolicy", + "diversityStaffingReport", + "documentation", + "downloadUrl", + "duringMedia", + "editEIDR", + "educationalCredentialAwarded", + "educationalLevel", + "educationalProgramMode", + "embedUrl", + "encodingFormat", + "engineType", + "ethicsPolicy", + "featureList", + "feesAndCommissionsSpecification", + "fileFormat", + "fuelType", + "gameLocation", + "gamePlatform", + "genre", + "gettingTestedInfo", + "gtin", + "hasGS1DigitalLink", + "hasMap", + "hasMenu", + "hasMolecularFunction", + "hasRepresentation", + "healthPlanMarketingUrl", + "identifier", + "image", + "inCodeSet", + "inDefinedTermSet", + "installUrl", + "isBasedOn", + "isBasedOnUrl", + "isInvolvedInBiologicalProcess", + "isLocatedInSubcellularLocation", + "isPartOf", + "keywords", + "knowsAbout", + "labelDetails", + "layoutImage", + "legislationIdentifier", + "license", + "loanType", + "logo", + "mainEntityOfPage", + "map", + "maps", + "masthead", + "material", + "measurementMethod", + "measurementTechnique", + "meetsEmissionStandard", + "memoryRequirements", + "menu", + "merchantReturnLink", + "missionCoveragePrioritiesPolicy", + "namedPosition", + "newsUpdatesAndGuidelines", + "noBylinesPolicy", + "occupationalCredentialAwarded", + "originalMediaLink", + "ownershipFundingInfo", + "parentTaxon", + "paymentUrl", + "physicalRequirement", + "prescribingInfo", + "productReturnLink", + "propertyID", + "publicTransportClosuresInfo", + "publishingPrinciples", + "quarantineGuidelines", + "relatedLink", + "releaseNotes", + "replyToUrl", + "requirements", + "roleName", + "sameAs", + "schemaVersion", + "schoolClosuresInfo", + "screenshot", + "sdLicense", + "season", + "securityClearanceRequirement", + "sensoryRequirement", + "serviceUrl", + "shippingSettingsLink", + "significantLink", + "significantLinks", + "softwareRequirements", + "speakable", + "sport", + "statType", + "storageRequirements", + "surface", + "target", + "targetUrl", + "taxonRank", + "taxonomicRange", + "temporalCoverage", + "termsOfService", + "thumbnailUrl", + "ticketToken", + "titleEIDR", + "tourBookingPage", + "trackingUrl", + "travelBans", + "unitCode", + "unnamedSourcesPolicy", + "url", + "usageInfo", + "usesHealthPlanIdStandard", + "vehicleTransmission", + "verificationFactCheckingPolicy", + "warning", + "webFeed", +]; + +function isEmpty(v: unknown) { + return v === undefined || v === null || v === ""; +} + +/** + * A plugin to insert structured JSON-LD data for SEO and social media + * @see https://lume.land/plugins/json_ld/ + */ +export function jsonLd(userOptions?: Options) { + const options = merge(defaults, userOptions); + + return (site: Site) => { + site.mergeKey(options.name, "object"); + site.process(options.extensions, (pages) => pages.forEach(jsonLdProcessor)); + + function jsonLdProcessor(page: Page) { + let jsonLdData = page.data[options.name] as JsonldData | undefined; + + if (!jsonLdData || !page.document) { + return; + } + const { document, data } = page; + + // Recursive function to traverse and process JSON-LD data + function traverse(key: string | undefined, value: unknown): unknown { + if (typeof value === "string") { + const dataValue = getDataValue(data, value); + // Check if the value is a URL or ID that needs to be processed + if ( + key && + urlKeys.includes(key) && + (dataValue.startsWith("/") || + dataValue.startsWith("./") || + dataValue.startsWith("../")) + ) { + const pageUrl = site.url(data.url, true); + return new URL(dataValue, pageUrl); + } + return isEmpty(dataValue) ? undefined : dataValue; + } + if (Array.isArray(value)) { + return value.reduce((p, c) => { + const processedValue = traverse(key, c); + if (!isEmpty(value)) p.push(processedValue); + return p; + }, []); + } + if (isPlainObject(value)) { + const processedObject: Record = {}; + let isEmptyObject = true; + for (const [key, v] of Object.entries(value)) { + const processedValue = traverse(key, v); + // If there's no valid value other than @type, remove this object + if (!(key === "@type") && processedValue) isEmptyObject = false; + processedObject[key] = processedValue; + } + return isEmptyObject ? undefined : processedObject; + } + return value; + } + jsonLdData = traverse(undefined, jsonLdData) as JsonldData; + if (jsonLdData || Object.keys(jsonLdData ?? {}).length !== 0) { + const script = document.createElement("script"); + script.setAttribute("type", "application/ld+json"); + script.textContent = JSON.stringify(jsonLdData); + document.head.appendChild(script); + document.head.appendChild(document.createTextNode("\n")); + } + } + }; +} + +export default jsonLd; + +/** Extends Data interface */ +declare global { + namespace Lume { + export interface Data { + /** + * JSON_LD elements + * @see https://lume.land/plugins/json_ld/ + */ + jsonLd?: JsonldData; + } + } +} diff --git a/plugins/purgecss.ts b/plugins/purgecss.ts new file mode 100644 index 00000000..20db37bd --- /dev/null +++ b/plugins/purgecss.ts @@ -0,0 +1,84 @@ +import { PurgeCSS, purgeHtml } from "../deps/purgecss.ts"; +import { merge } from "../core/utils/object.ts"; +import { concurrent } from "../core/utils/concurrent.ts"; +import { getExtension, matchExtension } from "../core/utils/path.ts"; + +import type Site from "../core/site.ts"; +import type { Page } from "../core/file.ts"; +import type { RawContent, UserDefinedOptions } from "../deps/purgecss.ts"; + +export interface Options { + /** The list of extensions this plugin applies to. */ + extensions?: string[]; + + /** The list of page extensions loaded as content. */ + contentExtensions?: string[]; + + /** Options for purgecss */ + options?: Partial; +} + +// Default options +export const defaults: Options = { + extensions: [".css"], + contentExtensions: [".html", ".js"], + options: { + extractors: [], + }, +}; + +/** + * A plugin to remove unused CSS + */ +export function purgecss(userOptions?: Options) { + const options = merge(defaults, userOptions); + + options.options.extractors!.push({ + extractor: purgeHtml, + extensions: ["html"], + }); + + return (site: Site) => { + site.process(options.extensions, async (pages, allPages) => { + const content: RawContent[] = []; + for (const page of allPages) { + if (!matchExtension(options.contentExtensions, page.outputPath)) { + continue; + } + + const pageContent = page.content; + if (typeof pageContent !== "string") { + return; + } + + content.push({ + raw: pageContent, + extension: getExtension(page.outputPath).slice(1), + }); + } + + await concurrent(pages, async (page: Page) => { + const pageContent = page.content; + if (typeof pageContent !== "string") { + return; + } + + const purgeOptions: UserDefinedOptions = { + ...options.options, + content: (options.options.content || []).concat(content), + css: [ + { + raw: pageContent, + }, + ], + }; + + const result = await new PurgeCSS().purge(purgeOptions); + + page.content = result[0].css; + }); + }); + }; +} + +export default purgecss; diff --git a/tests/__snapshots__/json_ld.test.ts.snap b/tests/__snapshots__/json_ld.test.ts.snap new file mode 100644 index 00000000..2ba8d47e --- /dev/null +++ b/tests/__snapshots__/json_ld.test.ts.snap @@ -0,0 +1,226 @@ +export const snapshot = {}; + +snapshot[`json_ld plugin 1`] = ` +{ + formats: [ + { + engines: 0, + ext: ".page.toml", + loader: [AsyncFunction: toml], + pageType: "page", + }, + { + engines: 1, + ext: ".page.ts", + loader: [AsyncFunction: module], + pageType: "page", + }, + { + engines: 1, + ext: ".page.js", + loader: [AsyncFunction: module], + pageType: "page", + }, + { + engines: 0, + ext: ".page.jsonc", + loader: [AsyncFunction: json], + pageType: "page", + }, + { + engines: 0, + ext: ".page.json", + loader: [AsyncFunction: json], + pageType: "page", + }, + { + dataLoader: [AsyncFunction: json], + engines: 0, + ext: ".json", + loader: [AsyncFunction: json], + }, + { + dataLoader: [AsyncFunction: json], + engines: 0, + ext: ".jsonc", + loader: [AsyncFunction: json], + }, + { + engines: 1, + ext: ".md", + loader: [AsyncFunction: text], + pageType: "page", + }, + { + engines: 1, + ext: ".markdown", + loader: [AsyncFunction: text], + pageType: "page", + }, + { + dataLoader: [AsyncFunction: module], + engines: 1, + ext: ".js", + loader: [AsyncFunction: module], + }, + { + dataLoader: [AsyncFunction: module], + engines: 1, + ext: ".ts", + loader: [AsyncFunction: module], + }, + { + engines: 1, + ext: ".vento", + loader: [AsyncFunction: text], + pageType: "page", + }, + { + engines: 1, + ext: ".vto", + loader: [AsyncFunction: text], + pageType: "page", + }, + { + dataLoader: [AsyncFunction: toml], + engines: 0, + ext: ".toml", + loader: [AsyncFunction: toml], + }, + { + dataLoader: [AsyncFunction: yaml], + engines: 0, + ext: ".yaml", + loader: [AsyncFunction: yaml], + pageType: "page", + }, + { + dataLoader: [AsyncFunction: yaml], + engines: 0, + ext: ".yml", + loader: [AsyncFunction: yaml], + pageType: "page", + }, + ], + src: [ + "/", + "/page-1.md", + "/page-2.vto", + ], +} +`; + +snapshot[`json_ld plugin 2`] = `[]`; + +snapshot[`json_ld plugin 3`] = ` +[ + { + content: ' + +

Welcome to my website

+

This is my first page using Lume, a static site generator for Deno. +test link +I hope you enjoy it.

+', + data: { + basename: "page-1", + children: '

Welcome to my website

+

This is my first page using Lume, a static site generator for Deno. +test link +I hope you enjoy it.

+', + content: "# Welcome to my website + +This is my first page using **Lume,** a static site generator for Deno. +[test link](/test/) +I hope you enjoy it. +", + cover: "./use-cover-as-meta-image.png", + date: [], + header: [ + "title", + ], + jsonLd: [ + "@context", + "@type", + "url", + "name", + "inLanguage", + "publisher", + "image", + "keywords", + "emptyThing", + ], + mergedKeys: [ + "tags", + "jsonLd", + ], + page: [ + "src", + "data", + "asset", + ], + paginate: "paginate", + search: [], + tags: "Array(0)", + url: "/page-1/", + }, + src: { + asset: false, + ext: ".md", + path: "/page-1", + remote: undefined, + }, + }, + { + content: " + + + + Pages without jsonld + + +", + data: { + basename: "page-2", + children: " + + + + Pages without jsonld + + +", + content: " + + + + Pages without jsonld + + +", + date: [], + jsonLd: [], + mergedKeys: [ + "tags", + "jsonLd", + ], + page: [ + "src", + "data", + "asset", + ], + paginate: "paginate", + search: [], + tags: "Array(0)", + url: "/page-2/", + }, + src: { + asset: false, + ext: ".vto", + path: "/page-2", + remote: undefined, + }, + }, +] +`; diff --git a/tests/__snapshots__/purgecss.test.ts.snap b/tests/__snapshots__/purgecss.test.ts.snap new file mode 100644 index 00000000..956b1d50 --- /dev/null +++ b/tests/__snapshots__/purgecss.test.ts.snap @@ -0,0 +1,854 @@ +export const snapshot = {}; + +snapshot[`purgecss plugin 1`] = ` +{ + formats: [ + { + engines: 0, + ext: ".page.toml", + loader: [AsyncFunction: toml], + pageType: "page", + }, + { + engines: 1, + ext: ".page.ts", + loader: [AsyncFunction: module], + pageType: "page", + }, + { + engines: 1, + ext: ".page.js", + loader: [AsyncFunction: module], + pageType: "page", + }, + { + engines: 0, + ext: ".page.jsonc", + loader: [AsyncFunction: json], + pageType: "page", + }, + { + engines: 0, + ext: ".page.json", + loader: [AsyncFunction: json], + pageType: "page", + }, + { + dataLoader: [AsyncFunction: json], + engines: 0, + ext: ".json", + loader: [AsyncFunction: json], + }, + { + dataLoader: [AsyncFunction: json], + engines: 0, + ext: ".jsonc", + loader: [AsyncFunction: json], + }, + { + engines: 1, + ext: ".md", + loader: [AsyncFunction: text], + pageType: "page", + }, + { + engines: 1, + ext: ".markdown", + loader: [AsyncFunction: text], + pageType: "page", + }, + { + assetLoader: [AsyncFunction: text], + dataLoader: [AsyncFunction: module], + engines: 1, + ext: ".js", + loader: [AsyncFunction: module], + pageType: "asset", + }, + { + dataLoader: [AsyncFunction: module], + engines: 1, + ext: ".ts", + loader: [AsyncFunction: module], + }, + { + engines: 1, + ext: ".vento", + loader: [AsyncFunction: text], + pageType: "page", + }, + { + engines: 1, + ext: ".vto", + loader: [AsyncFunction: text], + pageType: "page", + }, + { + dataLoader: [AsyncFunction: toml], + engines: 0, + ext: ".toml", + loader: [AsyncFunction: toml], + }, + { + dataLoader: [AsyncFunction: yaml], + engines: 0, + ext: ".yaml", + loader: [AsyncFunction: yaml], + pageType: "page", + }, + { + dataLoader: [AsyncFunction: yaml], + engines: 0, + ext: ".yml", + loader: [AsyncFunction: yaml], + pageType: "page", + }, + { + assetLoader: [AsyncFunction: text], + engines: undefined, + ext: ".css", + pageType: "asset", + }, + ], + src: [ + "/", + "/_includes", + "/_includes/footer.vto", + "/_includes/layout.vto", + "/index.vto", + "/pages", + "/pages/page1.md", + "/pages/page2.page.js", + "/script.js", + "/static", + "/static/static.html", + "/styles.css", + ], +} +`; + +snapshot[`purgecss plugin 2`] = ` +[ + { + entry: "/static/static.html", + flags: [], + outputPath: "/static.html", + }, +] +`; + +snapshot[`purgecss plugin 3`] = ` +[ + { + content: ' + + + + + + TÍTULO + + +

Título

+ +strong + +
Content
+ + +
+Título +
+ + +', + data: { + basename: "index", + children: '

Título

+ +strong + +
Content
+', + content: '

{{ title }}

+ +strong + +
Content
+', + date: [], + layout: "layout.vto", + mergedKeys: [ + "tags", + ], + page: [ + "src", + "data", + "asset", + ], + paginate: "paginate", + search: [], + tags: "Array(0)", + title: "Título", + url: "/", + }, + src: { + asset: false, + ext: ".vto", + path: "/index", + remote: undefined, + }, + }, + { + content: " +

Content of Page 1

+", + data: { + basename: "page1", + children: "

Content of Page 1

+", + content: "Content of Page 1 +", + date: [], + mergedKeys: [ + "tags", + ], + page: [ + "src", + "data", + "asset", + ], + paginate: "paginate", + search: [], + tags: "Array(0)", + title: "Page 1", + url: "/pages/page1/", + }, + src: { + asset: false, + ext: ".md", + path: "/pages/page1", + remote: undefined, + }, + }, + { + content: ' +
Content of Page 2
', + data: { + basename: "page2", + children: '
Content of Page 2
', + content: '
Content of Page 2
', + date: [], + mergedKeys: [ + "tags", + ], + page: [ + "src", + "data", + "asset", + ], + paginate: "paginate", + search: [], + tags: "Array(0)", + title: "Page 2", + url: "/page_2/", + }, + src: { + asset: false, + ext: ".page.js", + path: "/pages/page2", + remote: undefined, + }, + }, + { + content: "document.querySelector('.dynamic-js') + .addEventListener('click', (event) => { + event.target.classList.add('dynamic-jssub-open'); + }); +", + data: { + basename: "script", + content: "document.querySelector('.dynamic-js') + .addEventListener('click', (event) => { + event.target.classList.add('dynamic-jssub-open'); + }); +", + date: [], + mergedKeys: [ + "tags", + ], + page: [ + "src", + "data", + "asset", + ], + paginate: "paginate", + search: [], + tags: "Array(0)", + url: "/script.js", + }, + src: { + asset: true, + ext: ".js", + path: "/script", + remote: undefined, + }, + }, + { + content: "::root { + --font-family: sans-serif; +} + +body { + font-family: sans-serif; + color: black; +} + +strong { + font-weight: bolder; +} + + html > * .content-vento { + max-width: 800px; +} + +.content-vento:not(.test):focus-visible { + outline: 2px solid blue; +} + +.content-dynamic { + display: flex; +} + +.content-dynamic:focus-visible { + outline: 2px solid lightblue; +} + +.dynamic-js { + opacity: 0; +} + +.dynamic-jssub-open { + opacity: 1; +} +", + data: { + basename: "styles", + content: "::root { + --font-family: sans-serif; +} + +body { + font-family: sans-serif; + color: black; +} + +.unused { + margin-top: 30px; +} + +strong { + font-weight: bolder; +} + +em { + font-style: italic; +} + +a[href] { + text-decoration: underline; +} + +html > body .no-such-element .content-vento, html > * .content-vento { + max-width: 800px; +} + +.content-vento:not(.test):focus-visible { + outline: 2px solid blue; +} + +.content-dynamic { + display: flex; +} + +.content-dynamic:focus-visible { + outline: 2px solid lightblue; +} + +.content-static { + margin-top: 25px; +} + +.content-other { + padding: 20px 5px; +} + +.dynamic-js { + opacity: 0; +} + +.dynamic-jssub { + display: none; +} + +.dynamic-jssub-open { + opacity: 1; +} + +#img-option { + max-width: 100%; +} +", + date: [], + mergedKeys: [ + "tags", + ], + page: [ + "src", + "data", + "asset", + ], + paginate: "paginate", + search: [], + tags: "Array(0)", + url: "/styles.css", + }, + src: { + asset: true, + ext: ".css", + path: "/styles", + remote: undefined, + }, + }, +] +`; + +snapshot[`purgecss plugin with options 1`] = ` +{ + formats: [ + { + engines: 0, + ext: ".page.toml", + loader: [AsyncFunction: toml], + pageType: "page", + }, + { + engines: 1, + ext: ".page.ts", + loader: [AsyncFunction: module], + pageType: "page", + }, + { + engines: 1, + ext: ".page.js", + loader: [AsyncFunction: module], + pageType: "page", + }, + { + engines: 0, + ext: ".page.jsonc", + loader: [AsyncFunction: json], + pageType: "page", + }, + { + engines: 0, + ext: ".page.json", + loader: [AsyncFunction: json], + pageType: "page", + }, + { + dataLoader: [AsyncFunction: json], + engines: 0, + ext: ".json", + loader: [AsyncFunction: json], + }, + { + dataLoader: [AsyncFunction: json], + engines: 0, + ext: ".jsonc", + loader: [AsyncFunction: json], + }, + { + engines: 1, + ext: ".md", + loader: [AsyncFunction: text], + pageType: "page", + }, + { + engines: 1, + ext: ".markdown", + loader: [AsyncFunction: text], + pageType: "page", + }, + { + assetLoader: [AsyncFunction: text], + dataLoader: [AsyncFunction: module], + engines: 1, + ext: ".js", + loader: [AsyncFunction: module], + pageType: "asset", + }, + { + dataLoader: [AsyncFunction: module], + engines: 1, + ext: ".ts", + loader: [AsyncFunction: module], + }, + { + engines: 1, + ext: ".vento", + loader: [AsyncFunction: text], + pageType: "page", + }, + { + engines: 1, + ext: ".vto", + loader: [AsyncFunction: text], + pageType: "page", + }, + { + dataLoader: [AsyncFunction: toml], + engines: 0, + ext: ".toml", + loader: [AsyncFunction: toml], + }, + { + dataLoader: [AsyncFunction: yaml], + engines: 0, + ext: ".yaml", + loader: [AsyncFunction: yaml], + pageType: "page", + }, + { + dataLoader: [AsyncFunction: yaml], + engines: 0, + ext: ".yml", + loader: [AsyncFunction: yaml], + pageType: "page", + }, + { + assetLoader: [AsyncFunction: text], + engines: undefined, + ext: ".css", + pageType: "asset", + }, + ], + src: [ + "/", + "/_includes", + "/_includes/footer.vto", + "/_includes/layout.vto", + "/index.vto", + "/pages", + "/pages/page1.md", + "/pages/page2.page.js", + "/script.js", + "/static", + "/static/static.html", + "/styles.css", + ], +} +`; + +snapshot[`purgecss plugin with options 2`] = ` +[ + { + entry: "/static/static.html", + flags: [], + outputPath: "/static.html", + }, +] +`; + +snapshot[`purgecss plugin with options 3`] = ` +[ + { + content: ' + + + + + + TÍTULO + + +

Título

+ +strong + +
Content
+ + +
+Título +
+ + +', + data: { + basename: "index", + children: '

Título

+ +strong + +
Content
+', + content: '

{{ title }}

+ +strong + +
Content
+', + date: [], + layout: "layout.vto", + mergedKeys: [ + "tags", + ], + page: [ + "src", + "data", + "asset", + ], + paginate: "paginate", + search: [], + tags: "Array(0)", + title: "Título", + url: "/", + }, + src: { + asset: false, + ext: ".vto", + path: "/index", + remote: undefined, + }, + }, + { + content: " +

Content of Page 1

+", + data: { + basename: "page1", + children: "

Content of Page 1

+", + content: "Content of Page 1 +", + date: [], + mergedKeys: [ + "tags", + ], + page: [ + "src", + "data", + "asset", + ], + paginate: "paginate", + search: [], + tags: "Array(0)", + title: "Page 1", + url: "/pages/page1/", + }, + src: { + asset: false, + ext: ".md", + path: "/pages/page1", + remote: undefined, + }, + }, + { + content: ' +
Content of Page 2
', + data: { + basename: "page2", + children: '
Content of Page 2
', + content: '
Content of Page 2
', + date: [], + mergedKeys: [ + "tags", + ], + page: [ + "src", + "data", + "asset", + ], + paginate: "paginate", + search: [], + tags: "Array(0)", + title: "Page 2", + url: "/page_2/", + }, + src: { + asset: false, + ext: ".page.js", + path: "/pages/page2", + remote: undefined, + }, + }, + { + content: "document.querySelector('.dynamic-js') + .addEventListener('click', (event) => { + event.target.classList.add('dynamic-jssub-open'); + }); +", + data: { + basename: "script", + content: "document.querySelector('.dynamic-js') + .addEventListener('click', (event) => { + event.target.classList.add('dynamic-jssub-open'); + }); +", + date: [], + mergedKeys: [ + "tags", + ], + page: [ + "src", + "data", + "asset", + ], + paginate: "paginate", + search: [], + tags: "Array(0)", + url: "/script.js", + }, + src: { + asset: true, + ext: ".js", + path: "/script", + remote: undefined, + }, + }, + { + content: "::root { +} + +body { + font-family: sans-serif; + color: black; +} + +.unused { + margin-top: 30px; +} + + html > * .content-vento { + max-width: 800px; +} + +.content-vento:not(.test):focus-visible { + outline: 2px solid blue; +} + +.content-dynamic { + display: flex; +} + +.content-dynamic:focus-visible { + outline: 2px solid lightblue; +} + +.content-static { + margin-top: 25px; +} + +.dynamic-js { + opacity: 0; +} + +.dynamic-jssub-open { + opacity: 1; +} + +#img-option { + max-width: 100%; +} +", + data: { + basename: "styles", + content: "::root { + --font-family: sans-serif; +} + +body { + font-family: sans-serif; + color: black; +} + +.unused { + margin-top: 30px; +} + +strong { + font-weight: bolder; +} + +em { + font-style: italic; +} + +a[href] { + text-decoration: underline; +} + +html > body .no-such-element .content-vento, html > * .content-vento { + max-width: 800px; +} + +.content-vento:not(.test):focus-visible { + outline: 2px solid blue; +} + +.content-dynamic { + display: flex; +} + +.content-dynamic:focus-visible { + outline: 2px solid lightblue; +} + +.content-static { + margin-top: 25px; +} + +.content-other { + padding: 20px 5px; +} + +.dynamic-js { + opacity: 0; +} + +.dynamic-jssub { + display: none; +} + +.dynamic-jssub-open { + opacity: 1; +} + +#img-option { + max-width: 100%; +} +", + date: [], + mergedKeys: [ + "tags", + ], + page: [ + "src", + "data", + "asset", + ], + paginate: "paginate", + search: [], + tags: "Array(0)", + url: "/styles.css", + }, + src: { + asset: true, + ext: ".css", + path: "/styles", + remote: undefined, + }, + }, +] +`; diff --git a/tests/assets/json_ld/page-1.md b/tests/assets/json_ld/page-1.md new file mode 100644 index 00000000..b6ae7317 --- /dev/null +++ b/tests/assets/json_ld/page-1.md @@ -0,0 +1,29 @@ +--- +header: + title: Title from page data +cover: ./use-cover-as-meta-image.png +jsonLd: + "@context": "https://schema.org" + "@type": "WebSite" + url: "/" + name: "=header.title" + inLanguage: "gl" + publisher: + "@type": "Organization" + name: "=header.title" + logo: + "@type": "ImageObject" + url: "=cover" + image: "/my-image.png" + keywords: + - "one" + - "two" + emptyThing: + "@type": "EmptyThing" +--- + +# Welcome to my website + +This is my first page using **Lume,** a static site generator for Deno. +[test link](/test/) +I hope you enjoy it. diff --git a/tests/assets/json_ld/page-2.vto b/tests/assets/json_ld/page-2.vto new file mode 100644 index 00000000..aa5b4b42 --- /dev/null +++ b/tests/assets/json_ld/page-2.vto @@ -0,0 +1,9 @@ +--- +--- + + + + + Pages without jsonld + + diff --git a/tests/assets/purgecss/_includes/footer.vto b/tests/assets/purgecss/_includes/footer.vto new file mode 100644 index 00000000..ebf60231 --- /dev/null +++ b/tests/assets/purgecss/_includes/footer.vto @@ -0,0 +1,3 @@ +
+{{ title }} +
\ No newline at end of file diff --git a/tests/assets/purgecss/_includes/layout.vto b/tests/assets/purgecss/_includes/layout.vto new file mode 100644 index 00000000..c38f0226 --- /dev/null +++ b/tests/assets/purgecss/_includes/layout.vto @@ -0,0 +1,14 @@ + + + + + + + {{ title |> toUpperCase }} + + + {{ content }} + + {{ include "footer.vto" }} + + diff --git a/tests/assets/purgecss/index.vto b/tests/assets/purgecss/index.vto new file mode 100644 index 00000000..03490537 --- /dev/null +++ b/tests/assets/purgecss/index.vto @@ -0,0 +1,10 @@ +--- +title: Título +layout: layout.vto +--- + +

{{ title }}

+ +strong + +
Content
diff --git a/tests/assets/purgecss/pages/page1.md b/tests/assets/purgecss/pages/page1.md new file mode 100644 index 00000000..fb98681f --- /dev/null +++ b/tests/assets/purgecss/pages/page1.md @@ -0,0 +1,5 @@ +--- +title: Page 1 +--- + +Content of Page 1 diff --git a/tests/assets/purgecss/pages/page2.page.js b/tests/assets/purgecss/pages/page2.page.js new file mode 100644 index 00000000..72e11f13 --- /dev/null +++ b/tests/assets/purgecss/pages/page2.page.js @@ -0,0 +1,5 @@ +export const title = "Page 2"; + +export const url = "/page_2/"; + +export default `
Content of ${title}
`; diff --git a/tests/assets/purgecss/script.js b/tests/assets/purgecss/script.js new file mode 100644 index 00000000..c7ac76c2 --- /dev/null +++ b/tests/assets/purgecss/script.js @@ -0,0 +1,4 @@ +document.querySelector('.dynamic-js') + .addEventListener('click', (event) => { + event.target.classList.add('dynamic-jssub-open'); + }); diff --git a/tests/assets/purgecss/static/static.html b/tests/assets/purgecss/static/static.html new file mode 100644 index 00000000..7e6c934c --- /dev/null +++ b/tests/assets/purgecss/static/static.html @@ -0,0 +1,12 @@ + + + + + + + {{ title |> toUpperCase }} + + +
Content
+ + diff --git a/tests/assets/purgecss/styles.css b/tests/assets/purgecss/styles.css new file mode 100644 index 00000000..2ea0f72c --- /dev/null +++ b/tests/assets/purgecss/styles.css @@ -0,0 +1,64 @@ +::root { + --font-family: sans-serif; +} + +body { + font-family: sans-serif; + color: black; +} + +.unused { + margin-top: 30px; +} + +strong { + font-weight: bolder; +} + +em { + font-style: italic; +} + +a[href] { + text-decoration: underline; +} + +html > body .no-such-element .content-vento, html > * .content-vento { + max-width: 800px; +} + +.content-vento:not(.test):focus-visible { + outline: 2px solid blue; +} + +.content-dynamic { + display: flex; +} + +.content-dynamic:focus-visible { + outline: 2px solid lightblue; +} + +.content-static { + margin-top: 25px; +} + +.content-other { + padding: 20px 5px; +} + +.dynamic-js { + opacity: 0; +} + +.dynamic-jssub { + display: none; +} + +.dynamic-jssub-open { + opacity: 1; +} + +#img-option { + max-width: 100%; +} diff --git a/tests/json_ld.test.ts b/tests/json_ld.test.ts new file mode 100644 index 00000000..78a54854 --- /dev/null +++ b/tests/json_ld.test.ts @@ -0,0 +1,13 @@ +import { assertSiteSnapshot, build, getSite } from "./utils.ts"; +import jsonLd from "../plugins/json_ld.ts"; + +Deno.test("json_ld plugin", async (t) => { + const site = getSite({ + src: "json_ld", + }); + + site.use(jsonLd()); + + await build(site); + await assertSiteSnapshot(t, site); +}); diff --git a/tests/plugins.test.ts b/tests/plugins.test.ts index 8a792063..bd5e1dca 100644 --- a/tests/plugins.test.ts +++ b/tests/plugins.test.ts @@ -23,6 +23,7 @@ Deno.test("Plugins list in init", () => { "google_fonts", "gzip", "inline", + "json_ld", "jsx", "jsx_preact", "katex", @@ -43,6 +44,7 @@ Deno.test("Plugins list in init", () => { "postcss", "prism", "pug", + "purgecss", "reading_info", "redirects", "relations", diff --git a/tests/purgecss.test.ts b/tests/purgecss.test.ts new file mode 100644 index 00000000..25e57df4 --- /dev/null +++ b/tests/purgecss.test.ts @@ -0,0 +1,44 @@ +import { assertSiteSnapshot, build, getPath, getSite } from "./utils.ts"; +import { normalizePath } from "../core/utils/path.ts"; +import purgecss from "../plugins/purgecss.ts"; + +Deno.test("purgecss plugin", async (t) => { + const site = getSite({ + src: "purgecss", + }); + + site.loadAssets([".css", ".js"]); + site.copy("static", "."); + + site.use(purgecss()); + + await build(site); + await assertSiteSnapshot(t, site); +}); + +Deno.test("purgecss plugin with options", async (t) => { + const site = getSite({ + src: "purgecss", + }); + + site.loadAssets([".css", ".js"]); + site.copy("static", "."); + + site.use(purgecss({ + options: { + content: [ + normalizePath(getPath("./assets/purgecss/static/**/*.html")), + { + raw: '', + extension: "html", + }, + ], + safelist: ["unused"], + blocklist: ["strong"], + variables: true, + }, + })); + + await build(site); + await assertSiteSnapshot(t, site); +});