Add versioned design profile releases
This commit is contained in:
+246
-5
@@ -12,6 +12,7 @@ const runtimeDir = join(root, "runtime-data");
|
||||
const uploadDir = join(runtimeDir, "uploads");
|
||||
const applicationsDir = join(runtimeDir, "applications");
|
||||
const designProfilesDir = join(runtimeDir, "design-profiles");
|
||||
const designProfileReleasesDir = join(runtimeDir, "design-profile-releases");
|
||||
const layoutPath = join(runtimeDir, "layout.json");
|
||||
const pageRegistryPath = join(root, "registry", "pages.json");
|
||||
const port = Number(process.env.PORT || 3333);
|
||||
@@ -19,6 +20,7 @@ const port = Number(process.env.PORT || 3333);
|
||||
await mkdir(uploadDir, { recursive: true });
|
||||
await mkdir(applicationsDir, { recursive: true });
|
||||
await mkdir(designProfilesDir, { recursive: true });
|
||||
await mkdir(designProfileReleasesDir, { recursive: true });
|
||||
const pageRegistry = JSON.parse(await readFile(pageRegistryPath, "utf8"));
|
||||
|
||||
function findPageTemplate(id, version) {
|
||||
@@ -76,14 +78,144 @@ function designProfilePath(id) {
|
||||
return join(designProfilesDir, `${safeId}.json`);
|
||||
}
|
||||
|
||||
function designProfileReleaseDir(id) {
|
||||
const safeId = String(id || "");
|
||||
if (safeId !== "default" && !/^[0-9a-f-]{36}$/i.test(safeId)) throw applicationError("invalid_design_profile_id");
|
||||
return join(designProfileReleasesDir, safeId);
|
||||
}
|
||||
|
||||
function designProfileReleasePath(id, version) {
|
||||
const safeVersion = String(version || "");
|
||||
if (!/^\d+\.\d+\.\d+$/.test(safeVersion)) throw applicationError("invalid_design_profile_version");
|
||||
return join(designProfileReleaseDir(id), `${safeVersion}.json`);
|
||||
}
|
||||
|
||||
const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
const requireHex = (value, code) => {
|
||||
const normalized = String(value || "");
|
||||
if (!/^#[0-9a-f]{6}$/i.test(normalized)) throw applicationError(code);
|
||||
return normalized.toLowerCase();
|
||||
};
|
||||
const requireNumber = (value, min, max, code) => {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) throw applicationError(code);
|
||||
return value;
|
||||
};
|
||||
const requireInteger = (value, min, max, code) => {
|
||||
const normalized = requireNumber(value, min, max, code);
|
||||
if (!Number.isInteger(normalized)) throw applicationError(code);
|
||||
return normalized;
|
||||
};
|
||||
const requireString = (value, code, max = 4096) => {
|
||||
if (typeof value !== "string" || value.length > max) throw applicationError(code);
|
||||
return value;
|
||||
};
|
||||
const requireNonEmptyString = (value, code, max = 4096) => {
|
||||
const normalized = requireString(value, code, max);
|
||||
if (!normalized) throw applicationError(code);
|
||||
return normalized;
|
||||
};
|
||||
const requireBoolean = (value, code) => {
|
||||
if (typeof value !== "boolean") throw applicationError(code);
|
||||
return value;
|
||||
};
|
||||
|
||||
function validateMaterial(value, theme) {
|
||||
if (!isObject(value)) throw applicationError(`invalid_${theme}_material`);
|
||||
return {
|
||||
panelHex: requireHex(value.panelHex, `invalid_${theme}_panel_color`),
|
||||
panelOpacity: requireNumber(value.panelOpacity, 0, 100, `invalid_${theme}_panel_opacity`),
|
||||
fieldHex: requireHex(value.fieldHex, `invalid_${theme}_field_color`),
|
||||
fieldOpacity: requireNumber(value.fieldOpacity, 0, 100, `invalid_${theme}_field_opacity`),
|
||||
nestedHex: requireHex(value.nestedHex, `invalid_${theme}_nested_color`),
|
||||
};
|
||||
}
|
||||
|
||||
function validateDesignProfileLayout(value) {
|
||||
if (!isObject(value)) throw applicationError("invalid_design_profile_layout");
|
||||
if (value.theme !== "dark" && value.theme !== "light") throw applicationError("invalid_design_profile_theme");
|
||||
if (!isObject(value.materialByTheme) || !isObject(value.environment) || !isObject(value.media) || !isObject(value.glass) || !isObject(value.toolbar)) {
|
||||
throw applicationError("incomplete_design_profile_layout");
|
||||
}
|
||||
const environment = value.environment;
|
||||
const media = value.media;
|
||||
const glass = value.glass;
|
||||
const toolbar = value.toolbar;
|
||||
if (media.source !== "file" && media.source !== "url") throw applicationError("invalid_design_profile_media_source");
|
||||
if (media.logoSource !== "file" && media.logoSource !== "url") throw applicationError("invalid_design_profile_logo_source");
|
||||
if (!isObject(media.faviconAssets)) throw applicationError("invalid_design_profile_favicon_assets");
|
||||
if (glass.version !== 4) throw applicationError("unsupported_glass_material_version");
|
||||
if (!["left", "right", "bottom"].includes(toolbar.placement)) throw applicationError("invalid_toolbar_placement");
|
||||
return {
|
||||
theme: value.theme,
|
||||
accentHex: requireHex(value.accentHex, "invalid_design_profile_accent"),
|
||||
materialByTheme: {
|
||||
dark: validateMaterial(value.materialByTheme.dark, "dark"),
|
||||
light: validateMaterial(value.materialByTheme.light, "light"),
|
||||
},
|
||||
environment: {
|
||||
lightColor: requireHex(environment.lightColor, "invalid_environment_light_color"),
|
||||
brightness: requireNumber(environment.brightness, 0, 100, "invalid_environment_brightness"),
|
||||
glowDistance: requireNumber(environment.glowDistance, 0, 500, "invalid_environment_glow_distance"),
|
||||
connectionType: requireString(environment.connectionType, "invalid_environment_connection_type", 64),
|
||||
connectionColor: requireHex(environment.connectionColor, "invalid_environment_connection_color"),
|
||||
usePortColors: requireBoolean(environment.usePortColors, "invalid_environment_use_port_colors"),
|
||||
fillColor: requireHex(environment.fillColor, "invalid_environment_fill_color"),
|
||||
fillOpacity: requireNumber(environment.fillOpacity, 0, 100, "invalid_environment_fill_opacity"),
|
||||
strokeColor: requireHex(environment.strokeColor, "invalid_environment_stroke_color"),
|
||||
strokeOpacity: requireNumber(environment.strokeOpacity, 0, 100, "invalid_environment_stroke_opacity"),
|
||||
},
|
||||
media: {
|
||||
source: media.source,
|
||||
url: requireString(media.url, "invalid_design_profile_media_url"),
|
||||
fileName: requireString(media.fileName, "invalid_design_profile_media_file_name", 255),
|
||||
fileSrc: requireString(media.fileSrc, "invalid_design_profile_media_file_src"),
|
||||
visible: requireBoolean(media.visible, "invalid_design_profile_media_visibility"),
|
||||
logoSource: media.logoSource,
|
||||
logoUrl: requireString(media.logoUrl, "invalid_design_profile_logo_url"),
|
||||
logoFileName: requireString(media.logoFileName, "invalid_design_profile_logo_file_name", 255),
|
||||
logoFileSrc: requireString(media.logoFileSrc, "invalid_design_profile_logo_file_src"),
|
||||
faviconFileName: requireString(media.faviconFileName, "invalid_design_profile_favicon_file_name", 255),
|
||||
faviconAssets: {
|
||||
ico: requireNonEmptyString(media.faviconAssets.ico, "invalid_design_profile_favicon_ico"),
|
||||
apple: requireNonEmptyString(media.faviconAssets.apple, "invalid_design_profile_favicon_apple"),
|
||||
icon192: requireNonEmptyString(media.faviconAssets.icon192, "invalid_design_profile_favicon_192"),
|
||||
icon512: requireNonEmptyString(media.faviconAssets.icon512, "invalid_design_profile_favicon_512"),
|
||||
},
|
||||
},
|
||||
glass: {
|
||||
version: 4,
|
||||
tintHex: requireHex(glass.tintHex, "invalid_glass_tint"),
|
||||
tintOpacity: requireNumber(glass.tintOpacity, 0, 100, "invalid_glass_tint_opacity"),
|
||||
blur: requireNumber(glass.blur, 0, 120, "invalid_glass_blur"),
|
||||
saturation: requireNumber(glass.saturation, 0, 300, "invalid_glass_saturation"),
|
||||
brightness: requireNumber(glass.brightness, 0, 200, "invalid_glass_brightness"),
|
||||
outlineOpacity: requireNumber(glass.outlineOpacity, 0, 100, "invalid_glass_outline_opacity"),
|
||||
shadowOpacity: requireNumber(glass.shadowOpacity, 0, 100, "invalid_glass_shadow_opacity"),
|
||||
},
|
||||
toolbar: {
|
||||
placement: toolbar.placement,
|
||||
background: requireHex(toolbar.background, "invalid_toolbar_background"),
|
||||
border: requireHex(toolbar.border, "invalid_toolbar_border"),
|
||||
outline: requireHex(toolbar.outline, "invalid_toolbar_outline"),
|
||||
minSize: requireNumber(toolbar.minSize, 16, 64, "invalid_toolbar_min_size"),
|
||||
maxSize: requireNumber(toolbar.maxSize, 32, 160, "invalid_toolbar_max_size"),
|
||||
lensCount: requireInteger(toolbar.lensCount, 1, 12, "invalid_toolbar_lens_count"),
|
||||
autoHide: requireBoolean(toolbar.autoHide, "invalid_toolbar_auto_hide"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function validateDesignProfile(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw applicationError("invalid_design_profile");
|
||||
if (value.schemaVersion !== "0.1.0") throw applicationError("unsupported_design_profile_schema");
|
||||
if (value.id !== "default" && !/^[0-9a-f-]{36}$/i.test(String(value.id || ""))) throw applicationError("invalid_design_profile_id");
|
||||
const name = String(value.name || "").trim();
|
||||
if (!name || name.length > 120) throw applicationError("invalid_design_profile_name");
|
||||
if (!value.layout || typeof value.layout !== "object" || Array.isArray(value.layout)) throw applicationError("invalid_design_profile_layout");
|
||||
return { ...value, name };
|
||||
if (!/^\d+\.\d+\.\d+$/.test(String(value.version || ""))) throw applicationError("invalid_design_profile_version");
|
||||
if (value.status !== "draft" && value.status !== "published") throw applicationError("invalid_design_profile_status");
|
||||
if (!isObject(value.timestamps) || !value.timestamps.createdAt || !value.timestamps.updatedAt) throw applicationError("invalid_design_profile_timestamps");
|
||||
if (value.status === "published" && !value.timestamps.publishedAt) throw applicationError("missing_design_profile_published_at");
|
||||
return { ...value, name, layout: validateDesignProfileLayout(value.layout) };
|
||||
}
|
||||
|
||||
async function ensureDefaultDesignProfile() {
|
||||
@@ -115,8 +247,58 @@ async function writeDesignProfile(profile) {
|
||||
await rename(tempPath, targetPath);
|
||||
}
|
||||
|
||||
function designProfileSummary(profile) {
|
||||
return { id: profile.id, name: profile.name, version: profile.version, theme: profile.layout?.theme === "light" ? "light" : "dark", updatedAt: profile.timestamps.updatedAt };
|
||||
async function writeDesignProfileRelease(profile) {
|
||||
const release = validateDesignProfile({
|
||||
...profile,
|
||||
status: "published",
|
||||
timestamps: { ...profile.timestamps, publishedAt: new Date().toISOString() },
|
||||
});
|
||||
await mkdir(designProfileReleaseDir(release.id), { recursive: true });
|
||||
try {
|
||||
await writeFile(designProfileReleasePath(release.id, release.version), `${JSON.stringify(release, null, 2)}\n`, { encoding: "utf8", flag: "wx" });
|
||||
} catch (error) {
|
||||
if (error?.code === "EEXIST") throw applicationError("design_profile_version_already_published", 409);
|
||||
throw error;
|
||||
}
|
||||
return release;
|
||||
}
|
||||
|
||||
async function readDesignProfileReleases(id) {
|
||||
const releases = [];
|
||||
try {
|
||||
for (const entry of await readdir(designProfileReleaseDir(id), { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
||||
try { releases.push(validateDesignProfile(JSON.parse(await readFile(join(designProfileReleaseDir(id), entry.name), "utf8")))); } catch { /* skip damaged immutable snapshot */ }
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
}
|
||||
return releases.sort((left, right) => String(right.version).localeCompare(String(left.version), undefined, { numeric: true }));
|
||||
}
|
||||
|
||||
async function ensureDefaultDesignProfileRelease() {
|
||||
const profile = validateDesignProfile(JSON.parse(await readFile(designProfilePath("default"), "utf8")));
|
||||
try { await readFile(designProfileReleasePath(profile.id, profile.version), "utf8"); }
|
||||
catch (error) { if (error?.code === "ENOENT") await writeDesignProfileRelease(profile); else throw error; }
|
||||
}
|
||||
|
||||
await ensureDefaultDesignProfileRelease();
|
||||
|
||||
function designProfileVersionSummary(profile) {
|
||||
return { version: profile.version, status: profile.status, theme: profile.layout.theme, publishedAt: profile.timestamps.publishedAt };
|
||||
}
|
||||
|
||||
function designProfileSummary(profile, releases = []) {
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
version: profile.version,
|
||||
status: profile.status,
|
||||
theme: profile.layout.theme,
|
||||
updatedAt: profile.timestamps.updatedAt,
|
||||
versions: releases.map(designProfileVersionSummary),
|
||||
latestPublishedVersion: releases[0]?.version ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function nextPatchVersion(version) {
|
||||
@@ -136,6 +318,8 @@ function validateApplicationManifest(value) {
|
||||
if (!name || name.length > 120) throw applicationError("invalid_application_name");
|
||||
if (!slug) throw applicationError("invalid_application_slug");
|
||||
if (!value.designProfile || typeof value.designProfile !== "object") throw applicationError("invalid_design_profile");
|
||||
// Pre-versioning manifests already represented the stable default 0.6.0 snapshot.
|
||||
const designProfileStatus = value.designProfile.status === "draft" ? "draft" : "published";
|
||||
if (value.designProfile.theme !== "dark" && value.designProfile.theme !== "light") throw applicationError("invalid_application_theme");
|
||||
if (!Array.isArray(value.pages)) throw applicationError("invalid_application_pages");
|
||||
const pageIds = new Set();
|
||||
@@ -159,6 +343,7 @@ function validateApplicationManifest(value) {
|
||||
}
|
||||
return {
|
||||
...value,
|
||||
designProfile: { ...value.designProfile, status: designProfileStatus },
|
||||
metadata: {
|
||||
...value.metadata,
|
||||
name,
|
||||
@@ -189,6 +374,7 @@ function createApplicationManifest(input = {}) {
|
||||
designProfile: {
|
||||
id: "default",
|
||||
version: "0.6.0",
|
||||
status: "published",
|
||||
theme: input?.theme === "light" ? "light" : "dark",
|
||||
},
|
||||
pages: template ? [{
|
||||
@@ -229,6 +415,29 @@ function applicationSummary(manifest) {
|
||||
};
|
||||
}
|
||||
|
||||
async function assertDesignProfileReference(reference) {
|
||||
if (!reference || typeof reference !== "object") throw applicationError("invalid_design_profile_reference");
|
||||
const profileId = String(reference.id || "");
|
||||
const version = String(reference.version || "");
|
||||
let profile;
|
||||
try {
|
||||
const sourcePath = reference.status === "published"
|
||||
? designProfileReleasePath(profileId, version)
|
||||
: designProfilePath(profileId);
|
||||
profile = validateDesignProfile(JSON.parse(await readFile(sourcePath, "utf8")));
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") throw applicationError("design_profile_reference_not_found", 409);
|
||||
throw error;
|
||||
}
|
||||
if (profile.version !== version || profile.layout.theme !== reference.theme) {
|
||||
throw applicationError("design_profile_reference_mismatch", 409);
|
||||
}
|
||||
if (reference.status === "published" && profile.status !== "published") {
|
||||
throw applicationError("design_profile_reference_not_published", 409);
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
async function readJsonBody(request, maxBytes = 2 * 1024 * 1024) {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
@@ -328,7 +537,10 @@ const server = createServer(async (request, response) => {
|
||||
const profiles = [];
|
||||
for (const entry of await readdir(designProfilesDir, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
||||
try { profiles.push(designProfileSummary(validateDesignProfile(JSON.parse(await readFile(join(designProfilesDir, entry.name), "utf8"))))); } catch { /* skip damaged draft */ }
|
||||
try {
|
||||
const profile = validateDesignProfile(JSON.parse(await readFile(join(designProfilesDir, entry.name), "utf8")));
|
||||
profiles.push(designProfileSummary(profile, await readDesignProfileReleases(profile.id)));
|
||||
} catch { /* skip damaged draft */ }
|
||||
}
|
||||
profiles.sort((left, right) => left.name.localeCompare(right.name));
|
||||
json(response, 200, profiles);
|
||||
@@ -375,9 +587,37 @@ const server = createServer(async (request, response) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const designProfileVersionsMatch = url.pathname.match(/^\/api\/design-profiles\/(default|[0-9a-f-]{36})\/versions$/i);
|
||||
if (designProfileVersionsMatch && request.method === "GET") {
|
||||
let current;
|
||||
try { current = validateDesignProfile(JSON.parse(await readFile(designProfilePath(designProfileVersionsMatch[1]), "utf8"))); }
|
||||
catch (error) { if (error?.code === "ENOENT") return json(response, 404, { error: "design_profile_not_found" }); throw error; }
|
||||
const releases = await readDesignProfileReleases(current.id);
|
||||
json(response, 200, { id: current.id, draft: designProfileVersionSummary(current), published: releases.map(designProfileVersionSummary) });
|
||||
return;
|
||||
}
|
||||
|
||||
const designProfileVersionMatch = url.pathname.match(/^\/api\/design-profiles\/(default|[0-9a-f-]{36})\/versions\/(\d+\.\d+\.\d+)$/i);
|
||||
if (designProfileVersionMatch && request.method === "GET") {
|
||||
try { json(response, 200, validateDesignProfile(JSON.parse(await readFile(designProfileReleasePath(designProfileVersionMatch[1], designProfileVersionMatch[2]), "utf8")))); }
|
||||
catch (error) { if (error?.code === "ENOENT") return json(response, 404, { error: "design_profile_version_not_found" }); throw error; }
|
||||
return;
|
||||
}
|
||||
|
||||
const designProfilePublishMatch = url.pathname.match(/^\/api\/design-profiles\/(default|[0-9a-f-]{36})\/publish$/i);
|
||||
if (designProfilePublishMatch && request.method === "POST") {
|
||||
let current;
|
||||
try { current = validateDesignProfile(JSON.parse(await readFile(designProfilePath(designProfilePublishMatch[1]), "utf8"))); }
|
||||
catch (error) { if (error?.code === "ENOENT") return json(response, 404, { error: "design_profile_not_found" }); throw error; }
|
||||
const release = await writeDesignProfileRelease(current);
|
||||
json(response, 201, release);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/applications" && request.method === "POST") {
|
||||
const input = await readJsonBody(request);
|
||||
const manifest = validateApplicationManifest(createApplicationManifest(input));
|
||||
await assertDesignProfileReference(manifest.designProfile);
|
||||
await writeApplicationManifest(manifest);
|
||||
json(response, 201, manifest);
|
||||
return;
|
||||
@@ -413,6 +653,7 @@ const server = createServer(async (request, response) => {
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
await assertDesignProfileReference(next.designProfile);
|
||||
await writeApplicationManifest(next);
|
||||
json(response, 200, next);
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user