velora-plugin 0.9.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 (41) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +22 -0
  3. package/dist/build-federation-bundle.d.ts +29 -0
  4. package/dist/build-federation-bundle.js +93 -0
  5. package/dist/build.d.ts +41 -0
  6. package/dist/build.js +123 -0
  7. package/dist/bundle-server-manifest.d.ts +27 -0
  8. package/dist/bundle-server-manifest.js +93 -0
  9. package/dist/checks/audit.d.ts +2 -0
  10. package/dist/checks/audit.js +50 -0
  11. package/dist/checks/bundle-size.d.ts +3 -0
  12. package/dist/checks/bundle-size.js +21 -0
  13. package/dist/checks/db-compat.d.ts +3 -0
  14. package/dist/checks/db-compat.js +15 -0
  15. package/dist/checks/manifest.d.ts +3 -0
  16. package/dist/checks/manifest.js +12 -0
  17. package/dist/checks/shared-deps.d.ts +3 -0
  18. package/dist/checks/shared-deps.js +31 -0
  19. package/dist/checks/typescript.d.ts +2 -0
  20. package/dist/checks/typescript.js +34 -0
  21. package/dist/checks/views.d.ts +3 -0
  22. package/dist/checks/views.js +63 -0
  23. package/dist/copy-sample-content.d.ts +39 -0
  24. package/dist/copy-sample-content.js +57 -0
  25. package/dist/index.d.ts +12 -0
  26. package/dist/index.js +159 -0
  27. package/dist/load-manifest.d.ts +18 -0
  28. package/dist/load-manifest.js +49 -0
  29. package/dist/pack-starter.d.ts +48 -0
  30. package/dist/pack-starter.js +118 -0
  31. package/dist/pack.d.ts +12 -0
  32. package/dist/pack.js +26 -0
  33. package/dist/publish.d.ts +24 -0
  34. package/dist/publish.js +48 -0
  35. package/dist/types.d.ts +11 -0
  36. package/dist/types.js +1 -0
  37. package/dist/validate.d.ts +5 -0
  38. package/dist/validate.js +49 -0
  39. package/dist/write-manifest-json.d.ts +7 -0
  40. package/dist/write-manifest-json.js +72 -0
  41. package/package.json +54 -0
@@ -0,0 +1,118 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { create } from "tar";
4
+ // Only 'section' and 'bundle' manifests can carry a bundledTemplate
5
+ // contribution (SectionPluginContributions / BundlePluginContributions in
6
+ // manifest.ts) — 'template' plugins don't declare this key at all (see
7
+ // isStarterBearing below for how 'template' is still legally starter-
8
+ // bearing, just not through this specific structural check).
9
+ const BUNDLED_TEMPLATE_TYPES = ["section", "bundle"];
10
+ /**
11
+ * Structural check for "this manifest declares a bundledTemplate
12
+ * contribution" — mirrors the compile-time contribution matrix at
13
+ * runtime, the same way validate.ts's Zod schemas do, without importing
14
+ * any Zod schema here (loadManifest already validated the manifest before
15
+ * build.ts ever calls this).
16
+ */
17
+ export function hasBundledTemplateContribution(manifest) {
18
+ return (BUNDLED_TEMPLATE_TYPES.includes(manifest.type) &&
19
+ "bundledTemplate" in manifest.contributions &&
20
+ manifest.contributions.bundledTemplate !== undefined);
21
+ }
22
+ /**
23
+ * Whether a starter/ directory is LEGAL for this manifest — the check
24
+ * packStarterIfPresent actually gates on. True in two cases:
25
+ * - the manifest declares a bundledTemplate contribution (section/bundle,
26
+ * hasBundledTemplateContribution above), or
27
+ * - the manifest's type is 'template'. Per manifest.ts's
28
+ * TemplatePluginContributions doc comment: "A template plugin IS a
29
+ * bundled template (per the matrix), so there is no separate
30
+ * bundledTemplate key — it contributes the document types its starter
31
+ * needs." A template plugin is implicitly starter-bearing by design,
32
+ * not by an explicit contribution flag, so it can never fail the
33
+ * 'declares bundledTemplate' structural check the way section/bundle
34
+ * manifests do — it's legal unconditionally.
35
+ */
36
+ export function isStarterBearing(manifest) {
37
+ return hasBundledTemplateContribution(manifest) || manifest.type === "template";
38
+ }
39
+ export class UndeclaredStarterError extends Error {
40
+ constructor(pluginDir) {
41
+ super(`${pluginDir} has a starter/ directory but its manifest does not declare a bundledTemplate contribution ` +
42
+ `and is not a 'template'-type plugin. An undeclared starter/ is treated as a mistake, not a choice — ` +
43
+ `either add contributions.bundledTemplate (only 'section' and 'bundle' plugin types may), make the ` +
44
+ `plugin type 'template' (implicitly starter-bearing), or remove the starter/ directory.`);
45
+ this.name = "UndeclaredStarterError";
46
+ }
47
+ }
48
+ // Directory names excluded from the packed archive by name, at ANY depth —
49
+ // starter/ packs SOURCE only (the 20MB artifact cap and the "never ship
50
+ // node_modules" rule both depend on this). A vendored dir doesn't only
51
+ // appear at the starter/ root — e.g. a monorepo-shaped starter can have
52
+ // `apps/site/node_modules/...` — so every path segment is checked, not just
53
+ // the first. `.astro` is Astro's generated dir (content types, dev logs) —
54
+ // build output, never source.
55
+ const EXCLUDED_DIRS = new Set(["node_modules", "dist", ".git", ".next", ".astro"]);
56
+ // Env files are excluded by segment at any depth — `.env`, `.env.local`,
57
+ // `.env.production`, etc. can hold live secrets and must never ship inside
58
+ // a served archive. The one deliberate exception is `.env.example`: it is
59
+ // starter documentation (placeholder values a user copies to a real .env),
60
+ // so it keeps packing.
61
+ const ENV_FILE_PATTERN = /^\.env(\..*)?$/;
62
+ function isEnvFileSegment(segment) {
63
+ return ENV_FILE_PATTERN.test(segment) && segment !== ".env.example";
64
+ }
65
+ function isExcluded(entryPath) {
66
+ // node-tar's create() filter receives archive-relative entry paths,
67
+ // posix-normalized (forward slashes) regardless of host OS, and
68
+ // "./"-prefixed for entries directly under cwd (e.g. "./node_modules",
69
+ // "./node_modules/some-dep/index.js") — strip that prefix before splitting
70
+ // into segments, and split on either separator defensively rather than
71
+ // depend on posix-only output. Excluded if ANY segment matches, so a
72
+ // FILE merely named e.g. "dist" (no extension) is also excluded — that's
73
+ // an accepted ambiguity, same as npm-pack-style ignore matching, not a
74
+ // bug: the archive is source-only and a bare `dist`/`node_modules`/`.git`/
75
+ // `.next` file is not a plausible legitimate source file in a starter.
76
+ const normalized = entryPath.replace(/^\.\/?/, "");
77
+ const segments = normalized.split(/[\\/]/);
78
+ return segments.some((segment) => EXCLUDED_DIRS.has(segment) || isEnvFileSegment(segment));
79
+ }
80
+ /**
81
+ * Packs <pluginDir>/starter/ into <outDir>/starter.tar.gz when present —
82
+ * the S148 delivery mechanism (the archive rides inside the plugin
83
+ * artifact; packPlugin tars the whole outDir, so nothing else has to
84
+ * change for it to reach the marketplace).
85
+ *
86
+ * Rules (S148 Global Constraints):
87
+ * - starter/ absent → no-op, regardless of whether bundledTemplate is
88
+ * declared (a bundledTemplate without an archive stays legal —
89
+ * repository-only or metadata-only).
90
+ * - starter/ present, NOT starter-bearing (see isStarterBearing) → throws
91
+ * UndeclaredStarterError. An undeclared starter/ is a mistake, not a
92
+ * choice.
93
+ * - starter/ present and starter-bearing (bundledTemplate declared, OR
94
+ * type 'template') → archive created, entries relative to starter/,
95
+ * excluding node_modules/dist/.git/.next/.astro and env files
96
+ * (.env, .env.* — except .env.example) at any depth.
97
+ *
98
+ * Returns the archive path, or null when no archive was created.
99
+ */
100
+ export async function packStarterIfPresent(pluginDir, outDir, manifest) {
101
+ const starterDir = join(pluginDir, "starter");
102
+ if (!existsSync(starterDir))
103
+ return null;
104
+ if (!isStarterBearing(manifest)) {
105
+ throw new UndeclaredStarterError(pluginDir);
106
+ }
107
+ const archivePath = join(outDir, "starter.tar.gz");
108
+ await create({
109
+ gzip: true,
110
+ file: archivePath,
111
+ cwd: starterDir,
112
+ filter: (entryPath) => !isExcluded(entryPath),
113
+ },
114
+ // Same convention as pack.ts: "." packs everything directly under
115
+ // starter/ at the archive root, not nested under a "starter/" folder.
116
+ ["."]);
117
+ return archivePath;
118
+ }
package/dist/pack.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ import { type BuildResult } from "./build.js";
2
+ export interface PackResult {
3
+ pluginDir: string;
4
+ archivePath: string;
5
+ buildResult: BuildResult;
6
+ }
7
+ /**
8
+ * Always runs buildPlugin() first (never packs a stale package), then
9
+ * tars the resulting velora-package/ directory into
10
+ * <pluginDir>/<id>-<version>.tar.gz.
11
+ */
12
+ export declare function packPlugin(pluginDir: string, outFile?: string): Promise<PackResult>;
package/dist/pack.js ADDED
@@ -0,0 +1,26 @@
1
+ import { join } from "node:path";
2
+ import { create } from "tar";
3
+ import { buildPlugin } from "./build.js";
4
+ import { loadManifest } from "./load-manifest.js";
5
+ /**
6
+ * Always runs buildPlugin() first (never packs a stale package), then
7
+ * tars the resulting velora-package/ directory into
8
+ * <pluginDir>/<id>-<version>.tar.gz.
9
+ */
10
+ export async function packPlugin(pluginDir, outFile) {
11
+ const buildResult = await buildPlugin(pluginDir);
12
+ const manifestResult = await loadManifest(pluginDir);
13
+ if (!manifestResult.ok) {
14
+ throw new Error(`manifest became invalid between build and pack for ${pluginDir}`);
15
+ }
16
+ const { id, version } = manifestResult.manifest;
17
+ const archivePath = outFile ?? join(pluginDir, `${id}-${version}.tar.gz`);
18
+ await create({ gzip: true, file: archivePath, cwd: buildResult.outDir },
19
+ // tar's `create` lists entries relative to `cwd` — "." packs
20
+ // everything directly under velora-package/ at the archive root
21
+ // (not nested under a "velora-package/" folder), so extracting the
22
+ // archive lands manifest.json/remoteEntry.js/server/ directly in
23
+ // the target directory.
24
+ ["."]);
25
+ return { pluginDir, archivePath, buildResult };
26
+ }
@@ -0,0 +1,24 @@
1
+ export interface PublishOptions {
2
+ registryUrl: string;
3
+ token: string;
4
+ }
5
+ export type PublishResult = {
6
+ ok: true;
7
+ submissionId: string;
8
+ state: string;
9
+ failedChecks?: string[];
10
+ } | {
11
+ ok: false;
12
+ status: number;
13
+ message: string;
14
+ };
15
+ /** Upload an already-packed archive. Split from publishPlugin so tests
16
+ * can exercise the HTTP contract without a 90-second validate+build. */
17
+ export declare function uploadArchive(archivePath: string, opts: PublishOptions): Promise<PublishResult>;
18
+ /** pack (which validates once, first, and fails fast — never uploads a
19
+ * package that can't pass its own local floor) → upload. validatePlugin
20
+ * is deliberately NOT called again here: buildPlugin (invoked by
21
+ * packPlugin) already runs the full validation suite, including a real
22
+ * federation build, and doing that twice per publish would double an
23
+ * already expensive step for no benefit. */
24
+ export declare function publishPlugin(pluginDir: string, opts: PublishOptions): Promise<PublishResult>;
@@ -0,0 +1,48 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { basename } from "node:path";
3
+ import { BuildValidationError } from "./build.js";
4
+ import { packPlugin } from "./pack.js";
5
+ /** Upload an already-packed archive. Split from publishPlugin so tests
6
+ * can exercise the HTTP contract without a 90-second validate+build. */
7
+ export async function uploadArchive(archivePath, opts) {
8
+ const body = new FormData();
9
+ body.set("file", new Blob([readFileSync(archivePath)], { type: "application/gzip" }), basename(archivePath));
10
+ let response;
11
+ try {
12
+ response = await fetch(new URL("/api/v1/submissions", opts.registryUrl), {
13
+ method: "POST",
14
+ headers: { authorization: `Bearer ${opts.token}` },
15
+ body,
16
+ });
17
+ }
18
+ catch (error) {
19
+ return { ok: false, status: 0, message: `could not reach the registry at ${opts.registryUrl}: ${error instanceof Error ? error.message : String(error)}` };
20
+ }
21
+ const payload = (await response.json().catch(() => ({})));
22
+ if (!response.ok) {
23
+ return { ok: false, status: response.status, message: payload.message ?? `registry replied ${response.status}` };
24
+ }
25
+ return {
26
+ ok: true, submissionId: payload.submissionId ?? "", state: payload.state ?? "submitted",
27
+ ...(payload.failedChecks ? { failedChecks: payload.failedChecks } : {}),
28
+ };
29
+ }
30
+ /** pack (which validates once, first, and fails fast — never uploads a
31
+ * package that can't pass its own local floor) → upload. validatePlugin
32
+ * is deliberately NOT called again here: buildPlugin (invoked by
33
+ * packPlugin) already runs the full validation suite, including a real
34
+ * federation build, and doing that twice per publish would double an
35
+ * already expensive step for no benefit. */
36
+ export async function publishPlugin(pluginDir, opts) {
37
+ try {
38
+ const packed = await packPlugin(pluginDir);
39
+ return uploadArchive(packed.archivePath, opts);
40
+ }
41
+ catch (error) {
42
+ if (error instanceof BuildValidationError) {
43
+ const failed = error.validation.checks.filter((c) => !c.passed).map((c) => c.name).join(", ");
44
+ return { ok: false, status: 0, message: `local validate failed (${failed}) — fix these before publishing` };
45
+ }
46
+ throw error;
47
+ }
48
+ }
@@ -0,0 +1,11 @@
1
+ export interface CheckResult {
2
+ name: string;
3
+ passed: boolean;
4
+ message: string;
5
+ details?: string;
6
+ }
7
+ export interface ValidationResult {
8
+ pluginDir: string;
9
+ checks: CheckResult[];
10
+ passed: boolean;
11
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,5 @@
1
+ import type { ValidationResult } from "./types.js";
2
+ export type { CheckResult, ValidationResult } from "./types.js";
3
+ export { loadManifest } from "./load-manifest.js";
4
+ export { CLIENT_BUNDLE_TYPES, hasClientBundle } from "@velora-cms/plugin-sdk/manifest";
5
+ export declare function validatePlugin(pluginDir: string): Promise<ValidationResult>;
@@ -0,0 +1,49 @@
1
+ import { buildFederationBundle, cleanupFederationBuild } from "./build-federation-bundle.js";
2
+ import { checkNpmAudit } from "./checks/audit.js";
3
+ import { checkBundleSize } from "./checks/bundle-size.js";
4
+ import { checkDatabaseCompatibility } from "./checks/db-compat.js";
5
+ import { checkManifest } from "./checks/manifest.js";
6
+ import { checkSharedDeps } from "./checks/shared-deps.js";
7
+ import { checkTypeScript } from "./checks/typescript.js";
8
+ import { checkRequiredViews } from "./checks/views.js";
9
+ import { loadManifest } from "./load-manifest.js";
10
+ import { hasClientBundle } from "@velora-cms/plugin-sdk/manifest";
11
+ // Re-exported from the package root so consumers that only need manifest
12
+ // loading (e.g. the marketplace seed script) don't need a new subpath —
13
+ // validate.js is already the package's "." export.
14
+ export { loadManifest } from "./load-manifest.js";
15
+ // Same reasoning as loadManifest above: consumers of the artifact-shape
16
+ // contract (S144) get it from the package root without a new subpath.
17
+ export { CLIENT_BUNDLE_TYPES, hasClientBundle } from "@velora-cms/plugin-sdk/manifest";
18
+ export async function validatePlugin(pluginDir) {
19
+ const manifestResult = await loadManifest(pluginDir);
20
+ const checks = [
21
+ checkManifest(manifestResult),
22
+ checkRequiredViews(manifestResult),
23
+ checkTypeScript(pluginDir),
24
+ ];
25
+ // S144: only client-bearing types ship a federation bundle — for
26
+ // theme/template/bundle there is nothing to size-check and no shared
27
+ // singletons to verify, and running Vite for them is pure waste. The
28
+ // checks stay in the report (passed, "not applicable") so a report
29
+ // always has the same shape regardless of type.
30
+ const clientBundle = manifestResult.ok ? hasClientBundle(manifestResult.manifest.type) : true;
31
+ if (clientBundle) {
32
+ const build = await buildFederationBundle(pluginDir);
33
+ try {
34
+ checks.push(checkBundleSize(build));
35
+ checks.push(checkSharedDeps(pluginDir, build));
36
+ }
37
+ finally {
38
+ cleanupFederationBuild(build);
39
+ }
40
+ }
41
+ else {
42
+ const message = `not applicable — ${manifestResult.ok ? manifestResult.manifest.type : ""} plugins ship no client bundle`;
43
+ checks.push({ name: "bundle size", passed: true, message });
44
+ checks.push({ name: "react/react-dom not bundled", passed: true, message });
45
+ }
46
+ checks.push(checkDatabaseCompatibility(manifestResult));
47
+ checks.push(checkNpmAudit(pluginDir));
48
+ return { pluginDir, checks, passed: checks.every((check) => check.passed) };
49
+ }
@@ -0,0 +1,7 @@
1
+ import type { CMSPlugin } from "@velora-cms/plugin-sdk/manifest";
2
+ /** Writes `<outDir>/manifest.json` — a redacted, JSON-safe summary of a
3
+ * validated plugin manifest for marketplace listing/review without
4
+ * executing any code. Never the source of truth for install() (which
5
+ * needs the live manifest from server/plugin.js, see
6
+ * bundle-server-manifest.ts) — this file is display-only. */
7
+ export declare function writeManifestJson(manifest: CMSPlugin, outDir: string): void;
@@ -0,0 +1,72 @@
1
+ import { writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ const CONTRIBUTION_ENTRY_FIELDS = ["id", "name", "label", "icon", "version", "method", "path", "cron"];
4
+ /**
5
+ * Redacts one contribution-array entry (e.g. one DataTypePlugin, one
6
+ * AdminSectionContribution) down to its plain string identifying fields
7
+ * — never the functions (views, serialize, onSelect, handler, ...) or
8
+ * raw JSON schemas a real contribution object also carries. A bare
9
+ * string entry (bundledPlugins' plugin ids) passes through unchanged.
10
+ */
11
+ function summarizeContributionEntry(entry) {
12
+ if (entry === null || typeof entry !== "object")
13
+ return entry;
14
+ const record = entry;
15
+ const summary = {};
16
+ for (const field of CONTRIBUTION_ENTRY_FIELDS) {
17
+ if (typeof record[field] === "string")
18
+ summary[field] = record[field];
19
+ }
20
+ return summary;
21
+ }
22
+ /**
23
+ * Redacts a manifest's full `contributions` object to a JSON-safe
24
+ * summary suitable for marketplace listing/review without executing
25
+ * any code: array-valued contributions become arrays of
26
+ * summarizeContributionEntry results; `hooks` (a Record<event, handler
27
+ * function>) becomes just its event-name keys; `bundledTemplate` and
28
+ * `themeTokens` are already plain-string-valued objects, kept as-is;
29
+ * anything else present is recorded as `true` (contributed, no further
30
+ * detail available to redact safely).
31
+ */
32
+ function summarizeContributions(contributions) {
33
+ const summary = {};
34
+ for (const [key, value] of Object.entries(contributions)) {
35
+ if (value == null)
36
+ continue;
37
+ if (Array.isArray(value)) {
38
+ summary[key] = value.map(summarizeContributionEntry);
39
+ }
40
+ else if (key === "hooks" && typeof value === "object") {
41
+ summary[key] = Object.keys(value);
42
+ }
43
+ else if (key === "bundledTemplate" || key === "themeTokens") {
44
+ summary[key] = value;
45
+ }
46
+ else {
47
+ summary[key] = true;
48
+ }
49
+ }
50
+ return summary;
51
+ }
52
+ /** Writes `<outDir>/manifest.json` — a redacted, JSON-safe summary of a
53
+ * validated plugin manifest for marketplace listing/review without
54
+ * executing any code. Never the source of truth for install() (which
55
+ * needs the live manifest from server/plugin.js, see
56
+ * bundle-server-manifest.ts) — this file is display-only. */
57
+ export function writeManifestJson(manifest, outDir) {
58
+ const summary = {
59
+ id: manifest.id,
60
+ name: manifest.name,
61
+ version: manifest.version,
62
+ type: manifest.type,
63
+ description: manifest.description,
64
+ author: manifest.author,
65
+ license: manifest.license,
66
+ cmsVersion: manifest.cmsVersion,
67
+ databaseCompatibility: manifest.databaseCompatibility,
68
+ permissions: manifest.permissions,
69
+ contributions: summarizeContributions(manifest.contributions),
70
+ };
71
+ writeFileSync(join(outDir, "manifest.json"), JSON.stringify(summary, null, 2));
72
+ }
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "velora-plugin",
3
+ "version": "0.9.0",
4
+ "license": "Apache-2.0",
5
+ "type": "module",
6
+ "bin": {
7
+ "velora-plugin": "dist/index.js"
8
+ },
9
+ "main": "dist/validate.js",
10
+ "types": "dist/validate.d.ts",
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/velora-cms/velora.git",
17
+ "directory": "packages/velora-plugin"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/validate.d.ts",
25
+ "import": "./dist/validate.js",
26
+ "default": "./dist/validate.js"
27
+ },
28
+ "./pack": {
29
+ "types": "./dist/pack.d.ts",
30
+ "import": "./dist/pack.js",
31
+ "default": "./dist/pack.js"
32
+ }
33
+ },
34
+ "dependencies": {
35
+ "@module-federation/vite": "1.16.12",
36
+ "@vitejs/plugin-react": "4.7.0",
37
+ "tar": "^7.5.19",
38
+ "tsx": "4.22.4",
39
+ "typescript": "6.0.3",
40
+ "vite": "6.4.3",
41
+ "@velora-cms/plugin-sdk": "0.9.0"
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "20.19.43",
45
+ "@types/react": "19.2.17",
46
+ "react": "19.2.7",
47
+ "react-dom": "19.2.7",
48
+ "create-velora-plugin": "0.9.0"
49
+ },
50
+ "scripts": {
51
+ "build": "tsc -p .",
52
+ "test": "vitest run"
53
+ }
54
+ }