kempo 4.2.14 → 4.2.16

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.
Files changed (45) hide show
  1. package/app-public/account/split2.template.html +1 -0
  2. package/app-public/test.page.html +1 -0
  3. package/bin/cli.js +27 -5
  4. package/dist/kempo/api/fragments/lock/PUT.js +2 -0
  5. package/dist/kempo/api/fragments/lock/PUT.js.map +1 -0
  6. package/dist/kempo/api/globals/lock/PUT.js +2 -0
  7. package/dist/kempo/api/globals/lock/PUT.js.map +1 -0
  8. package/dist/kempo/api/pages/lock/PUT.js +2 -0
  9. package/dist/kempo/api/pages/lock/PUT.js.map +1 -0
  10. package/dist/kempo/api/pages/search/GET.js +2 -0
  11. package/dist/kempo/api/pages/search/GET.js.map +1 -0
  12. package/dist/kempo/api/templates/lock/PUT.js +2 -0
  13. package/dist/kempo/api/templates/lock/PUT.js.map +1 -0
  14. package/package.json +2 -2
  15. package/server/sdk.js +5 -0
  16. package/server/utils/fragments/createFragment.js +5 -1
  17. package/server/utils/fragments/deleteFragment.js +2 -3
  18. package/server/utils/fragments/disableFragment.js +0 -3
  19. package/server/utils/fragments/getFragmentMetadata.js +32 -0
  20. package/server/utils/fragments/moveFragment.js +0 -3
  21. package/server/utils/fragments/setFragmentLocked.js +48 -0
  22. package/server/utils/fragments/updateFragment.js +3 -3
  23. package/server/utils/fs/isResourceLocked.js +22 -0
  24. package/server/utils/global-content/createGlobalContent.js +3 -0
  25. package/server/utils/global-content/deleteGlobalContent.js +3 -3
  26. package/server/utils/global-content/getGlobalContentMetadata.js +37 -0
  27. package/server/utils/global-content/setGlobalContentLocked.js +44 -0
  28. package/server/utils/global-content/updateGlobalContent.js +3 -4
  29. package/server/utils/pages/createPage.js +19 -7
  30. package/server/utils/pages/deletePage.js +2 -3
  31. package/server/utils/pages/disablePage.js +0 -3
  32. package/server/utils/pages/getPage.js +7 -0
  33. package/server/utils/pages/getPageMetadata.js +32 -0
  34. package/server/utils/pages/listPages.js +11 -3
  35. package/server/utils/pages/movePage.js +0 -3
  36. package/server/utils/pages/searchByMetadata.js +61 -0
  37. package/server/utils/pages/setPageLocked.js +48 -0
  38. package/server/utils/pages/updatePage.js +19 -3
  39. package/server/utils/templates/createTemplate.js +5 -1
  40. package/server/utils/templates/deleteTemplate.js +2 -3
  41. package/server/utils/templates/disableTemplate.js +0 -3
  42. package/server/utils/templates/getTemplateMetadata.js +32 -0
  43. package/server/utils/templates/moveTemplate.js +0 -3
  44. package/server/utils/templates/setTemplateLocked.js +48 -0
  45. package/server/utils/templates/updateTemplate.js +3 -3
@@ -3,6 +3,7 @@
3
3
  author: Copilot Admin
4
4
  created: 2026-04-16T20:40:58.376Z
5
5
  updated: 2026-04-19T20:43:06.103Z
6
+ locked: true
6
7
  -->
7
8
  <!DOCTYPE html>
8
9
  <html lang="en">
@@ -6,6 +6,7 @@
6
6
  updated: 2026-04-16T21:13:12.153Z
7
7
  title: test
8
8
  description:
9
+ locked: true
9
10
  -->
10
11
  <page template="split" title="test">
11
12
  <content location="left">
package/bin/cli.js CHANGED
@@ -31,7 +31,7 @@ const projectName = basename(projectDir);
31
31
  */
32
32
 
33
33
  const generatePassword = () => {
34
- const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%&*';
34
+ const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789-_';
35
35
  return Array.from(
36
36
  { length: 16 },
37
37
  () => chars[crypto.randomInt(chars.length)]
@@ -404,6 +404,21 @@ execSync('npm install kempo', { cwd: projectDir, stdio: 'inherit' });
404
404
  console.log('\n--- Starting database ---\n');
405
405
 
406
406
  if(containerMode === 'new'){
407
+ const volumeName = `${dbName}_postgres_data`;
408
+
409
+ try {
410
+ execSync(`docker stop ${containerName}`, { stdio: 'ignore' });
411
+ execSync(`docker rm ${containerName}`, { stdio: 'ignore' });
412
+ } catch {
413
+ // Container doesn't exist, that's fine
414
+ }
415
+
416
+ try {
417
+ execSync(`docker volume rm ${volumeName}`, { stdio: 'ignore' });
418
+ } catch {
419
+ // Volume doesn't exist, that's fine
420
+ }
421
+
407
422
  const compose = spawnSync('docker', ['compose', 'up', '-d'], {
408
423
  cwd: projectDir,
409
424
  stdio: 'inherit',
@@ -429,8 +444,8 @@ if(containerMode === 'new'){
429
444
 
430
445
  console.log('\n--- Initializing database ---\n');
431
446
 
432
- // Give database a moment to initialize
433
- await new Promise(r => setTimeout(r, 2000));
447
+ // Give database plenty of time to fully initialize
448
+ await new Promise(r => setTimeout(r, 30000));
434
449
 
435
450
  // Clear migration snapshots so drizzle-kit push compares against the live DB only
436
451
  const migrationDir = join(projectDir, 'server', 'db', 'migrations');
@@ -438,8 +453,15 @@ if(existsSync(migrationDir)){
438
453
  rmSync(migrationDir, { recursive: true, force: true });
439
454
  }
440
455
 
441
- execSync('npx drizzle-kit push --force', { cwd: projectDir, stdio: 'inherit' });
442
- execSync('node node_modules/kempo/scripts/init-db.js', { cwd: projectDir, stdio: 'inherit' });
456
+ const drizzleResult = spawnSync('npx', ['drizzle-kit', 'push', '--force'], { cwd: projectDir, stdio: 'inherit', env: { ...process.env, DATABASE_URL: databaseUrl }, shell: true });
457
+ if(drizzleResult.status !== 0){
458
+ console.error('\n❌ Drizzle push failed. DATABASE_URL: ' + databaseUrl);
459
+ console.error('Container: ' + containerName);
460
+ console.error('Check: docker logs ' + containerName + '\n');
461
+ process.exit(1);
462
+ }
463
+
464
+ execSync('node node_modules/kempo/scripts/init-db.js', { cwd: projectDir, stdio: 'inherit', env: { ...process.env, DATABASE_URL: databaseUrl } });
443
465
 
444
466
  /*
445
467
  Step 8: Create admin user
@@ -0,0 +1,2 @@
1
+ import{resolve}from"path";import currentUserHasPermission from"../../../../../server/utils/permissions/currentUserHasPermission.js";import setFragmentLocked from"../../../../../server/utils/fragments/setFragmentLocked.js";const rootDir=resolve(import.meta.dirname,"../../../../../app-public");export default async(r,e)=>{const s=r.cookies.session_token,[o,t]=await currentUserHasPermission(s,"system:fragments:update");if(o)return e.status(o.code).json({error:o.msg});if(!t)return e.status(403).json({error:"Insufficient permissions"});const{file:i,locked:n}=r.body;if(!i)return e.status(400).json({error:"File path is required"});if("boolean"!=typeof n)return e.status(400).json({error:"locked must be a boolean"});const[a,m]=await setFragmentLocked({rootDir:rootDir,file:i,locked:n});if(a)return e.status(a.code).json({error:a.msg});e.json(m)};
2
+ //# sourceMappingURL=PUT.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"src/kempo/api/fragments/lock/PUT.js","names":["resolve","currentUserHasPermission","setFragmentLocked","rootDir","dirname","async","request","response","token","cookies","session_token","permError","hasPermission","status","code","json","error","msg","file","locked","body","data"],"sources":["0"],"sourcesContent":["import { resolve } from 'path';\nimport currentUserHasPermission from '../../../../../server/utils/permissions/currentUserHasPermission.js';\nimport setFragmentLocked from '../../../../../server/utils/fragments/setFragmentLocked.js';\n\nconst rootDir = resolve(import.meta.dirname, '../../../../../app-public');\n\nexport default async (request, response) => {\n\tconst token = request.cookies.session_token;\n\tconst [permError, hasPermission] = await currentUserHasPermission(token, 'system:fragments:update');\n\n\tif(permError){\n\t\treturn response.status(permError.code).json({ error: permError.msg });\n\t}\n\n\tif(!hasPermission){\n\t\treturn response.status(403).json({ error: 'Insufficient permissions' });\n\t}\n\n\tconst { file, locked } = request.body;\n\n\tif(!file){\n\t\treturn response.status(400).json({ error: 'File path is required' });\n\t}\n\n\tif(typeof locked !== 'boolean'){\n\t\treturn response.status(400).json({ error: 'locked must be a boolean' });\n\t}\n\n\tconst [error, data] = await setFragmentLocked({ rootDir, file, locked });\n\n\tif(error){\n\t\treturn response.status(error.code).json({ error: error.msg });\n\t}\n\n\tresponse.json(data);\n};\n"],"mappings":"OAASA,YAAe,cACjBC,6BAA8B,6EAC9BC,sBAAuB,6DAE9B,MAAMC,QAAUH,oBAAoBI,QAAS,4CAE9BC,MAAOC,EAASC,KAC9B,MAAMC,EAAQF,EAAQG,QAAQC,eACvBC,EAAWC,SAAuBX,yBAAyBO,EAAO,2BAEzE,GAAGG,EACF,OAAOJ,EAASM,OAAOF,EAAUG,MAAMC,KAAK,CAAEC,MAAOL,EAAUM,MAGhE,IAAIL,EACH,OAAOL,EAASM,OAAO,KAAKE,KAAK,CAAEC,MAAO,6BAG3C,MAAME,KAAEA,EAAIC,OAAEA,GAAWb,EAAQc,KAEjC,IAAIF,EACH,OAAOX,EAASM,OAAO,KAAKE,KAAK,CAAEC,MAAO,0BAG3C,GAAqB,kBAAXG,EACT,OAAOZ,EAASM,OAAO,KAAKE,KAAK,CAAEC,MAAO,6BAG3C,MAAOA,EAAOK,SAAcnB,kBAAkB,CAAEC,gBAASe,OAAMC,WAE/D,GAAGH,EACF,OAAOT,EAASM,OAAOG,EAAMF,MAAMC,KAAK,CAAEC,MAAOA,EAAMC,MAGxDV,EAASQ,KAAKM","ignoreList":[]}
@@ -0,0 +1,2 @@
1
+ import{resolve}from"path";import currentUserHasPermission from"../../../../../server/utils/permissions/currentUserHasPermission.js";import setGlobalContentLocked from"../../../../../server/utils/global-content/setGlobalContentLocked.js";const rootDir=resolve(import.meta.dirname,"../../../../../app-public");export default async(o,r)=>{const s=o.cookies.session_token,[e,t]=await currentUserHasPermission(s,"system:globals:update");if(e)return r.status(e.code).json({error:e.msg});if(!t)return r.status(403).json({error:"Insufficient permissions"});const{id:n,locked:i}=o.body;if(!n)return r.status(400).json({error:"Global content ID is required"});if("boolean"!=typeof i)return r.status(400).json({error:"locked must be a boolean"});const[a,l]=await setGlobalContentLocked({rootDir:rootDir,id:n,locked:i});if(a)return r.status(a.code).json({error:a.msg});r.json(l)};
2
+ //# sourceMappingURL=PUT.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"src/kempo/api/globals/lock/PUT.js","names":["resolve","currentUserHasPermission","setGlobalContentLocked","rootDir","dirname","async","request","response","token","cookies","session_token","permError","hasPermission","status","code","json","error","msg","id","locked","body","data"],"sources":["0"],"sourcesContent":["import { resolve } from 'path';\nimport currentUserHasPermission from '../../../../../server/utils/permissions/currentUserHasPermission.js';\nimport setGlobalContentLocked from '../../../../../server/utils/global-content/setGlobalContentLocked.js';\n\nconst rootDir = resolve(import.meta.dirname, '../../../../../app-public');\n\nexport default async (request, response) => {\n\tconst token = request.cookies.session_token;\n\tconst [permError, hasPermission] = await currentUserHasPermission(token, 'system:globals:update');\n\n\tif(permError){\n\t\treturn response.status(permError.code).json({ error: permError.msg });\n\t}\n\n\tif(!hasPermission){\n\t\treturn response.status(403).json({ error: 'Insufficient permissions' });\n\t}\n\n\tconst { id, locked } = request.body;\n\n\tif(!id){\n\t\treturn response.status(400).json({ error: 'Global content ID is required' });\n\t}\n\n\tif(typeof locked !== 'boolean'){\n\t\treturn response.status(400).json({ error: 'locked must be a boolean' });\n\t}\n\n\tconst [error, data] = await setGlobalContentLocked({ rootDir, id, locked });\n\n\tif(error){\n\t\treturn response.status(error.code).json({ error: error.msg });\n\t}\n\n\tresponse.json(data);\n};\n"],"mappings":"OAASA,YAAe,cACjBC,6BAA8B,6EAC9BC,2BAA4B,uEAEnC,MAAMC,QAAUH,oBAAoBI,QAAS,4CAE9BC,MAAOC,EAASC,KAC9B,MAAMC,EAAQF,EAAQG,QAAQC,eACvBC,EAAWC,SAAuBX,yBAAyBO,EAAO,yBAEzE,GAAGG,EACF,OAAOJ,EAASM,OAAOF,EAAUG,MAAMC,KAAK,CAAEC,MAAOL,EAAUM,MAGhE,IAAIL,EACH,OAAOL,EAASM,OAAO,KAAKE,KAAK,CAAEC,MAAO,6BAG3C,MAAME,GAAEA,EAAEC,OAAEA,GAAWb,EAAQc,KAE/B,IAAIF,EACH,OAAOX,EAASM,OAAO,KAAKE,KAAK,CAAEC,MAAO,kCAG3C,GAAqB,kBAAXG,EACT,OAAOZ,EAASM,OAAO,KAAKE,KAAK,CAAEC,MAAO,6BAG3C,MAAOA,EAAOK,SAAcnB,uBAAuB,CAAEC,gBAASe,KAAIC,WAElE,GAAGH,EACF,OAAOT,EAASM,OAAOG,EAAMF,MAAMC,KAAK,CAAEC,MAAOA,EAAMC,MAGxDV,EAASQ,KAAKM","ignoreList":[]}
@@ -0,0 +1,2 @@
1
+ import{resolve}from"path";import currentUserHasPermission from"../../../../../server/utils/permissions/currentUserHasPermission.js";import setPageLocked from"../../../../../server/utils/pages/setPageLocked.js";const rootDir=resolve(import.meta.dirname,"../../../../../app-public");export default async(e,s)=>{const r=e.cookies.session_token,[o,t]=await currentUserHasPermission(r,"system:pages:update");if(o)return s.status(o.code).json({error:o.msg});if(!t)return s.status(403).json({error:"Insufficient permissions"});const{file:i,locked:n}=e.body;if(!i)return s.status(400).json({error:"File path is required"});if("boolean"!=typeof n)return s.status(400).json({error:"locked must be a boolean"});const[a,u]=await setPageLocked({rootDir:rootDir,file:i,locked:n});if(a)return s.status(a.code).json({error:a.msg});s.json(u)};
2
+ //# sourceMappingURL=PUT.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"src/kempo/api/pages/lock/PUT.js","names":["resolve","currentUserHasPermission","setPageLocked","rootDir","dirname","async","request","response","token","cookies","session_token","permError","hasPermission","status","code","json","error","msg","file","locked","body","data"],"sources":["0"],"sourcesContent":["import { resolve } from 'path';\nimport currentUserHasPermission from '../../../../../server/utils/permissions/currentUserHasPermission.js';\nimport setPageLocked from '../../../../../server/utils/pages/setPageLocked.js';\n\nconst rootDir = resolve(import.meta.dirname, '../../../../../app-public');\n\nexport default async (request, response) => {\n\tconst token = request.cookies.session_token;\n\tconst [permError, hasPermission] = await currentUserHasPermission(token, 'system:pages:update');\n\n\tif(permError){\n\t\treturn response.status(permError.code).json({ error: permError.msg });\n\t}\n\n\tif(!hasPermission){\n\t\treturn response.status(403).json({ error: 'Insufficient permissions' });\n\t}\n\n\tconst { file, locked } = request.body;\n\n\tif(!file){\n\t\treturn response.status(400).json({ error: 'File path is required' });\n\t}\n\n\tif(typeof locked !== 'boolean'){\n\t\treturn response.status(400).json({ error: 'locked must be a boolean' });\n\t}\n\n\tconst [error, data] = await setPageLocked({ rootDir, file, locked });\n\n\tif(error){\n\t\treturn response.status(error.code).json({ error: error.msg });\n\t}\n\n\tresponse.json(data);\n};\n"],"mappings":"OAASA,YAAe,cACjBC,6BAA8B,6EAC9BC,kBAAmB,qDAE1B,MAAMC,QAAUH,oBAAoBI,QAAS,4CAE9BC,MAAOC,EAASC,KAC9B,MAAMC,EAAQF,EAAQG,QAAQC,eACvBC,EAAWC,SAAuBX,yBAAyBO,EAAO,uBAEzE,GAAGG,EACF,OAAOJ,EAASM,OAAOF,EAAUG,MAAMC,KAAK,CAAEC,MAAOL,EAAUM,MAGhE,IAAIL,EACH,OAAOL,EAASM,OAAO,KAAKE,KAAK,CAAEC,MAAO,6BAG3C,MAAME,KAAEA,EAAIC,OAAEA,GAAWb,EAAQc,KAEjC,IAAIF,EACH,OAAOX,EAASM,OAAO,KAAKE,KAAK,CAAEC,MAAO,0BAG3C,GAAqB,kBAAXG,EACT,OAAOZ,EAASM,OAAO,KAAKE,KAAK,CAAEC,MAAO,6BAG3C,MAAOA,EAAOK,SAAcnB,cAAc,CAAEC,gBAASe,OAAMC,WAE3D,GAAGH,EACF,OAAOT,EAASM,OAAOG,EAAMF,MAAMC,KAAK,CAAEC,MAAOA,EAAMC,MAGxDV,EAASQ,KAAKM","ignoreList":[]}
@@ -0,0 +1,2 @@
1
+ import{resolve}from"path";import currentUserHasPermission from"../../../../server/utils/permissions/currentUserHasPermission.js";import searchByMetadata from"../../../../server/utils/pages/searchByMetadata.js";const rootDir=resolve(import.meta.dirname,"../../../../app-public");export default async(r,s)=>{const e=r.cookies.session_token,[t,o]=await currentUserHasPermission(e,"system:pages:read");if(t)return s.status(t.code).json({error:t.msg});if(!o)return s.status(403).json({error:"Insufficient permissions"});const{query:a}=r.query;if(!a)return s.status(400).json({error:"query parameter is required"});let i;try{i=JSON.parse(a)}catch{return s.status(400).json({error:"query must be valid JSON"})}if("object"!=typeof i||Array.isArray(i)||!i)return s.status(400).json({error:"query must be a JSON object"});const[n,u]=await searchByMetadata({rootDir:rootDir,query:i});if(n)return s.status(n.code).json({error:n.msg});s.json(u)};
2
+ //# sourceMappingURL=GET.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"src/kempo/api/pages/search/GET.js","names":["resolve","currentUserHasPermission","searchByMetadata","rootDir","dirname","async","request","response","token","cookies","session_token","permError","hasPermission","status","code","json","error","msg","query","parsedQuery","JSON","parse","Array","isArray","data"],"sources":["0"],"sourcesContent":["import { resolve } from 'path';\nimport currentUserHasPermission from '../../../../server/utils/permissions/currentUserHasPermission.js';\nimport searchByMetadata from '../../../../server/utils/pages/searchByMetadata.js';\n\nconst rootDir = resolve(import.meta.dirname, '../../../../app-public');\n\nexport default async (request, response) => {\n\tconst token = request.cookies.session_token;\n\tconst [permError, hasPermission] = await currentUserHasPermission(token, 'system:pages:read');\n\n\tif(permError){\n\t\treturn response.status(permError.code).json({ error: permError.msg });\n\t}\n\n\tif(!hasPermission){\n\t\treturn response.status(403).json({ error: 'Insufficient permissions' });\n\t}\n\n\tconst { query } = request.query;\n\n\tif(!query){\n\t\treturn response.status(400).json({ error: 'query parameter is required' });\n\t}\n\n\tlet parsedQuery;\n\ttry {\n\t\tparsedQuery = JSON.parse(query);\n\t} catch {\n\t\treturn response.status(400).json({ error: 'query must be valid JSON' });\n\t}\n\n\tif(typeof parsedQuery !== 'object' || Array.isArray(parsedQuery) || !parsedQuery){\n\t\treturn response.status(400).json({ error: 'query must be a JSON object' });\n\t}\n\n\tconst [error, data] = await searchByMetadata({ rootDir, query: parsedQuery });\n\n\tif(error){\n\t\treturn response.status(error.code).json({ error: error.msg });\n\t}\n\n\tresponse.json(data);\n};\n"],"mappings":"OAASA,YAAe,cACjBC,6BAA8B,0EAC9BC,qBAAsB,qDAE7B,MAAMC,QAAUH,oBAAoBI,QAAS,yCAE9BC,MAAOC,EAASC,KAC9B,MAAMC,EAAQF,EAAQG,QAAQC,eACvBC,EAAWC,SAAuBX,yBAAyBO,EAAO,qBAEzE,GAAGG,EACF,OAAOJ,EAASM,OAAOF,EAAUG,MAAMC,KAAK,CAAEC,MAAOL,EAAUM,MAGhE,IAAIL,EACH,OAAOL,EAASM,OAAO,KAAKE,KAAK,CAAEC,MAAO,6BAG3C,MAAME,MAAEA,GAAUZ,EAAQY,MAE1B,IAAIA,EACH,OAAOX,EAASM,OAAO,KAAKE,KAAK,CAAEC,MAAO,gCAG3C,IAAIG,EACJ,IACCA,EAAcC,KAAKC,MAAMH,EAC1B,CAAE,MACD,OAAOX,EAASM,OAAO,KAAKE,KAAK,CAAEC,MAAO,4BAC3C,CAEA,GAA0B,iBAAhBG,GAA4BG,MAAMC,QAAQJ,KAAiBA,EACpE,OAAOZ,EAASM,OAAO,KAAKE,KAAK,CAAEC,MAAO,gCAG3C,MAAOA,EAAOQ,SAActB,iBAAiB,CAAEC,gBAASe,MAAOC,IAE/D,GAAGH,EACF,OAAOT,EAASM,OAAOG,EAAMF,MAAMC,KAAK,CAAEC,MAAOA,EAAMC,MAGxDV,EAASQ,KAAKS","ignoreList":[]}
@@ -0,0 +1,2 @@
1
+ import{resolve}from"path";import currentUserHasPermission from"../../../../../server/utils/permissions/currentUserHasPermission.js";import setTemplateLocked from"../../../../../server/utils/templates/setTemplateLocked.js";const rootDir=resolve(import.meta.dirname,"../../../../../app-public");export default async(e,s)=>{const r=e.cookies.session_token,[o,t]=await currentUserHasPermission(r,"system:pages:update");if(o)return s.status(o.code).json({error:o.msg});if(!t)return s.status(403).json({error:"Insufficient permissions"});const{file:i,locked:n}=e.body;if(!i)return s.status(400).json({error:"File path is required"});if("boolean"!=typeof n)return s.status(400).json({error:"locked must be a boolean"});const[a,m]=await setTemplateLocked({rootDir:rootDir,file:i,locked:n});if(a)return s.status(a.code).json({error:a.msg});s.json(m)};
2
+ //# sourceMappingURL=PUT.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"src/kempo/api/templates/lock/PUT.js","names":["resolve","currentUserHasPermission","setTemplateLocked","rootDir","dirname","async","request","response","token","cookies","session_token","permError","hasPermission","status","code","json","error","msg","file","locked","body","data"],"sources":["0"],"sourcesContent":["import { resolve } from 'path';\nimport currentUserHasPermission from '../../../../../server/utils/permissions/currentUserHasPermission.js';\nimport setTemplateLocked from '../../../../../server/utils/templates/setTemplateLocked.js';\n\nconst rootDir = resolve(import.meta.dirname, '../../../../../app-public');\n\nexport default async (request, response) => {\n\tconst token = request.cookies.session_token;\n\tconst [permError, hasPermission] = await currentUserHasPermission(token, 'system:pages:update');\n\n\tif(permError){\n\t\treturn response.status(permError.code).json({ error: permError.msg });\n\t}\n\n\tif(!hasPermission){\n\t\treturn response.status(403).json({ error: 'Insufficient permissions' });\n\t}\n\n\tconst { file, locked } = request.body;\n\n\tif(!file){\n\t\treturn response.status(400).json({ error: 'File path is required' });\n\t}\n\n\tif(typeof locked !== 'boolean'){\n\t\treturn response.status(400).json({ error: 'locked must be a boolean' });\n\t}\n\n\tconst [error, data] = await setTemplateLocked({ rootDir, file, locked });\n\n\tif(error){\n\t\treturn response.status(error.code).json({ error: error.msg });\n\t}\n\n\tresponse.json(data);\n};\n"],"mappings":"OAASA,YAAe,cACjBC,6BAA8B,6EAC9BC,sBAAuB,6DAE9B,MAAMC,QAAUH,oBAAoBI,QAAS,4CAE9BC,MAAOC,EAASC,KAC9B,MAAMC,EAAQF,EAAQG,QAAQC,eACvBC,EAAWC,SAAuBX,yBAAyBO,EAAO,uBAEzE,GAAGG,EACF,OAAOJ,EAASM,OAAOF,EAAUG,MAAMC,KAAK,CAAEC,MAAOL,EAAUM,MAGhE,IAAIL,EACH,OAAOL,EAASM,OAAO,KAAKE,KAAK,CAAEC,MAAO,6BAG3C,MAAME,KAAEA,EAAIC,OAAEA,GAAWb,EAAQc,KAEjC,IAAIF,EACH,OAAOX,EAASM,OAAO,KAAKE,KAAK,CAAEC,MAAO,0BAG3C,GAAqB,kBAAXG,EACT,OAAOZ,EAASM,OAAO,KAAKE,KAAK,CAAEC,MAAO,6BAG3C,MAAOA,EAAOK,SAAcnB,kBAAkB,CAAEC,gBAASe,OAAMC,WAE/D,GAAGH,EACF,OAAOT,EAASM,OAAOG,EAAMF,MAAMC,KAAK,CAAEC,MAAOA,EAAMC,MAGxDV,EAASQ,KAAKM","ignoreList":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kempo",
3
- "version": "4.2.14",
3
+ "version": "4.2.16",
4
4
  "type": "module",
5
5
  "description": "A fullstack CMS framework for creating websites with user authentication",
6
6
  "scripts": {
@@ -49,7 +49,7 @@
49
49
  "kempo": "^4.2.4",
50
50
  "kempo-server": "^3.1.0",
51
51
  "kempo-testing-framework": "^1.4.6",
52
- "kempo-ui": "^0.3.17",
52
+ "kempo-ui": "^0.4.6",
53
53
  "lexical": "^0.42.0",
54
54
  "monaco-editor": "^0.55.1",
55
55
  "postgres": "^3.4.7",
package/server/sdk.js CHANGED
@@ -103,6 +103,8 @@ export { default as listDirectories } from './utils/pages/listDirectories.js';
103
103
  export { default as listPages } from './utils/pages/listPages.js';
104
104
  export { default as listTemplates } from './utils/pages/listTemplates.js';
105
105
  export { default as movePage } from './utils/pages/movePage.js';
106
+ export { default as searchByMetadata } from './utils/pages/searchByMetadata.js';
107
+ export { default as setPageLocked } from './utils/pages/setPageLocked.js';
106
108
  export { default as updatePage } from './utils/pages/updatePage.js';
107
109
 
108
110
  /*
@@ -114,6 +116,7 @@ export { default as deleteTemplate } from './utils/templates/deleteTemplate.js';
114
116
  export { default as disableTemplate } from './utils/templates/disableTemplate.js';
115
117
  export { default as enableTemplate } from './utils/templates/enableTemplate.js';
116
118
  export { default as getTemplate } from './utils/templates/getTemplate.js';
119
+ export { default as setTemplateLocked } from './utils/templates/setTemplateLocked.js';
117
120
  export { default as updateTemplate } from './utils/templates/updateTemplate.js';
118
121
 
119
122
  /*
@@ -126,6 +129,7 @@ export { default as disableFragment } from './utils/fragments/disableFragment.js
126
129
  export { default as enableFragment } from './utils/fragments/enableFragment.js';
127
130
  export { default as getFragment } from './utils/fragments/getFragment.js';
128
131
  export { default as listFragments } from './utils/fragments/listFragments.js';
132
+ export { default as setFragmentLocked } from './utils/fragments/setFragmentLocked.js';
129
133
  export { default as updateFragment } from './utils/fragments/updateFragment.js';
130
134
 
131
135
  /*
@@ -138,6 +142,7 @@ export { default as disableGlobalContent } from './utils/global-content/disableG
138
142
  export { default as enableGlobalContent } from './utils/global-content/enableGlobalContent.js';
139
143
  export { default as getGlobalContent } from './utils/global-content/getGlobalContent.js';
140
144
  export { default as listGlobalContent } from './utils/global-content/listGlobalContent.js';
145
+ export { default as setGlobalContentLocked } from './utils/global-content/setGlobalContentLocked.js';
141
146
  export { default as updateGlobalContent } from './utils/global-content/updateGlobalContent.js';
142
147
 
143
148
  /*
@@ -1,6 +1,7 @@
1
1
  import { writeFile, mkdir } from 'fs/promises';
2
2
  import { join, dirname } from 'path';
3
3
  import { existsSync } from 'fs';
4
+ import triggerHook from '../hooks/triggerHook.js';
4
5
 
5
6
  const slugify = name => name
6
7
  .toLowerCase()
@@ -10,7 +11,7 @@ const slugify = name => name
10
11
  .replace(/-+/g, '-')
11
12
  .replace(/^-|-$/g, '');
12
13
 
13
- export default async ({ rootDir, directory, name, author }) => {
14
+ export default async ({ rootDir, directory, name, author, locked = false }) => {
14
15
  if(!rootDir){
15
16
  return [{ code: 400, msg: 'Root directory is required' }, null];
16
17
  }
@@ -42,6 +43,7 @@ export default async ({ rootDir, directory, name, author }) => {
42
43
  ` author: ${author || ''}`,
43
44
  ` created: ${now}`,
44
45
  ` updated: ${now}`,
46
+ ...(locked ? [' locked: true'] : []),
45
47
  '-->'
46
48
  ].join('\n');
47
49
 
@@ -53,5 +55,7 @@ export default async ({ rootDir, directory, name, author }) => {
53
55
 
54
56
  const relPath = dir ? `${dir}/${slug}` : slug;
55
57
 
58
+ await triggerHook('fragment:created', { file: `${relPath}.fragment.html`, name, slug });
59
+
56
60
  return [null, { file: `${relPath}.fragment.html`, name, slug }];
57
61
  };
@@ -1,6 +1,7 @@
1
1
  import { readFile, unlink } from 'fs/promises';
2
2
  import { join, normalize, sep } from 'path';
3
3
  import parseFrontmatter from '../fs/parseFrontmatter.js';
4
+ import triggerHook from '../hooks/triggerHook.js';
4
5
 
5
6
  export default async ({ rootDir, files }) => {
6
7
  if(!rootDir) return [{ code: 400, msg: 'Root directory is required' }, null];
@@ -21,14 +22,12 @@ export default async ({ rootDir, files }) => {
21
22
  }
22
23
 
23
24
  const meta = parseFrontmatter(content);
24
- if(meta.locked === 'true'){
25
- return [{ code: 403, msg: `Cannot delete locked fragment: ${file}` }, null];
26
- }
27
25
  if(meta.owner && meta.owner !== 'custom'){
28
26
  return [{ code: 403, msg: `Cannot delete fragment: ${file}` }, null];
29
27
  }
30
28
 
31
29
  await unlink(fullPath);
30
+ await triggerHook('fragment:deleted', { file });
32
31
  }
33
32
 
34
33
  return [null, { deleted: files.length }];
@@ -23,9 +23,6 @@ export default async ({ rootDir, file }) => {
23
23
 
24
24
  const content = await readFile(fullPath, 'utf-8');
25
25
  const meta = parseFrontmatter(content);
26
- if(meta.locked === 'true'){
27
- return [{ code: 403, msg: 'Cannot disable a locked fragment' }, null];
28
- }
29
26
 
30
27
  const disabledPath = fullPath.replace(/\.fragment\.html$/, '.fragment-disabled.html');
31
28
  if(existsSync(disabledPath)){
@@ -0,0 +1,32 @@
1
+ import { readFile } from 'fs/promises';
2
+ import { join } from 'path';
3
+ import { existsSync } from 'fs';
4
+ import parseFrontmatter from '../fs/parseFrontmatter.js';
5
+
6
+ export default async ({ rootDir, file }) => {
7
+ if(!rootDir){
8
+ return [{ code: 400, msg: 'Root directory is required' }, null];
9
+ }
10
+ if(!file){
11
+ return [{ code: 400, msg: 'File path is required' }, null];
12
+ }
13
+
14
+ const safePath = file.replace(/\.\./g, '').replace(/^\//, '');
15
+ const fullPath = join(rootDir, safePath);
16
+
17
+ if(!existsSync(fullPath)){
18
+ return [{ code: 404, msg: 'Fragment not found' }, null];
19
+ }
20
+
21
+ const raw = await readFile(fullPath, 'utf-8');
22
+ const meta = parseFrontmatter(raw);
23
+
24
+ return [null, {
25
+ name: meta.name,
26
+ owner: meta.owner,
27
+ locked: meta.locked === 'true',
28
+ author: meta.author,
29
+ created: meta.created,
30
+ updated: meta.updated
31
+ }];
32
+ };
@@ -28,9 +28,6 @@ export default async ({ rootDir, file, newFile }) => {
28
28
  }
29
29
 
30
30
  const meta = parseFrontmatter(content);
31
- if(meta.locked === 'true'){
32
- return [{ code: 403, msg: 'Cannot move a locked fragment' }, null];
33
- }
34
31
  if(meta.owner && meta.owner !== 'custom'){
35
32
  return [{ code: 403, msg: 'Cannot move system fragments' }, null];
36
33
  }
@@ -0,0 +1,48 @@
1
+ import { readFile, writeFile } from 'fs/promises';
2
+ import { join, normalize, sep } from 'path';
3
+ import { existsSync } from 'fs';
4
+
5
+ export default async ({ rootDir, file, locked }) => {
6
+ if(!rootDir) return [{ code: 400, msg: 'Root directory is required' }, null];
7
+ if(!file) return [{ code: 400, msg: 'File path is required' }, null];
8
+ if(typeof locked !== 'boolean') return [{ code: 400, msg: 'locked must be a boolean' }, null];
9
+
10
+ const normalized = normalize(file);
11
+ if(normalized.startsWith('..') || normalized.includes('..' + sep)){
12
+ return [{ code: 400, msg: 'Invalid file path' }, null];
13
+ }
14
+
15
+ const fullPath = join(rootDir, normalized);
16
+ if(!existsSync(fullPath)){
17
+ return [{ code: 404, msg: 'Fragment not found' }, null];
18
+ }
19
+
20
+ const raw = await readFile(fullPath, 'utf-8');
21
+ const frontmatterMatch = raw.match(/^<!--\s*\n([\s\S]*?)\n\s*-->/);
22
+ const existingMeta = {};
23
+
24
+ if(frontmatterMatch){
25
+ for(const line of frontmatterMatch[1].split('\n')){
26
+ const idx = line.indexOf(':');
27
+ if(idx === -1) continue;
28
+ const key = line.slice(0, idx).trim();
29
+ const value = line.slice(idx + 1).trim();
30
+ if(key) existingMeta[key] = value;
31
+ }
32
+ }
33
+
34
+ if(locked){
35
+ existingMeta.locked = 'true';
36
+ } else {
37
+ delete existingMeta.locked;
38
+ }
39
+
40
+ const frontmatter = '<!--\n' +
41
+ Object.entries(existingMeta).map(([k, v]) => ` ${k}: ${v}`).join('\n') +
42
+ '\n-->';
43
+
44
+ const body = frontmatterMatch ? raw.slice(frontmatterMatch[0].length) : '\n' + raw;
45
+ await writeFile(fullPath, frontmatter + body, 'utf-8');
46
+
47
+ return [null, { file: normalized, locked }];
48
+ };
@@ -1,6 +1,7 @@
1
1
  import { readFile, writeFile } from 'fs/promises';
2
2
  import { join } from 'path';
3
3
  import { existsSync } from 'fs';
4
+ import triggerHook from '../hooks/triggerHook.js';
4
5
 
5
6
  export default async ({ rootDir, file, author, markup }) => {
6
7
  if(!rootDir){
@@ -19,9 +20,6 @@ export default async ({ rootDir, file, author, markup }) => {
19
20
 
20
21
  const raw = await readFile(fullPath, 'utf-8');
21
22
 
22
- const lockedMatch = raw.match(/^<!--[\s\S]*?locked:\s*true[\s\S]*?-->/);
23
- if(lockedMatch) return [{ code: 403, msg: 'This fragment is locked and cannot be edited' }, null];
24
-
25
23
  const frontmatterMatch = raw.match(/^<!--\s*\n([\s\S]*?)\n\s*-->/);
26
24
  const existingMeta = {};
27
25
  if(frontmatterMatch){
@@ -54,5 +52,7 @@ export default async ({ rootDir, file, author, markup }) => {
54
52
 
55
53
  await writeFile(fullPath, newContent, 'utf-8');
56
54
 
55
+ await triggerHook('fragment:updated', { file: safePath, updatedAt: now });
56
+
57
57
  return [null, { file: safePath, updatedAt: now }];
58
58
  };
@@ -0,0 +1,22 @@
1
+ import { readFile } from 'fs/promises';
2
+ import { join } from 'path';
3
+ import { existsSync } from 'fs';
4
+ import parseFrontmatter from './parseFrontmatter.js';
5
+
6
+ export default async ({ rootDir, file, resourceType = 'file' }) => {
7
+ if(!rootDir || !file){
8
+ return [{ code: 400, msg: 'Root directory and file path are required' }, null];
9
+ }
10
+
11
+ const safePath = file.replace(/\.\./g, '').replace(/^\//, '');
12
+ const fullPath = join(rootDir, safePath);
13
+
14
+ if(!existsSync(fullPath)){
15
+ return [{ code: 404, msg: `${resourceType} not found` }, null];
16
+ }
17
+
18
+ const raw = await readFile(fullPath, 'utf-8');
19
+ const meta = parseFrontmatter(raw);
20
+
21
+ return [null, meta.locked === 'true'];
22
+ };
@@ -3,6 +3,7 @@ import { join } from 'path';
3
3
  import { existsSync } from 'fs';
4
4
  import crypto from 'crypto';
5
5
  import { GLOBAL_FILE, parseEntries } from './helpers.js';
6
+ import triggerHook from '../hooks/triggerHook.js';
6
7
 
7
8
  const serializeEntries = entries => entries.map(e => {
8
9
  const attrs = [
@@ -54,5 +55,7 @@ export default async ({ rootDir, name, location, priority, author }) => {
54
55
  entries.push(entry);
55
56
  await writeFile(filePath, serializeEntries(entries) + '\n', 'utf-8');
56
57
 
58
+ await triggerHook('globalContent:created', { id: entry.id, name, location });
59
+
57
60
  return [null, entry];
58
61
  };
@@ -2,6 +2,7 @@ import { readFile, writeFile } from 'fs/promises';
2
2
  import { join } from 'path';
3
3
  import { existsSync } from 'fs';
4
4
  import { GLOBAL_FILE, parseEntries } from './helpers.js';
5
+ import triggerHook from '../hooks/triggerHook.js';
5
6
 
6
7
  const serializeEntries = entries => entries.map(e => {
7
8
  const attrs = [
@@ -37,9 +38,6 @@ export default async ({ rootDir, ids }) => {
37
38
 
38
39
  for(const entry of entries){
39
40
  if(!idSet.has(entry.id)) continue;
40
- if(entry.locked){
41
- return [{ code: 403, msg: `Cannot delete locked global content: ${entry.name}` }, null];
42
- }
43
41
  if(entry.owner && entry.owner !== 'custom'){
44
42
  return [{ code: 403, msg: `Cannot delete global content: ${entry.name}` }, null];
45
43
  }
@@ -48,5 +46,7 @@ export default async ({ rootDir, ids }) => {
48
46
  const remaining = entries.filter(e => !idSet.has(e.id));
49
47
  await writeFile(filePath, remaining.length ? serializeEntries(remaining) + '\n' : '', 'utf-8');
50
48
 
49
+ for(const id of ids) await triggerHook('globalContent:deleted', { id });
50
+
51
51
  return [null, { deleted: ids.length }];
52
52
  };
@@ -0,0 +1,37 @@
1
+ import { readFile } from 'fs/promises';
2
+ import { join } from 'path';
3
+ import { existsSync } from 'fs';
4
+ import { GLOBAL_FILE, parseEntries } from './helpers.js';
5
+
6
+ export default async ({ rootDir, id }) => {
7
+ if(!rootDir){
8
+ return [{ code: 400, msg: 'Root directory is required' }, null];
9
+ }
10
+ if(!id){
11
+ return [{ code: 400, msg: 'Global content ID is required' }, null];
12
+ }
13
+
14
+ const filePath = join(rootDir, GLOBAL_FILE);
15
+ if(!existsSync(filePath)){
16
+ return [{ code: 404, msg: 'Global content not found' }, null];
17
+ }
18
+
19
+ const raw = await readFile(filePath, 'utf-8');
20
+ const entries = parseEntries(raw);
21
+ const entry = entries.find(e => e.id === id);
22
+
23
+ if(!entry){
24
+ return [{ code: 404, msg: 'Global content not found' }, null];
25
+ }
26
+
27
+ return [null, {
28
+ name: entry.name,
29
+ owner: entry.owner,
30
+ locked: entry.locked,
31
+ author: entry.author,
32
+ createdAt: entry.createdAt,
33
+ updatedAt: entry.updatedAt,
34
+ location: entry.location,
35
+ priority: entry.priority
36
+ }];
37
+ };
@@ -0,0 +1,44 @@
1
+ import { readFile, writeFile } from 'fs/promises';
2
+ import { join } from 'path';
3
+ import { existsSync } from 'fs';
4
+ import { GLOBAL_FILE, parseEntries } from './helpers.js';
5
+
6
+ const serializeEntries = entries => entries.map(e => {
7
+ const attrs = [
8
+ `id="${e.id}"`,
9
+ `name="${e.name}"`,
10
+ `owner="${e.owner}"`,
11
+ `author="${e.author}"`,
12
+ `created="${e.createdAt}"`,
13
+ `updated="${e.updatedAt}"`,
14
+ `locked="${e.locked}"`,
15
+ `location="${e.location}"`,
16
+ `priority="${e.priority}"`
17
+ ].join(' ');
18
+ return `<content ${attrs}>\n${e.markup}\n</content>`;
19
+ }).join('\n');
20
+
21
+ export default async ({ rootDir, id, locked }) => {
22
+ if(!rootDir) return [{ code: 400, msg: 'Root directory is required' }, null];
23
+ if(!id) return [{ code: 400, msg: 'Global content ID is required' }, null];
24
+ if(typeof locked !== 'boolean') return [{ code: 400, msg: 'locked must be a boolean' }, null];
25
+
26
+ const filePath = join(rootDir, GLOBAL_FILE);
27
+ if(!existsSync(filePath)){
28
+ return [{ code: 404, msg: 'Global content not found' }, null];
29
+ }
30
+
31
+ const raw = await readFile(filePath, 'utf-8');
32
+ const entries = parseEntries(raw);
33
+ const idx = entries.findIndex(e => e.id === id);
34
+
35
+ if(idx === -1){
36
+ return [{ code: 404, msg: 'Global content not found' }, null];
37
+ }
38
+
39
+ entries[idx].locked = locked;
40
+
41
+ await writeFile(filePath, serializeEntries(entries) + '\n', 'utf-8');
42
+
43
+ return [null, { id, locked }];
44
+ };
@@ -2,6 +2,7 @@ import { readFile, writeFile } from 'fs/promises';
2
2
  import { join } from 'path';
3
3
  import { existsSync } from 'fs';
4
4
  import { GLOBAL_FILE, parseEntries } from './helpers.js';
5
+ import triggerHook from '../hooks/triggerHook.js';
5
6
 
6
7
  const serializeEntries = entries => entries.map(e => {
7
8
  const attrs = [
@@ -39,10 +40,6 @@ export default async ({ rootDir, id, name, location, priority, markup }) => {
39
40
  return [{ code: 404, msg: 'Global content not found' }, null];
40
41
  }
41
42
 
42
- if(entries[idx].locked){
43
- return [{ code: 403, msg: 'This global content is locked and cannot be edited' }, null];
44
- }
45
-
46
43
  const now = new Date().toISOString();
47
44
  if(name !== undefined) entries[idx].name = name;
48
45
  if(location !== undefined) entries[idx].location = location;
@@ -52,5 +49,7 @@ export default async ({ rootDir, id, name, location, priority, markup }) => {
52
49
 
53
50
  await writeFile(filePath, serializeEntries(entries) + '\n', 'utf-8');
54
51
 
52
+ await triggerHook('globalContent:updated', { id, updatedAt: now });
53
+
55
54
  return [null, { id, updatedAt: now }];
56
55
  };
@@ -1,6 +1,9 @@
1
1
  import { writeFile, mkdir } from 'fs/promises';
2
2
  import { join, dirname } from 'path';
3
3
  import { existsSync } from 'fs';
4
+ import triggerHook from '../hooks/triggerHook.js';
5
+
6
+ const RESERVED_KEYS = new Set(['owner', 'name', 'author', 'created', 'updated', 'locked', 'title', 'description']);
4
7
 
5
8
  const slugify = name => name
6
9
  .toLowerCase()
@@ -10,7 +13,7 @@ const slugify = name => name
10
13
  .replace(/-+/g, '-')
11
14
  .replace(/^-|-$/g, '');
12
15
 
13
- export default async ({ rootDir, directory, name, template, author }) => {
16
+ export default async ({ rootDir, directory, name, template, author, locked = false, extraMetadata = {} }) => {
14
17
  if(!rootDir){
15
18
  return [{ code: 400, msg: 'Root directory is required' }, null];
16
19
  }
@@ -34,18 +37,24 @@ export default async ({ rootDir, directory, name, template, author }) => {
34
37
  return [{ code: 409, msg: 'A page with this name already exists in this location' }, null];
35
38
  }
36
39
 
40
+ const conflictingKeys = Object.keys(extraMetadata).filter(k => RESERVED_KEYS.has(k));
41
+ if(conflictingKeys.length){
42
+ return [{ code: 400, msg: `Cannot use reserved metadata keys: ${conflictingKeys.join(', ')}` }, null];
43
+ }
44
+
37
45
  const templateName = template || 'default';
38
46
 
39
47
  const now = new Date().toISOString();
40
- const frontmatter = [
41
- '<!--',
48
+ const defaultLines = [
42
49
  ` owner: custom`,
43
50
  ` name: ${name}`,
44
51
  ` author: ${author || ''}`,
45
52
  ` created: ${now}`,
46
- ` updated: ${now}`,
47
- '-->'
48
- ].join('\n');
53
+ ` updated: ${now}`
54
+ ];
55
+ if(locked) defaultLines.push(` locked: true`);
56
+ const extraLines = Object.entries(extraMetadata).map(([k, v]) => ` ${k}: ${v}`);
57
+ const frontmatter = ['<!--', ...defaultLines, ...extraLines, '-->'].join('\n');
49
58
 
50
59
  const pageContent = `${frontmatter}\n<page template="${templateName}" title="${name}">\n</page>\n`;
51
60
 
@@ -55,6 +64,9 @@ export default async ({ rootDir, directory, name, template, author }) => {
55
64
 
56
65
  const relPath = (dir ? `${dir}/${slug}` : slug);
57
66
  const url = '/' + relPath;
67
+ const result = { file: `${relPath}.page.html`, url, title: name, slug };
68
+
69
+ await triggerHook('page:created', { file: result.file, url, name, author, extraMetadata });
58
70
 
59
- return [null, { file: `${relPath}.page.html`, url, title: name, slug }];
71
+ return [null, result];
60
72
  };
@@ -1,6 +1,7 @@
1
1
  import { readFile, unlink } from 'fs/promises';
2
2
  import { join, normalize, sep } from 'path';
3
3
  import parseFrontmatter from '../fs/parseFrontmatter.js';
4
+ import triggerHook from '../hooks/triggerHook.js';
4
5
 
5
6
  export default async ({ rootDir, files }) => {
6
7
  if(!rootDir) return [{ code: 400, msg: 'Root directory is required' }, null];
@@ -21,14 +22,12 @@ export default async ({ rootDir, files }) => {
21
22
  }
22
23
 
23
24
  const meta = parseFrontmatter(content);
24
- if(meta.locked === 'true'){
25
- return [{ code: 403, msg: `Cannot delete locked page: ${file}` }, null];
26
- }
27
25
  if(meta.owner && meta.owner !== 'custom'){
28
26
  return [{ code: 403, msg: `Cannot delete page: ${file}` }, null];
29
27
  }
30
28
 
31
29
  await unlink(fullPath);
30
+ await triggerHook('page:deleted', { file });
32
31
  }
33
32
 
34
33
  return [null, { deleted: files.length }];
@@ -23,9 +23,6 @@ export default async ({ rootDir, file }) => {
23
23
 
24
24
  const content = await readFile(fullPath, 'utf-8');
25
25
  const meta = parseFrontmatter(content);
26
- if(meta.locked === 'true'){
27
- return [{ code: 403, msg: 'Cannot disable a locked page' }, null];
28
- }
29
26
 
30
27
  const disabledPath = fullPath.replace(/\.page\.html$/, '.page-disabled.html');
31
28
  if(existsSync(disabledPath)){
@@ -3,6 +3,8 @@ import { join } from 'path';
3
3
  import { existsSync } from 'fs';
4
4
  import parseFrontmatter from '../fs/parseFrontmatter.js';
5
5
 
6
+ const RESERVED_KEYS = new Set(['owner', 'name', 'author', 'created', 'updated', 'locked', 'title', 'description']);
7
+
6
8
  const parseContents = content => {
7
9
  const blocks = [];
8
10
  const regex = /<content(?:\s+location="([^"]*)")?\s*>([\s\S]*?)<\/content>/g;
@@ -37,6 +39,10 @@ export default async ({ rootDir, file }) => {
37
39
  const templateMatch = raw.match(/<page\s[^>]*template="([^"]*)"/);
38
40
  const contents = parseContents(raw);
39
41
 
42
+ const extraMetadata = Object.fromEntries(
43
+ Object.entries(meta).filter(([k]) => !RESERVED_KEYS.has(k))
44
+ );
45
+
40
46
  return [null, {
41
47
  file: safePath,
42
48
  name: meta.name || '',
@@ -48,6 +54,7 @@ export default async ({ rootDir, file }) => {
48
54
  description: meta.description || '',
49
55
  createdAt: meta.created || '',
50
56
  updatedAt: meta.updated || '',
57
+ extraMetadata,
51
58
  contents
52
59
  }];
53
60
  };
@@ -0,0 +1,32 @@
1
+ import { readFile } from 'fs/promises';
2
+ import { join } from 'path';
3
+ import { existsSync } from 'fs';
4
+ import parseFrontmatter from '../fs/parseFrontmatter.js';
5
+
6
+ export default async ({ rootDir, file }) => {
7
+ if(!rootDir){
8
+ return [{ code: 400, msg: 'Root directory is required' }, null];
9
+ }
10
+ if(!file){
11
+ return [{ code: 400, msg: 'File path is required' }, null];
12
+ }
13
+
14
+ const safePath = file.replace(/\.\./g, '').replace(/^\//, '');
15
+ const fullPath = join(rootDir, safePath);
16
+
17
+ if(!existsSync(fullPath)){
18
+ return [{ code: 404, msg: 'Page not found' }, null];
19
+ }
20
+
21
+ const raw = await readFile(fullPath, 'utf-8');
22
+ const meta = parseFrontmatter(raw);
23
+
24
+ return [null, {
25
+ name: meta.name,
26
+ owner: meta.owner,
27
+ locked: meta.locked === 'true',
28
+ author: meta.author,
29
+ created: meta.created,
30
+ updated: meta.updated
31
+ }];
32
+ };
@@ -3,7 +3,9 @@ import { relative } from 'path';
3
3
  import scanDir from '../fs/scanDir.js';
4
4
  import parseFrontmatter from '../fs/parseFrontmatter.js';
5
5
 
6
- export default async ({ rootDir }) => {
6
+ const RESERVED_KEYS = new Set(['owner', 'name', 'author', 'created', 'updated', 'locked', 'title', 'description']);
7
+
8
+ export default async ({ rootDir, owner }) => {
7
9
  if(!rootDir){
8
10
  return [{ code: 400, msg: 'Root directory is required' }, null];
9
11
  }
@@ -20,6 +22,9 @@ export default async ({ rootDir }) => {
20
22
  .replace(/\.page-disabled\.html$/, '')
21
23
  .replace(/\.page\.html$/, '')
22
24
  .replace(/(^|\/)index$/, '$1');
25
+ const extraMetadata = Object.fromEntries(
26
+ Object.entries(meta).filter(([k]) => !RESERVED_KEYS.has(k))
27
+ );
23
28
  return {
24
29
  file: relPath,
25
30
  url: urlPath || '/',
@@ -31,10 +36,13 @@ export default async ({ rootDir }) => {
31
36
  author: meta.author || '',
32
37
  description: meta.description || '',
33
38
  createdAt: meta.created || '',
34
- updatedAt: meta.updated || ''
39
+ updatedAt: meta.updated || '',
40
+ extraMetadata
35
41
  };
36
42
  });
37
43
 
38
- return [null, { pages, total: pages.length }];
44
+ const filtered = owner ? pages.filter(page => page.owner === owner) : pages;
45
+
46
+ return [null, { pages: filtered, total: filtered.length }];
39
47
  };
40
48
 
@@ -28,9 +28,6 @@ export default async ({ rootDir, file, newFile }) => {
28
28
  }
29
29
 
30
30
  const meta = parseFrontmatter(content);
31
- if(meta.locked === 'true'){
32
- return [{ code: 403, msg: 'Cannot move a locked page' }, null];
33
- }
34
31
  if(meta.owner && meta.owner !== 'custom'){
35
32
  return [{ code: 403, msg: 'Cannot move system pages' }, null];
36
33
  }
@@ -0,0 +1,61 @@
1
+ import { readFile } from 'fs/promises';
2
+ import { relative } from 'path';
3
+ import scanDir from '../fs/scanDir.js';
4
+ import parseFrontmatter from '../fs/parseFrontmatter.js';
5
+
6
+ const RESERVED_KEYS = new Set(['owner', 'name', 'author', 'created', 'updated', 'locked', 'title', 'description']);
7
+
8
+ export default async ({ rootDir, query = {} }) => {
9
+ if(!rootDir){
10
+ return [{ code: 400, msg: 'Root directory is required' }, null];
11
+ }
12
+ if(!Object.keys(query).length){
13
+ return [{ code: 400, msg: 'Search query must have at least one key' }, null];
14
+ }
15
+
16
+ const queryKeys = Object.keys(query);
17
+ const reservedConflicts = queryKeys.filter(k => RESERVED_KEYS.has(k));
18
+ if(reservedConflicts.length){
19
+ return [{ code: 400, msg: `Cannot search reserved metadata keys: ${reservedConflicts.join(', ')}` }, null];
20
+ }
21
+
22
+ const pages = await scanDir(rootDir, rootDir, async (fullPath, entry, root) => {
23
+ if(entry.isDirectory() || !entry.name.endsWith('.page.html')) return null;
24
+ const content = await readFile(fullPath, 'utf-8');
25
+ const meta = parseFrontmatter(content);
26
+
27
+ const matches = queryKeys.every(k => {
28
+ const val = meta[k];
29
+ if(val === undefined) return false;
30
+ const expected = String(query[k]);
31
+ return val === expected;
32
+ });
33
+
34
+ if(!matches) return null;
35
+
36
+ const relPath = relative(root, fullPath).replace(/\\/g, '/');
37
+ const urlPath = '/' + relPath
38
+ .replace(/\.page\.html$/, '')
39
+ .replace(/(^|\/)index$/, '$1');
40
+
41
+ const extraMetadata = Object.fromEntries(
42
+ Object.entries(meta).filter(([k]) => !RESERVED_KEYS.has(k))
43
+ );
44
+
45
+ return {
46
+ file: relPath,
47
+ url: urlPath || '/',
48
+ name: meta.name || meta.title || '',
49
+ title: meta.title || '',
50
+ owner: meta.owner || 'custom',
51
+ locked: meta.locked === 'true',
52
+ author: meta.author || '',
53
+ description: meta.description || '',
54
+ createdAt: meta.created || '',
55
+ updatedAt: meta.updated || '',
56
+ extraMetadata
57
+ };
58
+ });
59
+
60
+ return [null, { pages, total: pages.length }];
61
+ };
@@ -0,0 +1,48 @@
1
+ import { readFile, writeFile } from 'fs/promises';
2
+ import { join, normalize, sep } from 'path';
3
+ import { existsSync } from 'fs';
4
+
5
+ export default async ({ rootDir, file, locked }) => {
6
+ if(!rootDir) return [{ code: 400, msg: 'Root directory is required' }, null];
7
+ if(!file) return [{ code: 400, msg: 'File path is required' }, null];
8
+ if(typeof locked !== 'boolean') return [{ code: 400, msg: 'locked must be a boolean' }, null];
9
+
10
+ const normalized = normalize(file);
11
+ if(normalized.startsWith('..') || normalized.includes('..' + sep)){
12
+ return [{ code: 400, msg: 'Invalid file path' }, null];
13
+ }
14
+
15
+ const fullPath = join(rootDir, normalized);
16
+ if(!existsSync(fullPath)){
17
+ return [{ code: 404, msg: 'Page not found' }, null];
18
+ }
19
+
20
+ const raw = await readFile(fullPath, 'utf-8');
21
+ const frontmatterMatch = raw.match(/^<!--\s*\n([\s\S]*?)\n\s*-->/);
22
+ const existingMeta = {};
23
+
24
+ if(frontmatterMatch){
25
+ for(const line of frontmatterMatch[1].split('\n')){
26
+ const idx = line.indexOf(':');
27
+ if(idx === -1) continue;
28
+ const key = line.slice(0, idx).trim();
29
+ const value = line.slice(idx + 1).trim();
30
+ if(key) existingMeta[key] = value;
31
+ }
32
+ }
33
+
34
+ if(locked){
35
+ existingMeta.locked = 'true';
36
+ } else {
37
+ delete existingMeta.locked;
38
+ }
39
+
40
+ const frontmatter = '<!--\n' +
41
+ Object.entries(existingMeta).map(([k, v]) => ` ${k}: ${v}`).join('\n') +
42
+ '\n-->';
43
+
44
+ const body = frontmatterMatch ? raw.slice(frontmatterMatch[0].length) : '\n' + raw;
45
+ await writeFile(fullPath, frontmatter + body, 'utf-8');
46
+
47
+ return [null, { file: normalized, locked }];
48
+ };
@@ -1,8 +1,11 @@
1
1
  import { readFile, writeFile } from 'fs/promises';
2
2
  import { join } from 'path';
3
3
  import { existsSync } from 'fs';
4
+ import triggerHook from '../hooks/triggerHook.js';
4
5
 
5
- export default async ({ rootDir, file, name, title, description, author, template, contents }) => {
6
+ const RESERVED_KEYS = new Set(['owner', 'name', 'author', 'created', 'updated', 'locked', 'title', 'description']);
7
+
8
+ export default async ({ rootDir, file, name, title, description, author, template, contents, extraMetadata, force = false }) => {
6
9
  if(!rootDir){
7
10
  return [{ code: 400, msg: 'Root directory is required' }, null];
8
11
  }
@@ -20,8 +23,7 @@ export default async ({ rootDir, file, name, title, description, author, templat
20
23
  const raw = await readFile(fullPath, 'utf-8');
21
24
 
22
25
  const frontmatterMatch = raw.match(/^<!--\s*\n([\s\S]*?)\n\s*-->/);
23
- const lockedMatch = raw.match(/^<!--[\s\S]*?locked:\s*true[\s\S]*?-->/);
24
- if(lockedMatch) return [{ code: 403, msg: 'This page is locked and cannot be edited' }, null];
26
+
25
27
  const existingMeta = {};
26
28
  if(frontmatterMatch){
27
29
  for(const line of frontmatterMatch[1].split('\n')){
@@ -33,6 +35,13 @@ export default async ({ rootDir, file, name, title, description, author, templat
33
35
  }
34
36
  }
35
37
 
38
+ if(extraMetadata !== undefined){
39
+ const conflictingKeys = Object.keys(extraMetadata).filter(k => RESERVED_KEYS.has(k));
40
+ if(conflictingKeys.length){
41
+ return [{ code: 400, msg: `Cannot use reserved metadata keys: ${conflictingKeys.join(', ')}` }, null];
42
+ }
43
+ }
44
+
36
45
  const now = new Date().toISOString();
37
46
  const newMeta = {
38
47
  ...existingMeta,
@@ -42,6 +51,11 @@ export default async ({ rootDir, file, name, title, description, author, templat
42
51
  if(title !== undefined) newMeta.title = title;
43
52
  if(description !== undefined) newMeta.description = description;
44
53
  if(author !== undefined) newMeta.author = author;
54
+ if(extraMetadata !== undefined){
55
+ for(const [k, v] of Object.entries(extraMetadata)){
56
+ newMeta[k] = v;
57
+ }
58
+ }
45
59
 
46
60
  const templateMatch = raw.match(/<page\s[^>]*template="([^"]*)"/)
47
61
  const resolvedTemplate = template !== undefined ? template : (templateMatch ? templateMatch[1] : 'default');
@@ -70,5 +84,7 @@ export default async ({ rootDir, file, name, title, description, author, templat
70
84
 
71
85
  await writeFile(fullPath, newPageContent, 'utf-8');
72
86
 
87
+ await triggerHook('page:updated', { file: safePath, updatedAt: now });
88
+
73
89
  return [null, { file: safePath, updatedAt: now }];
74
90
  };
@@ -1,6 +1,7 @@
1
1
  import { readFile, writeFile, mkdir } from 'fs/promises';
2
2
  import { join, dirname } from 'path';
3
3
  import { existsSync } from 'fs';
4
+ import triggerHook from '../hooks/triggerHook.js';
4
5
 
5
6
  const slugify = name => name
6
7
  .toLowerCase()
@@ -10,7 +11,7 @@ const slugify = name => name
10
11
  .replace(/-+/g, '-')
11
12
  .replace(/^-|-$/g, '');
12
13
 
13
- export default async ({ rootDir, directory, name, author, copyFrom }) => {
14
+ export default async ({ rootDir, directory, name, author, copyFrom, locked = false }) => {
14
15
  if(!rootDir) return [{ code: 400, msg: 'Root directory is required' }, null];
15
16
  if(!name) return [{ code: 400, msg: 'Template name is required' }, null];
16
17
 
@@ -33,6 +34,7 @@ export default async ({ rootDir, directory, name, author, copyFrom }) => {
33
34
  ` author: ${author || ''}`,
34
35
  ` created: ${now}`,
35
36
  ` updated: ${now}`,
37
+ ...(locked ? [' locked: true'] : []),
36
38
  '-->'
37
39
  ].join('\n');
38
40
 
@@ -67,5 +69,7 @@ export default async ({ rootDir, directory, name, author, copyFrom }) => {
67
69
 
68
70
  const relPath = dir ? `${dir}/${slug}.template.html` : `${slug}.template.html`;
69
71
 
72
+ await triggerHook('template:created', { file: relPath, name, slug });
73
+
70
74
  return [null, { file: relPath, name, slug }];
71
75
  };
@@ -1,6 +1,7 @@
1
1
  import { readFile, unlink } from 'fs/promises';
2
2
  import { join, normalize, sep } from 'path';
3
3
  import parseFrontmatter from '../fs/parseFrontmatter.js';
4
+ import triggerHook from '../hooks/triggerHook.js';
4
5
 
5
6
  export default async ({ rootDir, files }) => {
6
7
  if(!rootDir) return [{ code: 400, msg: 'Root directory is required' }, null];
@@ -21,14 +22,12 @@ export default async ({ rootDir, files }) => {
21
22
  }
22
23
 
23
24
  const meta = parseFrontmatter(content);
24
- if(meta.locked === 'true'){
25
- return [{ code: 403, msg: `Cannot delete locked template: ${file}` }, null];
26
- }
27
25
  if(meta.owner && meta.owner !== 'custom'){
28
26
  return [{ code: 403, msg: `Cannot delete template: ${file}` }, null];
29
27
  }
30
28
 
31
29
  await unlink(fullPath);
30
+ await triggerHook('template:deleted', { file });
32
31
  }
33
32
 
34
33
  return [null, { deleted: files.length }];
@@ -23,9 +23,6 @@ export default async ({ rootDir, file }) => {
23
23
 
24
24
  const content = await readFile(fullPath, 'utf-8');
25
25
  const meta = parseFrontmatter(content);
26
- if(meta.locked === 'true'){
27
- return [{ code: 403, msg: 'Cannot disable a locked template' }, null];
28
- }
29
26
 
30
27
  const disabledPath = fullPath.replace(/\.template\.html$/, '.template-disabled.html');
31
28
  if(existsSync(disabledPath)){
@@ -0,0 +1,32 @@
1
+ import { readFile } from 'fs/promises';
2
+ import { join } from 'path';
3
+ import { existsSync } from 'fs';
4
+ import parseFrontmatter from '../fs/parseFrontmatter.js';
5
+
6
+ export default async ({ rootDir, file }) => {
7
+ if(!rootDir){
8
+ return [{ code: 400, msg: 'Root directory is required' }, null];
9
+ }
10
+ if(!file){
11
+ return [{ code: 400, msg: 'File path is required' }, null];
12
+ }
13
+
14
+ const safePath = file.replace(/\.\./g, '').replace(/^\//, '');
15
+ const fullPath = join(rootDir, safePath);
16
+
17
+ if(!existsSync(fullPath)){
18
+ return [{ code: 404, msg: 'Template not found' }, null];
19
+ }
20
+
21
+ const raw = await readFile(fullPath, 'utf-8');
22
+ const meta = parseFrontmatter(raw);
23
+
24
+ return [null, {
25
+ name: meta.name,
26
+ owner: meta.owner,
27
+ locked: meta.locked === 'true',
28
+ author: meta.author,
29
+ created: meta.created,
30
+ updated: meta.updated
31
+ }];
32
+ };
@@ -28,9 +28,6 @@ export default async ({ rootDir, file, newFile }) => {
28
28
  }
29
29
 
30
30
  const meta = parseFrontmatter(content);
31
- if(meta.locked === 'true'){
32
- return [{ code: 403, msg: 'Cannot move a locked template' }, null];
33
- }
34
31
  if(meta.owner && meta.owner !== 'custom'){
35
32
  return [{ code: 403, msg: 'Cannot move system templates' }, null];
36
33
  }
@@ -0,0 +1,48 @@
1
+ import { readFile, writeFile } from 'fs/promises';
2
+ import { join, normalize, sep } from 'path';
3
+ import { existsSync } from 'fs';
4
+
5
+ export default async ({ rootDir, file, locked }) => {
6
+ if(!rootDir) return [{ code: 400, msg: 'Root directory is required' }, null];
7
+ if(!file) return [{ code: 400, msg: 'File path is required' }, null];
8
+ if(typeof locked !== 'boolean') return [{ code: 400, msg: 'locked must be a boolean' }, null];
9
+
10
+ const normalized = normalize(file);
11
+ if(normalized.startsWith('..') || normalized.includes('..' + sep)){
12
+ return [{ code: 400, msg: 'Invalid file path' }, null];
13
+ }
14
+
15
+ const fullPath = join(rootDir, normalized);
16
+ if(!existsSync(fullPath)){
17
+ return [{ code: 404, msg: 'Template not found' }, null];
18
+ }
19
+
20
+ const raw = await readFile(fullPath, 'utf-8');
21
+ const frontmatterMatch = raw.match(/^<!--\s*\n([\s\S]*?)\n\s*-->/);
22
+ const existingMeta = {};
23
+
24
+ if(frontmatterMatch){
25
+ for(const line of frontmatterMatch[1].split('\n')){
26
+ const idx = line.indexOf(':');
27
+ if(idx === -1) continue;
28
+ const key = line.slice(0, idx).trim();
29
+ const value = line.slice(idx + 1).trim();
30
+ if(key) existingMeta[key] = value;
31
+ }
32
+ }
33
+
34
+ if(locked){
35
+ existingMeta.locked = 'true';
36
+ } else {
37
+ delete existingMeta.locked;
38
+ }
39
+
40
+ const frontmatter = '<!--\n' +
41
+ Object.entries(existingMeta).map(([k, v]) => ` ${k}: ${v}`).join('\n') +
42
+ '\n-->';
43
+
44
+ const body = frontmatterMatch ? raw.slice(frontmatterMatch[0].length) : '\n' + raw;
45
+ await writeFile(fullPath, frontmatter + body, 'utf-8');
46
+
47
+ return [null, { file: normalized, locked }];
48
+ };
@@ -1,6 +1,7 @@
1
1
  import { readFile, writeFile } from 'fs/promises';
2
2
  import { join } from 'path';
3
3
  import { existsSync } from 'fs';
4
+ import triggerHook from '../hooks/triggerHook.js';
4
5
 
5
6
  export default async ({ rootDir, file, author, markup }) => {
6
7
  if(!rootDir) return [{ code: 400, msg: 'Root directory is required' }, null];
@@ -13,9 +14,6 @@ export default async ({ rootDir, file, author, markup }) => {
13
14
 
14
15
  const raw = await readFile(fullPath, 'utf-8');
15
16
 
16
- const lockedMatch = raw.match(/^<!--[\s\S]*?locked:\s*true[\s\S]*?-->/);
17
- if(lockedMatch) return [{ code: 403, msg: 'This template is locked and cannot be edited' }, null];
18
-
19
17
  const frontmatterMatch = raw.match(/^<!--\s*\n([\s\S]*?)\n\s*-->/);
20
18
  const existingMeta = {};
21
19
  if(frontmatterMatch){
@@ -42,5 +40,7 @@ export default async ({ rootDir, file, author, markup }) => {
42
40
 
43
41
  await writeFile(fullPath, newContent, 'utf-8');
44
42
 
43
+ await triggerHook('template:updated', { file: safePath, updatedAt: now });
44
+
45
45
  return [null, { file: safePath, updatedAt: now }];
46
46
  };