@natilon/cms-server 0.1.2 → 0.5.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/package.json +8 -4
- package/src/adapters/_shared.mjs +125 -0
- package/src/adapters/basic-auth.mjs +31 -9
- package/src/adapters/cdn-proxy-media.mjs +3 -4
- package/src/adapters/cloudflare-access.mjs +140 -0
- package/src/adapters/fs-json-content.mjs +17 -60
- package/src/adapters/fs-templates.mjs +57 -0
- package/src/adapters/github-api.mjs +91 -0
- package/src/adapters/github-content.mjs +142 -101
- package/src/adapters/github-oauth.mjs +40 -57
- package/src/adapters/github-templates.mjs +100 -0
- package/src/adapters/index.mjs +3 -0
- package/src/adapters/local-assets-media.mjs +1 -4
- package/src/adapters/types.mjs +16 -0
- package/src/default-public-config.mjs +15 -12
- package/src/express-shim.mjs +51 -0
- package/src/hono-shim.mjs +108 -0
- package/src/index.mjs +2 -0
- package/src/locks.mjs +64 -0
- package/src/routes.mjs +439 -0
- package/src/server.mjs +91 -222
package/src/server.mjs
CHANGED
|
@@ -3,6 +3,8 @@ import fs from "fs";
|
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { resolveAdminUiDir } from "./admin-ui-path.mjs";
|
|
5
5
|
import { defaultPublicConfig } from "./default-public-config.mjs";
|
|
6
|
+
import { allRoutes } from "./routes.mjs";
|
|
7
|
+
import { mountRoutes, buildPublicAllowlist } from "./express-shim.mjs";
|
|
6
8
|
import {
|
|
7
9
|
createFsJsonContent,
|
|
8
10
|
createGitHubContent,
|
|
@@ -10,7 +12,10 @@ import {
|
|
|
10
12
|
createCdnProxyMedia,
|
|
11
13
|
createBasicAuth,
|
|
12
14
|
createGitHubOAuth,
|
|
15
|
+
createCloudflareAccess,
|
|
13
16
|
createNetlifyBuild,
|
|
17
|
+
createFsTemplates,
|
|
18
|
+
createGitHubTemplates,
|
|
14
19
|
} from "./adapters/index.mjs";
|
|
15
20
|
|
|
16
21
|
/**
|
|
@@ -23,8 +28,6 @@ import {
|
|
|
23
28
|
* @param {string} [opts.realm] HTTP basic-auth realm (default "CMS Admin")
|
|
24
29
|
*/
|
|
25
30
|
export function createCmsServer({ config, rootDir, publicConfig: publicConfigFn, realm = "CMS Admin" }) {
|
|
26
|
-
// Auto-derive publicConfig when the consumer doesn't provide one.
|
|
27
|
-
const publicConfig = publicConfigFn ?? (() => defaultPublicConfig(config));
|
|
28
31
|
const content =
|
|
29
32
|
config.content?.provider === "github"
|
|
30
33
|
? createGitHubContent({
|
|
@@ -59,13 +62,21 @@ export function createCmsServer({ config, rootDir, publicConfig: publicConfigFn,
|
|
|
59
62
|
jwtTtl: config.auth.jwtTtl,
|
|
60
63
|
realm,
|
|
61
64
|
})
|
|
62
|
-
:
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
: config.auth?.provider === "cloudflare-access"
|
|
66
|
+
? createCloudflareAccess({
|
|
67
|
+
teamDomain: config.auth.teamDomain,
|
|
68
|
+
audience: config.auth.audience,
|
|
69
|
+
roles: config.auth.roles || {},
|
|
70
|
+
defaultRole: config.auth.defaultRole || "editor",
|
|
71
|
+
})
|
|
72
|
+
: createBasicAuth({
|
|
73
|
+
user: process.env[config.auth.userEnv] || "admin",
|
|
74
|
+
pass: process.env[config.auth.passEnv],
|
|
75
|
+
users: config.auth.users,
|
|
76
|
+
jwtSecret: process.env[config.auth.jwtSecretEnv],
|
|
77
|
+
jwtTtl: config.auth.jwtTtl,
|
|
78
|
+
realm,
|
|
79
|
+
});
|
|
69
80
|
|
|
70
81
|
const cdnMedia = createCdnProxyMedia({
|
|
71
82
|
baseUrl: config.media.cdnBase,
|
|
@@ -78,162 +89,68 @@ export function createCmsServer({ config, rootDir, publicConfig: publicConfigFn,
|
|
|
78
89
|
defaultBranch: process.env.STAGING_BRANCH || config.content.publishBranch,
|
|
79
90
|
});
|
|
80
91
|
|
|
92
|
+
const templates =
|
|
93
|
+
config.content?.provider === "github"
|
|
94
|
+
? createGitHubTemplates({
|
|
95
|
+
token: process.env[config.content.githubTokenEnv || "GITHUB_TOKEN"],
|
|
96
|
+
owner: config.content.owner,
|
|
97
|
+
repo: config.content.repo,
|
|
98
|
+
branch: process.env.STAGING_BRANCH || config.content.branch || "main",
|
|
99
|
+
templatesDir: config.content.templatesDir,
|
|
100
|
+
commitMessage: config.content.commitMessage,
|
|
101
|
+
})
|
|
102
|
+
: createFsTemplates({ rootDir });
|
|
103
|
+
|
|
104
|
+
const adapters = {
|
|
105
|
+
content,
|
|
106
|
+
templates,
|
|
107
|
+
localMedia,
|
|
108
|
+
cdnMedia,
|
|
109
|
+
auth,
|
|
110
|
+
build,
|
|
111
|
+
publicConfig: () => (publicConfigFn ?? defaultPublicConfig)(config),
|
|
112
|
+
};
|
|
113
|
+
|
|
81
114
|
const app = express();
|
|
82
115
|
app.use(express.json({ limit: "10mb" }));
|
|
83
116
|
|
|
84
|
-
|
|
85
|
-
|
|
117
|
+
const corsOrigin = config.cors?.origin ?? "*";
|
|
118
|
+
if (corsOrigin === "*" && config.auth?.provider) {
|
|
119
|
+
console.warn(
|
|
120
|
+
"WARNING: CORS origin is '*' — any site can call this API in a logged-in user's browser. Set cors.origin in cms.config.mjs to lock down.",
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
const corsAllowList = Array.isArray(corsOrigin) ? corsOrigin : [corsOrigin];
|
|
124
|
+
app.use((req, res, next) => {
|
|
125
|
+
const reqOrigin = req.headers.origin;
|
|
126
|
+
if (corsAllowList.includes("*")) {
|
|
127
|
+
res.header("Access-Control-Allow-Origin", "*");
|
|
128
|
+
} else if (reqOrigin && corsAllowList.includes(reqOrigin)) {
|
|
129
|
+
res.header("Access-Control-Allow-Origin", reqOrigin);
|
|
130
|
+
res.header("Vary", "Origin");
|
|
131
|
+
}
|
|
86
132
|
res.header("Access-Control-Allow-Methods", "GET,PUT,POST,DELETE,OPTIONS");
|
|
87
133
|
res.header("Access-Control-Allow-Headers", "Content-Type,Authorization");
|
|
88
|
-
if (
|
|
134
|
+
if (req.method === "OPTIONS") return res.sendStatus(204);
|
|
89
135
|
next();
|
|
90
136
|
});
|
|
91
137
|
|
|
92
|
-
|
|
93
|
-
if (auth.oauthRoutes) {
|
|
94
|
-
app.get("/admin/oauth/login", (req, res) => auth.oauthRoutes.login(req, res));
|
|
95
|
-
app.get("/admin/oauth/callback", (req, res) => auth.oauthRoutes.callback(req, res));
|
|
96
|
-
}
|
|
138
|
+
const routes = allRoutes(config);
|
|
97
139
|
|
|
98
140
|
if (auth.configured) {
|
|
141
|
+
const allow = buildPublicAllowlist(routes);
|
|
99
142
|
app.use(
|
|
100
143
|
auth.middlewareWith(
|
|
101
|
-
(req) =>
|
|
102
|
-
req.path.startsWith(`${localMedia.urlPrefix}/`) ||
|
|
103
|
-
req.path === "/api/config" ||
|
|
104
|
-
req.path.startsWith("/admin/oauth/"),
|
|
144
|
+
(req) => req.path.startsWith(`${localMedia.urlPrefix}/`) || allow(req),
|
|
105
145
|
),
|
|
106
146
|
);
|
|
107
147
|
} else {
|
|
108
148
|
console.warn(`WARNING: ${config.auth.passEnv} not set — running without authentication`);
|
|
109
149
|
}
|
|
110
150
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
function sanitizeSlug(s) {
|
|
114
|
-
return String(s).replace(/[^a-zA-Z0-9._-]/g, "");
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function requireAdmin(req, res, next) {
|
|
118
|
-
if (req.cmsUser && req.cmsUser.role !== "admin") {
|
|
119
|
-
return res.status(403).json({ error: "Admin role required" });
|
|
120
|
-
}
|
|
121
|
-
next();
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
app.get("/api/config", (_req, res) => {
|
|
125
|
-
const cfg = publicConfig ? publicConfig() : {};
|
|
126
|
-
// Always expose auth provider and previewUrlPattern to the browser
|
|
127
|
-
if (!cfg.auth) cfg.auth = {};
|
|
128
|
-
if (!cfg.auth.provider) cfg.auth.provider = config.auth?.provider || "basic";
|
|
129
|
-
if (!cfg.previewUrlPattern && config.previewUrlPattern) {
|
|
130
|
-
cfg.previewUrlPattern = config.previewUrlPattern;
|
|
131
|
-
}
|
|
132
|
-
res.json(cfg);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
app.get("/api/me", (req, res) => {
|
|
136
|
-
res.json(req.cmsUser || { login: "admin", role: "admin" });
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
app.get("/api/collections", async (_req, res) => {
|
|
140
|
-
res.json(await content.listCollections());
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
app.get("/api/collections/:collection", async (req, res) => {
|
|
144
|
-
const sortConfig = config.collections?.[req.params.collection]?.sort ?? null;
|
|
145
|
-
const pages = await content.listPages(req.params.collection, sortConfig);
|
|
146
|
-
if (pages === null) return res.status(404).json({ error: "Not found" });
|
|
147
|
-
res.json(pages);
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
app.get("/api/collections/:collection/:file", async (req, res) => {
|
|
151
|
-
const data = await content.readPage(req.params.collection, req.params.file);
|
|
152
|
-
if (!data) return res.status(404).json({ error: "Not found" });
|
|
153
|
-
res.json(data);
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
app.put("/api/collections/:collection/:file", async (req, res) => {
|
|
157
|
-
await content.writePage(req.params.collection, req.params.file, req.body);
|
|
158
|
-
res.json({ ok: true });
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
app.post("/api/collections/:collection", async (req, res) => {
|
|
162
|
-
const result = await content.createPage(req.params.collection, req.body);
|
|
163
|
-
res.json({ ok: true, file: result.file });
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
app.delete("/api/collections/:collection/:file", requireAdmin, async (req, res) => {
|
|
167
|
-
const ok = await content.deletePage(req.params.collection, req.params.file);
|
|
168
|
-
if (!ok) return res.status(404).json({ error: "Not found" });
|
|
169
|
-
res.json({ ok: true });
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
app.post("/api/collections/:collection/:file/duplicate", async (req, res) => {
|
|
173
|
-
const result = await content.duplicatePage(req.params.collection, req.params.file);
|
|
174
|
-
if (!result) return res.status(404).json({ error: "Not found" });
|
|
175
|
-
res.json({ ok: true, file: result.file });
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
app.get("/api/history/:collection/:file", async (req, res) => {
|
|
179
|
-
try {
|
|
180
|
-
res.json(await content.listHistory(req.params.collection, req.params.file));
|
|
181
|
-
} catch (err) {
|
|
182
|
-
res.status(500).json({ error: err.message });
|
|
183
|
-
}
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
app.post("/api/history/:collection/:file/:ts/restore", async (req, res) => {
|
|
187
|
-
try {
|
|
188
|
-
const ok = await content.restoreHistory(req.params.collection, req.params.file, req.params.ts);
|
|
189
|
-
if (!ok) return res.status(404).json({ error: "Revision not found" });
|
|
190
|
-
res.json({ ok: true });
|
|
191
|
-
} catch (err) {
|
|
192
|
-
res.status(500).json({ error: err.message });
|
|
193
|
-
}
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
app.get("/api/templates", (_req, res) => {
|
|
197
|
-
if (!fs.existsSync(TEMPLATES_DIR)) return res.json([]);
|
|
198
|
-
const files = fs.readdirSync(TEMPLATES_DIR).filter((f) => f.endsWith(".json"));
|
|
199
|
-
const list = files.map((f) => {
|
|
200
|
-
const data = JSON.parse(fs.readFileSync(path.join(TEMPLATES_DIR, f), "utf-8"));
|
|
201
|
-
return { name: data.name, slug: f.replace(".json", ""), blockCount: (data.blocks || []).length };
|
|
202
|
-
});
|
|
203
|
-
res.json(list);
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
app.get("/api/templates/:slug", (req, res) => {
|
|
207
|
-
const file = path.join(TEMPLATES_DIR, sanitizeSlug(req.params.slug) + ".json");
|
|
208
|
-
if (!fs.existsSync(file)) return res.status(404).json({ error: "Not found" });
|
|
209
|
-
res.json(JSON.parse(fs.readFileSync(file, "utf-8")));
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
app.post("/api/templates", (req, res) => {
|
|
213
|
-
const { name, blocks } = req.body;
|
|
214
|
-
if (!name || !blocks) return res.status(400).json({ error: "name and blocks required" });
|
|
215
|
-
if (!fs.existsSync(TEMPLATES_DIR)) fs.mkdirSync(TEMPLATES_DIR, { recursive: true });
|
|
216
|
-
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
217
|
-
const file = path.join(TEMPLATES_DIR, slug + ".json");
|
|
218
|
-
fs.writeFileSync(file, JSON.stringify({ name, blocks }, null, 2), "utf-8");
|
|
219
|
-
res.json({ ok: true, slug });
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
app.delete("/api/templates/:slug", requireAdmin, (req, res) => {
|
|
223
|
-
const file = path.join(TEMPLATES_DIR, sanitizeSlug(req.params.slug) + ".json");
|
|
224
|
-
if (!fs.existsSync(file)) return res.status(404).json({ error: "Not found" });
|
|
225
|
-
fs.unlinkSync(file);
|
|
226
|
-
res.json({ ok: true });
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
app.get("/api/assets", async (_req, res) => {
|
|
230
|
-
res.json(await localMedia.listGrouped());
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
app.get("/api/assets/:folder", async (req, res) => {
|
|
234
|
-
res.json(await localMedia.listFolder(req.params.folder));
|
|
235
|
-
});
|
|
151
|
+
mountRoutes(app, routes, { adapters, config });
|
|
236
152
|
|
|
153
|
+
// Local media static file serve (Express-only — Worker has no local fs).
|
|
237
154
|
app.get(`${localMedia.urlPrefix}/{*filepath}`, (req, res) => {
|
|
238
155
|
const sub = Array.isArray(req.params.filepath)
|
|
239
156
|
? req.params.filepath.join("/")
|
|
@@ -243,78 +160,15 @@ export function createCmsServer({ config, rootDir, publicConfig: publicConfigFn,
|
|
|
243
160
|
res.sendFile(full);
|
|
244
161
|
});
|
|
245
162
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
res.status(status >= 400 && status < 600 ? status : 502).json({
|
|
249
|
-
error: err.message,
|
|
250
|
-
});
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
app.get("/api/media/folders", async (_req, res) => {
|
|
254
|
-
try {
|
|
255
|
-
res.json(await cdnMedia.listFolders());
|
|
256
|
-
} catch (err) {
|
|
257
|
-
handleMediaError(res, err);
|
|
258
|
-
}
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
app.get("/api/media/folder/:folder", async (req, res) => {
|
|
262
|
-
try {
|
|
263
|
-
res.json(
|
|
264
|
-
await cdnMedia.listFolder(req.params.folder, {
|
|
265
|
-
page: parseInt(req.query.page) || 1,
|
|
266
|
-
perPage: parseInt(req.query.per_page) || 30,
|
|
267
|
-
search: req.query.search || "",
|
|
268
|
-
}),
|
|
269
|
-
);
|
|
270
|
-
} catch (err) {
|
|
271
|
-
handleMediaError(res, err);
|
|
272
|
-
}
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
app.post("/api/media/upload", async (req, res) => {
|
|
276
|
-
try {
|
|
277
|
-
res.json(await cdnMedia.upload(req.body));
|
|
278
|
-
} catch (err) {
|
|
279
|
-
handleMediaError(res, err);
|
|
280
|
-
}
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
app.post("/api/publish", requireAdmin, async (_req, res) => {
|
|
284
|
-
try {
|
|
285
|
-
const result = await content.publish();
|
|
286
|
-
res.json(result);
|
|
287
|
-
} catch (err) {
|
|
288
|
-
res.status(500).json({ ok: false, message: err.message });
|
|
289
|
-
}
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
app.get("/api/publish/status", async (_req, res) => {
|
|
293
|
-
try {
|
|
294
|
-
res.json(await content.pendingChanges());
|
|
295
|
-
} catch (err) {
|
|
296
|
-
res.status(500).json({ error: err.message });
|
|
297
|
-
}
|
|
163
|
+
app.get("/api/assets", async (_req, res) => {
|
|
164
|
+
res.json(await localMedia.listGrouped());
|
|
298
165
|
});
|
|
299
166
|
|
|
300
|
-
app.get("/api/
|
|
301
|
-
|
|
302
|
-
try {
|
|
303
|
-
res.json(
|
|
304
|
-
await build.getDeployStatus({
|
|
305
|
-
branch: req.query.branch,
|
|
306
|
-
sha: req.query.sha,
|
|
307
|
-
}),
|
|
308
|
-
);
|
|
309
|
-
} catch (err) {
|
|
310
|
-
if (err.upstreamStatus) {
|
|
311
|
-
return res.status(502).json({ configured: true, error: err.message });
|
|
312
|
-
}
|
|
313
|
-
res.status(500).json({ configured: true, error: err.message });
|
|
314
|
-
}
|
|
167
|
+
app.get("/api/assets/:folder", async (req, res) => {
|
|
168
|
+
res.json(await localMedia.listFolder(req.params.folder));
|
|
315
169
|
});
|
|
316
170
|
|
|
317
|
-
return { app, adapters: { content, localMedia, cdnMedia, auth, build } };
|
|
171
|
+
return { app, adapters: { content, templates, localMedia, cdnMedia, auth, build } };
|
|
318
172
|
}
|
|
319
173
|
|
|
320
174
|
/**
|
|
@@ -327,12 +181,13 @@ export function startScheduler(content, intervalMs = 60_000) {
|
|
|
327
181
|
try {
|
|
328
182
|
const due = await content.listScheduled();
|
|
329
183
|
if (!due.length) return;
|
|
330
|
-
|
|
184
|
+
const items = due.map(({ collection, file, data }) => {
|
|
331
185
|
delete data.meta.publishAt;
|
|
332
186
|
data.meta.draft = false;
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
187
|
+
return { collection, file, data };
|
|
188
|
+
});
|
|
189
|
+
await content.writeBatch(items, "[scheduler] Auto-publish");
|
|
190
|
+
console.log(`[scheduler] Auto-published ${items.length} item(s)`);
|
|
336
191
|
const result = await content.publish();
|
|
337
192
|
console.log("[scheduler] " + result.message);
|
|
338
193
|
} catch (err) {
|
|
@@ -355,10 +210,20 @@ export async function mountAdminUi(app, adminUi) {
|
|
|
355
210
|
const resolved =
|
|
356
211
|
adminUi.mode === "auto"
|
|
357
212
|
? process.env.NODE_ENV === "production"
|
|
358
|
-
? { mode: "static", dir: adminUi.distDir }
|
|
359
|
-
: { mode: "vite-dev", root: adminUi.sourceDir, base: adminUi.base || "/admin/" }
|
|
213
|
+
? { mode: "static", dir: adminUi.distDir, previewThemeCss: adminUi.previewThemeCss }
|
|
214
|
+
: { mode: "vite-dev", root: adminUi.sourceDir, base: adminUi.base || "/admin/", previewThemeCss: adminUi.previewThemeCss }
|
|
360
215
|
: adminUi;
|
|
361
216
|
|
|
217
|
+
// Optional user CSS override for in-canvas preview styling.
|
|
218
|
+
// Served before the static handler so it always responds (empty if unset),
|
|
219
|
+
// preventing a 404 from the <link> tag in index.html.
|
|
220
|
+
const themePath = resolved.previewThemeCss;
|
|
221
|
+
app.get("/admin/preview-theme.css", (_req, res) => {
|
|
222
|
+
res.type("text/css");
|
|
223
|
+
if (themePath && fs.existsSync(themePath)) res.sendFile(path.resolve(themePath));
|
|
224
|
+
else res.send("/* no preview-theme.css configured */");
|
|
225
|
+
});
|
|
226
|
+
|
|
362
227
|
if (resolved.mode === "static") {
|
|
363
228
|
if (!fs.existsSync(path.join(resolved.dir, "index.html"))) {
|
|
364
229
|
throw new Error(
|
|
@@ -395,14 +260,18 @@ export async function mountAdminUi(app, adminUi) {
|
|
|
395
260
|
export async function startCmsServer(opts) {
|
|
396
261
|
const { app, adapters } = createCmsServer(opts);
|
|
397
262
|
|
|
398
|
-
|
|
263
|
+
const previewThemeCss =
|
|
264
|
+
opts.config?.previewThemeCss
|
|
265
|
+
? path.resolve(opts.rootDir || process.cwd(), opts.config.previewThemeCss)
|
|
266
|
+
: null;
|
|
267
|
+
|
|
399
268
|
const adminUi = opts.adminUi ?? (() => {
|
|
400
269
|
const dir = resolveAdminUiDir();
|
|
401
270
|
if (!dir) {
|
|
402
271
|
console.warn("admin-ui dist not found — run `npm run build:admin-ui` first, or pass adminUi option explicitly.");
|
|
403
272
|
return null;
|
|
404
273
|
}
|
|
405
|
-
return { mode: "static", dir };
|
|
274
|
+
return { mode: "static", dir, previewThemeCss };
|
|
406
275
|
})();
|
|
407
276
|
|
|
408
277
|
await mountAdminUi(app, adminUi);
|