Add versioned design profile releases

This commit is contained in:
DCCONSTRUCTIONS 2026-07-11 22:34:01 +03:00
parent 50718a33a1
commit 1f2dffc031
8 changed files with 473 additions and 17 deletions

View File

@ -56,6 +56,7 @@ import {
type ApplicationManifestV01, type ApplicationManifestV01,
type ApplicationSummary, type ApplicationSummary,
type DesignProfileSummary, type DesignProfileSummary,
type DesignProfileStatus,
} from "./applicationManifest.js"; } from "./applicationManifest.js";
type CatalogSection = "controls" | "media" | "glass" | "modals" | "icons"; type CatalogSection = "controls" | "media" | "glass" | "modals" | "icons";
@ -315,6 +316,7 @@ export function CatalogApp() {
const [designProfileSaveOpen, setDesignProfileSaveOpen] = useState(false); const [designProfileSaveOpen, setDesignProfileSaveOpen] = useState(false);
const [designProfileSaveMode, setDesignProfileSaveMode] = useState<"save" | "save-as">("save"); const [designProfileSaveMode, setDesignProfileSaveMode] = useState<"save" | "save-as">("save");
const [designProfileName, setDesignProfileName] = useState(""); const [designProfileName, setDesignProfileName] = useState("");
const [designProfilePublishState, setDesignProfilePublishState] = useState<"idle" | "publishing" | "published" | "error">("idle");
const [activeDesignProfileId, setActiveDesignProfileId] = useState("default"); const [activeDesignProfileId, setActiveDesignProfileId] = useState("default");
const workspace = useApplicationWorkspace<StudioView>(); const workspace = useApplicationWorkspace<StudioView>();
const { navigationOpen: guidelineOpen, activeView, contentExpanded: panelExpanded } = workspace; const { navigationOpen: guidelineOpen, activeView, contentExpanded: panelExpanded } = workspace;
@ -885,8 +887,18 @@ export function CatalogApp() {
body: JSON.stringify({ name: creating ? designProfileName : selected?.name, layout }), body: JSON.stringify({ name: creating ? designProfileName : selected?.name, layout }),
}); });
if (!response.ok) return setLayoutSaveState("error"); if (!response.ok) return setLayoutSaveState("error");
const saved = await response.json() as { id: string; name: string; version: string; layout: StoredLayout; timestamps: { updatedAt: string } }; const saved = await response.json() as { id: string; name: string; version: string; status: DesignProfileStatus; layout: StoredLayout; timestamps: { updatedAt: string } };
const summary: DesignProfileSummary = { id: saved.id, name: saved.name, version: saved.version, theme: saved.layout.theme === "light" ? "light" : "dark", updatedAt: saved.timestamps.updatedAt }; const previous = designProfiles.find((profile) => profile.id === saved.id);
const summary: DesignProfileSummary = {
id: saved.id,
name: saved.name,
version: saved.version,
status: saved.status,
theme: saved.layout.theme === "light" ? "light" : "dark",
updatedAt: saved.timestamps.updatedAt,
versions: previous?.versions ?? [],
latestPublishedVersion: previous?.latestPublishedVersion ?? null,
};
setDesignProfiles((current) => [...current.filter((profile) => profile.id !== summary.id), summary].sort((left, right) => left.name.localeCompare(right.name))); setDesignProfiles((current) => [...current.filter((profile) => profile.id !== summary.id), summary].sort((left, right) => left.name.localeCompare(right.name)));
setActiveDesignProfileId(summary.id); setActiveDesignProfileId(summary.id);
setDesignProfileSaveOpen(false); setDesignProfileSaveOpen(false);
@ -894,6 +906,25 @@ export function CatalogApp() {
setLayoutSaveState("saved"); setLayoutSaveState("saved");
}; };
const publishDesignProfile = async () => {
const current = designProfiles.find((profile) => profile.id === activeDesignProfileId);
if (!current || current.versions.some((release) => release.version === current.version)) return;
setDesignProfilePublishState("publishing");
const response = await fetch(`/api/design-profiles/${current.id}/publish`, { method: "POST" });
if (!response.ok) {
setDesignProfilePublishState("error");
return;
}
const release = await response.json() as { version: string; status: DesignProfileStatus; layout: StoredLayout; timestamps: { publishedAt?: string } };
setDesignProfiles((profiles) => profiles.map((profile) => profile.id !== current.id ? profile : {
...profile,
versions: [{ version: release.version, status: release.status, theme: release.layout.theme === "light" ? "light" : "dark", publishedAt: release.timestamps.publishedAt }, ...profile.versions.filter((item) => item.version !== release.version)],
latestPublishedVersion: release.version,
}));
setDesignProfilePublishState("published");
setDesignProfileSaveOpen(false);
};
const addPageTemplateToApplication = (template: PageTemplateDefinition) => { const addPageTemplateToApplication = (template: PageTemplateDefinition) => {
updateApplicationDraft((current) => { updateApplicationDraft((current) => {
const order = current.pages.length; const order = current.pages.length;
@ -1312,6 +1343,19 @@ export function CatalogApp() {
const renderApplicationDraft = () => { const renderApplicationDraft = () => {
if (!applicationDraft || applicationDraft.id !== activeApplicationId) return null; if (!applicationDraft || applicationDraft.id !== activeApplicationId) return null;
const selectedProfile = designProfiles.find((profile) => profile.id === applicationDraft.designProfile.id) ?? designProfiles[0]; const selectedProfile = designProfiles.find((profile) => profile.id === applicationDraft.designProfile.id) ?? designProfiles[0];
const designProfileOptions = designProfiles.flatMap((profile) => [
...profile.versions.map((release) => ({
value: `${profile.id}@${release.version}@published`,
label: profile.name,
description: `${release.version} · Published · ${release.theme}`,
})),
{
value: `${profile.id}@${profile.version}@draft`,
label: `${profile.name} — Draft`,
description: `${profile.version} · Draft · ${profile.theme}`,
},
]);
const selectedProfileValue = `${applicationDraft.designProfile.id}@${applicationDraft.designProfile.version}@${applicationDraft.designProfile.status}`;
const availableTemplates = pageTemplates; const availableTemplates = pageTemplates;
if (applicationMode === "preview") { if (applicationMode === "preview") {
@ -1373,13 +1417,17 @@ export function CatalogApp() {
> >
<Select <Select
label="Design Profile" label="Design Profile"
value={selectedProfile?.id ?? "default"} value={designProfileOptions.some((option) => option.value === selectedProfileValue) ? selectedProfileValue : `${selectedProfile?.id ?? "default"}@${selectedProfile?.version ?? "0.6.0"}@draft`}
options={designProfiles.map((profile) => ({ value: profile.id, label: profile.name, description: `${profile.version} · ${profile.theme}` }))} options={designProfileOptions}
onChange={(profileId) => { onChange={(profileReference) => {
const [profileId, version, statusValue] = profileReference.split("@");
const profile = designProfiles.find((item) => item.id === profileId); const profile = designProfiles.find((item) => item.id === profileId);
if (!profile) return; if (!profile) return;
setTheme(profile.theme); const status: DesignProfileStatus = statusValue === "published" ? "published" : "draft";
updateApplicationDraft((current) => ({ ...current, designProfile: { id: profile.id, version: profile.version, theme: profile.theme } })); const release = status === "published" ? profile.versions.find((item) => item.version === version) : undefined;
const profileTheme = release?.theme ?? profile.theme;
setTheme(profileTheme);
updateApplicationDraft((current) => ({ ...current, designProfile: { id: profile.id, version, status, theme: profileTheme } }));
}} }}
/> />
</SettingsCard> </SettingsCard>
@ -1623,7 +1671,7 @@ export function CatalogApp() {
className="catalog-module-switcher" className="catalog-module-switcher"
label="Design Profile" label="Design Profile"
value={activeDesignProfileId} value={activeDesignProfileId}
options={designProfiles.map((profile) => ({ value: profile.id, label: profile.name, description: `${profile.version} · ${profile.theme}`, icon: <Icon name="settings" /> }))} options={designProfiles.map((profile) => ({ value: profile.id, label: profile.name, description: `${profile.version} · Draft · ${profile.versions.length} releases`, icon: <Icon name="settings" /> }))}
onChange={(id) => { void openDesignProfile(id); }} onChange={(id) => { void openDesignProfile(id); }}
/> />
) : undefined} ) : undefined}
@ -1798,6 +1846,10 @@ export function CatalogApp() {
{designProfileSaveMode === "save" ? ( {designProfileSaveMode === "save" ? (
<> <>
<Button onClick={() => { void saveDesignProfile("save"); }}>Сохранить</Button> <Button onClick={() => { void saveDesignProfile("save"); }}>Сохранить</Button>
<Button
disabled={designProfilePublishState === "publishing" || Boolean(designProfiles.find((profile) => profile.id === activeDesignProfileId)?.versions.some((release) => release.version === designProfiles.find((profile) => profile.id === activeDesignProfileId)?.version))}
onClick={() => { void publishDesignProfile(); }}
>{designProfilePublishState === "publishing" ? "Публикуется" : `Опубликовать v${designProfiles.find((profile) => profile.id === activeDesignProfileId)?.version ?? ""}`}</Button>
<Button variant="primary" shape="pill" onClick={() => { setDesignProfileName(""); setDesignProfileSaveMode("save-as"); }}>Сохранить как новый</Button> <Button variant="primary" shape="pill" onClick={() => { setDesignProfileName(""); setDesignProfileSaveMode("save-as"); }}>Сохранить как новый</Button>
</> </>
) : ( ) : (
@ -1810,7 +1862,7 @@ export function CatalogApp() {
<div className="catalog-form"> <div className="catalog-form">
<SegmentedControl label="Режим сохранения" value={designProfileSaveMode} items={[{ value: "save", label: "Сохранить" }, { value: "save-as", label: "Сохранить как новый" }]} onChange={setDesignProfileSaveMode} /> <SegmentedControl label="Режим сохранения" value={designProfileSaveMode} items={[{ value: "save", label: "Сохранить" }, { value: "save-as", label: "Сохранить как новый" }]} onChange={setDesignProfileSaveMode} />
{designProfileSaveMode === "save-as" ? <TextField label="Название нового профиля" hint="обязательно" value={designProfileName} onChange={(event) => setDesignProfileName(event.target.value)} /> : ( {designProfileSaveMode === "save-as" ? <TextField label="Название нового профиля" hint="обязательно" value={designProfileName} onChange={(event) => setDesignProfileName(event.target.value)} /> : (
<div className="catalog-save-profile-current"><Icon name="settings" /><span><strong>{designProfiles.find((profile) => profile.id === activeDesignProfileId)?.name ?? "NODE.DC Default"}</strong><small>Будет создана новая draft-версия текущего профиля.</small></span></div> <div className="catalog-save-profile-current"><Icon name="settings" /><span><strong>{designProfiles.find((profile) => profile.id === activeDesignProfileId)?.name ?? "NODE.DC Default"}</strong><small>Сохранение создаёт новую draft-версию. Publish фиксирует текущую сохранённую версию неизменяемым release.</small></span></div>
)} )}
</div> </div>
</Window> </Window>

View File

@ -3,6 +3,7 @@ import type { NodedcTheme } from "@nodedc/ui-core";
export const applicationManifestSchemaVersion = "0.1.0" as const; export const applicationManifestSchemaVersion = "0.1.0" as const;
export type ApplicationDraftStatus = "draft"; export type ApplicationDraftStatus = "draft";
export type DesignProfileStatus = "draft" | "published";
export interface ApplicationPageManifest { export interface ApplicationPageManifest {
id: string; id: string;
@ -33,6 +34,7 @@ export interface ApplicationManifestV01 {
designProfile: { designProfile: {
id: string; id: string;
version: string; version: string;
status: DesignProfileStatus;
theme: NodedcTheme; theme: NodedcTheme;
}; };
pages: ApplicationPageManifest[]; pages: ApplicationPageManifest[];
@ -60,8 +62,18 @@ export interface DesignProfileSummary {
id: string; id: string;
name: string; name: string;
version: string; version: string;
status: DesignProfileStatus;
theme: NodedcTheme; theme: NodedcTheme;
updatedAt: string; updatedAt: string;
versions: DesignProfileVersionSummary[];
latestPublishedVersion: string | null;
}
export interface DesignProfileVersionSummary {
version: string;
status: DesignProfileStatus;
theme: NodedcTheme;
publishedAt?: string;
} }
export type ApplicationDraftSaveState = "idle" | "loading" | "saving" | "saved" | "error"; export type ApplicationDraftSaveState = "idle" | "loading" | "saving" | "saved" | "error";

View File

@ -23,7 +23,7 @@
Manifest фиксирует: Manifest фиксирует:
- identity, slug, draft status и версию; - identity, slug, draft status и версию;
- ссылку на Design Profile и Dark/Light; - ссылку на конкретный Design Profile release (`id + version + status + theme`);
- хотя бы одну страницу и pinned page-template version; - хотя бы одну страницу и pinned page-template version;
- navigation visibility; - navigation visibility;
- только заранее разрешённые feature flags; - только заранее разрешённые feature flags;
@ -58,7 +58,11 @@ Catalog server предоставляет минимальный временн
## Design Profile lifecycle ## Design Profile lifecycle
Visual Library использует отдельный Hub-style selector профилей. Общая кнопка Save открывает каноническую модалку `Сохранить / Сохранить как новый`. Приложение хранит только pinned `profile id + version + theme`; media, favicon и material settings принадлежат Design Profile. Каноническая JSON Schema: `registry/schemas/design-profile-v0.1.schema.json`.
Visual Library использует отдельный Hub-style selector профилей. Общая кнопка Save открывает каноническую модалку `Сохранить / Сохранить как новый / Опубликовать`. Приложение хранит pinned `profile id + version + status + theme`; media, favicon и material settings принадлежат Design Profile.
`Draft` — изменяемая рабочая голова профиля. Каждое сохранение увеличивает patch-версию. `Published` — неизменяемый снимок конкретной версии: повторная публикация той же версии запрещена. Application Manifest может ссылаться на draft для внутренней разработки, но стабильный модуль должен фиксировать published release. Изменение будущего draft или публикация следующей версии не меняют уже закреплённое приложение.
Временный catalog server предоставляет: Временный catalog server предоставляет:
@ -66,4 +70,9 @@ Visual Library использует отдельный Hub-style selector про
- `GET /api/design-profiles/:id`; - `GET /api/design-profiles/:id`;
- `POST /api/design-profiles`; - `POST /api/design-profiles`;
- `PUT /api/design-profiles/:id`; - `PUT /api/design-profiles/:id`;
- `GET /api/design-profiles/:id/versions`;
- `GET /api/design-profiles/:id/versions/:version`;
- `POST /api/design-profiles/:id/publish`;
- `DELETE /api/applications/:id` с переносом draft в локальное deleted-storage. - `DELETE /api/applications/:id` с переносом draft в локальное deleted-storage.
Catalog server проверяет полный layout-контракт Design Profile и существование выбранной Application Manifest ссылки при создании и сохранении модуля. Опубликованные снимки лежат отдельно от draft-head в `runtime-data/design-profile-releases` и исключены из Git; в production этот lifecycle должен перейти в Platform `design-profile-core` без изменения публичного контракта.

View File

@ -50,5 +50,6 @@
"consumption": "../docs/CONSUMPTION.md" "consumption": "../docs/CONSUMPTION.md"
}, },
"applicationManifestSchema": "schemas/application-manifest-v0.1.schema.json", "applicationManifestSchema": "schemas/application-manifest-v0.1.schema.json",
"designProfileSchema": "schemas/design-profile-v0.1.schema.json",
"operatingRule": "Check this registry before creating a NODE.DC interface element. Reuse or extend the canonical export instead of creating an application-local copy." "operatingRule": "Check this registry before creating a NODE.DC interface element. Reuse or extend the canonical export instead of creating an application-local copy."
} }

View File

@ -23,10 +23,11 @@
"designProfile": { "designProfile": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["id", "version", "theme"], "required": ["id", "version", "status", "theme"],
"properties": { "properties": {
"id": { "type": "string", "minLength": 1 }, "id": { "type": "string", "minLength": 1 },
"version": { "type": "string", "minLength": 1 }, "version": { "type": "string", "minLength": 1 },
"status": { "enum": ["draft", "published"] },
"theme": { "enum": ["dark", "light"] } "theme": { "enum": ["dark", "light"] }
} }
}, },

View File

@ -0,0 +1,134 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://nodedc.ru/schemas/design-profile-v0.1.schema.json",
"title": "NODE.DC Design Profile v0.1",
"type": "object",
"additionalProperties": false,
"required": ["schemaVersion", "id", "name", "version", "status", "layout", "timestamps"],
"properties": {
"schemaVersion": { "const": "0.1.0" },
"id": { "type": "string", "anyOf": [{ "const": "default" }, { "format": "uuid" }] },
"name": { "type": "string", "minLength": 1, "maxLength": 120 },
"version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" },
"status": { "enum": ["draft", "published"] },
"layout": {
"type": "object",
"additionalProperties": false,
"required": ["theme", "accentHex", "materialByTheme", "environment", "media", "glass", "toolbar"],
"properties": {
"theme": { "enum": ["dark", "light"] },
"accentHex": { "$ref": "#/$defs/hex" },
"materialByTheme": {
"type": "object",
"additionalProperties": false,
"required": ["dark", "light"],
"properties": {
"dark": { "$ref": "#/$defs/material" },
"light": { "$ref": "#/$defs/material" }
}
},
"environment": {
"type": "object",
"additionalProperties": false,
"required": ["lightColor", "brightness", "glowDistance", "connectionType", "connectionColor", "usePortColors", "fillColor", "fillOpacity", "strokeColor", "strokeOpacity"],
"properties": {
"lightColor": { "$ref": "#/$defs/hex" },
"brightness": { "$ref": "#/$defs/percent" },
"glowDistance": { "type": "number", "minimum": 0, "maximum": 500 },
"connectionType": { "type": "string", "minLength": 1, "maxLength": 64 },
"connectionColor": { "$ref": "#/$defs/hex" },
"usePortColors": { "type": "boolean" },
"fillColor": { "$ref": "#/$defs/hex" },
"fillOpacity": { "$ref": "#/$defs/percent" },
"strokeColor": { "$ref": "#/$defs/hex" },
"strokeOpacity": { "$ref": "#/$defs/percent" }
}
},
"media": {
"type": "object",
"additionalProperties": false,
"required": ["source", "url", "fileName", "fileSrc", "visible", "logoSource", "logoUrl", "logoFileName", "logoFileSrc", "faviconFileName", "faviconAssets"],
"properties": {
"source": { "enum": ["file", "url"] },
"url": { "type": "string" },
"fileName": { "type": "string" },
"fileSrc": { "type": "string" },
"visible": { "type": "boolean" },
"logoSource": { "enum": ["file", "url"] },
"logoUrl": { "type": "string" },
"logoFileName": { "type": "string" },
"logoFileSrc": { "type": "string" },
"faviconFileName": { "type": "string" },
"faviconAssets": {
"type": "object",
"additionalProperties": false,
"required": ["ico", "apple", "icon192", "icon512"],
"properties": {
"ico": { "type": "string", "minLength": 1 },
"apple": { "type": "string", "minLength": 1 },
"icon192": { "type": "string", "minLength": 1 },
"icon512": { "type": "string", "minLength": 1 }
}
}
}
},
"glass": {
"type": "object",
"additionalProperties": false,
"required": ["version", "tintHex", "tintOpacity", "blur", "saturation", "brightness", "outlineOpacity", "shadowOpacity"],
"properties": {
"version": { "const": 4 },
"tintHex": { "$ref": "#/$defs/hex" },
"tintOpacity": { "$ref": "#/$defs/percent" },
"blur": { "type": "number", "minimum": 0, "maximum": 120 },
"saturation": { "type": "number", "minimum": 0, "maximum": 300 },
"brightness": { "type": "number", "minimum": 0, "maximum": 200 },
"outlineOpacity": { "$ref": "#/$defs/percent" },
"shadowOpacity": { "$ref": "#/$defs/percent" }
}
},
"toolbar": {
"type": "object",
"additionalProperties": false,
"required": ["placement", "background", "border", "outline", "minSize", "maxSize", "lensCount", "autoHide"],
"properties": {
"placement": { "enum": ["left", "right", "bottom"] },
"background": { "$ref": "#/$defs/hex" },
"border": { "$ref": "#/$defs/hex" },
"outline": { "$ref": "#/$defs/hex" },
"minSize": { "type": "number", "minimum": 16, "maximum": 64 },
"maxSize": { "type": "number", "minimum": 32, "maximum": 160 },
"lensCount": { "type": "integer", "minimum": 1, "maximum": 12 },
"autoHide": { "type": "boolean" }
}
}
}
},
"timestamps": {
"type": "object",
"additionalProperties": false,
"required": ["createdAt", "updatedAt"],
"properties": {
"createdAt": { "type": "string", "format": "date-time" },
"updatedAt": { "type": "string", "format": "date-time" },
"publishedAt": { "type": "string", "format": "date-time" }
}
}
},
"$defs": {
"hex": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" },
"percent": { "type": "number", "minimum": 0, "maximum": 100 },
"material": {
"type": "object",
"additionalProperties": false,
"required": ["panelHex", "panelOpacity", "fieldHex", "fieldOpacity", "nestedHex"],
"properties": {
"panelHex": { "$ref": "#/$defs/hex" },
"panelOpacity": { "$ref": "#/$defs/percent" },
"fieldHex": { "$ref": "#/$defs/hex" },
"fieldOpacity": { "$ref": "#/$defs/percent" },
"nestedHex": { "$ref": "#/$defs/hex" }
}
}
}
}

View File

@ -11,6 +11,8 @@ const components = await parse("registry/components.json");
const candidates = await parse("registry/candidates.json"); const candidates = await parse("registry/candidates.json");
const icons = await parse("registry/icons.json"); const icons = await parse("registry/icons.json");
const pages = await parse("registry/pages.json"); const pages = await parse("registry/pages.json");
const applicationManifestSchema = await parse("registry/schemas/application-manifest-v0.1.schema.json");
const designProfileSchema = await parse("registry/schemas/design-profile-v0.1.schema.json");
const failures = []; const failures = [];
const requireValue = (condition, message) => { const requireValue = (condition, message) => {
@ -26,6 +28,10 @@ requireValue(Array.isArray(candidates.candidates) && candidates.candidates.lengt
requireValue(Array.isArray(icons.groups) && icons.groups.length >= 5, "icon registry is incomplete"); requireValue(Array.isArray(icons.groups) && icons.groups.length >= 5, "icon registry is incomplete");
requireValue(pages.schemaVersion === "0.1.0", "page registry schemaVersion must be 0.1.0"); requireValue(pages.schemaVersion === "0.1.0", "page registry schemaVersion must be 0.1.0");
requireValue(Array.isArray(pages.templates) && pages.templates.length >= 1, "page registry must contain at least one template"); requireValue(Array.isArray(pages.templates) && pages.templates.length >= 1, "page registry must contain at least one template");
requireValue(registry.applicationManifestSchema === "schemas/application-manifest-v0.1.schema.json", "application manifest schema must be registered");
requireValue(registry.designProfileSchema === "schemas/design-profile-v0.1.schema.json", "design profile schema must be registered");
requireValue(applicationManifestSchema.properties?.schemaVersion?.const === "0.1.0", "application manifest schema version must be 0.1.0");
requireValue(designProfileSchema.properties?.schemaVersion?.const === "0.1.0", "design profile schema version must be 0.1.0");
const ids = components.components.map((component) => component.id); const ids = components.components.map((component) => component.id);
requireValue(new Set(ids).size === ids.length, "component ids must be unique"); requireValue(new Set(ids).size === ids.length, "component ids must be unique");

View File

@ -12,6 +12,7 @@ const runtimeDir = join(root, "runtime-data");
const uploadDir = join(runtimeDir, "uploads"); const uploadDir = join(runtimeDir, "uploads");
const applicationsDir = join(runtimeDir, "applications"); const applicationsDir = join(runtimeDir, "applications");
const designProfilesDir = join(runtimeDir, "design-profiles"); const designProfilesDir = join(runtimeDir, "design-profiles");
const designProfileReleasesDir = join(runtimeDir, "design-profile-releases");
const layoutPath = join(runtimeDir, "layout.json"); const layoutPath = join(runtimeDir, "layout.json");
const pageRegistryPath = join(root, "registry", "pages.json"); const pageRegistryPath = join(root, "registry", "pages.json");
const port = Number(process.env.PORT || 3333); 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(uploadDir, { recursive: true });
await mkdir(applicationsDir, { recursive: true }); await mkdir(applicationsDir, { recursive: true });
await mkdir(designProfilesDir, { recursive: true }); await mkdir(designProfilesDir, { recursive: true });
await mkdir(designProfileReleasesDir, { recursive: true });
const pageRegistry = JSON.parse(await readFile(pageRegistryPath, "utf8")); const pageRegistry = JSON.parse(await readFile(pageRegistryPath, "utf8"));
function findPageTemplate(id, version) { function findPageTemplate(id, version) {
@ -76,14 +78,144 @@ function designProfilePath(id) {
return join(designProfilesDir, `${safeId}.json`); 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) { function validateDesignProfile(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) throw applicationError("invalid_design_profile"); 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.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"); 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(); const name = String(value.name || "").trim();
if (!name || name.length > 120) throw applicationError("invalid_design_profile_name"); 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"); if (!/^\d+\.\d+\.\d+$/.test(String(value.version || ""))) throw applicationError("invalid_design_profile_version");
return { ...value, name }; 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() { async function ensureDefaultDesignProfile() {
@ -115,8 +247,58 @@ async function writeDesignProfile(profile) {
await rename(tempPath, targetPath); await rename(tempPath, targetPath);
} }
function designProfileSummary(profile) { async function writeDesignProfileRelease(profile) {
return { id: profile.id, name: profile.name, version: profile.version, theme: profile.layout?.theme === "light" ? "light" : "dark", updatedAt: profile.timestamps.updatedAt }; 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) { function nextPatchVersion(version) {
@ -136,6 +318,8 @@ function validateApplicationManifest(value) {
if (!name || name.length > 120) throw applicationError("invalid_application_name"); if (!name || name.length > 120) throw applicationError("invalid_application_name");
if (!slug) throw applicationError("invalid_application_slug"); if (!slug) throw applicationError("invalid_application_slug");
if (!value.designProfile || typeof value.designProfile !== "object") throw applicationError("invalid_design_profile"); 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 (value.designProfile.theme !== "dark" && value.designProfile.theme !== "light") throw applicationError("invalid_application_theme");
if (!Array.isArray(value.pages)) throw applicationError("invalid_application_pages"); if (!Array.isArray(value.pages)) throw applicationError("invalid_application_pages");
const pageIds = new Set(); const pageIds = new Set();
@ -159,6 +343,7 @@ function validateApplicationManifest(value) {
} }
return { return {
...value, ...value,
designProfile: { ...value.designProfile, status: designProfileStatus },
metadata: { metadata: {
...value.metadata, ...value.metadata,
name, name,
@ -189,6 +374,7 @@ function createApplicationManifest(input = {}) {
designProfile: { designProfile: {
id: "default", id: "default",
version: "0.6.0", version: "0.6.0",
status: "published",
theme: input?.theme === "light" ? "light" : "dark", theme: input?.theme === "light" ? "light" : "dark",
}, },
pages: template ? [{ 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) { async function readJsonBody(request, maxBytes = 2 * 1024 * 1024) {
const chunks = []; const chunks = [];
let size = 0; let size = 0;
@ -328,7 +537,10 @@ const server = createServer(async (request, response) => {
const profiles = []; const profiles = [];
for (const entry of await readdir(designProfilesDir, { withFileTypes: true })) { for (const entry of await readdir(designProfilesDir, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith(".json")) continue; 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)); profiles.sort((left, right) => left.name.localeCompare(right.name));
json(response, 200, profiles); json(response, 200, profiles);
@ -375,9 +587,37 @@ const server = createServer(async (request, response) => {
return; 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") { if (url.pathname === "/api/applications" && request.method === "POST") {
const input = await readJsonBody(request); const input = await readJsonBody(request);
const manifest = validateApplicationManifest(createApplicationManifest(input)); const manifest = validateApplicationManifest(createApplicationManifest(input));
await assertDesignProfileReference(manifest.designProfile);
await writeApplicationManifest(manifest); await writeApplicationManifest(manifest);
json(response, 201, manifest); json(response, 201, manifest);
return; return;
@ -413,6 +653,7 @@ const server = createServer(async (request, response) => {
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}, },
}); });
await assertDesignProfileReference(next.designProfile);
await writeApplicationManifest(next); await writeApplicationManifest(next);
json(response, 200, next); json(response, 200, next);
return; return;