@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.
- package/LICENSE +28 -0
- package/README.md +34 -0
- package/dist/admin/assets/hls-0EiSYoTw.js +40 -0
- package/dist/admin/assets/index-BCd-a4uR.css +2 -0
- package/dist/admin/assets/index-Db1a-6aD.js +203 -0
- package/dist/admin/favicon.png +0 -0
- package/dist/admin/index.html +21 -0
- package/dist/admin/particles-texture.png +0 -0
- package/dist/admin/plank-logo-w.svg +4 -0
- package/dist/index.js +177 -0
- package/dist/migrations/001_plank_roles.sql +5 -0
- package/dist/migrations/002_plank_users.sql +7 -0
- package/dist/migrations/003_plank_content_types.sql +9 -0
- package/dist/migrations/004_plank_api_tokens.sql +7 -0
- package/dist/migrations/005_plank_media.sql +10 -0
- package/dist/migrations/006_plank_users_name.sql +3 -0
- package/dist/migrations/007_api_tokens_access_type.sql +2 -0
- package/dist/migrations/008_content_types_default.sql +2 -0
- package/dist/migrations/009_plank_settings.sql +7 -0
- package/dist/migrations/010_users_avatar.sql +1 -0
- package/dist/migrations/011_entries_status.sql +17 -0
- package/dist/migrations/012_entries_published_data.sql +14 -0
- package/dist/migrations/013_entries_published_at.sql +14 -0
- package/dist/migrations/014_timezone_setting.sql +3 -0
- package/dist/migrations/015_entries_scheduled.sql +14 -0
- package/dist/migrations/016_entries_created_by.sql +14 -0
- package/dist/migrations/017_content_types_kind.sql +2 -0
- package/dist/migrations/018_plank_webhooks.sql +8 -0
- package/dist/migrations/019_user_prefs.sql +7 -0
- package/dist/migrations/020_plank_media_folders.sql +8 -0
- package/dist/migrations/021_users_profile_fields.sql +4 -0
- package/dist/migrations/022_plank_media_metadata.sql +4 -0
- package/dist/migrations/023_entries_localized.sql +17 -0
- package/dist/server-VPVKRT67.js +2742 -0
- 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,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,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 @@
|
|
|
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,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,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,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
|
+
$$;
|