@pygmalionjs/pygmalion 0.8.1 → 0.10.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 +34 -0
- package/binding-source.d.ts +25 -0
- package/dist-lib/{CameraLayer-B2D29VXQ.js → CameraLayer-BUOlJcUa.js} +17 -17
- package/dist-lib/pygmalion.js +24524 -12555
- package/dist-lib/{runtime-BAy8W1mJ.js → runtime-BOfDlGPR.js} +12438 -11118
- package/dist-lib/style.css +1 -1
- package/dist-lib/testing.js +19 -19
- package/dist-lib/types/binding/catalog.d.ts +10 -0
- package/dist-lib/types/binding/contracts.d.ts +202 -0
- package/dist-lib/types/binding/documentCommands.d.ts +38 -0
- package/dist-lib/types/binding/duplicateMetadata.d.ts +8 -0
- package/dist-lib/types/binding/index.d.ts +7 -0
- package/dist-lib/types/binding/preview.d.ts +37 -0
- package/dist-lib/types/binding/source.d.ts +109 -0
- package/dist-lib/types/binding/useDataProjection.d.ts +9 -0
- package/dist-lib/types/binding/validate.d.ts +8 -0
- package/dist-lib/types/core/runtime.d.ts +6 -0
- package/dist-lib/types/document/appearance.d.ts +112 -0
- package/dist-lib/types/document/assets.d.ts +204 -0
- package/dist-lib/types/document/components.d.ts +169 -0
- package/dist-lib/types/document/contracts.d.ts +171 -0
- package/dist-lib/types/document/engine.d.ts +20 -0
- package/dist-lib/types/document/exportSettings.d.ts +45 -0
- package/dist-lib/types/document/geometry.d.ts +11 -0
- package/dist-lib/types/document/layout.d.ts +53 -0
- package/dist-lib/types/document/prototype.d.ts +424 -0
- package/dist-lib/types/document/prototypeReferences.d.ts +3 -0
- package/dist-lib/types/document/richText.d.ts +185 -0
- package/dist-lib/types/document/styles.d.ts +216 -0
- package/dist-lib/types/document/svgImport.d.ts +17 -0
- package/dist-lib/types/document/vector.d.ts +222 -0
- package/dist-lib/types/editor/designCompiler.d.ts +7 -0
- package/dist-lib/types/editor/designImport.d.ts +7 -0
- package/dist-lib/types/editor/inspect.d.ts +8 -0
- package/dist-lib/types/editor/revisionCatalogInstall.d.ts +1 -1
- package/dist-lib/types/editor/screenInteractions.d.ts +3 -3
- package/dist-lib/types/editor/store.d.ts +1 -1
- package/dist-lib/types/host/project.d.ts +44 -0
- package/dist-lib/types/host/registryConnections.d.ts +11 -0
- package/dist-lib/types/host/sourceFilePlan.d.ts +15 -0
- package/dist-lib/types/host/sourceWriteback.d.ts +50 -0
- package/dist-lib/types/lib.d.ts +103 -3
- package/dist-lib/types/token-library/bindings.d.ts +62 -0
- package/dist-lib/types/token-library/contracts.d.ts +117 -0
- package/dist-lib/types/token-library/engine.d.ts +13 -0
- package/dist-lib/types/token-library/valueResolver.d.ts +19 -0
- package/dist-lib/types/workspace/WorkspaceShell.d.ts +9 -0
- package/dist-lib/types/workspace/application/ApplicationWorkspace.d.ts +3 -0
- package/dist-lib/types/workspace/configuration.d.ts +47 -0
- package/dist-lib/types/workspace/contracts.d.ts +64 -0
- package/dist-lib/types/workspace/controller.d.ts +35 -0
- package/dist-lib/types/workspace/documentSessions.d.ts +42 -0
- package/dist-lib/types/workspace/edit/AppearancePanel.d.ts +23 -0
- package/dist-lib/types/workspace/edit/ArrangeLayers.d.ts +10 -0
- package/dist-lib/types/workspace/edit/AssetsPanel.d.ts +16 -0
- package/dist-lib/types/workspace/edit/CanvasGuides.d.ts +19 -0
- package/dist-lib/types/workspace/edit/ComponentPanel.d.ts +11 -0
- package/dist-lib/types/workspace/edit/DataPanel.d.ts +16 -0
- package/dist-lib/types/workspace/edit/DesignWorkspace.d.ts +10 -0
- package/dist-lib/types/workspace/edit/LayerTree.d.ts +14 -0
- package/dist-lib/types/workspace/edit/LayoutPanel.d.ts +13 -0
- package/dist-lib/types/workspace/edit/PrototypePanel.d.ts +12 -0
- package/dist-lib/types/workspace/edit/ScopeTransferDialog.d.ts +40 -0
- package/dist-lib/types/workspace/edit/SelectionHandles.d.ts +52 -0
- package/dist-lib/types/workspace/edit/StylesPanel.d.ts +8 -0
- package/dist-lib/types/workspace/edit/TextEditor.d.ts +50 -0
- package/dist-lib/types/workspace/edit/VectorEditor.d.ts +89 -0
- package/dist-lib/types/workspace/edit/VectorToolbar.d.ts +5 -0
- package/dist-lib/types/workspace/edit/canvasGuideModel.d.ts +102 -0
- package/dist-lib/types/workspace/edit/clipboard.d.ts +35 -0
- package/dist-lib/types/workspace/edit/duplicateMetadata.d.ts +2 -0
- package/dist-lib/types/workspace/edit/geometryHandles.d.ts +224 -0
- package/dist-lib/types/workspace/edit/layerArrangement.d.ts +39 -0
- package/dist-lib/types/workspace/edit/layerMoves.d.ts +44 -0
- package/dist-lib/types/workspace/edit/projection.d.ts +12 -0
- package/dist-lib/types/workspace/edit/scopeTransfer.d.ts +96 -0
- package/dist-lib/types/workspace/edit/textStyles.d.ts +75 -0
- package/dist-lib/types/workspace/edit/vectorTools.d.ts +136 -0
- package/dist-lib/types/workspace/legacyModeAdapter.d.ts +13 -0
- package/dist-lib/types/workspace/source/ResponsiveLayoutPanel.d.ts +20 -0
- package/dist-lib/types/workspace/source/SourceReview.d.ts +11 -0
- package/dist-lib/types/workspace/source/appearanceSource.d.ts +19 -0
- package/dist-lib/types/workspace/source/compiler.d.ts +5 -0
- package/dist-lib/types/workspace/source/contracts.d.ts +156 -0
- package/dist-lib/types/workspace/source/controller.d.ts +50 -0
- package/dist-lib/types/workspace/source/documentRevision.d.ts +2 -0
- package/dist-lib/types/workspace/source/draftPreviewClient.d.ts +13 -0
- package/dist-lib/types/workspace/source/draftRecipe.d.ts +30 -0
- package/dist-lib/types/workspace/source/flowContracts.d.ts +72 -0
- package/dist-lib/types/workspace/source/prototypeSource.d.ts +4 -0
- package/dist-lib/types/workspace/source/responsive.d.ts +13 -0
- package/dist-lib/types/workspace/source/responsiveContracts.d.ts +78 -0
- package/dist-lib/types/workspace/source/structure.d.ts +21 -0
- package/dist-lib/types/workspace/source/textSource.d.ts +28 -0
- package/dist-lib/types/workspace/tokens/TokenLibraryContext.d.ts +11 -0
- package/dist-lib/types/workspace/tokens/TokenLibraryWorkspace.d.ts +14 -0
- package/dist-lib/types/workspace/tokens/contracts.d.ts +74 -0
- package/dist-lib/types/workspace/tokens/controller.d.ts +44 -0
- package/dist-lib/types/workspace/view/PrototypePlayer.d.ts +17 -0
- package/dist-lib/types/workspace/view/StoryboardWorkspace.d.ts +3 -0
- package/dist-lib/types/workspace/view/prototypePlayback.d.ts +46 -0
- package/draft-preview.d.ts +54 -0
- package/host-runtime.d.ts +73 -0
- package/inspect.d.ts +2 -0
- package/jsx-source-reconciler.d.ts +35 -0
- package/node/design-session.mjs +3 -0
- package/node/host-runtime.mjs +4 -0
- package/node/inspect-plugin.mjs +3 -1
- package/node/inspect-writeback.mjs +18 -2
- package/node/jsx-source-reconciler.mjs +155 -0
- package/node/preview-artifact-store.mjs +10 -7
- package/node/revision-catalog-resolver.mjs +159 -0
- package/node/source-archive-runtime.mjs +63 -0
- package/node/source-archive.mjs +88 -0
- package/node/source-bindings.mjs +65 -0
- package/node/source-file-lock.mjs +15 -0
- package/node/source-file-plan.mjs +162 -0
- package/node/source-file-proofs.mjs +44 -0
- package/node/source-module.mjs +17 -0
- package/node/source-responsive-codec.d.mts +17 -0
- package/node/source-responsive-codec.mjs +133 -0
- package/node/source-responsive.mjs +189 -0
- package/node/source-service-inputs.mjs +81 -0
- package/node/source-service-plugin.mjs +57 -0
- package/node/source-service.mjs +4 -0
- package/node/source-structure-reconcile.mjs +186 -0
- package/node/source-structure-wrapper-codec.d.mts +20 -0
- package/node/source-structure-wrapper-codec.mjs +205 -0
- package/node/source-structure.mjs +404 -0
- package/node/token-source-references.mjs +136 -0
- package/node/token-source-service.mjs +151 -0
- package/node/workspace-draft-entry.mjs +52 -0
- package/node/workspace-draft-preview-plugin.mjs +70 -0
- package/node/workspace-draft-preview.mjs +232 -0
- package/node/workspace-draft-process.mjs +129 -0
- package/node/workspace-draft-runtime.mjs +67 -0
- package/node/workspace-source-identity.d.mts +1 -0
- package/node/workspace-source-identity.mjs +21 -0
- package/node/workspace-source-plan.mjs +195 -0
- package/node/workspace-source-session.mjs +187 -0
- package/package.json +90 -6
- package/source-files.d.ts +35 -0
- package/source-module.d.ts +16 -0
- package/source-responsive.d.ts +10 -0
- package/source-service.d.ts +92 -0
- package/source-structure.d.ts +155 -0
- package/workspace-draft-entry.d.ts +15 -0
- package/workspace-source-plan.d.ts +44 -0
- package/docs/coverage-contracts.md +0 -444
- package/docs/screen-state-contract.md +0 -252
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { workspaceSourceFingerprint as fingerprint } from './workspace-source-identity.mjs';
|
|
2
|
+
export const RESPONSIVE_PROPERTIES = Object.freeze(['flex-direction', 'flex-wrap', 'align-items', 'justify-content', 'gap', 'padding', 'width', 'height', 'min-width', 'max-width', 'min-height', 'max-height', 'display']);
|
|
3
|
+
/** A collapsed state is the only authored display change: a media range declares none and the base keeps the source display. */
|
|
4
|
+
export const RESPONSIVE_COLLAPSED_VALUE = 'none';
|
|
5
|
+
const SIZING_PROPERTY = /^(?:min-|max-)?(?:width|height)$/;
|
|
6
|
+
export const responsiveSizingProperty = property => SIZING_PROPERTY.test(property);
|
|
7
|
+
/** Flex controls need a flex source container; sizing and the collapsed state do not. */
|
|
8
|
+
export const responsiveFlexControl = property => property !== 'display' && !SIZING_PROPERTY.test(property);
|
|
9
|
+
const enums = {
|
|
10
|
+
'flex-direction': ['row', 'row-reverse', 'column', 'column-reverse'], 'flex-wrap': ['nowrap', 'wrap', 'wrap-reverse'],
|
|
11
|
+
'align-items': ['stretch', 'flex-start', 'flex-end', 'center', 'baseline'],
|
|
12
|
+
'justify-content': ['flex-start', 'flex-end', 'center', 'space-between', 'space-around', 'space-evenly'],
|
|
13
|
+
display: ['flex', 'inline-flex', 'block', 'inline-block', 'grid', 'inline-grid', RESPONSIVE_COLLAPSED_VALUE],
|
|
14
|
+
};
|
|
15
|
+
const lengthUnits = ['px', 'rem', 'em', '%', 'vw', 'vh'];
|
|
16
|
+
const sizingKeywords = ['auto', 'min-content', 'max-content', 'fit-content'];
|
|
17
|
+
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
18
|
+
const exact = (value, keys) => record(value) && Object.keys(value).every(key => keys.includes(key));
|
|
19
|
+
const name = value => typeof value === 'string' && /^[A-Za-z_][\w-]*$/.test(value);
|
|
20
|
+
export const responsiveSourcePath = value => typeof value === 'string' && value.length > 0 && !/[\\\0\r\n?#]/.test(value)
|
|
21
|
+
&& !value.startsWith('/') && !/^[a-z]:/i.test(value) && !value.split('/').some(part => !part || part === '.' || part === '..' || part === '.git');
|
|
22
|
+
export function responsiveMediaQuery(media) {
|
|
23
|
+
return [media.minWidth !== undefined ? `(min-width: ${media.minWidth}px)` : '', media.maxWidth !== undefined ? `(max-width: ${media.maxWidth}px)` : ''].filter(Boolean).join(' and ');
|
|
24
|
+
}
|
|
25
|
+
export function responsiveSourceHeader(range) {
|
|
26
|
+
return range.source?.kind === 'scss-include' ? `@include ${range.source.name}(${range.source.argument})` : `@media ${responsiveMediaQuery(range.media)}`;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Breakpoint order: the base first, then max-width ranges by descending width, then bounded and
|
|
30
|
+
* min-width ranges by ascending minimum width. Media ranges never overlap, so for a proven catalog
|
|
31
|
+
* this reads from the narrowest viewport to the widest while keeping each cascade family in its
|
|
32
|
+
* conventional source order. The order is the declared order; hosts declare ranges in it.
|
|
33
|
+
*/
|
|
34
|
+
export function sortResponsiveRanges(ranges) {
|
|
35
|
+
const key = range => range.media?.minWidth !== undefined ? [1, range.media.minWidth] : [0, -(range.media?.maxWidth ?? Infinity)];
|
|
36
|
+
return [ranges[0], ...ranges.slice(1).map((range, index) => ({ range, index })).sort((left, right) => {
|
|
37
|
+
const [a, b] = [key(left.range), key(right.range)];
|
|
38
|
+
return a[0] - b[0] || a[1] - b[1] || left.index - right.index;
|
|
39
|
+
}).map(item => item.range)];
|
|
40
|
+
}
|
|
41
|
+
const covered = (ranges, width) => ranges.some(range => range.media && width >= (range.media.minWidth ?? 0) && width <= (range.media.maxWidth ?? Infinity));
|
|
42
|
+
/** A preview width that resolves to the selected range; the base needs a width no media range covers. */
|
|
43
|
+
export function responsiveRangePreviewWidth(ranges, rangeId, preferred = 1024) {
|
|
44
|
+
const range = ranges.find(item => item.id === rangeId);
|
|
45
|
+
if (!range) throw new Error('The responsive preview range is not declared.');
|
|
46
|
+
if (range.media) return Math.min(Math.max(preferred, range.media.minWidth ?? 0, 1), range.media.maxWidth ?? 4000);
|
|
47
|
+
if (!covered(ranges, preferred)) return preferred;
|
|
48
|
+
const candidates = ranges.flatMap(item => item.media ? [(item.media.maxWidth ?? 4000) + 1, (item.media.minWidth ?? 0) - 1] : [])
|
|
49
|
+
.filter(width => width >= 1 && width <= 4000 && !covered(ranges, width));
|
|
50
|
+
return candidates.sort((left, right) => Math.abs(left - preferred) - Math.abs(right - preferred) || right - left)[0] ?? preferred;
|
|
51
|
+
}
|
|
52
|
+
export function validateResponsiveRanges(ranges) {
|
|
53
|
+
if (!Array.isArray(ranges) || !ranges.length || ranges.length > 20) throw new Error('Responsive ranges require one base and at most nineteen media ranges.');
|
|
54
|
+
const ids = new Set(); const intervals = [];
|
|
55
|
+
for (const [index, range] of ranges.entries()) {
|
|
56
|
+
if (!exact(range, ['id', 'label', 'media', 'source']) || !name(range.id) || typeof range.label !== 'string' || !range.label.trim() || ids.has(range.id)) throw new Error('Responsive ranges require unique stable IDs and labels.');
|
|
57
|
+
ids.add(range.id);
|
|
58
|
+
if (!index) { if (range.id !== 'base' || range.media !== undefined || range.source !== undefined) throw new Error('The first responsive range must be base without a media directive.'); continue; }
|
|
59
|
+
const media = range.media;
|
|
60
|
+
if (!exact(media, ['minWidth', 'maxWidth']) || !Object.keys(media).length || Object.values(media).some(value => !Number.isInteger(value) || value < 0 || value > 4000)
|
|
61
|
+
|| (media.minWidth ?? 0) > (media.maxWidth ?? Infinity)) throw new Error('Responsive media requires an exact width interval between zero and four thousand pixels.');
|
|
62
|
+
const interval = [media.minWidth ?? 0, media.maxWidth ?? Infinity];
|
|
63
|
+
if (intervals.some(previous => interval[0] <= previous[1] && previous[0] <= interval[1])) throw new Error('Responsive media ranges must not overlap.');
|
|
64
|
+
intervals.push(interval);
|
|
65
|
+
if (range.source !== undefined && !(exact(range.source, ['kind']) && range.source.kind === 'media')
|
|
66
|
+
&& !(exact(range.source, ['kind', 'name', 'argument', 'proofFile']) && range.source.kind === 'scss-include' && name(range.source.name)
|
|
67
|
+
&& typeof range.source.argument === 'string' && /^\$[A-Za-z_][\w-]*$/.test(range.source.argument) && responsiveSourcePath(range.source.proofFile))) throw new Error('Responsive source directives require an explicit media rule or a proven local mixin and variable.');
|
|
68
|
+
}
|
|
69
|
+
if (sortResponsiveRanges(ranges).some((range, index) => range !== ranges[index])) throw new Error('Responsive media ranges must follow breakpoint order: max-width ranges by descending width, then bounded and min-width ranges by ascending minimum width.');
|
|
70
|
+
}
|
|
71
|
+
export function validResponsiveValue(property, value, permitted) {
|
|
72
|
+
if (typeof value !== 'string' || !value || value.trim() !== value || value.length > 160 || /[;{}\0\r\n]/.test(value) || value.includes('</') || /!important/i.test(value)) return false;
|
|
73
|
+
if (permitted !== undefined) return Array.isArray(permitted) && permitted.includes(value);
|
|
74
|
+
if (enums[property]) return enums[property].includes(value);
|
|
75
|
+
const length = token => token === '0' || /^(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em|%|vw|vh)$/.test(token);
|
|
76
|
+
if (property === 'gap' || property === 'padding') {
|
|
77
|
+
const parts = value.split(/\s+/); return parts.length <= (property === 'gap' ? 2 : 4) && parts.every(length);
|
|
78
|
+
}
|
|
79
|
+
return length(value) || sizingKeywords.includes(value) || (property.startsWith('max-') && value === 'none');
|
|
80
|
+
}
|
|
81
|
+
/** One sentence a panel can show next to a rejected value. */
|
|
82
|
+
export function describeResponsiveValueContract(property, permitted) {
|
|
83
|
+
if (permitted !== undefined) return `${property} accepts only the host-permitted spellings: ${permitted.join(', ')}.`;
|
|
84
|
+
if (property === 'display') return 'display collapses a media range with none; the base range keeps the source display.';
|
|
85
|
+
if (enums[property]) return `${property} accepts ${enums[property].join(', ')}.`;
|
|
86
|
+
const lengths = `a length in ${lengthUnits.join(', ')} or 0`;
|
|
87
|
+
if (property === 'gap') return `gap accepts one or two lengths (${lengths}).`;
|
|
88
|
+
if (property === 'padding') return `padding accepts one to four lengths (${lengths}).`;
|
|
89
|
+
return `${property} accepts ${lengths}, ${sizingKeywords.join(', ')}${property.startsWith('max-') ? ' or none' : ''}.`;
|
|
90
|
+
}
|
|
91
|
+
export function validateResponsiveTargets(targets) {
|
|
92
|
+
if (!Array.isArray(targets) || !targets.length || targets.length > 100) throw new Error('Responsive mappings require explicit source targets.');
|
|
93
|
+
const ids = new Set(); const sources = new Set();
|
|
94
|
+
for (const target of targets) {
|
|
95
|
+
if (!exact(target, ['nodeId', 'file', 'className', 'componentFile', 'affectedFrames', 'properties', 'permittedValues']) || typeof target.nodeId !== 'string' || !target.nodeId || ids.has(target.nodeId)
|
|
96
|
+
|| !responsiveSourcePath(target.file) || !target.file.endsWith('.module.scss') || !responsiveSourcePath(target.componentFile) || !/\.[cm]?[jt]sx?$/.test(target.componentFile) || !name(target.className)
|
|
97
|
+
|| !Array.isArray(target.properties) || !target.properties.length || new Set(target.properties).size !== target.properties.length || target.properties.some(property => !RESPONSIVE_PROPERTIES.includes(property))
|
|
98
|
+
|| !Array.isArray(target.affectedFrames) || !target.affectedFrames.length || target.affectedFrames.some(frame => !exact(frame, ['id', 'name']) || typeof frame.id !== 'string' || !frame.id || typeof frame.name !== 'string' || !frame.name)) throw new Error('Responsive targets require unique node identities, admitted properties, source paths and affected frames.');
|
|
99
|
+
const address = JSON.stringify([target.file, target.className]);
|
|
100
|
+
if (sources.has(address)) throw new Error('A responsive source class must have one canonical owner.');
|
|
101
|
+
sources.add(address); ids.add(target.nodeId);
|
|
102
|
+
if (target.permittedValues !== undefined && (!record(target.permittedValues) || Object.entries(target.permittedValues).some(([property, values]) => !target.properties.includes(property)
|
|
103
|
+
|| !Array.isArray(values) || !values.length || values.some(value => !validResponsiveValue(property, value, values))))) throw new Error('Responsive permitted values must be nonempty safe source spellings for admitted properties.');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
export function validateResponsiveState(state, ranges, targets) {
|
|
107
|
+
validateResponsiveRanges(ranges); validateResponsiveTargets(targets);
|
|
108
|
+
if (!exact(state, ['version', 'ranges', 'styles']) || state.version !== 1 || !record(state.styles) || fingerprint(state.ranges) !== fingerprint(ranges)) throw new Error('Responsive authored state must preserve the host range catalog.');
|
|
109
|
+
if (Object.keys(state.styles).length !== targets.length || targets.some(target => !Object.hasOwn(state.styles, target.nodeId))) throw new Error('Responsive authored state must preserve every mapped source owner.');
|
|
110
|
+
for (const target of targets) {
|
|
111
|
+
const styles = state.styles[target.nodeId];
|
|
112
|
+
if (!record(styles) || Object.keys(styles).some(id => !ranges.some(range => range.id === id)) || !record(styles.base)
|
|
113
|
+
|| target.properties.some(property => !Object.hasOwn(styles.base, property))) throw new Error('Every responsive property requires an authored base declaration.');
|
|
114
|
+
for (const [id, declarations] of Object.entries(styles)) {
|
|
115
|
+
if (!record(declarations) || Object.entries(declarations).some(([property, value]) => !target.properties.includes(property)
|
|
116
|
+
|| !validResponsiveValue(property, value, target.permittedValues?.[property]))) throw new Error('Responsive values must match the host properties and value contract.');
|
|
117
|
+
if (Object.hasOwn(declarations, 'display') && (id === 'base') === (declarations.display === RESPONSIVE_COLLAPSED_VALUE)) throw new Error('A collapsed state declares display none only inside a media range; the base range keeps the source display.');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
export function planResponsiveStyleChanges(before, after, ranges, targets) {
|
|
122
|
+
validateResponsiveState(before, ranges, targets); validateResponsiveState(after, ranges, targets);
|
|
123
|
+
const changes = [];
|
|
124
|
+
for (const target of targets) for (const range of ranges) {
|
|
125
|
+
const previous = before.styles[target.nodeId][range.id] ?? {}; const next = after.styles[target.nodeId][range.id] ?? {};
|
|
126
|
+
const declarations = {};
|
|
127
|
+
for (const property of target.properties) if (previous[property] !== next[property]) declarations[property] = next[property] ?? '';
|
|
128
|
+
if (Object.keys(declarations).length) changes.push({ file: target.file, className: target.className, componentFile: target.componentFile,
|
|
129
|
+
declarations, affectedFrames: [...target.affectedFrames], ...(range.media ? { media: { ...range.media } } : {}) });
|
|
130
|
+
}
|
|
131
|
+
if (changes.length > 100) throw new Error('Responsive source changes exceed the inspect operation limit.');
|
|
132
|
+
return changes;
|
|
133
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { writeInspectChanges } from './inspect-writeback.mjs';
|
|
6
|
+
import { validateInspectSourceProofs } from './source-file-proofs.mjs';
|
|
7
|
+
import { workspaceSourceFingerprint as fingerprint } from './workspace-source-identity.mjs';
|
|
8
|
+
import { planResponsiveStyleChanges, responsiveFlexControl, responsiveMediaQuery, responsiveSourceHeader, responsiveSourcePath, validateResponsiveRanges, validateResponsiveState, validateResponsiveTargets } from './source-responsive-codec.mjs';
|
|
9
|
+
|
|
10
|
+
const hash = source => createHash('sha256').update(source).digest('hex');
|
|
11
|
+
const inspectMarker = (target, range) => `/* Pygmalion responsive: .${target.className} @media ${responsiveMediaQuery(range.media)} */`;
|
|
12
|
+
const marker = (target, range) => `/* Pygmalion responsive: .${target.className} ${responsiveSourceHeader(range)} */`;
|
|
13
|
+
const abort = signal => { if (signal?.aborted) throw signal.reason ?? new Error('Responsive source planning was aborted.'); };
|
|
14
|
+
|
|
15
|
+
// A bounded declaration reader, not a Sass evaluator. Unowned cascade rules are rejected.
|
|
16
|
+
function masked(source) {
|
|
17
|
+
return source.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g, value => value.replace(/[^\n]/g, ' '));
|
|
18
|
+
}
|
|
19
|
+
function blocks(source) {
|
|
20
|
+
const text = masked(source); const stack = []; const result = []; let start = 0;
|
|
21
|
+
for (let index = 0; index < text.length; index++) {
|
|
22
|
+
if (text[index] === '{') {
|
|
23
|
+
const block = { header: text.slice(start, index).trim(), start, open: index, close: -1, parent: stack.at(-1) ?? null, declarations: [] };
|
|
24
|
+
stack.push(block); result.push(block); start = index + 1;
|
|
25
|
+
} else if (text[index] === ';') {
|
|
26
|
+
stack.at(-1)?.declarations.push(source.slice(start, index)); start = index + 1;
|
|
27
|
+
} else if (text[index] === '}') {
|
|
28
|
+
const block = stack.pop(); if (!block) throw new Error('Responsive source contains unmatched braces.');
|
|
29
|
+
if (text.slice(start, index).trim()) block.declarations.push(source.slice(start, index));
|
|
30
|
+
block.close = index; start = index + 1;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (stack.length) throw new Error('Responsive source contains unmatched braces.');
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
function declarations(block, properties) {
|
|
37
|
+
const result = {};
|
|
38
|
+
for (const raw of block.declarations) {
|
|
39
|
+
const cleaned = raw.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, '').trim();
|
|
40
|
+
const match = /^([\w-]+)\s*:\s*([\s\S]+)$/.exec(cleaned);
|
|
41
|
+
if (!match || !properties.includes(match[1])) continue;
|
|
42
|
+
if (Object.hasOwn(result, match[1])) throw new Error('Responsive source contains duplicate declarations.');
|
|
43
|
+
result[match[1]] = match[2].trim();
|
|
44
|
+
}
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
function normalizedState(state) {
|
|
48
|
+
const next = structuredClone(state);
|
|
49
|
+
for (const styles of Object.values(next.styles)) for (const [id, values] of Object.entries(styles)) if (id !== 'base' && !Object.keys(values).length) delete styles[id];
|
|
50
|
+
return next;
|
|
51
|
+
}
|
|
52
|
+
// The existing writer removes declarations one at a time. A managed range block that no longer
|
|
53
|
+
// carries any declaration is removed with its marker so cleared overrides leave no empty rule behind.
|
|
54
|
+
function pruneEmptyManagedBlocks(source, targets, ranges) {
|
|
55
|
+
let content = source;
|
|
56
|
+
const whitespaceOnly = block => block.declarations.every(raw => !raw.trim());
|
|
57
|
+
for (const target of targets) for (const range of ranges.slice(1)) {
|
|
58
|
+
const text = marker(target, range); const index = content.indexOf(text);
|
|
59
|
+
if (index < 0) continue;
|
|
60
|
+
const parsed = blocks(content);
|
|
61
|
+
const directive = parsed.find(block => !block.parent && block.open > index && content.slice(index + text.length, block.open).trim() === responsiveSourceHeader(range));
|
|
62
|
+
if (!directive) continue;
|
|
63
|
+
const children = parsed.filter(block => block.parent === directive);
|
|
64
|
+
if (!whitespaceOnly(directive) || children.length !== 1 || children[0].header !== `.${target.className}` || !whitespaceOnly(children[0]) || parsed.some(block => block.parent === children[0])) continue;
|
|
65
|
+
let start = index; let end = directive.close + 1;
|
|
66
|
+
if (content[end] === '\n') end += 1;
|
|
67
|
+
while (start > 0 && content[start - 1] === '\n' && content[start - 2] === '\n') start -= 1;
|
|
68
|
+
content = content.slice(0, start) + content.slice(end);
|
|
69
|
+
}
|
|
70
|
+
return content;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Read authored declarations only. The host owns mixin semantics and supplies exact file proofs. */
|
|
74
|
+
export function readResponsiveLayoutSource({ files, ranges, targets }) {
|
|
75
|
+
validateResponsiveRanges(ranges); validateResponsiveTargets(targets);
|
|
76
|
+
const sources = new Map();
|
|
77
|
+
for (const file of files) {
|
|
78
|
+
if (!responsiveSourcePath(file.file) || sources.has(file.file) || typeof file.source !== 'string' || !/^[a-f\d]{64}$/.test(file.sha256) || hash(file.source) !== file.sha256) throw new Error('Responsive source inputs require unique paths and exact byte hashes.');
|
|
79
|
+
sources.set(file.file, file.source);
|
|
80
|
+
}
|
|
81
|
+
for (const range of ranges) if (range.source?.kind === 'scss-include') {
|
|
82
|
+
const source = sources.get(range.source.proofFile);
|
|
83
|
+
if (source === undefined) throw new Error('The responsive mixin source proof is missing.');
|
|
84
|
+
const text = masked(source);
|
|
85
|
+
const variable = range.source.argument.slice(1);
|
|
86
|
+
if (!new RegExp(`@mixin\\s+${range.source.name}\\s*\\(`).test(text) || !new RegExp(`\\$${variable}\\s*:`).test(text)) throw new Error('The responsive mixin and breakpoint variable must exist in the proven source.');
|
|
87
|
+
}
|
|
88
|
+
const state = { version: 1, ranges: structuredClone(ranges), styles: {} };
|
|
89
|
+
for (const target of targets) {
|
|
90
|
+
const source = sources.get(target.file);
|
|
91
|
+
if (source === undefined || !sources.has(target.componentFile)) throw new Error('Responsive source requires the stylesheet and importing component.');
|
|
92
|
+
const parsed = blocks(source); const styles = {}; const owned = new Set();
|
|
93
|
+
const base = parsed.filter(block => !block.parent && block.header === `.${target.className}`);
|
|
94
|
+
if (base.length !== 1) throw new Error('Responsive source requires one unambiguous top-level class.');
|
|
95
|
+
if (base[0].declarations.some(raw => /^\s*@(?:include|extend|apply)\b/.test(masked(raw)))) throw new Error('Responsive source cannot infer declarations introduced by an unexpanded directive.');
|
|
96
|
+
styles.base = declarations(base[0], target.properties); owned.add(base[0]);
|
|
97
|
+
// Flex controls need actual source flex semantics; sizing-only and collapse-only owners may omit them.
|
|
98
|
+
if (target.properties.some(responsiveFlexControl)) {
|
|
99
|
+
const display = declarations(base[0], ['display']).display;
|
|
100
|
+
if (display !== 'flex' && display !== 'inline-flex') throw new Error('Responsive layout controls require an existing flex source container.');
|
|
101
|
+
}
|
|
102
|
+
for (const range of ranges.slice(1)) {
|
|
103
|
+
const text = marker(target, range); const index = source.indexOf(text);
|
|
104
|
+
if (index < 0) continue;
|
|
105
|
+
if (source.indexOf(text, index + text.length) >= 0) throw new Error('Responsive source markers must be unique.');
|
|
106
|
+
const header = responsiveSourceHeader(range);
|
|
107
|
+
const directive = parsed.find(block => !block.parent && block.open > index && source.slice(index + text.length, block.open).trim() === header);
|
|
108
|
+
if (!directive) throw new Error('The responsive marker does not match its proven source directive.');
|
|
109
|
+
const matches = parsed.filter(block => block.parent === directive && block.header === `.${target.className}`);
|
|
110
|
+
if (matches.length !== 1) throw new Error('A managed responsive range requires one exact class.');
|
|
111
|
+
const values = declarations(matches[0], target.properties); if (Object.keys(values).length) styles[range.id] = values;
|
|
112
|
+
owned.add(matches[0]);
|
|
113
|
+
}
|
|
114
|
+
const classReference = new RegExp(`\\.${target.className}(?![\\w-])`);
|
|
115
|
+
for (const block of parsed) {
|
|
116
|
+
let ancestor = block.parent; let nested = false;
|
|
117
|
+
while (ancestor) { if (owned.has(ancestor)) nested = true; ancestor = ancestor.parent; }
|
|
118
|
+
if (!owned.has(block) && (classReference.test(block.header) || nested) && Object.keys(declarations(block, target.properties)).length) throw new Error('An unowned selector or media rule also controls this responsive source.');
|
|
119
|
+
}
|
|
120
|
+
state.styles[target.nodeId] = styles;
|
|
121
|
+
}
|
|
122
|
+
validateResponsiveState(state, ranges, targets);
|
|
123
|
+
return state;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Return a complete SCSS file plan using existing inspect writers in a private temporary root. */
|
|
127
|
+
export async function compileResponsiveSourcePlan({ appRoot, sourceRevision, mapping: inputMapping, before: inputBefore, after: inputAfter, additionalStyles: inputStyles = [], signal }) {
|
|
128
|
+
abort(signal);
|
|
129
|
+
const { mapping, before, after, additionalStyles } = JSON.parse(fingerprint({ mapping: inputMapping, before: inputBefore, after: inputAfter, additionalStyles: inputStyles }));
|
|
130
|
+
if (mapping?.version !== 1 || mapping.target?.sourceRevision !== sourceRevision) throw new Error('Responsive source plan requires the exact mapped source revision.');
|
|
131
|
+
const styles = planResponsiveStyleChanges(before, after, mapping.ranges, mapping.targets);
|
|
132
|
+
if (!Array.isArray(additionalStyles)) throw new Error('Additional visual styles must be an array.');
|
|
133
|
+
const addresses = new Set();
|
|
134
|
+
for (const style of [...styles, ...additionalStyles]) for (const property of Object.keys(style.declarations)) {
|
|
135
|
+
const address = fingerprint([style.file, style.className, style.media ?? null, property]);
|
|
136
|
+
if (addresses.has(address)) throw new Error('Responsive and visual operations overlap on one source declaration.');
|
|
137
|
+
addresses.add(address);
|
|
138
|
+
}
|
|
139
|
+
const payload = { styles: [...styles, ...additionalStyles], texts: [], operations: [], sourceProof: { sourceRevision,
|
|
140
|
+
files: mapping.files.filter(proof => proof.sha256 !== null) } };
|
|
141
|
+
await validateInspectSourceProofs(appRoot, payload, { sourceRevision });
|
|
142
|
+
const files = [];
|
|
143
|
+
for (const proof of mapping.files) {
|
|
144
|
+
abort(signal);
|
|
145
|
+
if (!responsiveSourcePath(proof.file)) throw new Error('Invalid responsive proof path.');
|
|
146
|
+
if (proof.sha256 === null) {
|
|
147
|
+
try { await fs.lstat(path.join(appRoot, proof.file)); throw new Error('A responsive absence proof is stale.'); }
|
|
148
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
149
|
+
} else files.push({ ...proof, source: await fs.readFile(path.join(appRoot, proof.file), 'utf8') });
|
|
150
|
+
}
|
|
151
|
+
const baseline = readResponsiveLayoutSource({ files, ranges: mapping.ranges, targets: mapping.targets });
|
|
152
|
+
if (fingerprint(normalizedState(before)) !== fingerprint(baseline)) throw new Error('Responsive baseline does not match the actual source declarations.');
|
|
153
|
+
if (!payload.styles.length) return { version: 1, sourceRevision, proofs: structuredClone(mapping.files), changes: [] };
|
|
154
|
+
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'pygmalion-responsive-'));
|
|
155
|
+
try {
|
|
156
|
+
const temporaryProofs = [];
|
|
157
|
+
for (const file of files) {
|
|
158
|
+
abort(signal);
|
|
159
|
+
let content = file.source;
|
|
160
|
+
for (const target of mapping.targets.filter(target => target.file === file.file)) for (const range of mapping.ranges.slice(1)) {
|
|
161
|
+
if (range.source?.kind === 'scss-include') content = content.replace(marker(target, range), inspectMarker(target, range));
|
|
162
|
+
}
|
|
163
|
+
const destination = path.join(temporary, file.file); await fs.mkdir(path.dirname(destination), { recursive: true }); await fs.writeFile(destination, content);
|
|
164
|
+
temporaryProofs.push({ file: file.file, sha256: hash(content) });
|
|
165
|
+
}
|
|
166
|
+
abort(signal);
|
|
167
|
+
await writeInspectChanges(temporary, { ...payload, sourceProof: { sourceRevision, files: temporaryProofs } }, { sourceRevision });
|
|
168
|
+
const nextFiles = [];
|
|
169
|
+
for (const file of files) {
|
|
170
|
+
let content = await fs.readFile(path.join(temporary, file.file), 'utf8');
|
|
171
|
+
// The existing writer owns declarations; this bridge only translates proven directive spellings.
|
|
172
|
+
for (const target of mapping.targets.filter(target => target.file === file.file)) for (const range of mapping.ranges.slice(1)) {
|
|
173
|
+
if (range.source?.kind !== 'scss-include') continue;
|
|
174
|
+
const mediaHeader = `${inspectMarker(target, range)}\n@media ${responsiveMediaQuery(range.media)} {`;
|
|
175
|
+
if (content.includes(mediaHeader)) content = content.replace(mediaHeader, `${marker(target, range)}\n${responsiveSourceHeader(range)} {`);
|
|
176
|
+
else content = content.replace(inspectMarker(target, range), marker(target, range));
|
|
177
|
+
}
|
|
178
|
+
content = pruneEmptyManagedBlocks(content, mapping.targets.filter(target => target.file === file.file), mapping.ranges);
|
|
179
|
+
nextFiles.push({ file: file.file, source: content, sha256: hash(content) });
|
|
180
|
+
}
|
|
181
|
+
abort(signal);
|
|
182
|
+
const reimported = readResponsiveLayoutSource({ files: nextFiles, ranges: mapping.ranges, targets: mapping.targets });
|
|
183
|
+
if (fingerprint(reimported) !== fingerprint(normalizedState(after))) throw new Error('Responsive source output does not round-trip to the authored draft.');
|
|
184
|
+
return { version: 1, sourceRevision, proofs: structuredClone(mapping.files), changes: nextFiles.flatMap(file => {
|
|
185
|
+
const previous = files.find(input => input.file === file.file);
|
|
186
|
+
return file.source === previous.source ? [] : [{ file: file.file, beforeSha256: previous.sha256, content: file.source }];
|
|
187
|
+
}).sort((a, b) => a.file.localeCompare(b.file)) };
|
|
188
|
+
} finally { await fs.rm(temporary, { recursive: true, force: true }); }
|
|
189
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { lstat, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
|
|
6
|
+
const relative = file => typeof file === 'string' && file.length > 0 && !path.isAbsolute(file)
|
|
7
|
+
&& !/^[a-z]:/i.test(file) && !/[\\\x00-\x1f\x7f?#]/.test(file)
|
|
8
|
+
&& !file.split('/').some(part => !part || ['.', '..', '.git'].includes(part));
|
|
9
|
+
|
|
10
|
+
/** Absence is only proven through ordinary directories, including every existing ancestor. */
|
|
11
|
+
export async function assertSourceInputParent(root, file) {
|
|
12
|
+
if (!relative(file)) throw new Error('A source input requires a relative file path.');
|
|
13
|
+
let directory = root;
|
|
14
|
+
for (const part of file.split('/').slice(0, -1)) {
|
|
15
|
+
directory = path.join(directory, part);
|
|
16
|
+
let stat;
|
|
17
|
+
try { stat = await lstat(directory); }
|
|
18
|
+
catch (error) { if (error.code === 'ENOENT') return; throw error; }
|
|
19
|
+
if (!stat.isDirectory() || await realpath(directory) !== directory) throw new Error('Source inputs cannot traverse linked directories.');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function readSourceInputs(appRoot, files, { optionalFiles = [], signal, maxBytes = 32 * 1024 * 1024 } = {}) {
|
|
24
|
+
if (!Array.isArray(files) || !files.length || files.length > 3000 || new Set(files).size !== files.length
|
|
25
|
+
|| files.some(file => !relative(file)) || !Number.isSafeInteger(maxBytes) || maxBytes < 1) throw new Error('Source inputs require unique relative files and a positive byte limit.');
|
|
26
|
+
const selected = [...files]; const optional = new Set(optionalFiles); const inputs = {}; let bytes = 0;
|
|
27
|
+
const root = await realpath(appRoot);
|
|
28
|
+
for (const file of selected) {
|
|
29
|
+
signal?.throwIfAborted();
|
|
30
|
+
await assertSourceInputParent(root, file);
|
|
31
|
+
const absolute = path.join(root, file);
|
|
32
|
+
let stat;
|
|
33
|
+
try { stat = await lstat(absolute); }
|
|
34
|
+
catch (error) { if (!optional.has(file) || error.code !== 'ENOENT') throw error; Object.defineProperty(inputs, file, { value: null, enumerable: true }); continue; }
|
|
35
|
+
if (!stat.isFile() || await realpath(absolute) !== absolute) throw new Error(`Source input must be a regular unlinked file: ${file}`);
|
|
36
|
+
if (bytes + stat.size > maxBytes) throw new Error('Source inputs exceed the byte limit.');
|
|
37
|
+
const buffer = await readFile(absolute); bytes += buffer.length;
|
|
38
|
+
if (bytes > maxBytes || !Buffer.from(buffer.toString('utf8')).equals(buffer)) throw new Error('Source inputs must be UTF-8 within the byte limit.');
|
|
39
|
+
Object.defineProperty(inputs, file, { value: buffer.toString('utf8'), enumerable: true, configurable: true, writable: true });
|
|
40
|
+
}
|
|
41
|
+
signal?.throwIfAborted();
|
|
42
|
+
return inputs;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Seed isolated UTF-8 source files and always remove the directory after the callback settles. */
|
|
46
|
+
export async function withSourceFiles({ files, signal }, operation) {
|
|
47
|
+
const entries = files instanceof Map ? [...files] : null;
|
|
48
|
+
if (!entries || entries.length > 3000 || typeof operation !== 'function'
|
|
49
|
+
|| entries.some(([file, source]) => !relative(file) || typeof source !== 'string' || Buffer.from(source).toString('utf8') !== source)) {
|
|
50
|
+
throw new Error('Isolated source files require a map of relative paths to UTF-8 strings.');
|
|
51
|
+
}
|
|
52
|
+
signal?.throwIfAborted();
|
|
53
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'pygmalion-source-'));
|
|
54
|
+
try {
|
|
55
|
+
for (const [file, source] of entries) {
|
|
56
|
+
signal?.throwIfAborted();
|
|
57
|
+
const destination = path.join(root, file);
|
|
58
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
59
|
+
await writeFile(destination, source, { flag: 'wx' });
|
|
60
|
+
}
|
|
61
|
+
signal?.throwIfAborted();
|
|
62
|
+
const result = await operation(root);
|
|
63
|
+
signal?.throwIfAborted();
|
|
64
|
+
return result;
|
|
65
|
+
} finally { await rm(root, { recursive: true, force: true }); }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function readProvenSourceFiles(appRoot, proofs, options = {}) {
|
|
69
|
+
if (!Array.isArray(proofs) || !proofs.length || proofs.length > 500 || proofs.some(proof => !proof ||
|
|
70
|
+
!(proof.sha256 === null && options.optionalFiles?.includes(proof.file) || typeof proof.sha256 === 'string' && /^[a-f0-9]{64}$/.test(proof.sha256)))) {
|
|
71
|
+
throw new Error('Source proofs require exact hashes or explicitly allowed absence.');
|
|
72
|
+
}
|
|
73
|
+
const selected = proofs.map(({ file, sha256 }) => ({ file, sha256 }));
|
|
74
|
+
const inputs = await readSourceInputs(appRoot, selected.map(proof => proof.file), options);
|
|
75
|
+
return new Map(selected.map(proof => {
|
|
76
|
+
const source = inputs[proof.file];
|
|
77
|
+
const hash = source === null ? null : createHash('sha256').update(source).digest('hex');
|
|
78
|
+
if (hash !== proof.sha256) throw new Error(`Source changed since the baseline was loaded: ${proof.file}`);
|
|
79
|
+
return [proof.file, { ...proof, source }];
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** Local JSON transport. Operation names and all filesystem/build callbacks come from host configuration. */
|
|
2
|
+
export function sourceServicePlugin({ name = 'pygmalion-source-service', endpoint, operations, createService, transformInput = (_operation, input) => input, maxBytes = 40 * 1024 * 1024 }) {
|
|
3
|
+
if (!/^\/[A-Za-z0-9/_-]+$/.test(endpoint) || endpoint.endsWith('/') || !Array.isArray(operations) || !operations.length
|
|
4
|
+
|| operations.some(operation => !/^[a-z][A-Za-z]+$/.test(operation) || ['constructor', 'dispose'].includes(operation))
|
|
5
|
+
|| typeof createService !== 'function' || typeof transformInput !== 'function' || !Number.isSafeInteger(maxBytes) || maxBytes < 1) {
|
|
6
|
+
throw new Error('A source transport requires a local endpoint, operation allowlist, factory, and byte limit.');
|
|
7
|
+
}
|
|
8
|
+
operations = [...operations];
|
|
9
|
+
let service; const controllers = new Set();
|
|
10
|
+
const active = () => { if (!service) throw new Error('The source service has not started.'); return service; };
|
|
11
|
+
return {
|
|
12
|
+
validatePreviewRequest(request, signal) { return active().validatePreviewRequest(request, signal); },
|
|
13
|
+
preparePreviewSource(request, root, signal) { return active().preparePreviewSource(request, root, signal); },
|
|
14
|
+
plugin: {
|
|
15
|
+
name, apply: 'serve',
|
|
16
|
+
configureServer(server) {
|
|
17
|
+
service = createService(server);
|
|
18
|
+
if (operations.some(operation => typeof service?.[operation] !== 'function')) throw new Error('The source service must implement each configured operation.');
|
|
19
|
+
server.httpServer?.once('close', () => {
|
|
20
|
+
for (const controller of controllers) controller.abort();
|
|
21
|
+
controllers.clear();
|
|
22
|
+
Promise.resolve().then(() => service.dispose()).catch(error => server.config?.logger?.error(String(error)));
|
|
23
|
+
});
|
|
24
|
+
server.middlewares.use(async (req, res, next) => {
|
|
25
|
+
const pathname = req.url?.split('?')[0];
|
|
26
|
+
const operation = pathname?.startsWith(`${endpoint}/`) ? pathname.slice(endpoint.length + 1) : undefined;
|
|
27
|
+
if (!operations.includes(operation)) return next();
|
|
28
|
+
res.setHeader('content-type', 'application/json; charset=utf-8'); res.setHeader('cache-control', 'no-store');
|
|
29
|
+
if (req.method !== 'POST') { res.statusCode = 405; res.end(JSON.stringify({ error: 'Source operations require POST.' })); return; }
|
|
30
|
+
try {
|
|
31
|
+
if (req.headers.origin && new URL(req.headers.origin).host !== req.headers.host) throw new Error('Origin mismatch.');
|
|
32
|
+
} catch { res.statusCode = 403; res.end(JSON.stringify({ error: 'Source operations require the editor origin.' })); return; }
|
|
33
|
+
if (!/^application\/json(?:;|$)/i.test(req.headers['content-type'] ?? '')) {
|
|
34
|
+
res.statusCode = 415; res.end(JSON.stringify({ error: 'Source operations require JSON.' })); return;
|
|
35
|
+
}
|
|
36
|
+
const controller = new AbortController(); controllers.add(controller);
|
|
37
|
+
const abort = () => { if (!res.writableEnded) controller.abort(); };
|
|
38
|
+
req.once('aborted', abort); res.once('close', abort);
|
|
39
|
+
try {
|
|
40
|
+
const chunks = []; let bytes = 0;
|
|
41
|
+
for await (const chunk of req) {
|
|
42
|
+
bytes += chunk.length;
|
|
43
|
+
if (bytes > maxBytes) throw new Error('Source request exceeds the byte limit.');
|
|
44
|
+
chunks.push(chunk);
|
|
45
|
+
}
|
|
46
|
+
controller.signal.throwIfAborted();
|
|
47
|
+
const input = transformInput(operation, JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
|
48
|
+
const result = await service[operation](input, controller.signal);
|
|
49
|
+
controller.signal.throwIfAborted(); res.end(JSON.stringify(result));
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (!res.destroyed && !res.writableEnded) { res.statusCode = 409; res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) })); }
|
|
52
|
+
} finally { controllers.delete(controller); req.removeListener('aborted', abort); res.removeListener('close', abort); }
|
|
53
|
+
});
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { createWorkspaceSourceSession } from './workspace-source-session.mjs';
|
|
2
|
+
export { createTokenSourceService } from './token-source-service.mjs';
|
|
3
|
+
export { sourceServicePlugin } from './source-service-plugin.mjs';
|
|
4
|
+
export { assertSourceInputParent, readSourceInputs, readProvenSourceFiles, withSourceFiles } from './source-service-inputs.mjs';
|