@plank-cms/plank 0.12.0

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 (35) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +34 -0
  3. package/dist/admin/assets/hls-0EiSYoTw.js +40 -0
  4. package/dist/admin/assets/index-BCd-a4uR.css +2 -0
  5. package/dist/admin/assets/index-Db1a-6aD.js +203 -0
  6. package/dist/admin/favicon.png +0 -0
  7. package/dist/admin/index.html +21 -0
  8. package/dist/admin/particles-texture.png +0 -0
  9. package/dist/admin/plank-logo-w.svg +4 -0
  10. package/dist/index.js +177 -0
  11. package/dist/migrations/001_plank_roles.sql +5 -0
  12. package/dist/migrations/002_plank_users.sql +7 -0
  13. package/dist/migrations/003_plank_content_types.sql +9 -0
  14. package/dist/migrations/004_plank_api_tokens.sql +7 -0
  15. package/dist/migrations/005_plank_media.sql +10 -0
  16. package/dist/migrations/006_plank_users_name.sql +3 -0
  17. package/dist/migrations/007_api_tokens_access_type.sql +2 -0
  18. package/dist/migrations/008_content_types_default.sql +2 -0
  19. package/dist/migrations/009_plank_settings.sql +7 -0
  20. package/dist/migrations/010_users_avatar.sql +1 -0
  21. package/dist/migrations/011_entries_status.sql +17 -0
  22. package/dist/migrations/012_entries_published_data.sql +14 -0
  23. package/dist/migrations/013_entries_published_at.sql +14 -0
  24. package/dist/migrations/014_timezone_setting.sql +3 -0
  25. package/dist/migrations/015_entries_scheduled.sql +14 -0
  26. package/dist/migrations/016_entries_created_by.sql +14 -0
  27. package/dist/migrations/017_content_types_kind.sql +2 -0
  28. package/dist/migrations/018_plank_webhooks.sql +8 -0
  29. package/dist/migrations/019_user_prefs.sql +7 -0
  30. package/dist/migrations/020_plank_media_folders.sql +8 -0
  31. package/dist/migrations/021_users_profile_fields.sql +4 -0
  32. package/dist/migrations/022_plank_media_metadata.sql +4 -0
  33. package/dist/migrations/023_entries_localized.sql +17 -0
  34. package/dist/server-VPVKRT67.js +2742 -0
  35. package/package.json +67 -0
Binary file
@@ -0,0 +1,21 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Plank Admin</title>
7
+
8
+ <link rel="icon" type="image/png" href="/admin/favicon.png" />
9
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
11
+ <link
12
+ href="https://fonts.googleapis.com/css2?family=Google+Sans:ital,opsz,wght@0,17..18,400..700;1,17..18,400..700&display=swap"
13
+ rel="stylesheet"
14
+ />
15
+ <script type="module" crossorigin src="/admin/assets/index-Db1a-6aD.js"></script>
16
+ <link rel="stylesheet" crossorigin href="/admin/assets/index-BCd-a4uR.css">
17
+ </head>
18
+ <body>
19
+ <div id="root"></div>
20
+ </body>
21
+ </html>
Binary file
@@ -0,0 +1,4 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" width="984.8739" height="1079.5625" viewBox="0 0 984.8739 1079.5625">
3
+ <path d="M654.0548,0h-166.4155L0,399.8729v565.8804l441.927-363.306v180.4008h212.1278c174.2523,0,330.8192-98.4874,330.8192-395.2159C984.8739,88.3854,834.6244,0,654.0548,0ZM0,965.7533v113.8092h441.927v-113.8092c0-7.4991-441.927,0-441.927,0Z" fill="#fff"/>
4
+ </svg>
package/dist/index.js ADDED
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/commands/init.ts
4
+ import { intro, outro, text, spinner, note, isCancel, cancel } from "@clack/prompts";
5
+ import chalk from "chalk";
6
+ import { randomBytes } from "crypto";
7
+ import { resolve, join } from "path";
8
+ import fs from "fs-extra";
9
+ import { execa } from "execa";
10
+ var PACKAGE_VERSION = "0.12.0";
11
+ function generateSecret() {
12
+ return randomBytes(32).toString("hex");
13
+ }
14
+ function buildEnv(jwtSecret, encryptionKey) {
15
+ return [
16
+ `PLANK_DATABASE_URL=postgresql://user:password@localhost:5432/plank`,
17
+ `PLANK_JWT_SECRET=${jwtSecret}`,
18
+ `PLANK_ENCRYPTION_KEY=${encryptionKey}`,
19
+ `PLANK_PORT=5500`
20
+ ].join("\n") + "\n";
21
+ }
22
+ function buildPackageJson(name) {
23
+ return {
24
+ name,
25
+ version: "0.1.0",
26
+ private: true,
27
+ scripts: {
28
+ start: "plank start"
29
+ },
30
+ dependencies: {
31
+ "@plank-cms/plank": PACKAGE_VERSION
32
+ }
33
+ };
34
+ }
35
+ async function init(projectName) {
36
+ intro(chalk.bold("\u25B2 Plank CMS"));
37
+ const useCurrentDir = projectName === ".";
38
+ let name = useCurrentDir ? void 0 : projectName;
39
+ if (!name) {
40
+ if (useCurrentDir) {
41
+ name = process.cwd().split("/").pop() ?? "plank-cms";
42
+ } else {
43
+ const answer = await text({
44
+ message: "Project name",
45
+ placeholder: "my-plank-cms",
46
+ defaultValue: "my-plank-cms",
47
+ validate(value) {
48
+ if (!value.trim()) return "Project name is required";
49
+ if (!/^[a-z0-9-_]+$/.test(value)) return "Use only lowercase letters, numbers, hyphens, and underscores";
50
+ }
51
+ });
52
+ if (isCancel(answer)) {
53
+ cancel("Cancelled.");
54
+ process.exit(0);
55
+ }
56
+ name = answer;
57
+ }
58
+ }
59
+ const projectDir = useCurrentDir ? process.cwd() : resolve(process.cwd(), name);
60
+ if (!useCurrentDir && await fs.pathExists(projectDir)) {
61
+ const entries = await fs.readdir(projectDir);
62
+ if (entries.length > 0) {
63
+ cancel(`Directory "${name}" already exists and is not empty.`);
64
+ process.exit(1);
65
+ }
66
+ }
67
+ const s = spinner();
68
+ s.start("Creating project...");
69
+ await fs.ensureDir(projectDir);
70
+ await fs.writeFile(join(projectDir, ".env"), buildEnv(generateSecret(), generateSecret()));
71
+ await fs.writeJSON(join(projectDir, "package.json"), buildPackageJson(name), { spaces: 2 });
72
+ await fs.writeFile(join(projectDir, ".gitignore"), ".env\nnode_modules\n");
73
+ s.stop("Project created");
74
+ s.start("Installing dependencies...");
75
+ await execa("npm", ["install"], { cwd: projectDir });
76
+ s.stop("Dependencies installed");
77
+ note(
78
+ [
79
+ `Edit ${chalk.cyan(".env")} in your project and replace:`,
80
+ "",
81
+ ` ${chalk.yellow("PLANK_DATABASE_URL")}=${chalk.dim("postgresql://user:password@localhost:5432/plank")}`,
82
+ "",
83
+ `with your real PostgreSQL connection string.`,
84
+ "",
85
+ `Then start your CMS:`,
86
+ "",
87
+ ...!useCurrentDir ? [` ${chalk.cyan(`cd ${name}`)}`, ""] : [],
88
+ ` ${chalk.cyan("npm start")}`
89
+ ].join("\n"),
90
+ "Next steps"
91
+ );
92
+ outro(`Your Plank CMS is ready${useCurrentDir ? "" : ` at ${chalk.cyan(`./${name}`)}`}`);
93
+ }
94
+
95
+ // src/commands/start.ts
96
+ import { config } from "dotenv";
97
+ import { fileURLToPath } from "url";
98
+ import { dirname, join as join2, resolve as resolve2 } from "path";
99
+ async function start() {
100
+ config({ path: resolve2(process.cwd(), ".env") });
101
+ process.env.PLANK_ADMIN_DIST = join2(dirname(fileURLToPath(import.meta.url)), "admin");
102
+ const { start: startServer } = await import("./server-VPVKRT67.js");
103
+ await startServer();
104
+ }
105
+
106
+ // src/commands/publish-scheduled.ts
107
+ import { config as config2 } from "dotenv";
108
+ import { resolve as resolve3 } from "path";
109
+ import pg from "pg";
110
+ var SNAPSHOT_EXCLUDED = ["'id'", "'status'", "'published_data'", "'published_at'", "'scheduled_for'", "'created_at'", "'updated_at'"];
111
+ var snapshotExpr = (table) => `(SELECT ${SNAPSHOT_EXCLUDED.reduce((expr, col) => `${expr} - ${col}`, `to_jsonb(t.*)`)} FROM ${table} t WHERE t.id = $1)`;
112
+ async function publishScheduled() {
113
+ config2({ path: resolve3(process.cwd(), ".env") });
114
+ const connectionString = process.env.PLANK_DATABASE_URL;
115
+ if (!connectionString) {
116
+ console.error("[publish-scheduled] PLANK_DATABASE_URL is not set");
117
+ process.exit(1);
118
+ }
119
+ const pool = new pg.Pool({ connectionString });
120
+ try {
121
+ console.log(`[publish-scheduled] Running at ${(/* @__PURE__ */ new Date()).toISOString()}`);
122
+ const { rows: cts } = await pool.query(
123
+ "SELECT slug, table_name FROM plank_content_types"
124
+ );
125
+ const published = [];
126
+ for (const { slug, table_name } of cts) {
127
+ const { rows: due } = await pool.query(
128
+ `SELECT id FROM ${table_name} WHERE status = 'scheduled' AND scheduled_for <= NOW()`
129
+ );
130
+ if (due.length === 0) continue;
131
+ for (const { id } of due) {
132
+ await pool.query(
133
+ `UPDATE ${table_name} SET
134
+ status = 'published',
135
+ published_data = ${snapshotExpr(table_name)},
136
+ published_at = NOW(),
137
+ scheduled_for = NULL,
138
+ updated_at = NOW()
139
+ WHERE id = $1`,
140
+ [id]
141
+ );
142
+ published.push({ slug, id });
143
+ }
144
+ }
145
+ if (published.length === 0) {
146
+ console.log("[publish-scheduled] No entries ready to publish.");
147
+ return;
148
+ }
149
+ console.log(`[publish-scheduled] ${published.length} entry(s) published:`);
150
+ for (const { slug, id } of published) {
151
+ console.log(` \u2713 ${slug} \u2014 ${id}`);
152
+ }
153
+ } finally {
154
+ await pool.end();
155
+ }
156
+ }
157
+
158
+ // src/index.ts
159
+ var [, , command, ...args] = process.argv;
160
+ switch (command) {
161
+ case "init":
162
+ await init(args[0]);
163
+ break;
164
+ case "start":
165
+ await start();
166
+ break;
167
+ case "publish-scheduled":
168
+ await publishScheduled();
169
+ break;
170
+ default:
171
+ if (command && !command.startsWith("-")) {
172
+ await init(command);
173
+ } else {
174
+ console.error("Usage: plank <init|start|publish-scheduled> [project-name]");
175
+ process.exit(1);
176
+ }
177
+ }
@@ -0,0 +1,5 @@
1
+ CREATE TABLE IF NOT EXISTS plank_roles (
2
+ id TEXT PRIMARY KEY,
3
+ name VARCHAR(100) NOT NULL UNIQUE,
4
+ permissions JSONB NOT NULL DEFAULT '[]'
5
+ );
@@ -0,0 +1,7 @@
1
+ CREATE TABLE IF NOT EXISTS plank_users (
2
+ id TEXT PRIMARY KEY,
3
+ email VARCHAR(255) NOT NULL UNIQUE,
4
+ password VARCHAR(255) NOT NULL,
5
+ role_id TEXT NOT NULL REFERENCES plank_roles(id),
6
+ created_at TIMESTAMP NOT NULL DEFAULT NOW()
7
+ );
@@ -0,0 +1,9 @@
1
+ CREATE TABLE IF NOT EXISTS plank_content_types (
2
+ id TEXT PRIMARY KEY,
3
+ name VARCHAR(255) NOT NULL UNIQUE,
4
+ slug VARCHAR(255) NOT NULL UNIQUE,
5
+ table_name VARCHAR(255) NOT NULL UNIQUE,
6
+ fields JSONB NOT NULL DEFAULT '[]',
7
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
8
+ updated_at TIMESTAMP NOT NULL DEFAULT NOW()
9
+ );
@@ -0,0 +1,7 @@
1
+ CREATE TABLE IF NOT EXISTS plank_api_tokens (
2
+ id TEXT PRIMARY KEY,
3
+ name VARCHAR(255) NOT NULL,
4
+ token VARCHAR(512) NOT NULL UNIQUE,
5
+ created_by TEXT NOT NULL REFERENCES plank_users(id),
6
+ created_at TIMESTAMP NOT NULL DEFAULT NOW()
7
+ );
@@ -0,0 +1,10 @@
1
+ CREATE TABLE IF NOT EXISTS plank_media (
2
+ id TEXT PRIMARY KEY,
3
+ filename VARCHAR(512) NOT NULL,
4
+ url TEXT NOT NULL,
5
+ provider_key TEXT NOT NULL,
6
+ mime_type VARCHAR(255),
7
+ size INTEGER,
8
+ uploaded_by TEXT REFERENCES plank_users(id),
9
+ created_at TIMESTAMP NOT NULL DEFAULT NOW()
10
+ );
@@ -0,0 +1,3 @@
1
+ ALTER TABLE plank_users
2
+ ADD COLUMN IF NOT EXISTS first_name VARCHAR(100),
3
+ ADD COLUMN IF NOT EXISTS last_name VARCHAR(100);
@@ -0,0 +1,2 @@
1
+ ALTER TABLE plank_api_tokens
2
+ ADD COLUMN IF NOT EXISTS access_type VARCHAR(20) NOT NULL DEFAULT 'read-only';
@@ -0,0 +1,2 @@
1
+ ALTER TABLE plank_content_types
2
+ ADD COLUMN IF NOT EXISTS is_default BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,7 @@
1
+ CREATE TABLE IF NOT EXISTS plank_settings (
2
+ namespace VARCHAR(64) NOT NULL,
3
+ key VARCHAR(128) NOT NULL,
4
+ value TEXT,
5
+ updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
6
+ PRIMARY KEY (namespace, key)
7
+ );
@@ -0,0 +1 @@
1
+ ALTER TABLE plank_users ADD COLUMN IF NOT EXISTS avatar_url TEXT;
@@ -0,0 +1,17 @@
1
+ DO $$
2
+ DECLARE
3
+ tbl TEXT;
4
+ BEGIN
5
+ FOR tbl IN SELECT table_name FROM plank_content_types LOOP
6
+ IF NOT EXISTS (
7
+ SELECT 1 FROM information_schema.columns
8
+ WHERE table_name = tbl AND column_name = 'status'
9
+ ) THEN
10
+ EXECUTE format(
11
+ 'ALTER TABLE %I ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT ''draft''',
12
+ tbl
13
+ );
14
+ END IF;
15
+ END LOOP;
16
+ END;
17
+ $$;
@@ -0,0 +1,14 @@
1
+ DO $$
2
+ DECLARE
3
+ tbl TEXT;
4
+ BEGIN
5
+ FOR tbl IN SELECT table_name FROM plank_content_types LOOP
6
+ IF NOT EXISTS (
7
+ SELECT 1 FROM information_schema.columns
8
+ WHERE table_name = tbl AND column_name = 'published_data'
9
+ ) THEN
10
+ EXECUTE format('ALTER TABLE %I ADD COLUMN published_data JSONB', tbl);
11
+ END IF;
12
+ END LOOP;
13
+ END;
14
+ $$;
@@ -0,0 +1,14 @@
1
+ DO $$
2
+ DECLARE
3
+ tbl TEXT;
4
+ BEGIN
5
+ FOR tbl IN SELECT table_name FROM plank_content_types LOOP
6
+ IF NOT EXISTS (
7
+ SELECT 1 FROM information_schema.columns
8
+ WHERE table_name = tbl AND column_name = 'published_at'
9
+ ) THEN
10
+ EXECUTE format('ALTER TABLE %I ADD COLUMN published_at TIMESTAMP', tbl);
11
+ END IF;
12
+ END LOOP;
13
+ END;
14
+ $$;
@@ -0,0 +1,3 @@
1
+ INSERT INTO plank_settings (namespace, key, value)
2
+ VALUES ('general', 'timezone', 'UTC')
3
+ ON CONFLICT (namespace, key) DO NOTHING;
@@ -0,0 +1,14 @@
1
+ DO $$
2
+ DECLARE
3
+ tbl TEXT;
4
+ BEGIN
5
+ FOR tbl IN SELECT table_name FROM plank_content_types LOOP
6
+ IF NOT EXISTS (
7
+ SELECT 1 FROM information_schema.columns
8
+ WHERE table_name = tbl AND column_name = 'scheduled_for'
9
+ ) THEN
10
+ EXECUTE format('ALTER TABLE %I ADD COLUMN scheduled_for TIMESTAMP', tbl);
11
+ END IF;
12
+ END LOOP;
13
+ END;
14
+ $$;
@@ -0,0 +1,14 @@
1
+ DO $$
2
+ DECLARE
3
+ tbl TEXT;
4
+ BEGIN
5
+ FOR tbl IN SELECT table_name FROM plank_content_types LOOP
6
+ IF NOT EXISTS (
7
+ SELECT 1 FROM information_schema.columns
8
+ WHERE table_name = tbl AND column_name = 'created_by'
9
+ ) THEN
10
+ EXECUTE format('ALTER TABLE %I ADD COLUMN created_by TEXT REFERENCES plank_users(id) ON DELETE SET NULL', tbl);
11
+ END IF;
12
+ END LOOP;
13
+ END;
14
+ $$;
@@ -0,0 +1,2 @@
1
+ ALTER TABLE plank_content_types
2
+ ADD COLUMN IF NOT EXISTS kind VARCHAR(20) NOT NULL DEFAULT 'collection';
@@ -0,0 +1,8 @@
1
+ CREATE TABLE IF NOT EXISTS plank_webhooks (
2
+ id TEXT NOT NULL PRIMARY KEY,
3
+ name VARCHAR(255) NOT NULL,
4
+ url TEXT NOT NULL,
5
+ events TEXT[] NOT NULL DEFAULT '{}',
6
+ enabled BOOLEAN NOT NULL DEFAULT TRUE,
7
+ created_at TIMESTAMP NOT NULL DEFAULT NOW()
8
+ );
@@ -0,0 +1,7 @@
1
+ CREATE TABLE IF NOT EXISTS plank_user_prefs (
2
+ user_id TEXT NOT NULL REFERENCES plank_users(id) ON DELETE CASCADE,
3
+ key VARCHAR(255) NOT NULL,
4
+ value TEXT NOT NULL,
5
+ updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
6
+ PRIMARY KEY (user_id, key)
7
+ );
@@ -0,0 +1,8 @@
1
+ CREATE TABLE IF NOT EXISTS plank_folders (
2
+ id TEXT PRIMARY KEY,
3
+ name VARCHAR(255) NOT NULL,
4
+ parent_id TEXT REFERENCES plank_folders(id) ON DELETE CASCADE,
5
+ created_at TIMESTAMP NOT NULL DEFAULT NOW()
6
+ );
7
+
8
+ ALTER TABLE plank_media ADD COLUMN IF NOT EXISTS folder_id TEXT REFERENCES plank_folders(id) ON DELETE SET NULL;
@@ -0,0 +1,4 @@
1
+ ALTER TABLE plank_users
2
+ ADD COLUMN IF NOT EXISTS job_title VARCHAR(100),
3
+ ADD COLUMN IF NOT EXISTS organization VARCHAR(150),
4
+ ADD COLUMN IF NOT EXISTS country VARCHAR(100);
@@ -0,0 +1,4 @@
1
+ ALTER TABLE plank_media
2
+ ADD COLUMN alt TEXT,
3
+ ADD COLUMN width INTEGER,
4
+ ADD COLUMN height INTEGER;
@@ -0,0 +1,17 @@
1
+ DO $$
2
+ DECLARE
3
+ tbl TEXT;
4
+ BEGIN
5
+ FOR tbl IN SELECT table_name FROM plank_content_types LOOP
6
+ IF NOT EXISTS (
7
+ SELECT 1 FROM information_schema.columns
8
+ WHERE table_name = tbl AND column_name = 'localized'
9
+ ) THEN
10
+ EXECUTE format('ALTER TABLE %I ADD COLUMN localized JSONB NOT NULL DEFAULT ''{}''::jsonb', tbl);
11
+ END IF;
12
+
13
+ -- Create a per-table GIN index for localized to improve JSONB queries
14
+ EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON %I USING GIN (localized)', 'idx_' || tbl || '_localized_gin', tbl);
15
+ END LOOP;
16
+ END;
17
+ $$;