astro-archify 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +169 -0
  3. package/astro-archify-integration.d.ts +100 -0
  4. package/astro-archify-integration.js +0 -0
  5. package/package.json +64 -0
  6. package/vendor/archify/LICENSE +22 -0
  7. package/vendor/archify/NOTICE.md +48 -0
  8. package/vendor/archify/assets/template.html +14787 -0
  9. package/vendor/archify/renderers/architecture/grid.mjs +62 -0
  10. package/vendor/archify/renderers/architecture/render-architecture.mjs +1089 -0
  11. package/vendor/archify/renderers/dataflow/render-dataflow.mjs +482 -0
  12. package/vendor/archify/renderers/lifecycle/render-lifecycle.mjs +570 -0
  13. package/vendor/archify/renderers/sequence/render-sequence.mjs +468 -0
  14. package/vendor/archify/renderers/shared/brand-marks.mjs +563 -0
  15. package/vendor/archify/renderers/shared/cli.mjs +220 -0
  16. package/vendor/archify/renderers/shared/desktop-readability.mjs +26 -0
  17. package/vendor/archify/renderers/shared/diagnostics.mjs +116 -0
  18. package/vendor/archify/renderers/shared/engineering-profiles.mjs +157 -0
  19. package/vendor/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
  20. package/vendor/archify/renderers/shared/generated-validators.mjs +13 -0
  21. package/vendor/archify/renderers/shared/geometry.mjs +1334 -0
  22. package/vendor/archify/renderers/shared/i18n.mjs +594 -0
  23. package/vendor/archify/renderers/shared/layout-report.mjs +40 -0
  24. package/vendor/archify/renderers/shared/legend.mjs +217 -0
  25. package/vendor/archify/renderers/shared/output-path.mjs +321 -0
  26. package/vendor/archify/renderers/shared/repository-evidence.mjs +235 -0
  27. package/vendor/archify/renderers/shared/text-fit.mjs +49 -0
  28. package/vendor/archify/renderers/shared/utils.mjs +232 -0
  29. package/vendor/archify/renderers/shared/validator.mjs +86 -0
  30. package/vendor/archify/renderers/workflow/render-workflow.mjs +749 -0
@@ -0,0 +1,40 @@
1
+ /** Serialize computed layout for dry-run / inspect (#9). */
2
+
3
+ export function componentBox(c) {
4
+ return {
5
+ id: c.id,
6
+ type: c.type,
7
+ label: c.label,
8
+ x: Math.round(c.x),
9
+ y: Math.round(c.y),
10
+ width: c.width,
11
+ height: c.height,
12
+ ...(Number.isInteger(c.row) ? { row: c.row } : {}),
13
+ ...(Number.isInteger(c.col) ? { col: c.col } : {}),
14
+ ...(Array.isArray(c.pos) ? { pos: c.pos.map(Math.round) } : {}),
15
+ };
16
+ }
17
+
18
+ export function boundaryBox(b) {
19
+ return {
20
+ kind: b.kind,
21
+ label: b.label,
22
+ x: Math.round(b.x),
23
+ y: Math.round(b.y),
24
+ width: Math.round(b.width),
25
+ height: Math.round(b.height),
26
+ wraps: b.wraps,
27
+ };
28
+ }
29
+
30
+ export function connectionPath(conn, routed, labelAt) {
31
+ return {
32
+ from: conn.from,
33
+ to: conn.to,
34
+ label: conn.label ?? null,
35
+ variant: conn.variant ?? 'default',
36
+ route: conn.route ?? 'auto',
37
+ points: routed.points.map(([x, y]) => [Math.round(x), Math.round(y)]),
38
+ ...(labelAt ? { labelAt: labelAt.map(Math.round) } : {}),
39
+ };
40
+ }
@@ -0,0 +1,217 @@
1
+ import { throwDiagnosticError } from './diagnostics.mjs';
2
+ import { rectsOverlap, segmentIntersectsRect } from './geometry.mjs';
3
+ import { esc, textUnits } from './utils.mjs';
4
+ import { translateMessage } from './i18n.mjs';
5
+
6
+ const DEFAULT_FONT_SIZE = 8;
7
+ const DEFAULT_ITEM_GAP = 22;
8
+ const DEFAULT_LINE_GAP = 22;
9
+ const DEFAULT_SWATCH_GAP = 8;
10
+ const TEXT_ADVANCE_EM = 0.62;
11
+ const INTERACTIVE_BADGE_ALLOWANCE = 21;
12
+
13
+ export function relationshipLegendObstacles(relations, { pointsFor, labelRectFor } = {}) {
14
+ const obstacles = [];
15
+ for (const [index, relation] of (Array.isArray(relations) ? relations : []).entries()) {
16
+ const points = typeof pointsFor === 'function' ? pointsFor(relation, index) : [];
17
+ const finitePoints = (Array.isArray(points) ? points : []).filter((point) => (
18
+ Array.isArray(point) && point.length === 2 && point.every(Number.isFinite)
19
+ ));
20
+ for (let pointIndex = 0; pointIndex < finitePoints.length - 1; pointIndex += 1) {
21
+ obstacles.push({
22
+ kind: 'relationship-segment',
23
+ start: finitePoints[pointIndex],
24
+ end: finitePoints[pointIndex + 1],
25
+ });
26
+ }
27
+ const labelRect = typeof labelRectFor === 'function' ? labelRectFor(relation, index) : null;
28
+ if (labelRect && [labelRect.x, labelRect.y, labelRect.width, labelRect.height].every(Number.isFinite)) {
29
+ obstacles.push({ kind: 'relationship-label', ...labelRect });
30
+ }
31
+ }
32
+ return obstacles;
33
+ }
34
+
35
+ export function resolveLegend(config, catalog, presentKinds) {
36
+ const mode = config?.mode || 'auto';
37
+ if (mode === 'hidden') return [];
38
+ const present = presentKinds instanceof Set ? presentKinds : new Set(presentKinds || []);
39
+ const overrides = config?.entries || {};
40
+
41
+ return catalog.flatMap((catalogEntry) => {
42
+ const override = overrides[catalogEntry.kind] || {};
43
+ const selectedByMode = mode === 'all' || present.has(catalogEntry.kind);
44
+ const visible = override.visible === true || (selectedByMode && override.visible !== false);
45
+ if (!visible) return [];
46
+ return [{
47
+ ...catalogEntry,
48
+ label: override.label || catalogEntry.label,
49
+ present: present.has(catalogEntry.kind),
50
+ interactive: catalogEntry.interactive !== false && present.has(catalogEntry.kind),
51
+ }];
52
+ });
53
+ }
54
+
55
+ function measuredEntryWidth(entry, fontSize, swatchGap) {
56
+ const swatchWidth = entry.swatchWidth ?? 14;
57
+ return Math.ceil(
58
+ swatchWidth
59
+ + swatchGap
60
+ + textUnits(entry.label) * fontSize * TEXT_ADVANCE_EM
61
+ + (entry.interactive ? INTERACTIVE_BADGE_ALLOWANCE : 0),
62
+ );
63
+ }
64
+
65
+ // One pure footprint calculation owns both auto-viewBox sizing and final SVG
66
+ // placement. Callers must not maintain a second approximation of legend width
67
+ // or row count; that would make generated geometry disagree with validation.
68
+ export function legendFootprint(entries, {
69
+ width,
70
+ fontSize = DEFAULT_FONT_SIZE,
71
+ itemGap = DEFAULT_ITEM_GAP,
72
+ lineGap = DEFAULT_LINE_GAP,
73
+ swatchGap = DEFAULT_SWATCH_GAP,
74
+ } = {}) {
75
+ if (!entries.length) {
76
+ return { measured: [], rows: [], rowCount: 0, minWidth: 0, extraHeight: 0 };
77
+ }
78
+ const measured = entries.map((entry) => ({
79
+ ...entry,
80
+ width: measuredEntryWidth(entry, fontSize, entry.swatchGap ?? swatchGap),
81
+ }));
82
+ const rows = [[]];
83
+ let cursor = 0;
84
+ for (const entry of measured) {
85
+ const row = rows.at(-1);
86
+ const required = (row.length ? itemGap : 0) + entry.width;
87
+ if (row.length && cursor + required > width) {
88
+ rows.push([entry]);
89
+ cursor = entry.width;
90
+ } else {
91
+ row.push(entry);
92
+ cursor += required;
93
+ }
94
+ }
95
+ return {
96
+ measured,
97
+ rows,
98
+ rowCount: rows.length,
99
+ minWidth: Math.max(...measured.map((entry) => entry.width)),
100
+ extraHeight: (rows.length - 1) * lineGap,
101
+ };
102
+ }
103
+
104
+ export function measureLegend(entries, {
105
+ x,
106
+ baselineY,
107
+ width,
108
+ fontSize = DEFAULT_FONT_SIZE,
109
+ itemGap = DEFAULT_ITEM_GAP,
110
+ lineGap = DEFAULT_LINE_GAP,
111
+ swatchGap = DEFAULT_SWATCH_GAP,
112
+ minTitleY = 0,
113
+ obstacles = [],
114
+ unfit = 'error',
115
+ diagramType = 'diagram',
116
+ } = {}) {
117
+ if (!entries.length) return { entries: [], rowCount: 0, titleY: null };
118
+ const footprint = legendFootprint(entries, { width, fontSize, itemGap, lineGap, swatchGap });
119
+ const tooWide = footprint.measured.find((entry) => entry.width > width);
120
+ if (tooWide) {
121
+ if (unfit === 'hide') return null;
122
+ const message = `[legend/label-too-wide] ${diagramType} legend label for "${tooWide.kind}" needs ${tooWide.width}px but only ${width}px is available.`;
123
+ throwDiagnosticError(message, [{
124
+ code: 'legend/label-too-wide',
125
+ severity: 'error',
126
+ message,
127
+ subject: { diagramType, path: `/meta/legend/entries/${tooWide.kind}/label` },
128
+ evidence: { kind: tooWide.kind, measuredWidthPx: tooWide.width, availableWidthPx: width },
129
+ supportedFixes: ['shorten the legend label or use a wider viewBox'],
130
+ }]);
131
+ }
132
+
133
+ const titleY = baselineY - footprint.extraHeight - 20;
134
+ const legendTopY = titleY - 10;
135
+ if (legendTopY < minTitleY) {
136
+ if (unfit === 'hide') return null;
137
+ const message = `[legend/vertical-overflow] ${diagramType} legend needs ${footprint.rowCount} rows, which would start at y=${legendTopY} above the available legend band at y=${minTitleY}.`;
138
+ throwDiagnosticError(message, [{
139
+ code: 'legend/vertical-overflow',
140
+ severity: 'error',
141
+ message,
142
+ subject: { diagramType, path: '/meta/legend' },
143
+ evidence: { rowCount: footprint.rowCount, requiredTopY: legendTopY, availableTopY: minTitleY },
144
+ supportedFixes: ['shorten legend labels, hide nonessential entries, or use a wider viewBox'],
145
+ }]);
146
+ }
147
+
148
+ const positioned = [];
149
+ footprint.rows.forEach((row, rowIndex) => {
150
+ let entryX = x;
151
+ const baseline = baselineY - (footprint.rowCount - rowIndex - 1) * lineGap;
152
+ for (const entry of row) {
153
+ positioned.push({ ...entry, x: entryX, baseline, row: rowIndex });
154
+ entryX += entry.width + itemGap;
155
+ }
156
+ });
157
+
158
+ const legendRects = [
159
+ { kind: 'title', x, y: legendTopY, width: 48, height: 14 },
160
+ ...positioned.map((entry) => ({
161
+ kind: entry.kind,
162
+ x: entry.x,
163
+ y: entry.baseline - 10,
164
+ width: entry.width,
165
+ height: 14,
166
+ })),
167
+ ];
168
+ const collision = legendRects.find((legendRect) => obstacles.some((obstacle) => (
169
+ Array.isArray(obstacle.start) && Array.isArray(obstacle.end)
170
+ ? segmentIntersectsRect({ start: obstacle.start, end: obstacle.end }, legendRect)
171
+ : rectsOverlap(obstacle, legendRect)
172
+ )));
173
+ if (collision) {
174
+ if (unfit === 'hide') return null;
175
+ const message = `[legend/content-overlap] ${diagramType} legend entry "${collision.kind}" overlaps authored relationship geometry.`;
176
+ throwDiagnosticError(message, [{
177
+ code: 'legend/content-overlap',
178
+ severity: 'error',
179
+ message,
180
+ subject: { diagramType, path: '/meta/legend' },
181
+ evidence: { legendKind: collision.kind, legendRect: collision },
182
+ supportedFixes: ['shorten or hide legend entries, use a wider viewBox, or move the authored relationship route/label out of the legend band'],
183
+ }]);
184
+ }
185
+
186
+ return {
187
+ entries: positioned,
188
+ rowCount: footprint.rowCount,
189
+ titleY,
190
+ fontSize,
191
+ };
192
+ }
193
+
194
+ export function renderLegend({ entries, layout, renderSwatch, locale }) {
195
+ if (!entries.length) return '';
196
+ const measured = measureLegend(entries, layout);
197
+ if (!measured) return '';
198
+ const hasInteractiveEntries = measured.entries.some((entry) => entry.interactive);
199
+ const renderedFontSize = measured.fontSize < 8 ? measured.fontSize + 0.5 : measured.fontSize + 2;
200
+ const rootAttributes = hasInteractiveEntries ? ' data-legend="" data-legend-bridge=""' : ' data-legend=""';
201
+ const parts = [
202
+ ` <g${rootAttributes}>`,
203
+ ` <text x="${layout.x}" y="${measured.titleY}" class="t-primary" font-size="12" font-weight="650">${esc(translateMessage(locale, 'legend.title'))}</text>`,
204
+ ];
205
+
206
+ for (const entry of measured.entries) {
207
+ const interactive = entry.interactive
208
+ ? ` data-legend-kind="${esc(entry.kind)}" data-legend-label="${esc(entry.label)}"`
209
+ : '';
210
+ parts.push(` <g data-legend-semantic-kind="${esc(entry.kind)}"${interactive} data-legend-x="${entry.x}" data-legend-baseline="${entry.baseline}" data-legend-width="${entry.width}">`);
211
+ parts.push(` ${renderSwatch(entry)}`);
212
+ parts.push(` <text x="${entry.x + (entry.swatchWidth ?? 14) + (entry.swatchGap ?? DEFAULT_SWATCH_GAP)}" y="${entry.baseline}" class="t-muted" font-size="${renderedFontSize}" font-weight="500">${esc(entry.label)}</text>`);
213
+ parts.push(' </g>');
214
+ }
215
+ parts.push(' </g>');
216
+ return parts.join('\n');
217
+ }
@@ -0,0 +1,321 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ const MAX_SYMLINK_DEPTH = 64;
5
+ const directorySemanticsCache = new Map();
6
+ let semanticsProbeSequence = 0;
7
+
8
+ function splitAbsolute(absolutePath) {
9
+ const root = path.parse(absolutePath).root;
10
+ return {
11
+ root,
12
+ segments: absolutePath.slice(root.length).split(path.sep).filter(Boolean),
13
+ };
14
+ }
15
+
16
+ function canonicalize(targetPath, depth) {
17
+ const absolutePath = path.resolve(targetPath);
18
+ const { root, segments } = splitAbsolute(absolutePath);
19
+ let current = root;
20
+
21
+ for (let index = 0; index < segments.length; index += 1) {
22
+ const candidate = path.join(current, segments[index]);
23
+ let stat;
24
+ try {
25
+ stat = fs.lstatSync(candidate);
26
+ } catch (error) {
27
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
28
+ return path.resolve(current, ...segments.slice(index));
29
+ }
30
+ throw error;
31
+ }
32
+
33
+ if (stat.isSymbolicLink()) {
34
+ if (depth >= MAX_SYMLINK_DEPTH) {
35
+ const error = new Error(`Could not resolve path because a symbolic-link cycle includes "${candidate}".`);
36
+ error.code = 'ELOOP';
37
+ error.path = candidate;
38
+ throw error;
39
+ }
40
+ const link = fs.readlinkSync(candidate);
41
+ const linkTarget = path.isAbsolute(link) ? link : path.resolve(path.dirname(candidate), link);
42
+ return canonicalize(path.join(linkTarget, ...segments.slice(index + 1)), depth + 1);
43
+ }
44
+
45
+ current = fs.realpathSync.native(candidate);
46
+ }
47
+
48
+ return path.normalize(current);
49
+ }
50
+
51
+ export function canonicalFuturePath(targetPath) {
52
+ try {
53
+ return canonicalize(targetPath, 0);
54
+ } catch (error) {
55
+ if (error?.code !== 'ELOOP') throw error;
56
+ const output = path.resolve(targetPath);
57
+ throw new OutputPathError(`Output path contains a symbolic-link cycle: "${output}".`, {
58
+ code: 'output/symlink-cycle',
59
+ message: 'Output path could not be resolved because it contains a symbolic-link cycle.',
60
+ subject: { output },
61
+ evidence: {
62
+ systemCode: 'ELOOP',
63
+ ...(error.path ? { cycleAt: path.resolve(error.path) } : {}),
64
+ },
65
+ supportedFixes: ['remove the symbolic-link cycle or choose an output path outside it'],
66
+ });
67
+ }
68
+ }
69
+
70
+ function hasFileIdentity(stat) {
71
+ return stat.ino !== 0 && stat.ino !== 0n;
72
+ }
73
+
74
+ function sameFileIdentity(left, right) {
75
+ return hasFileIdentity(left)
76
+ && hasFileIdentity(right)
77
+ && left.dev === right.dev
78
+ && left.ino === right.ino;
79
+ }
80
+
81
+ function nearestExistingDirectory(targetPath) {
82
+ let directory = path.dirname(targetPath);
83
+ while (true) {
84
+ try {
85
+ const stat = fs.statSync(directory);
86
+ if (stat.isDirectory()) {
87
+ return {
88
+ path: fs.realpathSync.native(directory),
89
+ stat,
90
+ };
91
+ }
92
+ } catch (error) {
93
+ if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') return null;
94
+ }
95
+ const parent = path.dirname(directory);
96
+ if (parent === directory) return null;
97
+ directory = parent;
98
+ }
99
+ }
100
+
101
+ function directoryIdentityKey(directory) {
102
+ if (!hasFileIdentity(directory.stat)) return null;
103
+ return `${directory.stat.dev}:${directory.stat.ino}`;
104
+ }
105
+
106
+ function probeNamesAlias(directoryPath, authoredName, lookupName) {
107
+ let fileDescriptor;
108
+ let created = false;
109
+ let result = null;
110
+ let cleaned = true;
111
+ const authoredPath = path.join(directoryPath, authoredName);
112
+ const lookupPath = path.join(directoryPath, lookupName);
113
+ try {
114
+ fileDescriptor = fs.openSync(authoredPath, 'wx', 0o600);
115
+ created = true;
116
+ fs.closeSync(fileDescriptor);
117
+ fileDescriptor = undefined;
118
+
119
+ let authored;
120
+ let lookup;
121
+ try {
122
+ authored = fs.statSync(authoredPath);
123
+ lookup = fs.statSync(lookupPath);
124
+ } catch (error) {
125
+ if (error.code === 'ENOENT') result = false;
126
+ }
127
+ if (authored && lookup) {
128
+ if (sameFileIdentity(authored, lookup)) {
129
+ result = true;
130
+ } else {
131
+ try {
132
+ result = fs.realpathSync.native(authoredPath) === fs.realpathSync.native(lookupPath);
133
+ } catch {
134
+ result = null;
135
+ }
136
+ }
137
+ }
138
+ } catch {
139
+ result = null;
140
+ } finally {
141
+ if (fileDescriptor !== undefined) {
142
+ try {
143
+ fs.closeSync(fileDescriptor);
144
+ } catch {
145
+ cleaned = false;
146
+ }
147
+ }
148
+ if (created) {
149
+ try {
150
+ fs.unlinkSync(authoredPath);
151
+ } catch {
152
+ cleaned = false;
153
+ }
154
+ }
155
+ }
156
+ return cleaned ? result : null;
157
+ }
158
+
159
+ function probeDirectorySemantics(directory) {
160
+ const cacheKey = directoryIdentityKey(directory);
161
+ if (cacheKey && directorySemanticsCache.has(cacheKey)) {
162
+ return directorySemanticsCache.get(cacheKey);
163
+ }
164
+
165
+ semanticsProbeSequence += 1;
166
+ const suffix = `${process.pid}-${Date.now().toString(36)}-${semanticsProbeSequence}`;
167
+ const caseAuthored = `.archify-Case-Probe-${suffix}`;
168
+ const normalizationAuthored = `.archify-norm-\u00e9-probe-${suffix}`;
169
+ const semantics = {
170
+ caseInsensitive: probeNamesAlias(
171
+ directory.path,
172
+ caseAuthored,
173
+ caseAuthored.toLowerCase(),
174
+ ),
175
+ normalizationInsensitive: probeNamesAlias(
176
+ directory.path,
177
+ normalizationAuthored,
178
+ normalizationAuthored.normalize('NFD'),
179
+ ),
180
+ };
181
+ if (
182
+ cacheKey
183
+ && semantics.caseInsensitive !== null
184
+ && semantics.normalizationInsensitive !== null
185
+ ) {
186
+ directorySemanticsCache.set(cacheKey, semantics);
187
+ }
188
+ return semantics;
189
+ }
190
+
191
+ function sameDirectory(left, right) {
192
+ return left.path === right.path || sameFileIdentity(left.stat, right.stat);
193
+ }
194
+
195
+ function futurePathsAlias(leftPath, rightPath) {
196
+ const left = canonicalFuturePath(leftPath);
197
+ const right = canonicalFuturePath(rightPath);
198
+ if (left === right) return true;
199
+
200
+ const leftDirectory = nearestExistingDirectory(left);
201
+ const rightDirectory = nearestExistingDirectory(right);
202
+ if (!leftDirectory || !rightDirectory || !sameDirectory(leftDirectory, rightDirectory)) {
203
+ return false;
204
+ }
205
+
206
+ const semantics = probeDirectorySemantics(leftDirectory);
207
+ let comparableLeft = path.relative(leftDirectory.path, left);
208
+ let comparableRight = path.relative(rightDirectory.path, right);
209
+ if (semantics.normalizationInsensitive !== false) {
210
+ comparableLeft = comparableLeft.normalize('NFC');
211
+ comparableRight = comparableRight.normalize('NFC');
212
+ }
213
+ if (semantics.caseInsensitive !== false) {
214
+ comparableLeft = comparableLeft.toLowerCase();
215
+ comparableRight = comparableRight.toLowerCase();
216
+ }
217
+ return comparableLeft === comparableRight;
218
+ }
219
+
220
+ export function pathsAlias(leftPath, rightPath) {
221
+ if (futurePathsAlias(leftPath, rightPath)) return true;
222
+ try {
223
+ const left = fs.statSync(leftPath);
224
+ const right = fs.statSync(rightPath);
225
+ return sameFileIdentity(left, right);
226
+ } catch {
227
+ return false;
228
+ }
229
+ }
230
+
231
+ function pathIsInside(directoryPath, targetPath) {
232
+ const relative = path.relative(canonicalFuturePath(directoryPath), canonicalFuturePath(targetPath));
233
+ return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`));
234
+ }
235
+
236
+ export class OutputPathError extends Error {
237
+ constructor(message, diagnostic) {
238
+ super(message);
239
+ this.name = 'OutputPathError';
240
+ this.archifyDiagnostics = [{
241
+ severity: 'error',
242
+ subject: {},
243
+ evidence: {},
244
+ supportedFixes: [],
245
+ ...diagnostic,
246
+ }];
247
+ }
248
+ }
249
+
250
+ export function resolveOutputPath({
251
+ requestedOutput,
252
+ authoredOutput,
253
+ defaultOutput,
254
+ inputPaths = [],
255
+ inputDescription = 'an input',
256
+ otherOutputPaths = [],
257
+ cwd = process.cwd(),
258
+ }) {
259
+ const rawOutput = requestedOutput || authoredOutput || defaultOutput;
260
+ const source = requestedOutput ? 'cli' : (authoredOutput ? 'meta' : 'default');
261
+ if (
262
+ source === 'meta'
263
+ && (path.isAbsolute(rawOutput) || path.posix.isAbsolute(rawOutput) || path.win32.isAbsolute(rawOutput))
264
+ ) {
265
+ throw new OutputPathError('meta.output must be a relative path.', {
266
+ code: 'output/meta-absolute',
267
+ message: 'meta.output must be a relative path resolved from the current working directory.',
268
+ subject: { output: rawOutput },
269
+ supportedFixes: ['set meta.output to a relative .html path inside the current working directory'],
270
+ });
271
+ }
272
+ if (source === 'meta' && path.extname(rawOutput).toLowerCase() !== '.html') {
273
+ throw new OutputPathError('meta.output must target an .html file.', {
274
+ code: 'output/meta-extension',
275
+ message: 'meta.output must target an .html file.',
276
+ subject: { output: rawOutput },
277
+ supportedFixes: ['change meta.output to a path ending in .html'],
278
+ });
279
+ }
280
+ const outputPath = path.resolve(cwd, rawOutput);
281
+ if (source === 'meta' && path.extname(canonicalFuturePath(outputPath)).toLowerCase() !== '.html') {
282
+ throw new OutputPathError('meta.output must resolve to an .html file.', {
283
+ code: 'output/meta-resolved-extension',
284
+ message: 'meta.output must resolve to an .html file after symbolic links are followed.',
285
+ subject: { output: rawOutput },
286
+ supportedFixes: ['remove the symbolic-link alias or point it to an .html target inside the current working directory'],
287
+ });
288
+ }
289
+ if (source === 'meta' && !pathIsInside(cwd, outputPath)) {
290
+ throw new OutputPathError('meta.output must stay inside the current working directory.', {
291
+ code: 'output/meta-outside-cwd',
292
+ message: 'meta.output must stay inside the current working directory after symbolic links are resolved.',
293
+ subject: { output: rawOutput, cwd: path.resolve(cwd) },
294
+ supportedFixes: ['set meta.output to a relative .html path inside the current working directory'],
295
+ });
296
+ }
297
+
298
+ for (const inputPath of inputPaths) {
299
+ if (!pathsAlias(outputPath, inputPath)) continue;
300
+ throw new OutputPathError(`Output must not replace ${inputDescription}.`, {
301
+ code: 'output/input-alias',
302
+ message: `Output must not replace ${inputDescription}, including through a symbolic-link or future-path alias.`,
303
+ subject: { output: outputPath, input: path.resolve(inputPath) },
304
+ supportedFixes: ['choose an output path that is distinct from every input path'],
305
+ });
306
+ }
307
+ for (const otherOutputPath of otherOutputPaths) {
308
+ if (!pathsAlias(outputPath, otherOutputPath)) continue;
309
+ throw new OutputPathError('Output targets must use distinct paths.', {
310
+ code: 'output/target-alias',
311
+ message: 'Output targets must use distinct paths, including symbolic-link and future-path aliases.',
312
+ subject: { output: outputPath, conflictingOutput: path.resolve(otherOutputPath) },
313
+ supportedFixes: ['choose distinct paths for every generated output'],
314
+ });
315
+ }
316
+
317
+ return {
318
+ outputPath,
319
+ source,
320
+ };
321
+ }