kempo-media 0.3.1 → 0.3.3

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.
@@ -26,6 +26,25 @@ jobs:
26
26
  permissions:
27
27
  contents: write
28
28
  id-token: write
29
+ # The database-backed suites skip themselves when no database is reachable, which reads as a pass.
30
+ # Publishing used to run without one, so a release could go out while the test workflow was red.
31
+ # This is the same service and schema the test workflow uses, so the two gates agree.
32
+ services:
33
+ postgres:
34
+ image: postgres:16-alpine
35
+ env:
36
+ POSTGRES_USER: kempo
37
+ POSTGRES_PASSWORD: kempo
38
+ POSTGRES_DB: kempo_media_test
39
+ ports:
40
+ - 5432:5432
41
+ options: >-
42
+ --health-cmd pg_isready
43
+ --health-interval 10s
44
+ --health-timeout 5s
45
+ --health-retries 5
46
+ env:
47
+ DATABASE_URL: postgresql://kempo:kempo@localhost:5432/kempo_media_test
29
48
  steps:
30
49
  - uses: actions/checkout@v4
31
50
  with:
@@ -49,9 +68,15 @@ jobs:
49
68
  - name: Install the host for the test gate
50
69
  run: npm install --no-save kempo@latest kempo-files@latest kempo-thumbs@latest kempo-ui@latest kempo-server@latest
51
70
 
52
- - name: Run Unit Tests
71
+ - name: Apply database schema
72
+ run: npx drizzle-kit push --force
73
+
74
+ - name: Run unit tests
53
75
  run: npm test
54
76
 
77
+ - name: Run integration tests
78
+ run: npm run test:integration
79
+
55
80
  - name: Set version type
56
81
  id: version
57
82
  run: |
@@ -11,9 +11,30 @@ on:
11
11
  - master
12
12
 
13
13
  jobs:
14
- # kempo/kempo-ui/kempo-server resolve from the registry, which is what a site installing this
15
- # extension gets. Peer dependencies are installed automatically by npm ci.
16
- published:
14
+ # Unit tier: an in-memory Postgres (PGlite), so there is no service to start and a database
15
+ # suite cannot skip for want of one.
16
+ unit:
17
+ if: "!contains(github.event.head_commit.message, '[skip ci]')"
18
+ runs-on: ubuntu-latest
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+
22
+ - uses: actions/setup-node@v4
23
+ with:
24
+ node-version: '22.x'
25
+
26
+ - run: npm ci
27
+
28
+ # kempo, kempo-files, kempo-thumbs, kempo-ui and kempo-server are peer dependencies, which npm ci does not install.
29
+ - name: Install the host for the test gate
30
+ run: npm install --no-save kempo@latest kempo-files@latest kempo-thumbs@latest kempo-ui@latest kempo-server@latest
31
+
32
+ - name: Run unit tests
33
+ run: npm test
34
+
35
+
36
+ # Integration tier: the whole suite on a real Postgres. An unreachable database fails it.
37
+ integration:
17
38
  if: "!contains(github.event.head_commit.message, '[skip ci]')"
18
39
  runs-on: ubuntu-latest
19
40
  services:
@@ -50,8 +71,9 @@ jobs:
50
71
  - name: Apply database schema
51
72
  run: npx drizzle-kit push --force
52
73
 
53
- - name: Run Unit Tests
54
- run: npm test
74
+ - name: Run integration tests
75
+ run: npm run test:integration
76
+
55
77
 
56
78
  # Same suite against the sibling checkout of the host. This extension resolves imports out of
57
79
  # kempo, so it breaks whenever the host moves something it reaches into — this job is what
@@ -129,6 +151,10 @@ jobs:
129
151
  run: npx drizzle-kit push --force
130
152
  working-directory: kempo-media
131
153
 
132
- - name: Run Unit Tests
154
+ - name: Run unit tests
133
155
  run: npm test
134
156
  working-directory: kempo-media
157
+
158
+ - name: Run integration tests
159
+ run: npm run test:integration
160
+ working-directory: kempo-media
package/CHANGELOG.md CHANGED
@@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
+
9
+ ### Added
10
+
11
+ - **Dashboard widget.** A **Media** widget on the kempo admin dashboard showing the asset count and the latest uploads, with a **Upload media** button, declared in `widgets` in `kempo-config.json`. Needs a kempo with the dashboard; ignored on older versions.
8
12
  ### Changed
9
13
  - **Media now sits on kempo-files, with thumbnails from kempo-thumbs.** Before, this extension kept its own copy of everything: uploads in the site's `public/media/uploads/` (static serving, no permissions), and `sharp` thumbnails in `public/media/thumbnails/`, parallel to kempo-files' permission-checked library and kempo-thumbs' ffmpeg thumbnails. Anything consuming images had to handle both stacks. Now an asset is a kempo-files file plus a `kempoMediaAsset` row (kind, dimensions, alt text, display name), the **asset id is the file id** (no mapping table), the bytes live in kempo-files' `files/media/` folder, and a thumbnail is whatever kempo-thumbs made. One storage layer, one set of file permissions, one thumbnail path.
10
14
  - `kempo-config.json` declares `"dependencies": ["kempo-files", "kempo-thumbs"]`; kempo will not install this extension until both are enabled. kempo-thumbs is required rather than optional so every consumer can rely on `thumbnail` meaning the same thing. Requires `kempo-files` >= 0.1.5, which adds `uploadFile`, caller-chosen ids and download caching.
package/README.md CHANGED
@@ -158,3 +158,7 @@ npm test
158
158
  - **Only assets uploaded through this library are in it.** Other files in kempo-files do not appear in the media grid unless registered with `createMediaAsset`.
159
159
  - **A replaced public file can be cached for up to an hour** by browsers (kempo-files' cache lifetime).
160
160
  - **No usage tracking.** Deleting an asset does not check whether a page links to it.
161
+
162
+ ## Dashboard widget
163
+
164
+ Adds a **Media** widget to the kempo admin dashboard showing the asset count and the latest uploads, with a **Upload media** button. It is declared in `widgets` in `kempo-config.json` and registered when the extension is installed or updated; people only see it if they hold its permission, and can hide, reorder or resize it from the dashboard's Customize dialog. Needs a kempo with the dashboard (see kempo's `docs/dashboard.md`); on an older kempo the entry is ignored.
package/kempo-config.json CHANGED
@@ -3,6 +3,22 @@
3
3
  "public-scope": "kempo-media",
4
4
  "schema": "./server/db/schema.js",
5
5
  "dependencies": ["kempo-files", "kempo-thumbs"],
6
+ "widgets": [
7
+ {
8
+ "key": "library",
9
+ "title": "Media",
10
+ "description": "How many media assets there are and the latest uploads.",
11
+ "icon": "image",
12
+ "element": "k-media-widget",
13
+ "module": "/kempo-media/components/MediaWidget.js",
14
+ "defaultWidth": 2,
15
+ "defaultHeight": 2,
16
+ "minWidth": 1,
17
+ "minHeight": 2,
18
+ "permission": "media:browse",
19
+ "priority": 73
20
+ }
21
+ ],
6
22
  "permissions": [
7
23
  { "name": "media:browse", "description": "List and view media library assets" },
8
24
  { "name": "media:upload", "description": "Upload new media assets" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kempo-media",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Media library extension for kempo — upload and manage images, video, audio, 3D models, and archives, stored in kempo-files with thumbnails from kempo-thumbs",
5
5
  "license": "MIT",
6
6
  "author": "Dustin Poissant",
@@ -8,7 +8,8 @@
8
8
  "main": "sdk.js",
9
9
  "scripts": {
10
10
  "build": "node scripts/build.js",
11
- "test": "kempo-test -l minimal",
11
+ "test": "node scripts/run-tests.js unit kempo-test -l minimal",
12
+ "test:integration": "node scripts/run-tests.js integration kempo-test -l minimal",
12
13
  "link:local": "npm install ../kempo ../kempo-files ../kempo-thumbs ../kempo-server ../kempo-ui --no-save --no-legacy-peer-deps --force"
13
14
  },
14
15
  "kempo": {
@@ -33,6 +34,7 @@
33
34
  "kempo-ui": ">=0.4.43"
34
35
  },
35
36
  "devDependencies": {
37
+ "@electric-sql/pglite": "^0.5.9",
36
38
  "drizzle-kit": "^0.31.10",
37
39
  "kempo-testing-framework": "^1.4.11",
38
40
  "postgres": "^3.4.9"
@@ -0,0 +1,50 @@
1
+ import DashboardWidget from '/kempo/components/DashboardWidget.js';
2
+ import { html, nothing } from '/kempo-ui/lit-all.min.js';
3
+ import { getMediaAssets } from '/kempo-media/sdk.js';
4
+
5
+ /*
6
+ The size of the media library and the most recent uploads.
7
+ */
8
+ export default class MediaWidget extends DashboardWidget {
9
+ static properties = {
10
+ ...DashboardWidget.properties,
11
+ total: { state: true },
12
+ recent: { state: true }
13
+ };
14
+
15
+ constructor(){
16
+ super();
17
+ this.total = 0;
18
+ this.recent = [];
19
+ this.cta = { label: 'Upload media', icon: 'upload', href: '/admin/extension/kempo-media/', permission: 'media:upload' };
20
+ }
21
+
22
+ /*
23
+ Public Methods
24
+ */
25
+
26
+ load = async () => {
27
+ const { assets, total } = await this.unwrap(getMediaAssets({ limit: 9 }));
28
+ this.total = total;
29
+ this.recent = assets;
30
+ };
31
+
32
+ /*
33
+ Rendering
34
+ */
35
+
36
+ renderContent(){
37
+ if(!this.total) return html`<p class="tc-muted">No media yet.</p>`;
38
+ const rows = this.recent.slice(0, this.height * 3);
39
+ return html`
40
+ <div class="h2 m0">${this.total}</div>
41
+ <div class="small tc-muted mb">${this.total === 1 ? 'Asset' : 'Assets'}</div>
42
+ ${rows.length ? html`<div class="small tc-muted mbq">Latest</div>` : nothing}
43
+ ${rows.map(asset => html`
44
+ <div class="d-f pyq bb"><span class="flex">${asset.originalName || asset.name || asset.id}</span><span class="small tc-muted">${asset.kind}</span></div>
45
+ `)}
46
+ `;
47
+ }
48
+ }
49
+
50
+ customElements.define('k-media-widget', MediaWidget);
@@ -0,0 +1,47 @@
1
+ import { spawnSync } from 'child_process';
2
+ import { pathToFileURL } from 'url';
3
+ import path from 'path';
4
+
5
+ /*
6
+ The two test tiers, then the command to run in each.
7
+
8
+ unit `KEMPO_DB=pglite`: kempo's database module gives an in-process, in-memory Postgres
9
+ (PGlite) holding the tables in drizzle.config.js, so nothing external is needed and a
10
+ database suite can never skip for want of one.
11
+ integration a real Postgres at DATABASE_URL, for the suites that boot a second server process.
12
+ Here an unreachable database fails the run instead of skipping, so it cannot look
13
+ green without having run.
14
+
15
+ `npm test` is the unit tier. `npm run test:integration` runs the whole suite on a real database. CI
16
+ runs both.
17
+
18
+ Usage: node scripts/run-tests.js <unit|integration> <command> [arguments]
19
+ */
20
+
21
+ const [tier, command, ...args] = process.argv.slice(2);
22
+
23
+ if(!['unit', 'integration'].includes(tier) || !command){
24
+ console.error('Usage: node scripts/run-tests.js <unit|integration> <command> [arguments]');
25
+ process.exit(2);
26
+ }
27
+
28
+ const env = { ...process.env };
29
+
30
+ if(tier === 'unit'){
31
+ env.KEMPO_DB = 'pglite';
32
+ delete env.KEMPO_TEST_TIER;
33
+ // Create the database before any test file can install globals that confuse it. See the preload.
34
+ const preload = pathToFileURL(path.resolve(import.meta.dirname, '..', 'tests', 'helpers', 'preload.js')).href;
35
+ env.NODE_OPTIONS = `${env.NODE_OPTIONS || ''} --import=${preload}`.trim();
36
+ } else {
37
+ delete env.KEMPO_DB;
38
+ env.KEMPO_TEST_TIER = 'integration';
39
+ if(!env.DATABASE_URL){
40
+ console.error('The integration tier needs DATABASE_URL pointing at a throwaway Postgres (named *_test) with the schema applied (npx drizzle-kit push).');
41
+ process.exit(2);
42
+ }
43
+ }
44
+
45
+ const result = spawnSync(command, args, { stdio: 'inherit', env, shell: true });
46
+
47
+ process.exit(result.status ?? 1);
@@ -0,0 +1,85 @@
1
+ import path from 'path';
2
+ import { existsSync } from 'fs';
3
+ import { readFile } from 'fs/promises';
4
+ import { fileURLToPath, pathToFileURL } from 'url';
5
+
6
+ /*
7
+ This extension adds widgets to the kempo admin dashboard through `widgets` in kempo-config.json. Core
8
+ refuses a definition it could not render (and the extension would fail to install), and a module path
9
+ that points at nothing only shows up as a broken tile, so both are checked here.
10
+
11
+ The rules are checked inline so this works against any installed kempo, including one released before
12
+ the dashboard existed. When the installed kempo does have the dashboard, its own validation also runs.
13
+ */
14
+
15
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
16
+ const config = JSON.parse(await readFile(path.join(root, 'kempo-config.json'), 'utf8'));
17
+ const pkg = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
18
+ const core = await import(pathToFileURL(path.join(root, 'node_modules', 'kempo', 'server', 'utils', 'dashboard', 'helpers.js')).href).catch(() => null);
19
+
20
+ const scope = `/${pkg.kempo?.['public-scope'] || config['public-scope']}/`;
21
+ const permissions = new Set((config.permissions || []).map(p => p.name));
22
+
23
+ const ELEMENT = /^[a-z][a-z0-9]*(-[a-z0-9]+)+$/;
24
+ const KEY = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
25
+ const LOCAL_PATH = /^\/(?![/\\])[^\\\s\u0000-\u001f\u007f]*$/;
26
+ const blocks = (value, max) => Number.isInteger(value) && value >= 1 && value <= max;
27
+
28
+ const problems = widget => {
29
+ const { key, title, element, module, defaultWidth = 2, defaultHeight = 1, minWidth = 1, maxWidth = 4, minHeight = 1, maxHeight = 3, actions = [] } = widget;
30
+ const found = [];
31
+ if(typeof key !== 'string' || !KEY.test(key)) found.push('key must be letters, numbers, ".", "_" or "-"');
32
+ if(typeof title !== 'string' || !title.trim() || title.length > 100) found.push('title is required, at most 100 characters');
33
+ if(typeof element !== 'string' || !ELEMENT.test(element)) found.push('element must be a custom element name such as "my-ext-widget"');
34
+ if(typeof module !== 'string' || !LOCAL_PATH.test(module)) found.push('module must be a path on this site beginning with a single "/"');
35
+ if(!blocks(minWidth, 4) || !blocks(maxWidth, 4) || minWidth > maxWidth) found.push('minWidth and maxWidth must be 1 to 4, min no greater than max');
36
+ if(!blocks(minHeight, 3) || !blocks(maxHeight, 3) || minHeight > maxHeight) found.push('minHeight and maxHeight must be 1 to 3, min no greater than max');
37
+ if(!Number.isInteger(defaultWidth) || defaultWidth < minWidth || defaultWidth > maxWidth) found.push('defaultWidth must be within the width bounds');
38
+ if(!Number.isInteger(defaultHeight) || defaultHeight < minHeight || defaultHeight > maxHeight) found.push('defaultHeight must be within the height bounds');
39
+ if(!Array.isArray(actions) || actions.length > 3 || actions.some(action => !action?.label || !LOCAL_PATH.test(action?.href))) found.push('actions must be at most 3 { label, href } with a site path');
40
+ return found;
41
+ };
42
+
43
+ export default {
44
+ 'kempo-config.json declares at least one widget': async ({ pass, fail }) => {
45
+ if(!Array.isArray(config.widgets) || !config.widgets.length) return fail('no widgets declared');
46
+ pass(`${config.widgets.length} widget(s)`);
47
+ },
48
+
49
+ 'every declared widget is a definition the dashboard can render': async ({ pass, fail }) => {
50
+ for(const widget of config.widgets){
51
+ const found = problems(widget);
52
+ if(found.length) return fail(`${widget.key}: ${found.join('; ')}`);
53
+ if(core){
54
+ const [error] = core.normalizeWidget({ ...widget, owner: pkg.name });
55
+ if(error) return fail(`${widget.key}: kempo core refuses it: ${error.msg}`);
56
+ }
57
+ }
58
+ pass(core ? 'checked inline and by kempo core' : 'checked inline');
59
+ },
60
+
61
+ 'every widget module is served from the public scope and exists': async ({ pass, fail }) => {
62
+ for(const { key, module } of config.widgets){
63
+ if(!module.startsWith(scope)) return fail(`${key}: ${module} is outside ${scope}`);
64
+ if(!existsSync(path.join(root, 'public', module.slice(scope.length)))) return fail(`${key}: ${module} does not exist under public/`);
65
+ }
66
+ pass();
67
+ },
68
+
69
+ 'a widget permission is one the extension declares, or from core or a dependency when it declares none': async ({ pass, fail }) => {
70
+ for(const { key, permission } of config.widgets){
71
+ if(permission && permissions.size && !permissions.has(permission) && !permission.startsWith('system:')){
72
+ return fail(`${key}: permission "${permission}" is not declared by this extension`);
73
+ }
74
+ }
75
+ pass();
76
+ },
77
+
78
+ 'every widget module defines the element it is registered with': async ({ pass, fail }) => {
79
+ for(const { key, module, element } of config.widgets){
80
+ const source = await readFile(path.join(root, 'public', module.slice(scope.length)), 'utf8');
81
+ if(!source.includes(`customElements.define('${element}'`)) return fail(`${key}: ${module} never defines <${element}>`);
82
+ }
83
+ pass();
84
+ },
85
+ };
@@ -0,0 +1,19 @@
1
+ /*
2
+ Shared by the database-backed suites. See scripts/run-tests.js for the tiers.
3
+ */
4
+
5
+ export const unitTier = process.env.KEMPO_DB === 'pglite';
6
+ export const integrationTier = process.env.KEMPO_TEST_TIER === 'integration';
7
+
8
+ /*
9
+ The suite for a file that belongs to the integration tier, reported as such in the unit tier. It is
10
+ explicit rather than a silent skip, and `npm run test:integration` is where it runs.
11
+ */
12
+ export const integrationOnly = name => ({
13
+ [`${name} (integration tier)`]: async ({ pass }) => pass('runs in `npm run test:integration`: it needs a real Postgres or a second server process'),
14
+ });
15
+
16
+ /*
17
+ Seeds kempo's system groups, permissions and settings in-process. Needs kempo >= 4.5.5.
18
+ */
19
+ export const seedDatabase = async () => (await import('kempo/server/utils/setup/seedDatabase.js')).default({ log: () => {} });
@@ -0,0 +1,10 @@
1
+ /*
2
+ Loaded with `--import` by scripts/run-tests.js in the unit tier, before any test file.
3
+
4
+ The in-memory database is created on first import of kempo's database module. Creating it here, in a
5
+ clean process, keeps the run from depending on which suite imports it first or on globals a test
6
+ file has installed by then.
7
+ */
8
+ if(process.env.KEMPO_DB === 'pglite'){
9
+ await import('kempo/server/db/index.js');
10
+ }
@@ -1,3 +1,4 @@
1
+ import { unitTier, integrationOnly } from './helpers/db.js';
1
2
  import { spawn, execFileSync } from 'child_process';
2
3
  import { writeFile, mkdir, rm, readFile, stat } from 'fs/promises';
3
4
  import path from 'path';
@@ -465,6 +466,8 @@ const buildTests = () => {
465
466
  };
466
467
  };
467
468
 
468
- export default databaseReachable
469
+ export default unitTier
470
+ ? integrationOnly('media API contract')
471
+ : databaseReachable
469
472
  ? buildTests()
470
473
  : skipped('no reachable database — see the "Local development" section of the README (docker compose up -d, then npx drizzle-kit push)');
@@ -79,6 +79,13 @@ const purge = async () => {
79
79
  }
80
80
  };
81
81
 
82
+ /*
83
+ kempo-files' media folder also lives on disk, and the disk outlives a database. A fresh in-memory
84
+ database has no row for a folder an earlier run left behind, so the first upload would be refused
85
+ ("already exists on disk"). This suite owns that folder, so start from none.
86
+ */
87
+ await rm(path.join(process.cwd(), 'files', 'media'), { recursive: true, force: true }).catch(() => {});
88
+
82
89
  const buildTests = () => ({
83
90
  'createMediaAsset records every column and stamps timestamps': async ({ pass, fail }) => {
84
91
  const [error, row] = await create({ altText: 'A test image' });
@@ -78,6 +78,12 @@ const cleanup = async () => {
78
78
  for(const id of Object.values(IDS)) await deleteFile({ id }).catch(() => {});
79
79
  await db.delete(kempoMediaAsset).where(inArray(kempoMediaAsset.id, Object.values(IDS))).catch(() => {});
80
80
  await db.delete(settingTable).where(eq(settingTable.name, 'kempo-media:thumbnail_max_dimension')).catch(() => {});
81
+ /*
82
+ kempo-files' folders also live on disk, and the disk outlives a database. A run on a fresh in-memory
83
+ database has no row for the media folder an earlier run left behind, and the migration then refuses
84
+ to create it ("already exists on disk"). This suite owns that folder, so reset it.
85
+ */
86
+ await rm(path.join(process.cwd(), 'files', 'media'), { recursive: true, force: true }).catch(() => {});
81
87
  await removeExtensions();
82
88
  await db.execute(sql.raw(`ALTER TABLE "kempoMediaAsset" DROP COLUMN IF EXISTS "filename", DROP COLUMN IF EXISTS "path", DROP COLUMN IF EXISTS "thumbnailPath"`)).catch(() => {});
83
89
  if(state.site) await rm(state.site, { recursive: true, force: true }).catch(() => {});