dsh-archify-projects 0.0.0-stage → 0.1.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/README.md +204 -2
- package/cordis.patch.yml +99 -0
- package/dist/client.js +2261 -0
- package/dist/client.js.map +1 -0
- package/docs/CONTAINER.md +197 -0
- package/docs/DESIGN.md +905 -0
- package/docs/HANDOVER.md +391 -0
- package/docs/PUBLISH.md +107 -0
- package/icon.svg +12 -0
- package/lib/host/archify-skill.js +116 -0
- package/lib/host/archify-skill.js.map +1 -0
- package/lib/host/config.js +95 -0
- package/lib/host/config.js.map +1 -0
- package/lib/host/context.js +28 -0
- package/lib/host/context.js.map +1 -0
- package/lib/host/git.js +185 -0
- package/lib/host/git.js.map +1 -0
- package/lib/host/http.js +184 -0
- package/lib/host/http.js.map +1 -0
- package/lib/host/index.js +90 -0
- package/lib/host/index.js.map +1 -0
- package/lib/host/policy.js +124 -0
- package/lib/host/policy.js.map +1 -0
- package/lib/host/routes.js +667 -0
- package/lib/host/routes.js.map +1 -0
- package/lib/host/runner.js +913 -0
- package/lib/host/runner.js.map +1 -0
- package/lib/host/store.js +370 -0
- package/lib/host/store.js.map +1 -0
- package/lib/host/workspace.js +65 -0
- package/lib/host/workspace.js.map +1 -0
- package/lib/shared/format.js +138 -0
- package/lib/shared/format.js.map +1 -0
- package/lib/shared/prompt.js +257 -0
- package/lib/shared/prompt.js.map +1 -0
- package/lib/shared/protocol.js +31 -0
- package/lib/shared/protocol.js.map +1 -0
- package/lib/shared/types.js +173 -0
- package/lib/shared/types.js.map +1 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +98 -4
- package/skills/archify/LICENSE +22 -0
- package/skills/archify/SKILL.md +109 -0
- package/skills/archify/THIRD_PARTY_NOTICES.md +69 -0
- package/skills/archify/assets/JetBrainsMono-OFL.txt +93 -0
- package/skills/archify/assets/template.html +13709 -0
- package/skills/archify/bin/archify.mjs +6936 -0
- package/skills/archify/bin/delivery-update.mjs +91 -0
- package/skills/archify/bin/finalize.mjs +895 -0
- package/skills/archify/bin/open-artifact.mjs +146 -0
- package/skills/archify/bin/preview.mjs +969 -0
- package/skills/archify/bin/recover-output.mjs +41 -0
- package/skills/archify/bin/visual-check.mjs +2667 -0
- package/skills/archify/brand-marks/README.md +31 -0
- package/skills/archify/brand-marks/catalog.json +131 -0
- package/skills/archify/delta/architecture-delta.mjs +1313 -0
- package/skills/archify/examples/agent-run.lifecycle.json +55 -0
- package/skills/archify/examples/agent-tool-call.workflow.json +74 -0
- package/skills/archify/examples/async-job-roundtrip.sequence.json +56 -0
- package/skills/archify/examples/brand-aware-delivery.architecture.json +34 -0
- package/skills/archify/examples/cache-miss-request.sequence.json +77 -0
- package/skills/archify/examples/checkout-platform.base.architecture.json +32 -0
- package/skills/archify/examples/checkout-platform.head.architecture.json +32 -0
- package/skills/archify/examples/dataflow-product-analytics.html +13820 -0
- package/skills/archify/examples/deployment-release.lifecycle.json +48 -0
- package/skills/archify/examples/event-stream.dataflow.json +52 -0
- package/skills/archify/examples/holiday-planning.workflow.json +143 -0
- package/skills/archify/examples/incident-response.workflow.json +59 -0
- package/skills/archify/examples/lifecycle-agent-run.html +13765 -0
- package/skills/archify/examples/locales/de.json +425 -0
- package/skills/archify/examples/locales/es.json +440 -0
- package/skills/archify/examples/locales/fr.json +425 -0
- package/skills/archify/examples/locales/fr.partial.json +22 -0
- package/skills/archify/examples/locales/it.json +425 -0
- package/skills/archify/examples/locales/ja.json +425 -0
- package/skills/archify/examples/locales/ko.json +440 -0
- package/skills/archify/examples/locales/pt.json +425 -0
- package/skills/archify/examples/locales/ru.json +425 -0
- package/skills/archify/examples/product-analytics.dataflow.json +71 -0
- package/skills/archify/examples/production-deployment.architecture.json +51 -0
- package/skills/archify/examples/release-delivery.workflow.json +57 -0
- package/skills/archify/examples/sequence-cache-miss-request.html +13835 -0
- package/skills/archify/examples/web-app-rendered.html +13808 -0
- package/skills/archify/examples/web-app.architecture.json +41 -0
- package/skills/archify/examples/workflow-agent-tool-call-rendered.html +13826 -0
- package/skills/archify/migrations/workflow-v2.mjs +280 -0
- package/skills/archify/package.json +17 -0
- package/skills/archify/recipes/scenarios.mjs +430 -0
- package/skills/archify/references/architecture-layout-repair.md +18 -0
- package/skills/archify/references/authoring-contract.md +419 -0
- package/skills/archify/references/authoring-defaults.md +41 -0
- package/skills/archify/references/brand-marks.md +82 -0
- package/skills/archify/references/delivery-contract.md +588 -0
- package/skills/archify/references/repository-authoring.md +105 -0
- package/skills/archify/references/update-awareness.md +12 -0
- package/skills/archify/references/viewer-runtime.md +35 -0
- package/skills/archify/renderers/architecture/grid.mjs +62 -0
- package/skills/archify/renderers/architecture/labels.mjs +215 -0
- package/skills/archify/renderers/architecture/render-architecture.mjs +1001 -0
- package/skills/archify/renderers/architecture/routing.mjs +1157 -0
- package/skills/archify/renderers/dataflow/README.md +105 -0
- package/skills/archify/renderers/dataflow/render-dataflow.mjs +544 -0
- package/skills/archify/renderers/lifecycle/README.md +171 -0
- package/skills/archify/renderers/lifecycle/grid-routing.mjs +429 -0
- package/skills/archify/renderers/lifecycle/render-lifecycle.mjs +938 -0
- package/skills/archify/renderers/sequence/README.md +129 -0
- package/skills/archify/renderers/sequence/render-sequence.mjs +526 -0
- package/skills/archify/renderers/shared/atomic-output.mjs +1982 -0
- package/skills/archify/renderers/shared/brand-marks.mjs +670 -0
- package/skills/archify/renderers/shared/cli.mjs +451 -0
- package/skills/archify/renderers/shared/desktop-readability.mjs +132 -0
- package/skills/archify/renderers/shared/diagnostics.mjs +173 -0
- package/skills/archify/renderers/shared/engineering-profiles.mjs +157 -0
- package/skills/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
- package/skills/archify/renderers/shared/generated-validators.mjs +134 -0
- package/skills/archify/renderers/shared/geometry.mjs +1941 -0
- package/skills/archify/renderers/shared/i18n.mjs +603 -0
- package/skills/archify/renderers/shared/layout-report.mjs +42 -0
- package/skills/archify/renderers/shared/legend.mjs +217 -0
- package/skills/archify/renderers/shared/output-path.mjs +440 -0
- package/skills/archify/renderers/shared/path-semantics.mjs +1017 -0
- package/skills/archify/renderers/shared/portable-path.mjs +408 -0
- package/skills/archify/renderers/shared/repository-evidence.mjs +352 -0
- package/skills/archify/renderers/shared/repository-location.mjs +59 -0
- package/skills/archify/renderers/shared/route-quality.mjs +614 -0
- package/skills/archify/renderers/shared/sidecar-path.mjs +38 -0
- package/skills/archify/renderers/shared/spatial-grid.mjs +66 -0
- package/skills/archify/renderers/shared/text-fit.mjs +105 -0
- package/skills/archify/renderers/shared/utils.mjs +254 -0
- package/skills/archify/renderers/shared/validator.mjs +86 -0
- package/skills/archify/renderers/workflow/README.md +301 -0
- package/skills/archify/renderers/workflow/render-workflow.mjs +37 -0
- package/skills/archify/renderers/workflow/workflow-compiler.mjs +4913 -0
- package/skills/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
- package/skills/archify/schemas/README.md +256 -0
- package/skills/archify/schemas/architecture.schema.json +152 -0
- package/skills/archify/schemas/common.schema.json +181 -0
- package/skills/archify/schemas/dataflow.schema.json +254 -0
- package/skills/archify/schemas/lifecycle.schema.json +280 -0
- package/skills/archify/schemas/sequence.schema.json +234 -0
- package/skills/archify/schemas/workflow.schema.json +439 -0
- package/skills/archify/scripts/check-render-output.mjs +1388 -0
- package/skills/archify/scripts/check-update.mjs +1710 -0
- package/skills/archify/scripts/delivery-update-child.mjs +23 -0
- package/skills/archify/scripts/render-examples.mjs +26 -0
- package/skills/archify/scripts/update-contract.mjs +182 -0
- package/skills/archify/skill-release.json +10 -0
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { applyTemplate, renderCards, esc } from './utils.mjs';
|
|
5
|
+
import { validateSchema } from './validator.mjs';
|
|
6
|
+
import { verifyRepositoryEvidence } from './repository-evidence.mjs';
|
|
7
|
+
import { installRendererDiagnosticBoundary, throwDiagnosticError, throwDiagnosticProblems, recordDiagnostic } from './diagnostics.mjs';
|
|
8
|
+
import { validateEngineeringProfile } from './engineering-profiles.mjs';
|
|
9
|
+
import {
|
|
10
|
+
resolveOutputPath,
|
|
11
|
+
validateAuthoredOutputPath,
|
|
12
|
+
} from './output-path.mjs';
|
|
13
|
+
import {
|
|
14
|
+
captureAtomicOutput,
|
|
15
|
+
captureRegularFileBinding,
|
|
16
|
+
publishRegularFileBinding,
|
|
17
|
+
releaseRegularFileBinding,
|
|
18
|
+
removeOwnedRegularFile,
|
|
19
|
+
verifyAtomicOutput,
|
|
20
|
+
} from './atomic-output.mjs';
|
|
21
|
+
import { resolveLocale, translateMessage, registerLocale, SUPPORTED_LOCALES } from './i18n.mjs';
|
|
22
|
+
import { prepareDiagramBrandMarks } from './brand-marks.mjs';
|
|
23
|
+
|
|
24
|
+
const outputPathGuards = new Map();
|
|
25
|
+
let renderCandidateSequence = 0;
|
|
26
|
+
|
|
27
|
+
// meta.locale is renderer-owned Viewer UI, not authored content.
|
|
28
|
+
// en and zh-CN ship as built-in catalogs.
|
|
29
|
+
// Any other tag needs meta.translations (validated against the English
|
|
30
|
+
// message-key set, layered over English per-key so partial/invalid entries
|
|
31
|
+
// never break rendering) or it falls back to the English Viewer chrome —
|
|
32
|
+
// the same "omit locale, disclose the fallback" contract as before, just
|
|
33
|
+
// resolved from data instead of a hard-coded enum. See i18n.mjs.
|
|
34
|
+
function applyLocaleTranslations(diagramType, diagram) {
|
|
35
|
+
const locale = diagram.meta?.locale;
|
|
36
|
+
if (!locale) return;
|
|
37
|
+
const translations = diagram.meta?.translations;
|
|
38
|
+
if (translations && Object.keys(translations).length) {
|
|
39
|
+
const report = registerLocale(locale, translations);
|
|
40
|
+
if (report.missingKeys.length || report.unknownKeys.length || report.placeholderMismatches.length) {
|
|
41
|
+
recordDiagnostic({
|
|
42
|
+
code: 'i18n/translation-coverage',
|
|
43
|
+
severity: 'warning',
|
|
44
|
+
message: `meta.translations for locale ${JSON.stringify(locale)} covers ${report.coveredKeys}/${report.totalKeys} renderer-owned messages (${Math.round(report.coverage * 100)}%); uncovered keys fall back to English.`,
|
|
45
|
+
subject: { diagramType, path: '/meta/translations' },
|
|
46
|
+
evidence: {
|
|
47
|
+
missingKeys: report.missingKeys.slice(0, 10),
|
|
48
|
+
missingKeysTotal: report.missingKeys.length,
|
|
49
|
+
unknownKeys: report.unknownKeys.slice(0, 10),
|
|
50
|
+
unknownKeysTotal: report.unknownKeys.length,
|
|
51
|
+
placeholderMismatches: report.placeholderMismatches.slice(0, 10),
|
|
52
|
+
placeholderMismatchesTotal: report.placeholderMismatches.length,
|
|
53
|
+
},
|
|
54
|
+
supportedFixes: ['Add the missing keys to meta.translations.', 'Match each translation\'s {placeholders} to the English source string.'],
|
|
55
|
+
});
|
|
56
|
+
// Coverage is a fact about this render, not just a diagnostic-mode
|
|
57
|
+
// artifact: print it to stderr unconditionally so `render`/`deliver`/
|
|
58
|
+
// `validate` disclose the fallback even without ARCHIFY_DIAGNOSTIC_FORMAT.
|
|
59
|
+
console.warn(`archify: meta.translations for locale ${JSON.stringify(locale)} covers ${report.coveredKeys}/${report.totalKeys} renderer-owned messages (${Math.round(report.coverage * 100)}%); uncovered keys fall back to English.`);
|
|
60
|
+
}
|
|
61
|
+
} else if (!SUPPORTED_LOCALES.includes(locale)) {
|
|
62
|
+
recordDiagnostic({
|
|
63
|
+
code: 'i18n/locale-fallback',
|
|
64
|
+
severity: 'warning',
|
|
65
|
+
message: `meta.locale ${JSON.stringify(locale)} has no built-in catalog and no meta.translations; the Viewer chrome and <html lang> fall back to English.`,
|
|
66
|
+
subject: { diagramType, path: '/meta/locale' },
|
|
67
|
+
supportedFixes: ['Supply meta.translations for this locale.', `Use a built-in locale: ${SUPPORTED_LOCALES.join(', ')}.`],
|
|
68
|
+
});
|
|
69
|
+
console.warn(`archify: meta.locale ${JSON.stringify(locale)} has no built-in catalog and no meta.translations; the Viewer chrome and <html lang> fall back to English.`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Common CLI head: node render-<type>.mjs [input.json] [output.html]
|
|
74
|
+
// Keep this synchronous because callers also use it to establish the guarded
|
|
75
|
+
// output path before testing a last-moment filesystem alias change.
|
|
76
|
+
export function loadDiagram({ rendererDir, diagramType, defaultExample, argv = process.argv }) {
|
|
77
|
+
// Compilers also import this module for SVG helpers. Only CLI execution
|
|
78
|
+
// should install a process-level handler, before reading or validating input.
|
|
79
|
+
installRendererDiagnosticBoundary();
|
|
80
|
+
const skillRoot = path.resolve(rendererDir, '../..');
|
|
81
|
+
const inputPath = path.resolve(argv[2] || path.join(skillRoot, 'examples', defaultExample));
|
|
82
|
+
let input;
|
|
83
|
+
try {
|
|
84
|
+
input = fs.readFileSync(inputPath, 'utf8');
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (!isFilesystemError(error)) throw error;
|
|
87
|
+
const message = `Input could not be read: ${error.message}`;
|
|
88
|
+
throwDiagnosticError(message, [{
|
|
89
|
+
code: 'input/read', message,
|
|
90
|
+
subject: { input: inputPath },
|
|
91
|
+
evidence: { systemCode: error.code, reason: error.message },
|
|
92
|
+
supportedFixes: ['provide one readable JSON input file'],
|
|
93
|
+
}]);
|
|
94
|
+
}
|
|
95
|
+
let diagram;
|
|
96
|
+
try {
|
|
97
|
+
diagram = JSON.parse(input);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (!(error instanceof SyntaxError)) throw error;
|
|
100
|
+
const message = `Input JSON could not be parsed: ${error.message}`;
|
|
101
|
+
throwDiagnosticError(message, [{
|
|
102
|
+
code: 'input/json-parse', message,
|
|
103
|
+
subject: { input: inputPath },
|
|
104
|
+
evidence: { reason: error.message },
|
|
105
|
+
supportedFixes: ['repair the JSON syntax and run validation again'],
|
|
106
|
+
}]);
|
|
107
|
+
}
|
|
108
|
+
const authoredOutput = diagram?.meta?.output;
|
|
109
|
+
if (authoredOutput !== undefined) validateAuthoredOutputPath(authoredOutput);
|
|
110
|
+
validateSchema(diagramType, diagram);
|
|
111
|
+
applyLocaleTranslations(diagramType, diagram);
|
|
112
|
+
validateCrossCollectionContracts(diagramType, diagram);
|
|
113
|
+
validateEngineeringProfile(diagramType, diagram);
|
|
114
|
+
const sourceEvidence = verifyRepositoryEvidence(diagramType, diagram, process.env.ARCHIFY_REPO_ROOT);
|
|
115
|
+
const template = fs.readFileSync(path.join(skillRoot, 'assets/template.html'), 'utf8');
|
|
116
|
+
const outputRequest = {
|
|
117
|
+
requestedOutput: argv[3],
|
|
118
|
+
authoredOutput: diagram.meta?.output,
|
|
119
|
+
defaultOutput: `${diagramType}.html`,
|
|
120
|
+
inputPaths: [inputPath],
|
|
121
|
+
cwd: process.cwd(),
|
|
122
|
+
};
|
|
123
|
+
let outPath;
|
|
124
|
+
try {
|
|
125
|
+
({ outputPath: outPath } = resolveOutputPath(outputRequest));
|
|
126
|
+
} catch (error) {
|
|
127
|
+
throwOutputError(error, path.resolve(outputRequest.requestedOutput || outputRequest.authoredOutput || outputRequest.defaultOutput));
|
|
128
|
+
}
|
|
129
|
+
outputPathGuards.set(outPath, outputRequest);
|
|
130
|
+
return { diagram, template, outPath, sourceEvidence };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Brand URL capture is the only asynchronous authoring step. Typed renderers
|
|
134
|
+
// opt into it through this wrapper without changing loadDiagram's long-lived
|
|
135
|
+
// synchronous safety contract.
|
|
136
|
+
export async function loadDiagramWithBrandMarks(options) {
|
|
137
|
+
const loaded = loadDiagram(options);
|
|
138
|
+
await prepareDiagramBrandMarks(options.diagramType, loaded.diagram);
|
|
139
|
+
return loaded;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const START_TYPES = new Set(['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle']);
|
|
143
|
+
|
|
144
|
+
function isFilesystemError(error) {
|
|
145
|
+
return typeof error?.code === 'string'
|
|
146
|
+
&& typeof error?.syscall === 'string'
|
|
147
|
+
&& typeof error?.errno === 'number';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function throwOutputError(error, output) {
|
|
151
|
+
if (error?.archifyDiagnostics || !isFilesystemError(error)) throw error;
|
|
152
|
+
const message = `Output could not be written: ${error.message}`;
|
|
153
|
+
throwDiagnosticError(message, [{
|
|
154
|
+
code: 'output/write', message,
|
|
155
|
+
subject: { output },
|
|
156
|
+
evidence: { systemCode: error.code, reason: error.message },
|
|
157
|
+
supportedFixes: ['choose a writable HTML file path and ensure its parent directories can be created'],
|
|
158
|
+
}]);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function throwAtomicOutputFailure(result, output) {
|
|
162
|
+
const reason = result.reason || { code: 'unclassified' };
|
|
163
|
+
const changed = result.status === 'different';
|
|
164
|
+
const candidate = reason.code.startsWith('candidate-');
|
|
165
|
+
const nonRegular = ['target-not-regular-file', 'candidate-not-regular-file'].includes(reason.code);
|
|
166
|
+
const hardlinked = ['target-hardlinked', 'candidate-hardlinked'].includes(reason.code);
|
|
167
|
+
const message = changed
|
|
168
|
+
? 'Output target changed while the rendered artifact was being prepared.'
|
|
169
|
+
: nonRegular
|
|
170
|
+
? candidate
|
|
171
|
+
? 'Temporary output candidate is no longer a regular file.'
|
|
172
|
+
: 'Output already exists and is not a regular file.'
|
|
173
|
+
: hardlinked
|
|
174
|
+
? candidate
|
|
175
|
+
? 'Temporary output candidate has multiple hard-link names.'
|
|
176
|
+
: 'Output already exists through multiple hard-link names.'
|
|
177
|
+
: 'Output target stability could not be determined safely before commit.';
|
|
178
|
+
throwDiagnosticError(message, [{
|
|
179
|
+
code: changed
|
|
180
|
+
? 'output/target-changed'
|
|
181
|
+
: nonRegular
|
|
182
|
+
? 'output/target-not-regular-file'
|
|
183
|
+
: hardlinked
|
|
184
|
+
? 'output/target-hardlinked'
|
|
185
|
+
: 'output/target-indeterminate',
|
|
186
|
+
message,
|
|
187
|
+
subject: { output },
|
|
188
|
+
evidence: { relation: reason },
|
|
189
|
+
supportedFixes: [hardlinked
|
|
190
|
+
? 'choose a non-hardlinked output path; atomic replacement cannot update every hard-link name'
|
|
191
|
+
: 'retry after other processes stop replacing or redirecting the output path'],
|
|
192
|
+
}]);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function stageRenderedHtml(outputPath, html, mode) {
|
|
196
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
197
|
+
renderCandidateSequence += 1;
|
|
198
|
+
const candidatePath = path.join(
|
|
199
|
+
path.dirname(outputPath),
|
|
200
|
+
`.archify-render-${process.pid}-${Date.now().toString(36)}-${renderCandidateSequence}.tmp`,
|
|
201
|
+
);
|
|
202
|
+
let descriptor;
|
|
203
|
+
let identity;
|
|
204
|
+
try {
|
|
205
|
+
const noFollow = process.platform === 'win32' ? 0 : (fs.constants.O_NOFOLLOW || 0);
|
|
206
|
+
descriptor = fs.openSync(
|
|
207
|
+
candidatePath,
|
|
208
|
+
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | noFollow,
|
|
209
|
+
mode ?? 0o666,
|
|
210
|
+
);
|
|
211
|
+
let metadata;
|
|
212
|
+
try {
|
|
213
|
+
metadata = fs.fstatSync(descriptor, { bigint: true });
|
|
214
|
+
} catch (error) {
|
|
215
|
+
// A transient first inspection failure must not strand the exclusive
|
|
216
|
+
// candidate. A successful retry binds cleanup to the still-open file;
|
|
217
|
+
// if both inspections fail, preserving the unknown entry is safer.
|
|
218
|
+
try {
|
|
219
|
+
const retry = fs.fstatSync(descriptor, { bigint: true });
|
|
220
|
+
if (retry.isFile() && retry.ino !== 0n) {
|
|
221
|
+
identity = { device: retry.dev, inode: retry.ino };
|
|
222
|
+
}
|
|
223
|
+
} catch {}
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
if (!metadata.isFile() || metadata.ino === 0n) {
|
|
227
|
+
throw new Error('Temporary render candidate identity could not be verified safely.');
|
|
228
|
+
}
|
|
229
|
+
identity = { device: metadata.dev, inode: metadata.ino };
|
|
230
|
+
fs.writeFileSync(descriptor, html);
|
|
231
|
+
// Creation modes are filtered through the process umask. An atomic
|
|
232
|
+
// replacement must retain the exact permissions of an existing target,
|
|
233
|
+
// while a brand-new target should keep normal umask behavior.
|
|
234
|
+
if (mode !== null) fs.fchmodSync(descriptor, mode);
|
|
235
|
+
fs.closeSync(descriptor);
|
|
236
|
+
descriptor = undefined;
|
|
237
|
+
return { candidatePath, identity };
|
|
238
|
+
} catch (error) {
|
|
239
|
+
if (descriptor !== undefined) {
|
|
240
|
+
try { fs.closeSync(descriptor); } catch {}
|
|
241
|
+
}
|
|
242
|
+
if (error.code === 'EEXIST') continue;
|
|
243
|
+
if (identity) {
|
|
244
|
+
const cleanup = removeOwnedRegularFile(candidatePath, identity);
|
|
245
|
+
if (!['removed', 'absent', 'preserved'].includes(cleanup.status)) {
|
|
246
|
+
const cleanupError = new Error(`${error.message}; temporary render candidate cleanup also failed.`);
|
|
247
|
+
cleanupError.cause = error;
|
|
248
|
+
throw cleanupError;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const error = new Error(`Could not reserve a temporary render candidate beside "${outputPath}".`);
|
|
255
|
+
error.code = 'EEXIST';
|
|
256
|
+
error.errno = -17;
|
|
257
|
+
error.syscall = 'open';
|
|
258
|
+
throw error;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Common CLI tail: fill the template and write the standalone HTML file.
|
|
262
|
+
export function writeDiagram({ outPath, template, diagramType, meta, svg, cards, sourceEvidence = null }) {
|
|
263
|
+
if (!START_TYPES.has(diagramType)) throw new Error(`writeDiagram: unknown diagram type ${JSON.stringify(diagramType)}`);
|
|
264
|
+
const outputGuard = outputPathGuards.get(outPath);
|
|
265
|
+
const html = applyTemplate(template, {
|
|
266
|
+
title: meta.title,
|
|
267
|
+
subtitle: meta.subtitle,
|
|
268
|
+
svg,
|
|
269
|
+
cards: renderCards(cards),
|
|
270
|
+
locale: meta.locale,
|
|
271
|
+
visualPreset: meta.visual_preset || 'classic',
|
|
272
|
+
sourceEvidence,
|
|
273
|
+
});
|
|
274
|
+
let candidatePath;
|
|
275
|
+
let candidateIdentity;
|
|
276
|
+
let candidateBinding;
|
|
277
|
+
try {
|
|
278
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
279
|
+
const outputCapture = captureAtomicOutput(outPath);
|
|
280
|
+
if (outputCapture.status !== 'captured') throwAtomicOutputFailure(outputCapture, outPath);
|
|
281
|
+
const beforeStage = verifyAtomicOutput(outputCapture.snapshot);
|
|
282
|
+
if (beforeStage.status !== 'match') throwAtomicOutputFailure(beforeStage, outPath);
|
|
283
|
+
({ candidatePath, identity: candidateIdentity } = stageRenderedHtml(
|
|
284
|
+
outputCapture.commitPath,
|
|
285
|
+
html,
|
|
286
|
+
outputCapture.mode,
|
|
287
|
+
));
|
|
288
|
+
|
|
289
|
+
// The renderer may spend substantial time building HTML after loadDiagram
|
|
290
|
+
// establishes the guard. Re-run it after staging so a last-moment alias
|
|
291
|
+
// cannot redirect the commit onto an input file.
|
|
292
|
+
if (outputGuard) resolveOutputPath(outputGuard);
|
|
293
|
+
const candidateCapture = captureRegularFileBinding(candidatePath, {
|
|
294
|
+
subject: 'candidate',
|
|
295
|
+
expectedSha256: createHash('sha256').update(html).digest('hex'),
|
|
296
|
+
expectedBytes: Buffer.byteLength(html),
|
|
297
|
+
expectedIdentity: candidateIdentity,
|
|
298
|
+
...(outputCapture.mode === null ? {} : { expectedMode: outputCapture.mode }),
|
|
299
|
+
});
|
|
300
|
+
if (candidateCapture.status !== 'captured') throwAtomicOutputFailure(candidateCapture, outPath);
|
|
301
|
+
candidateBinding = candidateCapture.binding;
|
|
302
|
+
const beforeCommit = verifyAtomicOutput(outputCapture.snapshot);
|
|
303
|
+
if (beforeCommit.status !== 'match') throwAtomicOutputFailure(beforeCommit, outPath);
|
|
304
|
+
const publication = publishRegularFileBinding(
|
|
305
|
+
candidateBinding,
|
|
306
|
+
candidatePath,
|
|
307
|
+
outputCapture.snapshot,
|
|
308
|
+
{ subject: 'candidate' },
|
|
309
|
+
);
|
|
310
|
+
if (!['committed', 'committed-with-warning'].includes(publication.status)) {
|
|
311
|
+
throwAtomicOutputFailure(publication, outPath);
|
|
312
|
+
}
|
|
313
|
+
const releasedCandidate = releaseRegularFileBinding(candidateBinding);
|
|
314
|
+
candidateBinding = undefined;
|
|
315
|
+
if (releasedCandidate.status !== 'released') throwAtomicOutputFailure(releasedCandidate, outPath);
|
|
316
|
+
candidatePath = undefined;
|
|
317
|
+
candidateIdentity = undefined;
|
|
318
|
+
} catch (error) {
|
|
319
|
+
throwOutputError(error, outPath);
|
|
320
|
+
} finally {
|
|
321
|
+
outputPathGuards.delete(outPath);
|
|
322
|
+
if (candidateBinding) releaseRegularFileBinding(candidateBinding);
|
|
323
|
+
if (candidatePath && candidateIdentity) {
|
|
324
|
+
const cleanup = removeOwnedRegularFile(candidatePath, candidateIdentity);
|
|
325
|
+
if (!['removed', 'absent', 'preserved'].includes(cleanup.status)) {
|
|
326
|
+
throwAtomicOutputFailure(cleanup, outPath);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
console.log(outPath);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const SEMANTIC_COLLECTIONS = {
|
|
334
|
+
architecture: 'components',
|
|
335
|
+
workflow: 'nodes',
|
|
336
|
+
sequence: 'participants',
|
|
337
|
+
dataflow: 'nodes',
|
|
338
|
+
lifecycle: 'states',
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const RELATIONSHIP_COLLECTIONS = {
|
|
342
|
+
architecture: 'connections',
|
|
343
|
+
workflow: 'edges',
|
|
344
|
+
sequence: 'messages',
|
|
345
|
+
dataflow: 'flows',
|
|
346
|
+
lifecycle: 'transitions',
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
// Relationship IDs are optional for backwards compatibility, but once an
|
|
350
|
+
// author supplies one it becomes the durable identity used by viewer links.
|
|
351
|
+
// Keep uniqueness enforcement in the shared zero-install path so every typed
|
|
352
|
+
// renderer fails the same way even when development dependencies are absent.
|
|
353
|
+
export function validateRelationshipIds(diagramType, diagram) {
|
|
354
|
+
const collection = RELATIONSHIP_COLLECTIONS[diagramType];
|
|
355
|
+
const relationships = collection && Array.isArray(diagram[collection]) ? diagram[collection] : [];
|
|
356
|
+
const seen = new Set();
|
|
357
|
+
const problems = [];
|
|
358
|
+
|
|
359
|
+
relationships.forEach((relationship, index) => {
|
|
360
|
+
if (relationship.id === undefined || relationship.id === null || relationship.id === '') return;
|
|
361
|
+
if (seen.has(relationship.id)) {
|
|
362
|
+
problems.push(`/${collection}/${index}/id duplicates relationship id ${JSON.stringify(relationship.id)}`);
|
|
363
|
+
}
|
|
364
|
+
seen.add(relationship.id);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
if (problems.length) {
|
|
368
|
+
throwDiagnosticProblems('Relationship identity validation failed', problems, {
|
|
369
|
+
code: 'relationship/duplicate-id',
|
|
370
|
+
subject: { diagramType, collection },
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Share relationship-ID semantic checks between the loader
|
|
376
|
+
// and workflow compiler without performing filesystem operations (see #429).
|
|
377
|
+
export function validateCrossCollectionContracts(diagramType, diagram) {
|
|
378
|
+
validateRelationshipIds(diagramType, diagram);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Accessible name for the generated diagram SVG.
|
|
382
|
+
export function svgRootAttrs(meta, explicitQualityProfile) {
|
|
383
|
+
const animation = meta.animation === 'trace' ? ' data-animation="trace"' : '';
|
|
384
|
+
const preset = ` data-preset="${esc(meta.visual_preset || 'classic')}"`;
|
|
385
|
+
const engineeringProfile = meta.engineering_profile
|
|
386
|
+
? ` data-engineering-profile="${esc(meta.engineering_profile)}"`
|
|
387
|
+
: '';
|
|
388
|
+
const requestedProfile = explicitQualityProfile || process.env.ARCHIFY_QUALITY_PROFILE || meta.quality_profile;
|
|
389
|
+
const qualityProfile = requestedProfile === 'showcase' ? 'showcase' : 'standard';
|
|
390
|
+
const advisory = requestedProfile ? '' : ' data-quality-gates="advisory"';
|
|
391
|
+
return `role="img" lang="${esc(resolveLocale(meta.locale))}" aria-labelledby="archify-diagram-title archify-diagram-description"${animation}${preset}${engineeringProfile} data-quality-profile="${esc(qualityProfile)}"${advisory}`;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Keep the accessible name inside the SVG so it survives standalone SVG
|
|
395
|
+
// export and embedding. The fixed IDs are deterministic because an Archify
|
|
396
|
+
// artifact intentionally contains one primary diagram SVG.
|
|
397
|
+
export function svgAccessibleText(meta, kind) {
|
|
398
|
+
const description = meta.subtitle || translateMessage(meta.locale, `diagram.description.${kind}`);
|
|
399
|
+
return ` <title id="archify-diagram-title">${esc(meta.title)}</title>\n <desc id="archify-diagram-description">${esc(description)}</desc>`;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export function animateAttr(meta, kind, step) {
|
|
403
|
+
if (meta.animation !== 'trace') return '';
|
|
404
|
+
// Ambient trace must finish inside the fixed six-second WebM capture. The
|
|
405
|
+
// cap affects visual delay only; authored order and semantic identity stay
|
|
406
|
+
// untouched in the JSON, DOM, and relationship contracts.
|
|
407
|
+
const safeStep = Number.isFinite(step) && step >= 0 ? Math.min(12, Math.floor(step)) : 0;
|
|
408
|
+
return ` data-animate="${kind}" style="--step:${safeStep}"`;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Stable semantic hooks for the standalone HTML explorer. IDs already pass
|
|
412
|
+
// the schema's conservative identifier pattern; escape again at the markup
|
|
413
|
+
// boundary so these helpers remain safe if that contract expands later.
|
|
414
|
+
export function focusNodeAttrs(id, label, metadata = {}, locale) {
|
|
415
|
+
const optional = [
|
|
416
|
+
['data-node-kind', metadata.kind],
|
|
417
|
+
['data-node-sublabel', metadata.sublabel],
|
|
418
|
+
['data-node-tag', metadata.tag],
|
|
419
|
+
['data-node-context', metadata.context],
|
|
420
|
+
['data-node-brand', metadata.brand],
|
|
421
|
+
['data-node-brand-id', metadata.brandId],
|
|
422
|
+
['data-node-brand-status', metadata.brandStatus],
|
|
423
|
+
['data-node-brand-source', metadata.brandSource],
|
|
424
|
+
].filter(([, value]) => value !== undefined && value !== null && String(value).trim() !== '')
|
|
425
|
+
.map(([name, value]) => ` ${name}="${esc(String(value))}"`)
|
|
426
|
+
.join('');
|
|
427
|
+
const detail = [metadata.sublabel, metadata.context, metadata.brand]
|
|
428
|
+
.filter((value) => value !== undefined && value !== null && String(value).trim() !== '')
|
|
429
|
+
.join(', ');
|
|
430
|
+
const aria = detail
|
|
431
|
+
? translateMessage(locale, 'node.focus.detail', { label, detail })
|
|
432
|
+
: translateMessage(locale, 'node.focus', { label });
|
|
433
|
+
return `id="node-${esc(id)}" data-node-id="${esc(id)}" data-node-label="${esc(label)}" tabindex="0" role="button" aria-label="${esc(aria)}" aria-pressed="false"${optional}`;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Native SVG titles preserve a compact details-on-demand fallback when the
|
|
437
|
+
// canonical SVG is embedded inline outside the full Archify viewer.
|
|
438
|
+
export function focusNodeTitle(label, metadata = {}) {
|
|
439
|
+
const parts = [label, metadata.sublabel, metadata.context, metadata.tag, metadata.brand]
|
|
440
|
+
.filter((value) => value !== undefined && value !== null && String(value).trim() !== '');
|
|
441
|
+
return `<title>${esc(parts.join(' · '))}</title>`;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export function focusEdgeAttrs(from, to, label, key, id) {
|
|
445
|
+
const named = label ? ` data-edge-label="${esc(label)}"` : '';
|
|
446
|
+
const keyed = key !== undefined && key !== null ? ` data-edge-key="${esc(String(key))}"` : '';
|
|
447
|
+
const identified = id !== undefined && id !== null && String(id).trim() !== ''
|
|
448
|
+
? ` data-edge-id="${esc(String(id))}"`
|
|
449
|
+
: '';
|
|
450
|
+
return `data-edge-from="${esc(from)}" data-edge-to="${esc(to)}"${named}${keyed}${identified}`;
|
|
451
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
export const DESKTOP_READABILITY_VIEWPORT = Object.freeze({ width: 1440, height: 900 });
|
|
2
|
+
export const DESKTOP_READER_MIN_WIDTH = 960;
|
|
3
|
+
export const DESKTOP_READER_HORIZONTAL_CHROME = 30;
|
|
4
|
+
export const DESKTOP_READER_DIAGRAM_WIDTH = DESKTOP_READER_MIN_WIDTH - DESKTOP_READER_HORIZONTAL_CHROME;
|
|
5
|
+
export const MIN_PROJECTED_NODE_TEXT_PX = 6;
|
|
6
|
+
export const DECLARED_WIDE_READER_CONTRACT = 'declared-wide-v1';
|
|
7
|
+
export const DECLARED_WIDE_READER_RATIO = 1.55;
|
|
8
|
+
export const DECLARED_WIDE_READER_MAX_WIDTH = 1920;
|
|
9
|
+
export const DECLARED_WIDE_REFERENCE_BODY_HORIZONTAL_PX = 64;
|
|
10
|
+
export const DECLARED_WIDE_REFERENCE_DIAGRAM_HORIZONTAL_PX = 30;
|
|
11
|
+
|
|
12
|
+
export function projectedNodeTextPx(sourceFontPx, viewBoxWidth, diagramWidth = DESKTOP_READER_DIAGRAM_WIDTH) {
|
|
13
|
+
if (![sourceFontPx, viewBoxWidth, diagramWidth].every(Number.isFinite) || viewBoxWidth <= 0 || diagramWidth <= 0) {
|
|
14
|
+
return Number.NaN;
|
|
15
|
+
}
|
|
16
|
+
return sourceFontPx * Math.min(1, diagramWidth / viewBoxWidth);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function minimumReadableSourceTextPx(
|
|
20
|
+
viewBoxWidth,
|
|
21
|
+
diagramWidth = DESKTOP_READER_DIAGRAM_WIDTH,
|
|
22
|
+
minimumProjectedPx = MIN_PROJECTED_NODE_TEXT_PX,
|
|
23
|
+
) {
|
|
24
|
+
if (![viewBoxWidth, diagramWidth, minimumProjectedPx].every(Number.isFinite)
|
|
25
|
+
|| viewBoxWidth <= 0
|
|
26
|
+
|| diagramWidth <= 0
|
|
27
|
+
|| minimumProjectedPx <= 0) {
|
|
28
|
+
return Number.NaN;
|
|
29
|
+
}
|
|
30
|
+
return minimumProjectedPx / Math.min(1, diagramWidth / viewBoxWidth);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// This is deliberately separate from the legacy 930px projection. Architecture
|
|
34
|
+
// boundary convergence depends on that legacy default, while only a recognized
|
|
35
|
+
// v2 wide Reader may use this declared-width proof.
|
|
36
|
+
export function declaredWideReadabilityBudget({
|
|
37
|
+
viewBoxWidth,
|
|
38
|
+
viewBoxHeight,
|
|
39
|
+
minimumSourceTextPx,
|
|
40
|
+
requestedMinimumTextPx,
|
|
41
|
+
viewportWidth = DESKTOP_READABILITY_VIEWPORT.width,
|
|
42
|
+
bodyHorizontalPx = DECLARED_WIDE_REFERENCE_BODY_HORIZONTAL_PX,
|
|
43
|
+
diagramHorizontalPx = DECLARED_WIDE_REFERENCE_DIAGRAM_HORIZONTAL_PX,
|
|
44
|
+
minimumReaderWidth = DESKTOP_READER_MIN_WIDTH,
|
|
45
|
+
maximumReaderWidth = DECLARED_WIDE_READER_MAX_WIDTH,
|
|
46
|
+
} = {}) {
|
|
47
|
+
const values = [
|
|
48
|
+
viewBoxWidth, viewBoxHeight, minimumSourceTextPx, requestedMinimumTextPx,
|
|
49
|
+
viewportWidth, bodyHorizontalPx, diagramHorizontalPx, minimumReaderWidth, maximumReaderWidth,
|
|
50
|
+
];
|
|
51
|
+
if (!values.every(Number.isFinite) || viewBoxWidth <= 0 || viewBoxHeight <= 0
|
|
52
|
+
|| minimumSourceTextPx <= 0 || requestedMinimumTextPx <= 0 || viewportWidth <= 0
|
|
53
|
+
|| bodyHorizontalPx < 0 || diagramHorizontalPx < 0 || minimumReaderWidth <= 0
|
|
54
|
+
|| maximumReaderWidth < minimumReaderWidth || viewBoxWidth / viewBoxHeight < DECLARED_WIDE_READER_RATIO) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const requestedTargetPx = Math.max(MIN_PROJECTED_NODE_TEXT_PX, requestedMinimumTextPx);
|
|
58
|
+
const requestedScale = Math.min(1, requestedTargetPx / minimumSourceTextPx);
|
|
59
|
+
const desiredReaderWidth = Math.max(minimumReaderWidth, viewBoxWidth * requestedScale + diagramHorizontalPx);
|
|
60
|
+
const viewportCap = Math.max(0, viewportWidth - bodyHorizontalPx);
|
|
61
|
+
const cap = Math.min(maximumReaderWidth, viewportCap);
|
|
62
|
+
const actualReaderWidth = Math.min(desiredReaderWidth, cap);
|
|
63
|
+
const guaranteedSvgWidth = Math.max(0, actualReaderWidth - diagramHorizontalPx);
|
|
64
|
+
const projectedMinimumTextPx = projectedNodeTextPx(minimumSourceTextPx, viewBoxWidth, guaranteedSvgWidth);
|
|
65
|
+
const limit = actualReaderWidth < desiredReaderWidth
|
|
66
|
+
? (viewportCap <= maximumReaderWidth ? 'viewport-cap' : 'reader-cap')
|
|
67
|
+
: 'source-size';
|
|
68
|
+
return {
|
|
69
|
+
requestedTargetPx,
|
|
70
|
+
requestedScale,
|
|
71
|
+
desiredReaderWidth,
|
|
72
|
+
viewportCap,
|
|
73
|
+
maximumReaderWidth,
|
|
74
|
+
actualReaderWidth,
|
|
75
|
+
guaranteedSvgWidth,
|
|
76
|
+
projectedMinimumTextPx,
|
|
77
|
+
hardFloorPx: MIN_PROJECTED_NODE_TEXT_PX,
|
|
78
|
+
hardFloorMet: projectedMinimumTextPx >= MIN_PROJECTED_NODE_TEXT_PX,
|
|
79
|
+
requestedTargetMet: projectedMinimumTextPx >= requestedMinimumTextPx,
|
|
80
|
+
limit,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Vertical chrome that always stacks with the SVG at the 1440x900 desktop
|
|
85
|
+
// viewport, measured from the delivered Viewer with the shortest one-line
|
|
86
|
+
// header and no cards: body padding 12, header 39, diagram padding/border 75.
|
|
87
|
+
// Cards are excluded so the prediction stays a lower bound.
|
|
88
|
+
export const DESKTOP_FIXED_VERTICAL_CHROME_PX = Object.freeze({ body: 12, header: 39, diagram: 75 });
|
|
89
|
+
|
|
90
|
+
// A canvas the Reader can neither narrow (viewBox ratio below the wide
|
|
91
|
+
// threshold) nor scroll readably (no intrinsic-height fit) renders at the full
|
|
92
|
+
// reader width, so its page height is a function of the viewBox alone. Returns
|
|
93
|
+
// null when the Reader has a way to fit the page; otherwise the certain
|
|
94
|
+
// overflow at 1440x900 before any cards are counted.
|
|
95
|
+
export function predictedFixedWidthOverflow({
|
|
96
|
+
viewBoxWidth,
|
|
97
|
+
viewBoxHeight,
|
|
98
|
+
readerFit,
|
|
99
|
+
diagramType,
|
|
100
|
+
viewport = DESKTOP_READABILITY_VIEWPORT,
|
|
101
|
+
bodyHorizontalPx = DECLARED_WIDE_REFERENCE_BODY_HORIZONTAL_PX,
|
|
102
|
+
diagramHorizontalPx = DECLARED_WIDE_REFERENCE_DIAGRAM_HORIZONTAL_PX,
|
|
103
|
+
chrome = DESKTOP_FIXED_VERTICAL_CHROME_PX,
|
|
104
|
+
} = {}) {
|
|
105
|
+
if (![viewBoxWidth, viewBoxHeight].every(Number.isFinite) || viewBoxWidth <= 0 || viewBoxHeight <= 0) return null;
|
|
106
|
+
const ratio = viewBoxWidth / viewBoxHeight;
|
|
107
|
+
if (readerFit === 'intrinsic-height'
|
|
108
|
+
|| (readerFit === 'authored-height' && diagramType === 'architecture')
|
|
109
|
+
|| ratio >= DECLARED_WIDE_READER_RATIO) return null;
|
|
110
|
+
const svgWidthPx = viewport.width - bodyHorizontalPx - diagramHorizontalPx;
|
|
111
|
+
const svgHeightPx = Math.round(svgWidthPx * viewBoxHeight / viewBoxWidth);
|
|
112
|
+
const fixedChromePx = chrome.body + chrome.header + chrome.diagram;
|
|
113
|
+
const pageHeightPx = svgHeightPx + fixedChromePx;
|
|
114
|
+
if (pageHeightPx <= viewport.height) return null;
|
|
115
|
+
return {
|
|
116
|
+
viewportWidth: viewport.width,
|
|
117
|
+
viewportHeight: viewport.height,
|
|
118
|
+
ratio: Math.round(ratio * 100) / 100,
|
|
119
|
+
wideRatio: DECLARED_WIDE_READER_RATIO,
|
|
120
|
+
svgWidthPx,
|
|
121
|
+
svgHeightPx,
|
|
122
|
+
fixedChromePx,
|
|
123
|
+
pageHeightPx,
|
|
124
|
+
overflowPx: pageHeightPx - viewport.height,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function describeFixedWidthOverflow(issue) {
|
|
129
|
+
const maximumViewBoxHeight = Math.floor(issue.viewBoxWidth / issue.wideRatio);
|
|
130
|
+
const wideViewBoxWidth = Math.ceil(issue.viewBoxHeight * issue.wideRatio);
|
|
131
|
+
return `Preserve every node, relationship, and label. This ${issue.viewBoxWidth}x${issue.viewBoxHeight} canvas (ratio ${issue.ratio}) declares no intrinsic-height fit and is below the ${issue.wideRatio} wide ratio, so the desktop Reader can neither narrow it nor accept vertical scroll: it renders ${issue.svgHeightPx}px tall at the full ${issue.svgWidthPx}px width and the page reaches ${issue.pageHeightPx}px before cards against ${issue.viewportHeight}px, a certain visual-check failure. Either compact vertical spacing so meta.viewBox height is at most ${maximumViewBoxHeight} at this width, or spread content sideways so the width is at least ${wideViewBoxWidth} at this height; for architecture, omitting meta.viewBox lets the renderer size the canvas and declare the fit.`;
|
|
132
|
+
}
|