@terrymooreii/sia 2.2.0 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/hooks.js ADDED
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Hook system for plugins
3
+ * Manages hook registration and execution
4
+ */
5
+
6
+ class HookRegistry {
7
+ constructor() {
8
+ this.hooks = new Map();
9
+ this.pluginHooks = new Map(); // Track which plugin registered which hooks
10
+ }
11
+
12
+ /**
13
+ * Register hooks from a plugin
14
+ */
15
+ registerPluginHooks(plugin) {
16
+ if (!plugin.hooks || typeof plugin.hooks !== 'object') {
17
+ return;
18
+ }
19
+
20
+ const pluginName = plugin.name;
21
+
22
+ for (const [hookName, hookFunction] of Object.entries(plugin.hooks)) {
23
+ if (typeof hookFunction !== 'function') {
24
+ console.warn(`⚠️ Plugin ${pluginName}: Hook "${hookName}" is not a function, skipping`);
25
+ continue;
26
+ }
27
+
28
+ if (!this.hooks.has(hookName)) {
29
+ this.hooks.set(hookName, []);
30
+ }
31
+
32
+ this.hooks.get(hookName).push({
33
+ plugin: pluginName,
34
+ fn: hookFunction
35
+ });
36
+
37
+ // Track plugin hooks
38
+ if (!this.pluginHooks.has(pluginName)) {
39
+ this.pluginHooks.set(pluginName, []);
40
+ }
41
+ this.pluginHooks.get(pluginName).push(hookName);
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Execute a hook with given arguments
47
+ */
48
+ async executeHook(hookName, ...args) {
49
+ const hooks = this.hooks.get(hookName) || [];
50
+
51
+ if (hooks.length === 0) {
52
+ return;
53
+ }
54
+
55
+ const results = [];
56
+
57
+ for (const { plugin, fn } of hooks) {
58
+ try {
59
+ const result = await fn(...args);
60
+ results.push({ plugin, result, error: null });
61
+ } catch (err) {
62
+ results.push({ plugin, result: null, error: err });
63
+ console.error(`❌ Plugin ${plugin} hook "${hookName}" failed:`, err.message);
64
+ if (process.env.VERBOSE || process.env.DEBUG) {
65
+ console.error(err.stack);
66
+ }
67
+ }
68
+ }
69
+
70
+ return results;
71
+ }
72
+
73
+ /**
74
+ * Execute a hook and return the last result (for hooks that modify data)
75
+ */
76
+ async executeHookWithResult(hookName, initialValue, ...args) {
77
+ const hooks = this.hooks.get(hookName) || [];
78
+
79
+ let value = initialValue;
80
+
81
+ for (const { plugin, fn } of hooks) {
82
+ try {
83
+ const result = await fn(value, ...args);
84
+ // If hook returns a value, use it as the new value
85
+ if (result !== undefined && result !== null) {
86
+ value = result;
87
+ }
88
+ } catch (err) {
89
+ console.error(`❌ Plugin ${plugin} hook "${hookName}" failed:`, err.message);
90
+ if (process.env.VERBOSE || process.env.DEBUG) {
91
+ console.error(err.stack);
92
+ }
93
+ }
94
+ }
95
+
96
+ return value;
97
+ }
98
+
99
+ /**
100
+ * Get all registered hooks for a plugin
101
+ */
102
+ getPluginHooks(pluginName) {
103
+ return this.pluginHooks.get(pluginName) || [];
104
+ }
105
+
106
+ /**
107
+ * Clear all hooks
108
+ */
109
+ clear() {
110
+ this.hooks.clear();
111
+ this.pluginHooks.clear();
112
+ }
113
+ }
114
+
115
+ // Global hook registry instance
116
+ let hookRegistry = null;
117
+
118
+ /**
119
+ * Initialize the hook system
120
+ */
121
+ export function initializeHooks() {
122
+ hookRegistry = new HookRegistry();
123
+ return hookRegistry;
124
+ }
125
+
126
+ /**
127
+ * Get the hook registry instance
128
+ */
129
+ export function getHookRegistry() {
130
+ if (!hookRegistry) {
131
+ hookRegistry = new HookRegistry();
132
+ }
133
+ return hookRegistry;
134
+ }
135
+
136
+ /**
137
+ * Register hooks from all plugins
138
+ */
139
+ export function registerPluginHooks(plugins) {
140
+ const registry = getHookRegistry();
141
+
142
+ for (const plugin of plugins) {
143
+ registry.registerPluginHooks(plugin);
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Execute a hook
149
+ */
150
+ export async function executeHook(hookName, ...args) {
151
+ const registry = getHookRegistry();
152
+ return registry.executeHook(hookName, ...args);
153
+ }
154
+
155
+ /**
156
+ * Execute a hook and return the result (for data transformation hooks)
157
+ */
158
+ export async function executeHookWithResult(hookName, initialValue, ...args) {
159
+ const registry = getHookRegistry();
160
+ return registry.executeHookWithResult(hookName, initialValue, ...args);
161
+ }
162
+
163
+ /**
164
+ * Check if a hook has any registered handlers
165
+ */
166
+ export function hasHook(hookName) {
167
+ const registry = getHookRegistry();
168
+ return registry.hooks.has(hookName) && registry.hooks.get(hookName).length > 0;
169
+ }
170
+
171
+ /**
172
+ * Clear all hooks (useful for testing)
173
+ */
174
+ export function clearHooks() {
175
+ const registry = getHookRegistry();
176
+ registry.clear();
177
+ }
178
+
179
+ export default {
180
+ initializeHooks,
181
+ getHookRegistry,
182
+ registerPluginHooks,
183
+ executeHook,
184
+ executeHookWithResult,
185
+ hasHook,
186
+ clearHooks
187
+ };
188
+
package/lib/index.js CHANGED
@@ -34,6 +34,25 @@ export { build, buildCommand } from './build.js';
34
34
  export { startServer, devCommand } from './server.js';
35
35
  export { newCommand } from './new.js';
36
36
  export { initSite, initCommand } from './init.js';
37
+ export {
38
+ discoverPlugins,
39
+ loadPlugins,
40
+ loadPlugin,
41
+ validatePlugin,
42
+ discoverLocalPlugins,
43
+ discoverNpmPlugins,
44
+ orderPlugins
45
+ } from './plugins.js';
46
+ export {
47
+ initializeHooks,
48
+ getHookRegistry,
49
+ registerPluginHooks,
50
+ executeHook,
51
+ executeHookWithResult,
52
+ hasHook,
53
+ clearHooks
54
+ } from './hooks.js';
55
+ export { addMarkedExtension } from './content.js';
37
56
 
38
57
  // Default export with version
39
58
  import { readFileSync } from 'fs';
package/lib/plugins.js ADDED
@@ -0,0 +1,350 @@
1
+ import { readdirSync, statSync, existsSync, readFileSync } from 'fs';
2
+ import { join, dirname } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ const __filename = fileURLToPath(import.meta.url);
6
+ const __dirname = dirname(__filename);
7
+
8
+ /**
9
+ * Validate plugin structure
10
+ */
11
+ export function validatePlugin(plugin, pluginPath) {
12
+ if (!plugin) {
13
+ throw new Error('Plugin is null or undefined');
14
+ }
15
+
16
+ if (!plugin.name || typeof plugin.name !== 'string') {
17
+ throw new Error('Plugin must have a "name" property (string)');
18
+ }
19
+
20
+ if (!plugin.version || typeof plugin.version !== 'string') {
21
+ throw new Error('Plugin must have a "version" property (string)');
22
+ }
23
+
24
+ // Validate hooks if present
25
+ if (plugin.hooks && typeof plugin.hooks !== 'object') {
26
+ throw new Error('Plugin "hooks" must be an object');
27
+ }
28
+
29
+ // Validate configSchema if present
30
+ if (plugin.configSchema && typeof plugin.configSchema !== 'object') {
31
+ throw new Error('Plugin "configSchema" must be an object');
32
+ }
33
+
34
+ // Validate dependencies if present
35
+ if (plugin.dependencies && !Array.isArray(plugin.dependencies)) {
36
+ throw new Error('Plugin "dependencies" must be an array');
37
+ }
38
+
39
+ return true;
40
+ }
41
+
42
+ /**
43
+ * Load a plugin from a file path
44
+ */
45
+ export async function loadPlugin(pluginPath, config) {
46
+ try {
47
+ // Support both .js and .mjs files
48
+ // Add cache busting query parameter to ensure fresh reloads during dev
49
+ // This prevents Node.js from using cached versions of plugins during hot reload
50
+ const cacheBuster = `?t=${Date.now()}`;
51
+ let module;
52
+
53
+ // Normalize path to use file:// protocol for consistent cache busting
54
+ // All plugin paths from discovery are absolute, so we can safely use file://
55
+ const normalizedPath = pluginPath.startsWith('file://')
56
+ ? pluginPath
57
+ : pluginPath.startsWith('/') || /^[A-Za-z]:/.test(pluginPath) // Unix absolute or Windows drive
58
+ ? `file://${pluginPath}`
59
+ : pluginPath; // Relative path (shouldn't happen, but handle it)
60
+
61
+ // Import with cache busting to ensure fresh reloads during dev
62
+ module = await import(`${normalizedPath}${cacheBuster}`);
63
+
64
+ // Support both default export and named export
65
+ const plugin = module.default || module;
66
+
67
+ if (!plugin) {
68
+ throw new Error('Plugin file does not export a plugin object');
69
+ }
70
+
71
+ // Validate plugin structure
72
+ validatePlugin(plugin, pluginPath);
73
+
74
+ // Add metadata
75
+ plugin._path = pluginPath;
76
+ plugin._loaded = true;
77
+
78
+ return plugin;
79
+ } catch (err) {
80
+ throw new Error(`Failed to load plugin from ${pluginPath}: ${err.message}`);
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Discover local plugins from _plugins directory
86
+ */
87
+ export function discoverLocalPlugins(rootDir) {
88
+ const pluginsDir = join(rootDir, '_plugins');
89
+ const plugins = [];
90
+
91
+ if (!existsSync(pluginsDir)) {
92
+ return plugins;
93
+ }
94
+
95
+ try {
96
+ const items = readdirSync(pluginsDir);
97
+ console.log(`🔍 Scanning _plugins directory: found ${items.length} item(s)`);
98
+
99
+ for (const item of items) {
100
+ const itemPath = join(pluginsDir, item);
101
+ const stat = statSync(itemPath);
102
+
103
+ // Check extension case-insensitively
104
+ const lowerItem = item.toLowerCase();
105
+ const isJsFile = lowerItem.endsWith('.js') || lowerItem.endsWith('.mjs');
106
+
107
+ // Debug: log what we found
108
+ if (stat.isFile()) {
109
+ console.log(` 📄 Found file: ${item} (${isJsFile ? 'plugin candidate' : 'skipped - not .js/.mjs'})`);
110
+ } else if (stat.isDirectory()) {
111
+ console.log(` 📁 Found directory: ${item} (skipped - plugins must be files)`);
112
+ }
113
+
114
+ // Only process .js and .mjs files (case-insensitive)
115
+ if (stat.isFile() && isJsFile) {
116
+ plugins.push({
117
+ type: 'local',
118
+ path: itemPath,
119
+ name: item.replace(/\.(js|mjs)$/i, '') // Case-insensitive replacement
120
+ });
121
+ console.log(` ✓ Added plugin: ${item}`);
122
+ }
123
+ }
124
+ } catch (err) {
125
+ console.warn(`⚠️ Error reading _plugins directory: ${err.message}`);
126
+ }
127
+
128
+ return plugins;
129
+ }
130
+
131
+ /**
132
+ * Discover npm plugins from node_modules
133
+ */
134
+ export function discoverNpmPlugins(rootDir) {
135
+ const nodeModulesDir = join(rootDir, 'node_modules');
136
+ const plugins = [];
137
+
138
+ if (!existsSync(nodeModulesDir)) {
139
+ return plugins;
140
+ }
141
+
142
+ try {
143
+ const packages = readdirSync(nodeModulesDir);
144
+
145
+ for (const pkg of packages) {
146
+ // Look for sia-plugin-* packages
147
+ if (pkg.startsWith('sia-plugin-')) {
148
+ const pkgPath = join(nodeModulesDir, pkg);
149
+ const pkgJsonPath = join(pkgPath, 'package.json');
150
+
151
+ if (existsSync(pkgJsonPath)) {
152
+ try {
153
+ const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
154
+ const mainFile = pkgJson.main || 'index.js';
155
+ const pluginPath = join(pkgPath, mainFile);
156
+
157
+ if (existsSync(pluginPath)) {
158
+ plugins.push({
159
+ type: 'npm',
160
+ path: pluginPath,
161
+ name: pkg,
162
+ packageName: pkg,
163
+ version: pkgJson.version
164
+ });
165
+ }
166
+ } catch (err) {
167
+ console.warn(`⚠️ Error reading package.json for ${pkg}: ${err.message}`);
168
+ }
169
+ }
170
+ }
171
+ }
172
+ } catch (err) {
173
+ console.warn(`⚠️ Error reading node_modules: ${err.message}`);
174
+ }
175
+
176
+ return plugins;
177
+ }
178
+
179
+ /**
180
+ * Resolve plugin dependencies and order
181
+ */
182
+ export function orderPlugins(plugins, config) {
183
+ // If explicit order is provided in config, use it
184
+ const explicitOrder = config.plugins?.order;
185
+ if (explicitOrder && Array.isArray(explicitOrder)) {
186
+ const ordered = [];
187
+ const pluginMap = new Map(plugins.map(p => [p.name, p]));
188
+ const added = new Set();
189
+
190
+ // Add plugins in explicit order
191
+ for (const name of explicitOrder) {
192
+ const plugin = pluginMap.get(name);
193
+ if (plugin) {
194
+ ordered.push(plugin);
195
+ added.add(name);
196
+ }
197
+ }
198
+
199
+ // Add remaining plugins
200
+ for (const plugin of plugins) {
201
+ if (!added.has(plugin.name)) {
202
+ ordered.push(plugin);
203
+ }
204
+ }
205
+
206
+ return ordered;
207
+ }
208
+
209
+ // Otherwise, resolve dependencies
210
+ const pluginMap = new Map(plugins.map(p => [p.name, p]));
211
+ const ordered = [];
212
+ const added = new Set();
213
+ const visiting = new Set();
214
+
215
+ function visit(plugin) {
216
+ if (added.has(plugin.name)) {
217
+ return;
218
+ }
219
+
220
+ if (visiting.has(plugin.name)) {
221
+ console.warn(`⚠️ Circular dependency detected involving plugin: ${plugin.name}`);
222
+ return;
223
+ }
224
+
225
+ visiting.add(plugin.name);
226
+
227
+ // Visit dependencies first
228
+ if (plugin.dependencies && Array.isArray(plugin.dependencies)) {
229
+ for (const depName of plugin.dependencies) {
230
+ const dep = pluginMap.get(depName);
231
+ if (dep) {
232
+ visit(dep);
233
+ } else {
234
+ console.warn(`⚠️ Plugin ${plugin.name} depends on ${depName}, but it's not found`);
235
+ }
236
+ }
237
+ }
238
+
239
+ visiting.delete(plugin.name);
240
+ ordered.push(plugin);
241
+ added.add(plugin.name);
242
+ }
243
+
244
+ // Visit all plugins
245
+ for (const plugin of plugins) {
246
+ visit(plugin);
247
+ }
248
+
249
+ return ordered;
250
+ }
251
+
252
+ /**
253
+ * Discover and load all plugins
254
+ */
255
+ export async function discoverPlugins(config) {
256
+ // Check if plugins are enabled
257
+ if (config.plugins?.enabled === false) {
258
+ return [];
259
+ }
260
+
261
+ const rootDir = config.rootDir || process.cwd();
262
+ const discovered = [];
263
+
264
+ // Discover local plugins
265
+ const localPlugins = discoverLocalPlugins(rootDir);
266
+ discovered.push(...localPlugins);
267
+
268
+ // Discover npm plugins
269
+ const npmPlugins = discoverNpmPlugins(rootDir);
270
+ discovered.push(...npmPlugins);
271
+
272
+ // Filter by explicit plugin list if provided
273
+ const explicitPlugins = config.plugins?.plugins;
274
+ if (explicitPlugins && Array.isArray(explicitPlugins) && explicitPlugins.length > 0) {
275
+ console.log(`🔍 Filtering plugins: only loading ${explicitPlugins.join(', ')}`);
276
+ const explicitSet = new Set(explicitPlugins);
277
+ const filtered = discovered.filter(p => explicitSet.has(p.name));
278
+ const filteredOut = discovered.filter(p => !explicitSet.has(p.name));
279
+ if (filteredOut.length > 0) {
280
+ console.log(` ⚠️ Filtered out ${filteredOut.length} plugin(s): ${filteredOut.map(p => p.name).join(', ')}`);
281
+ }
282
+ return filtered;
283
+ }
284
+
285
+ return discovered;
286
+ }
287
+
288
+ /**
289
+ * Load all discovered plugins
290
+ */
291
+ export async function loadPlugins(config) {
292
+ // Check if plugins are disabled
293
+ if (config.plugins?.enabled === false) {
294
+ console.log('🔌 Plugins are disabled in config');
295
+ return [];
296
+ }
297
+
298
+ const rootDir = config.rootDir || process.cwd();
299
+ const discovered = await discoverPlugins(config);
300
+
301
+ if (discovered.length === 0) {
302
+ // Log that we checked but found nothing (helps with debugging)
303
+ const pluginsDir = join(rootDir, '_plugins');
304
+ const hasPluginsDir = existsSync(pluginsDir);
305
+ if (hasPluginsDir) {
306
+ console.log('🔌 No plugins discovered (directory exists but no valid plugins found)');
307
+ } else {
308
+ console.log('🔌 No plugins directory found, skipping plugin discovery');
309
+ }
310
+ return [];
311
+ }
312
+
313
+ console.log(`🔌 Found ${discovered.length} plugin(s)`);
314
+
315
+ const loaded = [];
316
+ const errors = [];
317
+
318
+ for (const pluginInfo of discovered) {
319
+ try {
320
+ const plugin = await loadPlugin(pluginInfo.path, config);
321
+ plugin._type = pluginInfo.type;
322
+ plugin._packageName = pluginInfo.packageName;
323
+ loaded.push(plugin);
324
+ console.log(` ✓ Loaded ${plugin.name}@${plugin.version} (${pluginInfo.type})`);
325
+ } catch (err) {
326
+ errors.push({ name: pluginInfo.name, error: err.message });
327
+ console.warn(` ✗ Failed to load ${pluginInfo.name}: ${err.message}`);
328
+ }
329
+ }
330
+
331
+ if (errors.length > 0 && config.plugins?.strictMode) {
332
+ throw new Error(`Plugin loading failed in strict mode. ${errors.length} plugin(s) failed to load.`);
333
+ }
334
+
335
+ // Order plugins
336
+ const ordered = orderPlugins(loaded, config);
337
+
338
+ return ordered;
339
+ }
340
+
341
+ export default {
342
+ discoverPlugins,
343
+ loadPlugins,
344
+ loadPlugin,
345
+ validatePlugin,
346
+ discoverLocalPlugins,
347
+ discoverNpmPlugins,
348
+ orderPlugins
349
+ };
350
+
package/lib/server.js CHANGED
@@ -166,7 +166,8 @@ function setupWatcher(config, wss) {
166
166
  config.includesDir,
167
167
  join(config.rootDir, '_config.yml'),
168
168
  join(config.rootDir, '_config.json'),
169
- join(config.rootDir, 'styles')
169
+ join(config.rootDir, 'styles'),
170
+ join(config.rootDir, '_plugins')
170
171
  ].filter(p => existsSync(p));
171
172
 
172
173
  const watcher = chokidar.watch(watchPaths, {
package/lib/templates.js CHANGED
@@ -3,6 +3,7 @@ import { join, dirname } from 'path';
3
3
  import { existsSync } from 'fs';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { resolveTheme, getBuiltInThemesDir } from './theme-resolver.js';
6
+ import { executeHook } from './hooks.js';
6
7
 
7
8
  const __filename = fileURLToPath(import.meta.url);
8
9
  const __dirname = dirname(__filename);
@@ -195,7 +196,7 @@ function createUrlFilter(basePath) {
195
196
  * @param {object} config - Site configuration
196
197
  * @param {object} [resolvedTheme] - Pre-resolved theme info from resolveTheme()
197
198
  */
198
- export function createTemplateEngine(config, resolvedTheme = null) {
199
+ export async function createTemplateEngine(config, resolvedTheme = null) {
199
200
  // Set up template paths - user layouts first, then defaults
200
201
  const templatePaths = [];
201
202
 
@@ -247,6 +248,11 @@ export function createTemplateEngine(config, resolvedTheme = null) {
247
248
  // Add URL filter with basePath support
248
249
  env.addFilter('url', createUrlFilter(config.site.basePath));
249
250
 
251
+ // Execute addTemplateFilter and addTemplateFunction hooks
252
+ // These hooks allow plugins to register custom filters and functions
253
+ const filterResults = await executeHook('addTemplateFilter', env, config);
254
+ const functionResults = await executeHook('addTemplateFunction', env, config);
255
+
250
256
  return env;
251
257
  }
252
258
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terrymooreii/sia",
3
- "version": "2.2.0",
3
+ "version": "2.3.1",
4
4
  "description": "A simple, powerful static site generator with markdown, front matter, and Nunjucks templates",
5
5
  "main": "lib/index.js",
6
6
  "bin": {