diff --git a/README.md b/README.md index 3b8de5d..6c10822 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ A configurable [Eleventy](https://www.11ty.dev/) plugin that enables a powerful - 🧩 **Dynamic Component Rendering** - Render components based on content data - 🎨 **Template Language Agnostic** - Works with Nunjucks, Liquid, WebC, Vento, and more - 🏗️ **Flexible Configuration** - Customizable directories and options -- 🚀 **Production Ready** - Excludes development components from production builds +- 🚀 **Output Control** - Choose whether components are written to their own output files - 🔧 **Developer Friendly** - Comprehensive error handling and debugging ## Installation @@ -478,7 +478,7 @@ const defaultOptions = { componentsDir: "src/components/*.*", collectionName: "components", enableRenderPlugin: true, - excludeFromProduction: true + output: true }; ``` @@ -1024,7 +1024,7 @@ If no template language is specified, the filter will use the calling template's | `componentsDir` | `string` | `"src/components/*.*"` | Glob pattern for component files | | `collectionName` | `string` | `"components"` | Name of components collection | | `enableRenderPlugin` | `boolean` | `true` | Enable Eleventy Render Plugin | -| `excludeFromProduction` | `boolean` | `true` | Exclude components from production | +| `output` | `boolean` | `true` | Whether components are written to their own output files/endpoints | ### Filters diff --git a/src/collections.js b/src/collections.js index 2033d71..fa5ca0b 100644 --- a/src/collections.js +++ b/src/collections.js @@ -1,3 +1,8 @@ +import { glob } from "fs/promises"; +import matter from "gray-matter"; +import { readFileSync } from "fs"; +import path from "path"; + /** * Add components collection to Eleventy * @param {Object} eleventyConfig - Eleventy configuration object @@ -7,8 +12,28 @@ export function addCollections(eleventyConfig, options) { /** * Components Collection * This collection includes all components from the configured components directory. + * Uses manual globbing to allow components outside the Eleventy input directory. */ - eleventyConfig.addCollection(options.collectionName, function(collectionApi) { - return collectionApi.getFilteredByGlob(options.componentsDir); + eleventyConfig.addCollection(options.collectionName, async function() { + // Manually glob files from the components directory using Node's fs.glob (Node 20+) + const componentFiles = await Array.fromAsync(glob(options.componentsDir)); + + // Process each component file to extract frontmatter and content + const components = componentFiles.map(filePath => { + const fileContent = readFileSync(filePath, "utf-8"); + const { data, content } = matter(fileContent); + + return { + data: data, + rawInput: content, + inputPath: filePath, + page: { + inputPath: filePath, + fileSlug: path.basename(filePath, path.extname(filePath)) + } + }; + }); + + return components; }); } diff --git a/src/config.js b/src/config.js index 7e3e335..e7772c4 100644 --- a/src/config.js +++ b/src/config.js @@ -5,5 +5,6 @@ export const defaultOptions = { componentsDir: "src/components/*.*", collectionName: "components", enableRenderPlugin: true, - excludeFromProduction: true + output: true, + debug: process.env.DEBUG === "Eleventy:*" || process.env.DEBUG === "true" }; diff --git a/src/debug.js b/src/debug.js new file mode 100644 index 0000000..f3a5541 --- /dev/null +++ b/src/debug.js @@ -0,0 +1,210 @@ +const PLUGIN_NAME = "eleventy-plugin-reusable-components"; +let DEBUG_ENABLED = false; + +/** + * Enable or disable debug output + * @param {boolean} enabled - Whether debug is enabled + */ +export function setDebugEnabled(enabled) { + DEBUG_ENABLED = enabled; +} + +/** + * Debug logging utilities for the plugin + * Uses Eleventy's standard debug format + */ + +/** + * Log a debug message with the plugin prefix + * @param {string} message - The message to log + * @param {number} indent - Indentation level (0-2) + */ +export function debug(message, indent = 0) { + if (!DEBUG_ENABLED) return; + + const prefix = "[11ty]"; + const pluginPrefix = `[${PLUGIN_NAME}]`; + const indentation = " ".repeat(indent); + console.log(`${prefix} ${pluginPrefix} ${indentation}${message}`); +} + +/** + * Log plugin initialization with options + * @param {Object} options - Plugin options + */ +export function debugInit(options) { + debug("Plugin initialized with options:"); + debug(`componentsDir: "${options.componentsDir}"`, 1); + debug(`collectionName: "${options.collectionName}"`, 1); + debug(`enableRenderPlugin: ${options.enableRenderPlugin}`, 1); + debug(`output: ${options.output}`, 1); + debug(`ELEVENTY_ENV: "${process.env.ELEVENTY_ENV || "development"}"`, 1); +} + +/** + * Log production mode detection + * @param {string} componentsDir - The components directory pattern + */ +export function debugProductionMode(componentsDir) { + debug("Production mode detected"); + debug(`Ignoring components directory: ${componentsDir}`, 1); +} + +/** + * Log discovered components + * @param {Array} components - Array of component objects + * @param {Function} slugifyFilter - Eleventy's slugify filter + */ +export function debugComponents(components, slugifyFilter) { + debug(`Found ${components.length} component${components.length !== 1 ? "s" : ""}:`); + + if (components.length === 0) { + debug("⚠ No components found", 1); + return; + } + + components.forEach(component => { + if (component.data && component.data.title) { + const title = component.data.title; + const slug = slugifyFilter(title); + const path = component.inputPath || component.page?.inputPath || "unknown path"; + debug(`✓ ${title} (${slug}) → ${path}`, 1); + } + }); +} + +/** + * Log renderComponent filter call + * @param {Array} items - Array of items to render + * @param {string} lang - Template language + */ +export function debugRenderStart(items, lang) { + debug("renderComponent called:"); + debug(`Items: ${items.length}`, 1); + debug(`Template language: ${lang || "auto-detect"}`, 1); + debug("", 1); // Empty line for readability +} + +/** + * Log warning when no items provided + */ +export function debugNoItems() { + debug("⚠ Warning: No items provided"); + debug("Returning empty string", 1); +} + +/** + * Log warning when items are filtered out + * @param {number} filteredCount - Number of items filtered out + * @param {number} validCount - Number of valid items remaining + */ +export function debugFilteredItems(filteredCount, validCount) { + debug(`⚠ Warning: ${filteredCount} item${filteredCount !== 1 ? "s" : ""} without 'type' property ${filteredCount !== 1 ? "were" : "was"} filtered out`); + debug(`Valid items: ${validCount}`, 1); +} + +/** + * Log component match success + * @param {number} itemIndex - Index of the item (1-based) + * @param {string} type - Component type + * @param {string} componentPath - Path to component file + * @param {Object} mergedData - Merged data object + */ +export function debugMatchSuccess(itemIndex, type, componentPath, mergedData) { + debug(`Item ${itemIndex}:`, 1); + debug(`Type: ${type}`, 2); + debug(`Match: ✓ ${type}`, 2); + debug(`Component: ${componentPath}`, 2); + debug(`Merged data keys: ${Object.keys(mergedData).join(", ")}`, 2); +} + +/** + * Log component match success (simplified for multiple items) + * @param {number} itemIndex - Index of the item (1-based) + * @param {string} type - Component type + */ +export function debugMatchSuccessSimple(itemIndex, type) { + debug(`Item ${itemIndex}:`, 1); + debug(`Type: ${type}`, 2); + debug(`Match: ✓ ${type}`, 2); +} + +/** + * Log component match failure + * @param {number} itemIndex - Index of the item (1-based) + * @param {string} type - Component type that wasn't found + * @param {Array} availableComponents - Array of available component slugs + */ +export function debugMatchFailure(itemIndex, type, availableComponents) { + debug(`Item ${itemIndex}:`, 1); + debug(`Type: ${type}`, 2); + debug("Match: ✗ No matching component found", 2); + if (availableComponents.length > 0) { + debug(`Available: ${availableComponents.join(", ")}`, 2); + } +} + +/** + * Log warning when collections are missing + */ +export function debugNoCollections() { + if (!DEBUG_ENABLED) return; + debug("⚠ Warning: Collections not available or components collection is empty"); + debug("Returning empty string", 1); +} + +/** + * Debug wrapper for renderComponent filter + * Handles all debug logging for the filter lifecycle + */ +export function debugRenderComponent(context) { + if (!DEBUG_ENABLED) return; + + const { + phase, + validItems, + filteredCount, + lang, + itemIndex, + itemType, + componentPath, + mergedData, + availableComponents, + components, + slugifyFilter + } = context; + + switch (phase) { + case "no-items": + debugNoItems(); + break; + + case "filtered-items": + debugFilteredItems(filteredCount, validItems.length); + break; + + case "no-collections": + debugNoCollections(); + break; + + case "components-list": + debugComponents(components, slugifyFilter); + break; + + case "render-start": + debugRenderStart(validItems, lang); + break; + + case "match": + if (validItems.length === 1) { + debugMatchSuccess(itemIndex, itemType, componentPath, mergedData); + } else { + debugMatchSuccessSimple(itemIndex, itemType); + } + break; + + case "no-match": + debugMatchFailure(itemIndex, itemType, availableComponents); + break; + } +} diff --git a/src/filters.js b/src/filters.js index 26cd2de..e0e409f 100644 --- a/src/filters.js +++ b/src/filters.js @@ -1,3 +1,5 @@ +import { debugRenderComponent } from "./debug.js"; + /** * Add filters for the component system * @param {Object} eleventyConfig - Eleventy configuration object @@ -83,54 +85,113 @@ export function addFilters(eleventyConfig) { // Render components filter - returns matched component templates eleventyConfig.addFilter("renderComponent", async function (items, lang) { + // Early return if no items provided if (!items) { + debugRenderComponent({ phase: "no-items" }); return ""; } - // Normalize input to always be an array + // Normalize input to always be an array for consistent processing const itemsArray = Array.isArray(items) ? items : [items]; - // Filter out any items without a type + // Filter out any items that don't have a required 'type' property const validItems = itemsArray.filter(item => item && item.type); + // Early return if no valid items after filtering if (validItems.length === 0) { + debugRenderComponent({ phase: "no-items" }); return ""; } + // Log warning if some items were filtered out due to missing 'type' + if (validItems.length < itemsArray.length) { + debugRenderComponent({ + phase: "filtered-items", + filteredCount: itemsArray.length - validItems.length, + validItems + }); + } + + // Get the components collection from Eleventy's context const collections = this.ctx.collections || this.collections; if (!collections || !collections.components) { + debugRenderComponent({ phase: "no-collections" }); return ""; } + // Get required filters from Eleventy const slugifyFilter = eleventyConfig.getFilter("slugify"); const renderFilter = eleventyConfig.getFilter("renderContent"); + + // Determine template language: use provided lang, or auto-detect from current page + const templateLang = lang || (this.page && this.page.templateSyntax); + + // Log discovered components once on first render (avoids spam in logs) + if (!this._componentsDebugLogged) { + debugRenderComponent({ + phase: "components-list", + components: collections.components, + slugifyFilter + }); + this._componentsDebugLogged = true; + } + + debugRenderComponent({ phase: "render-start", validItems, lang: templateLang }); + + // Pre-build list of available component slugs for error reporting + const availableComponents = collections.components + .filter(c => c.data && c.data.title) + .map(c => slugifyFilter(c.data.title)); + const renderedComponents = []; - // Process each item - for (const item of validItems) { - // Find the matching component in the collections + // Process each valid item + for (let i = 0; i < validItems.length; i++) { + const item = validItems[i]; + let matched = false; + + // Search through all components for a matching type for (const component of collections.components) { if (component.data && component.data.title) { + // Slugify both the component title and item type for comparison const componentSlug = slugifyFilter(component.data.title); const itemSlug = slugifyFilter(item.type); + // Check if this component matches the item's type if (componentSlug === itemSlug) { - // Merge component defaults with item data (item data takes precedence) + // Merge component's default data with item data (item overrides defaults) const mergedData = { ...component.data, ...item }; - // If a language was passed to the filter as lang, use it, otherwise get the calling template's templateSyntax - const templateLang = lang || (this.page && this.page.templateSyntax); + debugRenderComponent({ + phase: "match", + validItems, + itemIndex: i + 1, + itemType: item.type, + componentPath: component.inputPath || component.page?.inputPath || "unknown path", + mergedData + }); - // Render the component's rawInput with merged data using the determined template language + // Render the component template with merged data const rendered = await renderFilter.call(this, component.rawInput, templateLang, mergedData); renderedComponents.push(rendered); - break; // Move to next item after finding a match + matched = true; + break; // Stop searching once we find a match } } } + + // Log if no matching component was found for this item + if (!matched) { + debugRenderComponent({ + phase: "no-match", + itemIndex: i + 1, + itemType: item.type, + availableComponents + }); + } } - // Join all rendered components with newlines + // Join all rendered components with newlines and return return renderedComponents.join("\n"); }); } diff --git a/src/plugin.js b/src/plugin.js index 9697087..534ad4d 100644 --- a/src/plugin.js +++ b/src/plugin.js @@ -2,6 +2,7 @@ import { EleventyRenderPlugin } from "@11ty/eleventy"; import { defaultOptions } from "./config.js"; import { addCollections } from "./collections.js"; import { addFilters } from "./filters.js"; +import { setDebugEnabled, debugInit, debugProductionMode } from "./debug.js"; /** * Universal Components for Eleventy Plugin @@ -13,6 +14,7 @@ import { addFilters } from "./filters.js"; * @param {string} [options.collectionName="components"] - Name of the components collection * @param {boolean} [options.enableRenderPlugin=true] - Whether to enable the Eleventy Render Plugin * @param {boolean} [options.excludeFromProduction=true] - Whether to exclude components from production builds + * @param {boolean} [options.debug=false] - Whether to enable debug output * * Collections: * - `components` (or custom name): A collection of components sourced from the components directory. @@ -25,6 +27,10 @@ export function reusableComponents(eleventyConfig, userOptions = {}) { // Merge user options with defaults const options = { ...defaultOptions, ...userOptions }; + // Configure debug output + setDebugEnabled(options.debug); + debugInit(options); + /** * Add the Eleventy Render Plugin. * Check if the plugin is already enabled before enabling it. @@ -34,10 +40,11 @@ export function reusableComponents(eleventyConfig, userOptions = {}) { } /** - * Exclude components from production builds + * Exclude components from builds */ - if (options.excludeFromProduction && process.env.ELEVENTY_ENV === "production") { + if (options.output == false) { eleventyConfig.ignores.add(options.componentsDir); + debugProductionMode(options.componentsDir); } // Add collections