UNPKG

ap-ssg

Version:

A fast, modular, SEO-optimized static site generator that minifies CSS, JS, and HTML for improved performance. It also supports JSON-LD, sitemap generation, and more, making it ideal for production-ready websites.

253 lines (210 loc) 8.24 kB
const Joi = require("joi"); const userConfig = require("./userConfig"); const ensureValidHtmlPath = require("../utils/ensureValidHtmlPath"); const formatMetaTitle = require("../utils/formatMetaTitle"); const { websiteName, websiteDescription } = require("./userConfig"); const { personSchema, organizationSchema } = require("./configObjects"); const makeCanonicalUrl = require("../utils/makeCanonicalUrl"); const documentConfigSchema = Joi.object({ // Required fields for every page or document title: Joi.string().required(), description: Joi.string().required(), path: Joi.string().required(), createdAt: Joi.string().isoDate().required(), updatedAt: Joi.string().isoDate().required(), // optional field useCommonCss: Joi.boolean().default(true), useCommonJs: Joi.boolean().default(true), // optional fields for document type: Joi.string().default("").valid("blogpost", "software", "article"), pageLanguage: Joi.string() .pattern(/^[a-z]{2}(-[A-Z]{2})?$/, "language code") // ISO 639-1 or BCP 47 .default(userConfig.websiteLang || "en") // Default to the website language or English .messages({ "string.pattern.base": "Language must be a valid ISO 639-1 code (e.g., 'en', 'fr') or a BCP 47 language tag (e.g., 'en-US').", }), isAccessibleForFree: Joi.boolean().default(true), metaTitleTemplate: Joi.string().default(""), shouldFollowLinks: Joi.boolean().default(true), shouldAllowIndexing: Joi.boolean().default(true), sponsor: Joi.alternatives().try(personSchema, organizationSchema).optional(), publishedAt: Joi.string().isoDate().optional(), alternativeHeadline: Joi.string().optional(), articleSection: Joi.string().optional(), articleBody: Joi.string().optional(), author: personSchema, editor: personSchema, priority: Joi.number().default(1).min(0).max(1), changefreq: Joi.string() .valid("always", "hourly", "daily", "weekly", "monthly", "yearly", "never") .optional(), twitterHandle: Joi.string() .optional() .default("") .regex(/^@?(\w+)$/) .custom((value) => (value.startsWith("@") ? value : "@" + value)), ogImage: Joi.string() .optional() .default("/assets/site/ogImage.png") .custom((value, helpers) => { const { path, title } = helpers.state.ancestors[0]; // Access 'path' and 'title' from the context // Ensure the value is either an absolute URL or a relative path if (value && !/^https?:\/\//i.test(value) && !/^\//.test(value)) { return helpers.message( `ogImage must be a valid URL or a relative path for "${title}" path => "${path}".` ); } // Validate that the file extension is a valid image extension const validImageExtensions = /\.(jpg|jpeg|png|gif|webp)$/i; if (value && !validImageExtensions.test(value)) { return helpers.message( `ogImage must have a valid image extension (e.g., .jpg, .png, .gif, .webp) for "${title}" path => "${path}".` ); } return value; }), keywords: Joi.string() .pattern(/^[a-zA-Z\s,]+$/) .messages({ "string.pattern.base": "Keywords must be a comma-separated list of words.", }) .optional(), wordCount: Joi.number() .integer() .min(1) .messages({ "number.base": "Word count must be a number.", "number.min": "Word count must be at least 1.", }) .optional(), genre: Joi.string() .pattern(/^[a-zA-Z\s,]+$/) .messages({ "string.pattern.base": "Genre must be a comma-separated list of words.", }), // Aggregate Rating schema rating: Joi.number().min(1).max(5).precision(1).optional().messages({ "number.base": `"rating" should be a number`, "number.min": `"rating" should be between 1 and 5`, "number.max": `"rating" should be between 1 and 5`, }), reviewCount: Joi.number().integer().min(0).optional().messages({ "number.base": `"reviewCount" should be an integer`, "number.min": `"reviewCount" should be at least 0`, }), // BreadCrumbList Schema1 breadCrumbList: Joi.array() .items( Joi.object({ name: Joi.string().required(), item: Joi.string() .required() .custom((value, helpers) => { if (!value.startsWith("http://") && !value.startsWith("https://")) { return new URL(value, userConfig.websiteUrl).href; } return value; // Return as is for absolute URLs }, "Prepend domain to relative URLs"), }) ) .optional(), // Software Application Schema fields softwareLicense: Joi.string() .valid( "MIT", "Apache-2.0", "GPL-3.0", "BSD-3-Clause", "CC-BY-4.0", "Proprietary" ) .messages({ "any.only": "License name must be one of the following: MIT, " + "Apache-2.0, GPL-3.0, BSD-3-Clause, CC-BY-4.0, or Proprietary.", }) .optional(), softwareVersion: Joi.string() .pattern(/^(\d+\.)?(\d+\.)?(\*|\d+)$/, "software version") .optional() .messages({ "string.pattern.base": "softwareVersion must follow the Semantic Versioning format (e.g., 1.0.0).", }), softwarePlatform: Joi.string() .valid("Web", "Mobile", "Desktop", "Server", "IoT", "Other") .optional() .messages({ "any.only": "softwarePlatform must be one of: Web, Mobile, Desktop, Server, IoT, Other.", "any.required": "platform is a required field.", }), softwareOperatingSystem: Joi.string() .valid("Windows", "macOS", "Linux", "Android", "iOS", "All", "Other") .optional() .messages({ "any.only": "softwareOperatingSystem must be one of: Windows, macOS, Linux, Android, iOS, All, Other.", }), softwareRepository: Joi.object({ url: Joi.string().uri().required().messages({ "string.uri": "softwareRepository.url must be a valid URI.", "any.required": "softwareRepository.url is a required field.", }), branch: Joi.string() .regex(/^[\w.-]+$/) // Allows typical branch name characters like alphanumerics, dashes, underscores, and dots .optional() .messages({ "string.pattern.base": "softwareRepository.branch must be a valid branch name.", }), }), }); /** * Validates the document passed before generating the HTML file and applies the default configurations * provided from the `apssg` config file. This function checks and ensures that the document adheres * to the required structure and modifies or adds default settings as needed from the configuration file. * * @param config * @returns {any} */ const validateDocumentConfig = (config) => { const { error, value: doc } = documentConfigSchema.validate(config); if (error) { throw new Error(error.details[0].message); process.exit(1); } const websiteUrl = userConfig.websiteUrl; const fullUrl = new URL(ensureValidHtmlPath(doc.path), websiteUrl).href; doc.filePath = doc.path; doc.ogImage = new URL(doc.ogImage, websiteUrl).href; doc.url = fullUrl; doc.canonicalUrl = makeCanonicalUrl(fullUrl); doc.websiteUrl = websiteUrl; doc.websiteName = websiteName; doc.websiteDescription = websiteDescription; const metaTitleTemplate = doc.metaTitleTemplate ? doc.metaTitleTemplate : userConfig.metaTitleTemplate; doc.themeColor = userConfig.themeColor; doc.title = formatMetaTitle( doc.title, metaTitleTemplate, userConfig.websiteName ); doc.integrations = userConfig.integrations; doc.pwa = userConfig.pwa; doc.organization = userConfig.organization; doc.datePublished = doc.publishedAt; doc.pageLanguage = doc.pageLanguage ? doc.pageLanguage : userConfig.websiteLang; doc.inLanguage = doc.pageLanguage; doc.autoImgAlt = userConfig.autoImgAlt; doc.commonCss = userConfig.commonCss || []; doc.commonJs = userConfig.commonJs || []; return doc; }; module.exports = validateDocumentConfig;