@shapeshift-labs/frontier-lang-css 0.1.0 → 0.1.2
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 +11 -2
- package/dist/index.d.ts +43 -0
- package/dist/index.js +5 -0
- package/dist/semantic-merge.js +221 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -211,7 +211,8 @@ Package source repositories:
|
|
|
211
211
|
```js
|
|
212
212
|
import {
|
|
213
213
|
createCssSemanticMergeEvidence,
|
|
214
|
-
emitCssWithSourceMap
|
|
214
|
+
emitCssWithSourceMap,
|
|
215
|
+
safeMergeCssSource
|
|
215
216
|
} from '@shapeshift-labs/frontier-lang-css';
|
|
216
217
|
|
|
217
218
|
const { code, sourceMap } = emitCssWithSourceMap(document, {
|
|
@@ -222,12 +223,20 @@ const { code, sourceMap } = emitCssWithSourceMap(document, {
|
|
|
222
223
|
const evidence = createCssSemanticMergeEvidence(code, {
|
|
223
224
|
sourcePath: 'todo.css'
|
|
224
225
|
});
|
|
226
|
+
|
|
227
|
+
const merge = safeMergeCssSource({
|
|
228
|
+
sourcePath: 'button.css',
|
|
229
|
+
baseSourceText: '.button { color: red; }\n',
|
|
230
|
+
workerSourceText: '.button { color: blue; }\n',
|
|
231
|
+
headSourceText: '.button { color: red; background-color: white; }\n'
|
|
232
|
+
});
|
|
225
233
|
```
|
|
226
234
|
|
|
227
|
-
`sourceMap.mappings` links emitted rule blocks back to Frontier Lang semantic node ids. `createCssSemanticMergeEvidence` records selectors, specificity, declarations, custom properties, cascade keys, source spans, stable hashes, and fail-closed proof gaps for cascade/render-sensitive CSS surfaces.
|
|
235
|
+
`sourceMap.mappings` links emitted rule blocks back to Frontier Lang semantic node ids. `createCssSemanticMergeEvidence` records selectors, specificity, declarations, custom properties, cascade keys, source spans, stable hashes, and fail-closed proof gaps for cascade/render-sensitive CSS surfaces. `safeMergeCssSource` admits independent unscoped declaration edits by cascade key and blocks overlapping declaration changes or proof gaps.
|
|
228
236
|
|
|
229
237
|
## Support Boundary
|
|
230
238
|
|
|
231
239
|
- Ready evidence: style rules, selectors, specificity, declarations, custom properties, source spans, stable hashes.
|
|
240
|
+
- Safe merge: independent unscoped declarations with non-overlapping cascade keys; output is a canonical CSS render and not a byte/trivia-preserving claim.
|
|
232
241
|
- Review-only gaps: shorthands without longhand expansion, scoped cascade under `@media` / `@supports` / `@container` / `@layer`, `@keyframes`, `@font-face`, `@page`, browser layout and render equivalence.
|
|
233
242
|
- Claims: `autoMergeClaim`, `semanticEquivalenceClaim`, `browserCascadeEquivalenceClaim`, and `browserRenderEquivalenceClaim` remain false.
|
package/dist/index.d.ts
CHANGED
|
@@ -217,6 +217,48 @@ export interface CssSemanticMergeEvidence {
|
|
|
217
217
|
readonly browserRenderEquivalenceClaim: false;
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
+
export interface CssSafeMergeConflict {
|
|
221
|
+
readonly code: string;
|
|
222
|
+
readonly gateId: 'css-semantic-merge' | string;
|
|
223
|
+
readonly sourcePath?: string;
|
|
224
|
+
readonly details: Readonly<Record<string, unknown>> & { readonly reasonCode: string; readonly conflictKey: string };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export interface CssSafeMergeAdmission {
|
|
228
|
+
readonly status: 'auto-merge-candidate' | 'blocked' | string;
|
|
229
|
+
readonly action: 'apply-css' | 'human-review' | string;
|
|
230
|
+
readonly reviewRequired: boolean;
|
|
231
|
+
readonly reasonCodes: readonly string[];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export interface CssSafeMergeResult {
|
|
235
|
+
readonly kind: 'frontier.lang.cssSafeMerge';
|
|
236
|
+
readonly version: 1;
|
|
237
|
+
readonly id: string;
|
|
238
|
+
readonly sourcePath?: string;
|
|
239
|
+
readonly status: 'merged' | 'blocked' | string;
|
|
240
|
+
readonly operation: string;
|
|
241
|
+
readonly mergedSourceText?: string;
|
|
242
|
+
readonly mergedSourceHash?: string;
|
|
243
|
+
readonly conflicts: readonly CssSafeMergeConflict[];
|
|
244
|
+
readonly admission: CssSafeMergeAdmission;
|
|
245
|
+
readonly autoMergeClaim: false;
|
|
246
|
+
readonly semanticEquivalenceClaim: false;
|
|
247
|
+
readonly baseSheetHash?: string;
|
|
248
|
+
readonly workerSheetHash?: string;
|
|
249
|
+
readonly headSheetHash?: string;
|
|
250
|
+
readonly workerChangedDeclarations?: number;
|
|
251
|
+
readonly headChangedDeclarations?: number;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export interface CssSafeMergeInput {
|
|
255
|
+
readonly id?: string;
|
|
256
|
+
readonly sourcePath?: string;
|
|
257
|
+
readonly baseSourceText?: string;
|
|
258
|
+
readonly workerSourceText?: string;
|
|
259
|
+
readonly headSourceText?: string;
|
|
260
|
+
}
|
|
261
|
+
|
|
220
262
|
export declare function toCssAst(document: FrontierLangDocument, options?: CssProjectionOptions): CssAstStylesheet;
|
|
221
263
|
export declare function renderCssAst(ast: CssAstStylesheet): string;
|
|
222
264
|
export declare function renderCssAstWithSourceMap(ast: CssAstStylesheet, options?: CssProjectionOptions): CssProjectionResult;
|
|
@@ -224,3 +266,4 @@ export declare function emitCss(document: FrontierLangDocument, options?: CssPro
|
|
|
224
266
|
export declare function emitCssWithSourceMap(document: FrontierLangDocument, options?: CssProjectionOptions): CssProjectionWithAstResult;
|
|
225
267
|
export declare function parseCssSemanticSheet(sourceText: string, options?: CssProjectionOptions): CssSemanticSheet;
|
|
226
268
|
export declare function createCssSemanticMergeEvidence(sourceText: string, options?: CssProjectionOptions): CssSemanticMergeEvidence;
|
|
269
|
+
export declare function safeMergeCssSource(input?: CssSafeMergeInput): CssSafeMergeResult;
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { hashSemanticValue } from '@shapeshift-labs/frontier-lang-kernel';
|
|
2
2
|
import { createCssModuleEvidence } from './css-modules.js';
|
|
3
|
+
import { safeMergeCssSource as safeMergeCssSourceImpl } from './semantic-merge.js';
|
|
3
4
|
|
|
4
5
|
const ShorthandProperties = new Set(['all', 'animation', 'background', 'border', 'border-block', 'border-color', 'border-image', 'border-inline', 'border-radius', 'border-style', 'border-width', 'columns', 'flex', 'font', 'gap', 'grid', 'grid-area', 'grid-column', 'grid-row', 'inset', 'list-style', 'margin', 'offset', 'outline', 'overflow', 'padding', 'place-content', 'place-items', 'place-self', 'text-decoration', 'transition']);
|
|
5
6
|
const RuntimeAtRules = new Set(['keyframes', 'font-face', 'page', 'property']);
|
|
@@ -107,6 +108,10 @@ export function createCssSemanticMergeEvidence(sourceText, options = {}) {
|
|
|
107
108
|
};
|
|
108
109
|
}
|
|
109
110
|
|
|
111
|
+
export function safeMergeCssSource(input = {}) {
|
|
112
|
+
return safeMergeCssSourceImpl(input, { parseCssSemanticSheet, hashSemanticValue });
|
|
113
|
+
}
|
|
114
|
+
|
|
110
115
|
function parseCssBlocks(sourceText, start, end, scopes, lineStarts, sourceHash) {
|
|
111
116
|
const records = [];
|
|
112
117
|
let index = start;
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
function safeMergeCssSource(input = {}, context = {}) {
|
|
2
|
+
const parseSheet = context.parseCssSemanticSheet;
|
|
3
|
+
const hash = context.hashSemanticValue;
|
|
4
|
+
const id = String(input.id ?? 'css_safe_merge');
|
|
5
|
+
const sourcePath = input.sourcePath;
|
|
6
|
+
const base = input.baseSourceText;
|
|
7
|
+
const worker = input.workerSourceText ?? base;
|
|
8
|
+
const head = input.headSourceText ?? base;
|
|
9
|
+
if (typeof base !== 'string' || typeof worker !== 'string' || typeof head !== 'string') return blocked(id, sourcePath, 'css-source-text-missing');
|
|
10
|
+
if (worker === head) return merged(id, sourcePath, worker, 'worker-head-identical', hash);
|
|
11
|
+
if (worker === base) return merged(id, sourcePath, head, 'worker-unchanged', hash);
|
|
12
|
+
if (head === base) return merged(id, sourcePath, worker, 'head-unchanged', hash);
|
|
13
|
+
const sheets = {
|
|
14
|
+
base: parseSheet(base, { sourcePath }),
|
|
15
|
+
worker: parseSheet(worker, { sourcePath }),
|
|
16
|
+
head: parseSheet(head, { sourcePath })
|
|
17
|
+
};
|
|
18
|
+
const indexes = Object.fromEntries(Object.entries(sheets).map(([name, sheet]) => [name, declarationIndex(sheet)]));
|
|
19
|
+
const changed = {
|
|
20
|
+
worker: changedDeclarations(indexes.base, indexes.worker, 'worker'),
|
|
21
|
+
head: changedDeclarations(indexes.base, indexes.head, 'head')
|
|
22
|
+
};
|
|
23
|
+
const proofConflicts = proofGapConflicts(id, sourcePath, changed, indexes);
|
|
24
|
+
const overlapConflicts = [
|
|
25
|
+
...overlapDeclarationConflicts(id, sourcePath, changed.worker, changed.head),
|
|
26
|
+
...shorthandOverlapConflicts(id, sourcePath, changed.worker, changed.head)
|
|
27
|
+
];
|
|
28
|
+
const conflicts = [...proofConflicts, ...overlapConflicts];
|
|
29
|
+
if (conflicts.length) return blocked(id, sourcePath, 'css-semantic-merge-conflict', conflicts);
|
|
30
|
+
const mergedIndex = applyDeclarationChanges(applyDeclarationChanges(indexes.base, changed.head), changed.worker);
|
|
31
|
+
return merged(id, sourcePath, renderDeclarationIndex(mergedIndex), 'semantic-declaration-merge', hash, {
|
|
32
|
+
baseSheetHash: sheets.base.sheetHash,
|
|
33
|
+
workerSheetHash: sheets.worker.sheetHash,
|
|
34
|
+
headSheetHash: sheets.head.sheetHash,
|
|
35
|
+
workerChangedDeclarations: changed.worker.length,
|
|
36
|
+
headChangedDeclarations: changed.head.length
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function declarationIndex(sheet) {
|
|
41
|
+
const declarations = new Map();
|
|
42
|
+
const order = [];
|
|
43
|
+
for (const record of sheet.records) {
|
|
44
|
+
if (record.kind !== 'rule') continue;
|
|
45
|
+
const ruleKey = ruleIdentityKey(record);
|
|
46
|
+
for (const declaration of record.declarations ?? []) {
|
|
47
|
+
const entry = {
|
|
48
|
+
key: declaration.cascadeKey,
|
|
49
|
+
ruleKey,
|
|
50
|
+
selectors: record.selectors,
|
|
51
|
+
scopes: record.scopes ?? [],
|
|
52
|
+
property: declaration.property,
|
|
53
|
+
value: declaration.value,
|
|
54
|
+
important: declaration.important,
|
|
55
|
+
declarationHash: declaration.declarationHash,
|
|
56
|
+
proofGaps: proofGapsForDeclaration(record, declaration)
|
|
57
|
+
};
|
|
58
|
+
declarations.set(entry.key, entry);
|
|
59
|
+
order.push(entry.key);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { declarations, order: unique(order) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function changedDeclarations(baseIndex, currentIndex, side) {
|
|
66
|
+
const keys = unique([...baseIndex.declarations.keys(), ...currentIndex.declarations.keys()]);
|
|
67
|
+
return keys.flatMap((key) => {
|
|
68
|
+
const before = baseIndex.declarations.get(key);
|
|
69
|
+
const after = currentIndex.declarations.get(key);
|
|
70
|
+
if ((before?.declarationHash ?? '') === (after?.declarationHash ?? '')) return [];
|
|
71
|
+
return [{ side, key, before, after, kind: before && after ? 'update' : before ? 'delete' : 'add' }];
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function proofGapConflicts(id, sourcePath, changed, indexes) {
|
|
76
|
+
const changedKeys = new Set([...changed.worker, ...changed.head].map((change) => change.key));
|
|
77
|
+
return [...changedKeys].flatMap((key) => {
|
|
78
|
+
const entry = indexes.worker.declarations.get(key) ?? indexes.head.declarations.get(key);
|
|
79
|
+
return (entry?.proofGaps ?? [])
|
|
80
|
+
.filter((gap) => !canAdmitProofGap(gap, entry, changed, indexes))
|
|
81
|
+
.map((gap) => conflict(id, sourcePath, 'css-proof-gap-blocked', gap.code, {
|
|
82
|
+
cascadeKey: key,
|
|
83
|
+
proofGap: gap
|
|
84
|
+
}));
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function overlapDeclarationConflicts(id, sourcePath, workerChanges, headChanges) {
|
|
89
|
+
const headByKey = new Map(headChanges.map((change) => [change.key, change]));
|
|
90
|
+
return workerChanges.flatMap((workerChange) => {
|
|
91
|
+
const headChange = headByKey.get(workerChange.key);
|
|
92
|
+
if (!headChange || sameChange(workerChange, headChange)) return [];
|
|
93
|
+
return [conflict(id, sourcePath, 'css-cascade-declaration-conflict', 'css-cascade-declaration-conflict', {
|
|
94
|
+
cascadeKey: workerChange.key,
|
|
95
|
+
worker: changeDetails(workerChange),
|
|
96
|
+
head: changeDetails(headChange)
|
|
97
|
+
})];
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function shorthandOverlapConflicts(id, sourcePath, workerChanges, headChanges) {
|
|
102
|
+
return workerChanges.flatMap((workerChange) => headChanges
|
|
103
|
+
.filter((headChange) => workerChange.key !== headChange.key && declarationsOverlapByShorthandGroup(workerChange.after ?? workerChange.before, headChange.after ?? headChange.before))
|
|
104
|
+
.map((headChange) => conflict(id, sourcePath, 'css-shorthand-longhand-conflict', 'css-shorthand-longhand-conflict', {
|
|
105
|
+
worker: changeDetails(workerChange),
|
|
106
|
+
head: changeDetails(headChange)
|
|
107
|
+
})));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function canAdmitProofGap(gap, entry, changed, indexes) {
|
|
111
|
+
if (gap.code !== 'css-shorthand-expansion-unproved' || !entry) return false;
|
|
112
|
+
const group = shorthandGroupForProperty(entry.property);
|
|
113
|
+
if (!group || hasRelatedExistingDeclaration(entry, indexes)) return false;
|
|
114
|
+
const oppositeChanges = [...changed.worker, ...changed.head].filter((change) => change.key !== entry.key);
|
|
115
|
+
return !oppositeChanges.some((change) => declarationsOverlapByShorthandGroup(entry, change.after ?? change.before));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function hasRelatedExistingDeclaration(entry, indexes) {
|
|
119
|
+
return Object.values(indexes).some((index) => [...index.declarations.values()].some((candidate) => candidate.key !== entry.key && declarationsOverlapByShorthandGroup(entry, candidate)));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function declarationsOverlapByShorthandGroup(left, right) {
|
|
123
|
+
if (!left || !right || left.ruleKey !== right.ruleKey) return false;
|
|
124
|
+
const leftGroup = shorthandGroupForProperty(left.property);
|
|
125
|
+
const rightGroup = shorthandGroupForProperty(right.property);
|
|
126
|
+
return Boolean(leftGroup && rightGroup && leftGroup === rightGroup);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function applyDeclarationChanges(index, changes) {
|
|
130
|
+
const declarations = new Map(index.declarations);
|
|
131
|
+
const order = [...index.order];
|
|
132
|
+
for (const change of changes) {
|
|
133
|
+
if (!change.after) declarations.delete(change.key);
|
|
134
|
+
else {
|
|
135
|
+
declarations.set(change.key, change.after);
|
|
136
|
+
if (!order.includes(change.key)) order.push(change.key);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return { declarations, order: order.filter((key) => declarations.has(key)) };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function renderDeclarationIndex(index) {
|
|
143
|
+
const groups = new Map();
|
|
144
|
+
for (const key of index.order) {
|
|
145
|
+
const declaration = index.declarations.get(key);
|
|
146
|
+
if (!declaration || declaration.scopes.length) continue;
|
|
147
|
+
groups.set(declaration.ruleKey, [...(groups.get(declaration.ruleKey) ?? []), declaration]);
|
|
148
|
+
}
|
|
149
|
+
const chunks = [];
|
|
150
|
+
for (const declarations of groups.values()) {
|
|
151
|
+
chunks.push(`${declarations[0].selectors.join(', ')} {`);
|
|
152
|
+
for (const declaration of declarations) chunks.push(` ${declaration.property}: ${declaration.value};`);
|
|
153
|
+
chunks.push('}', '');
|
|
154
|
+
}
|
|
155
|
+
return `${chunks.join('\n').trimEnd()}\n`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function merged(id, sourcePath, sourceText, operation, hash, extra = {}) {
|
|
159
|
+
return result(id, sourcePath, 'merged', {
|
|
160
|
+
operation,
|
|
161
|
+
mergedSourceText: sourceText,
|
|
162
|
+
mergedSourceHash: hash?.(sourceText),
|
|
163
|
+
conflicts: [],
|
|
164
|
+
...extra
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function blocked(id, sourcePath, reasonCode, conflicts = []) {
|
|
169
|
+
return result(id, sourcePath, 'blocked', {
|
|
170
|
+
operation: 'blocked',
|
|
171
|
+
conflicts: conflicts.length ? conflicts : [conflict(id, sourcePath, reasonCode, reasonCode)]
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function result(id, sourcePath, status, body) {
|
|
176
|
+
return {
|
|
177
|
+
kind: 'frontier.lang.cssSafeMerge',
|
|
178
|
+
version: 1,
|
|
179
|
+
id,
|
|
180
|
+
sourcePath,
|
|
181
|
+
status,
|
|
182
|
+
autoMergeClaim: false,
|
|
183
|
+
semanticEquivalenceClaim: false,
|
|
184
|
+
...body,
|
|
185
|
+
admission: {
|
|
186
|
+
status: status === 'merged' ? 'auto-merge-candidate' : 'blocked',
|
|
187
|
+
action: status === 'merged' ? 'apply-css' : 'human-review',
|
|
188
|
+
reviewRequired: status !== 'merged',
|
|
189
|
+
reasonCodes: unique((body.conflicts ?? []).map((item) => item.details.reasonCode))
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function conflict(id, sourcePath, code, reasonCode, details = {}) {
|
|
195
|
+
return { code, gateId: 'css-semantic-merge', sourcePath, details: { reasonCode, conflictKey: `css#${id}#${reasonCode}#${details.cascadeKey ?? sourcePath ?? 'source'}`, ...details } };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function sameChange(left, right) { return (left.after?.declarationHash ?? '') === (right.after?.declarationHash ?? '') && left.kind === right.kind; }
|
|
199
|
+
function changeDetails(change) { return { kind: change.kind, property: change.after?.property ?? change.before?.property, value: change.after?.value, beforeValue: change.before?.value }; }
|
|
200
|
+
function ruleIdentityKey(record) { return [...(record.scopes ?? []), record.selectors.join(',')].join('::'); }
|
|
201
|
+
function proofGapsForDeclaration(record, declaration) {
|
|
202
|
+
return (record.proofGaps ?? []).filter((gap) => gap.code !== 'css-shorthand-expansion-unproved' || gap.summary.includes(` ${declaration.property} `));
|
|
203
|
+
}
|
|
204
|
+
function unique(values) { return [...new Set(values.filter(Boolean))]; }
|
|
205
|
+
|
|
206
|
+
function shorthandGroupForProperty(property) {
|
|
207
|
+
if (ShorthandGroups.has(property)) return property;
|
|
208
|
+
for (const [group, longhands] of ShorthandGroups) if (longhands.includes(property)) return group;
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const ShorthandGroups = new Map([
|
|
213
|
+
['background', ['background-attachment', 'background-clip', 'background-color', 'background-image', 'background-origin', 'background-position', 'background-repeat', 'background-size']],
|
|
214
|
+
['border', ['border-color', 'border-style', 'border-width', 'border-top', 'border-right', 'border-bottom', 'border-left']],
|
|
215
|
+
['font', ['font-family', 'font-size', 'font-stretch', 'font-style', 'font-variant', 'font-weight', 'line-height']],
|
|
216
|
+
['list-style', ['list-style-image', 'list-style-position', 'list-style-type']],
|
|
217
|
+
['margin', ['margin-top', 'margin-right', 'margin-bottom', 'margin-left']],
|
|
218
|
+
['padding', ['padding-top', 'padding-right', 'padding-bottom', 'padding-left']]
|
|
219
|
+
]);
|
|
220
|
+
|
|
221
|
+
export { safeMergeCssSource };
|
package/package.json
CHANGED