@shapeshift-labs/frontier-lang-css 0.1.3 → 0.1.5

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 CHANGED
@@ -232,11 +232,11 @@ const merge = safeMergeCssSource({
232
232
  });
233
233
  ```
234
234
 
235
- `sourceMap.mappings` links emitted rule blocks back to Frontier Lang semantic node ids. `createCssSemanticMergeEvidence` records selectors, specificity, declarations, custom properties, cascade keys, CSS Modules exports, ICSS edges, 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 for `.module.css` files it classifies exported local classes, `composes`, and ICSS import/export records as explicit merge contracts.
235
+ `sourceMap.mappings` links emitted rule blocks back to Frontier Lang semantic node ids. `createCssSemanticMergeEvidence` records selectors, specificity, declarations, custom properties, cascade keys, statement-form at-rules, CSS Modules exports, ICSS edges, scoped cascade graph proof hashes, source spans, stable hashes, and fail-closed proof gaps for cascade/render-sensitive CSS surfaces. `safeMergeCssSource` admits independent declaration edits by cascade key, including existing scoped `@media` / `@supports` / `@container` / `@layer` declarations when a scoped cascade graph proof hash is supplied, preserves unchanged statement-form at-rules such as `@layer reset, components;`, and for `.module.css` files it classifies exported local classes, `composes`, and ICSS import/export records as explicit merge contracts.
236
236
 
237
237
  ## Support Boundary
238
238
 
239
- - Ready evidence: style rules, selectors, specificity, declarations, custom properties, CSS Modules local exports, generated class-name map coverage, JS/TS use-site graph hashes, composition graph hashes, ICSS graph hashes, source spans, stable hashes.
240
- - Safe merge: independent unscoped declarations with non-overlapping cascade keys; explicit CSS Modules export additions/deletions when generated class-name and JS/TS use-site graph proof is supplied; composition edits when composition graph proof is supplied; ICSS edits when ICSS graph proof is supplied. Output is a canonical CSS render and not a byte/trivia-preserving claim.
241
- - Review-only gaps: incomplete generated class-name maps, unproved CSS Modules use-site graphs, unproved composition or ICSS graphs, shorthands without longhand expansion, scoped cascade under `@media` / `@supports` / `@container` / `@layer`, `@keyframes`, `@font-face`, `@page`, browser layout and render equivalence.
239
+ - Ready evidence: style rules, selectors, specificity, declarations, custom properties, statement-form at-rules, CSS Modules local exports, generated class-name map coverage, JS/TS use-site graph hashes, composition graph hashes, ICSS graph hashes, scoped cascade graph hashes, source spans, stable hashes.
240
+ - Safe merge: independent declarations with non-overlapping cascade keys; unchanged statement-form at-rules preserved in canonical output; existing scoped declaration edits when scoped cascade graph proof is supplied; explicit CSS Modules export additions/deletions when generated class-name and JS/TS use-site graph proof is supplied; composition edits when composition graph proof is supplied; ICSS edits when ICSS graph proof is supplied. Output is a canonical CSS render and not a byte/trivia-preserving claim.
241
+ - Review-only gaps: incomplete generated class-name maps, unproved CSS Modules use-site graphs, unproved composition or ICSS graphs, shorthands without longhand expansion, statement-form at-rule order/condition changes, one-sided or structurally changed scoped cascade under `@media` / `@supports` / `@container` / `@layer`, `@keyframes`, `@font-face`, `@page`, browser layout and render equivalence.
242
242
  - Claims: `autoMergeClaim`, `semanticEquivalenceClaim`, `browserCascadeEquivalenceClaim`, and `browserRenderEquivalenceClaim` remain false.
package/dist/index.d.ts CHANGED
@@ -12,6 +12,7 @@ export interface CssProjectionOptions {
12
12
  readonly jsTsUseSiteGraphHash?: string;
13
13
  readonly cssModuleCompositionGraphHash?: string;
14
14
  readonly icssGraphHash?: string;
15
+ readonly scopedCascadeGraphHash?: string;
15
16
  readonly targetPath?: string;
16
17
  readonly semanticIndexId?: string;
17
18
  readonly sourceSpansBySemanticNodeId?: Readonly<Record<string, CssSourceSpan>>;
@@ -124,6 +125,8 @@ export interface CssSemanticRecord {
124
125
  readonly atRuleName?: string;
125
126
  readonly conditionText?: string;
126
127
  readonly scopeKey?: string;
128
+ readonly statementText?: string;
129
+ readonly scopedCascadeGraphHash?: string;
127
130
  readonly sourceSpan: CssSourceSpan;
128
131
  readonly sourceHash: string;
129
132
  readonly ruleHash?: string;
@@ -266,6 +269,7 @@ export interface CssSafeMergeInput {
266
269
  readonly jsTsUseSiteGraphHash?: string;
267
270
  readonly cssModuleCompositionGraphHash?: string;
268
271
  readonly icssGraphHash?: string;
272
+ readonly scopedCascadeGraphHash?: string;
269
273
  readonly baseGeneratedClassNameMap?: Readonly<Record<string, string>>;
270
274
  readonly workerGeneratedClassNameMap?: Readonly<Record<string, string>>;
271
275
  readonly headGeneratedClassNameMap?: Readonly<Record<string, string>>;
@@ -281,6 +285,9 @@ export interface CssSafeMergeInput {
281
285
  readonly baseIcssGraphHash?: string;
282
286
  readonly workerIcssGraphHash?: string;
283
287
  readonly headIcssGraphHash?: string;
288
+ readonly baseScopedCascadeGraphHash?: string;
289
+ readonly workerScopedCascadeGraphHash?: string;
290
+ readonly headScopedCascadeGraphHash?: string;
284
291
  }
285
292
 
286
293
  export declare function toCssAst(document: FrontierLangDocument, options?: CssProjectionOptions): CssAstStylesheet;
package/dist/index.js CHANGED
@@ -59,7 +59,7 @@ export function emitCssWithSourceMap(document, options = {}) {
59
59
  export function parseCssSemanticSheet(sourceText, options = {}) {
60
60
  const lineStarts = computeLineStarts(sourceText);
61
61
  const sourceHash = options.sourceHash ?? hashSemanticValue({ kind: 'frontier.lang.css.source.v1', sourceText });
62
- const records = parseCssBlocks(sourceText, 0, sourceText.length, [], lineStarts, sourceHash);
62
+ const records = parseCssBlocks(sourceText, 0, sourceText.length, [], lineStarts, sourceHash, options);
63
63
  const cssModules = createCssModuleEvidence(records, options, sourceHash);
64
64
  const proofGaps = [
65
65
  ...records.flatMap((record) => record.proofGaps ?? []),
@@ -112,8 +112,9 @@ export function safeMergeCssSource(input = {}) {
112
112
  return safeMergeCssSourceImpl(input, { parseCssSemanticSheet, hashSemanticValue });
113
113
  }
114
114
 
115
- function parseCssBlocks(sourceText, start, end, scopes, lineStarts, sourceHash) {
115
+ function parseCssBlocks(sourceText, start, end, scopes, lineStarts, sourceHash, options) {
116
116
  const records = [];
117
+ const blockRanges = [];
117
118
  let index = start;
118
119
  while (index < end) {
119
120
  const open = sourceText.indexOf('{', index);
@@ -121,26 +122,28 @@ function parseCssBlocks(sourceText, start, end, scopes, lineStarts, sourceHash)
121
122
  const close = matchingBrace(sourceText, open, end);
122
123
  if (close < 0) break;
123
124
  const preludeStart = previousBoundary(sourceText, index, open);
125
+ blockRanges.push([preludeStart, close + 1]);
124
126
  const prelude = sourceText.slice(preludeStart, open).replace(/\/\*[\s\S]*?\*\//g, '').trim();
125
127
  const body = sourceText.slice(open + 1, close);
126
128
  if (prelude.startsWith('@')) {
127
- const at = parseAtRule(prelude, preludeStart, close + 1, lineStarts, sourceHash, scopes);
129
+ const at = parseAtRule(prelude, preludeStart, close + 1, lineStarts, sourceHash, scopes, options);
128
130
  records.push(at);
129
- if (ScopeAtRules.has(at.atRuleName)) records.push(...parseCssBlocks(sourceText, open + 1, close, [...scopes, at.scopeKey], lineStarts, sourceHash));
131
+ if (ScopeAtRules.has(at.atRuleName)) records.push(...parseCssBlocks(sourceText, open + 1, close, [...scopes, at.scopeKey], lineStarts, sourceHash, options));
130
132
  } else if (prelude) {
131
- records.push(cssRuleRecord(prelude, body, preludeStart, close + 1, lineStarts, sourceHash, scopes));
133
+ records.push(cssRuleRecord(prelude, body, preludeStart, close + 1, lineStarts, sourceHash, scopes, options));
132
134
  }
133
135
  index = close + 1;
134
136
  }
135
- return records;
137
+ records.push(...parseAtRuleStatements(sourceText, start, end, scopes, lineStarts, sourceHash, options, blockRanges));
138
+ return records.sort((left, right) => left.sourceSpan.startOffset - right.sourceSpan.startOffset);
136
139
  }
137
140
 
138
- function cssRuleRecord(prelude, body, start, end, lineStarts, sourceHash, scopes) {
141
+ function cssRuleRecord(prelude, body, start, end, lineStarts, sourceHash, scopes, options) {
139
142
  const selectors = prelude.split(',').map((selector) => selector.trim()).filter(Boolean);
140
143
  const declarations = parseDeclarations(body);
141
144
  const proofGaps = [
142
145
  ...declarations.filter((declaration) => ShorthandProperties.has(declaration.property)).map((declaration) => proofGap('css-shorthand-expansion-unproved', `CSS shorthand ${declaration.property} needs longhand expansion evidence.`)),
143
- ...scopes.length ? [proofGap('css-scoped-cascade-equivalence-unproved', 'Scoped cascade equivalence requires browser/style evidence.')] : []
146
+ ...scopes.length && !options.scopedCascadeGraphHash ? [proofGap('css-scoped-cascade-equivalence-unproved', 'Scoped cascade equivalence requires browser/style evidence.')] : []
144
147
  ];
145
148
  return compactRecord({
146
149
  kind: 'rule',
@@ -155,6 +158,7 @@ function cssRuleRecord(prelude, body, start, end, lineStarts, sourceHash, scopes
155
158
  declarationHash: hashSemanticValue({ kind: 'frontier.lang.css.declaration.v1', scopes, selectors, declaration })
156
159
  })),
157
160
  customProperties: declarations.filter((declaration) => declaration.property.startsWith('--')).map((declaration) => declaration.property),
161
+ scopedCascadeGraphHash: scopes.length ? options.scopedCascadeGraphHash : undefined,
158
162
  sourceSpan: sourceSpan(start, end, lineStarts),
159
163
  sourceHash,
160
164
  ruleHash: hashSemanticValue({ kind: 'frontier.lang.css.rule.v1', selectors, scopes, declarations }),
@@ -162,19 +166,20 @@ function cssRuleRecord(prelude, body, start, end, lineStarts, sourceHash, scopes
162
166
  });
163
167
  }
164
168
 
165
- function parseAtRule(prelude, start, end, lineStarts, sourceHash, scopes) {
169
+ function parseAtRule(prelude, start, end, lineStarts, sourceHash, scopes, options) {
166
170
  const match = /^@([A-Za-z-]+)\s*([\s\S]*)$/.exec(prelude);
167
171
  const atRuleName = match?.[1]?.toLowerCase() ?? 'unknown';
168
172
  const conditionText = match?.[2]?.trim() ?? '';
169
173
  const proofGaps = [];
170
174
  if (RuntimeAtRules.has(atRuleName)) proofGaps.push(proofGap(`css-${atRuleName}-runtime-equivalence-unproved`, `CSS @${atRuleName} semantics require browser evidence.`));
171
- if (ScopeAtRules.has(atRuleName)) proofGaps.push(proofGap(`css-${atRuleName}-cascade-scope-unproved`, `CSS @${atRuleName} scoped cascade requires condition evaluation evidence.`));
175
+ if (ScopeAtRules.has(atRuleName) && !options.scopedCascadeGraphHash) proofGaps.push(proofGap(`css-${atRuleName}-cascade-scope-unproved`, `CSS @${atRuleName} scoped cascade requires condition evaluation evidence.`));
172
176
  return compactRecord({
173
177
  kind: 'at-rule',
174
178
  atRuleName,
175
179
  conditionText,
176
180
  scopeKey: `@${atRuleName} ${conditionText}`.trim(),
177
181
  scopes,
182
+ scopedCascadeGraphHash: ScopeAtRules.has(atRuleName) ? options.scopedCascadeGraphHash : undefined,
178
183
  sourceSpan: sourceSpan(start, end, lineStarts),
179
184
  sourceHash,
180
185
  atRuleHash: hashSemanticValue({ kind: 'frontier.lang.css.atRule.v1', atRuleName, conditionText, scopes }),
@@ -182,6 +187,47 @@ function parseAtRule(prelude, start, end, lineStarts, sourceHash, scopes) {
182
187
  });
183
188
  }
184
189
 
190
+ function parseAtRuleStatements(sourceText, start, end, scopes, lineStarts, sourceHash, options, blockRanges) {
191
+ const records = [];
192
+ let index = start;
193
+ while (index < end) {
194
+ const semicolon = sourceText.indexOf(';', index);
195
+ if (semicolon < 0 || semicolon >= end) break;
196
+ const range = blockRanges.find(([rangeStart, rangeEnd]) => semicolon >= rangeStart && semicolon < rangeEnd);
197
+ if (range) {
198
+ index = range[1];
199
+ continue;
200
+ }
201
+ const statementStart = previousBoundary(sourceText, index, semicolon);
202
+ const statementText = sourceText.slice(statementStart, semicolon + 1).replace(/\/\*[\s\S]*?\*\//g, '').trim();
203
+ if (statementText.startsWith('@') && !statementText.includes('{')) records.push(parseAtRuleStatement(statementText, statementStart, semicolon + 1, lineStarts, sourceHash, scopes, options));
204
+ index = semicolon + 1;
205
+ }
206
+ return records;
207
+ }
208
+
209
+ function parseAtRuleStatement(statementText, start, end, lineStarts, sourceHash, scopes, options) {
210
+ const body = statementText.replace(/;$/, '').trim();
211
+ const match = /^@([A-Za-z-]+)\s*([\s\S]*)$/.exec(body);
212
+ const atRuleName = match?.[1]?.toLowerCase() ?? 'unknown';
213
+ const conditionText = match?.[2]?.trim() ?? '';
214
+ const proofGaps = [];
215
+ if (atRuleName === 'layer') proofGaps.push(proofGap('css-layer-order-statement-unsupported', 'CSS @layer statement order requires cascade order evidence.'));
216
+ else proofGaps.push(proofGap(`css-${atRuleName}-statement-equivalence-unproved`, `CSS @${atRuleName} statement semantics require host evidence.`));
217
+ return compactRecord({
218
+ kind: 'at-rule-statement',
219
+ atRuleName,
220
+ conditionText,
221
+ statementText,
222
+ scopes,
223
+ scopedCascadeGraphHash: ScopeAtRules.has(atRuleName) ? options.scopedCascadeGraphHash : undefined,
224
+ sourceSpan: sourceSpan(start, end, lineStarts),
225
+ sourceHash,
226
+ atRuleHash: hashSemanticValue({ kind: 'frontier.lang.css.atRuleStatement.v1', atRuleName, conditionText, scopes, statementText }),
227
+ proofGaps
228
+ });
229
+ }
230
+
185
231
  function parseDeclarations(body) {
186
232
  return body
187
233
  .replace(/\/\*[\s\S]*?\*\//g, '')
@@ -254,7 +300,7 @@ function positionAt(offset, lineStarts) {
254
300
 
255
301
  function sourceRef(node, extra = {}) { return { semanticNodeId: node.id, semanticNodeKind: node.kind, semanticNodeName: node.name, ...extra }; }
256
302
  function proofGap(code, summary) { return { code, status: 'not-claimed', summary, failClosed: true, semanticEquivalenceClaim: false }; }
257
- function hashableCssRecord(record) { return { kind: record.kind, selectors: record.selectors, specificity: record.specificity, scopes: record.scopes, atRuleName: record.atRuleName, conditionText: record.conditionText, declarations: record.declarations?.map((item) => ({ property: item.property, value: item.value, important: item.important })), proofGaps: record.proofGaps?.map((gap) => gap.code) }; }
303
+ function hashableCssRecord(record) { return { kind: record.kind, selectors: record.selectors, specificity: record.specificity, scopes: record.scopes, atRuleName: record.atRuleName, conditionText: record.conditionText, statementText: record.statementText, declarations: record.declarations?.map((item) => ({ property: item.property, value: item.value, important: item.important })), proofGaps: record.proofGaps?.map((gap) => gap.code) }; }
258
304
  function computeLineStarts(text) { const starts = [0]; for (let index = 0; index < text.length; index += 1) if (text[index] === '\n') starts.push(index + 1); return starts; }
259
305
  function cssIdentifier(value) { return String(value ?? 'unknown').replace(/[^A-Za-z0-9_-]/g, '-').replace(/^-+/, '') || 'unknown'; }
260
306
  function cssString(value) { return String(value ?? '').replace(/\\/g, '\\\\').replace(/"/g, '\\"'); }
@@ -8,7 +8,8 @@ function sheetOptions(input, side, sourcePath) {
8
8
  generatedClassNameMapHash: input[`${prefix}GeneratedClassNameMapHash`] ?? input.generatedClassNameMapHash,
9
9
  jsTsUseSiteGraphHash: input[`${prefix}JsTsUseSiteGraphHash`] ?? input.jsTsUseSiteGraphHash,
10
10
  cssModuleCompositionGraphHash: input[`${prefix}CssModuleCompositionGraphHash`] ?? input.cssModuleCompositionGraphHash,
11
- icssGraphHash: input[`${prefix}IcssGraphHash`] ?? input.icssGraphHash
11
+ icssGraphHash: input[`${prefix}IcssGraphHash`] ?? input.icssGraphHash,
12
+ scopedCascadeGraphHash: input[`${prefix}ScopedCascadeGraphHash`] ?? input.scopedCascadeGraphHash
12
13
  };
13
14
  }
14
15
 
@@ -149,7 +150,7 @@ function unsupportedSourceShapeChanges(baseSheet, currentSheet, declarationChang
149
150
  if (changedDeclarationRuleKeys.has(before?.ruleKey) || changedDeclarationRuleKeys.has(after?.ruleKey)) return [];
150
151
  return [{
151
152
  side,
152
- reasonCode: 'css-source-shape-unsupported',
153
+ reasonCode: sourceShapeChangeReason(before, after),
153
154
  shapeKey: key,
154
155
  before: sourceShapeDetails(before),
155
156
  after: sourceShapeDetails(after)
@@ -180,6 +181,19 @@ function sourceShapeIndex(sheet, hash) {
180
181
  atRuleName: record.atRuleName,
181
182
  conditionText: record.conditionText,
182
183
  representedByDeclarations: false,
184
+ unsupportedReasonCode: atRuleUnsupportedReasonCode(record),
185
+ hash: record.atRuleHash
186
+ });
187
+ }
188
+ if (record.kind === 'at-rule-statement') {
189
+ const shapeKey = `at-rule-statement:${[...(record.scopes ?? []), record.atRuleName, record.conditionText].join('::')}`;
190
+ result.set(shapeKey, {
191
+ kind: 'at-rule-statement',
192
+ atRuleName: record.atRuleName,
193
+ conditionText: record.conditionText,
194
+ statementText: record.statementText,
195
+ representedByDeclarations: false,
196
+ unsupportedReasonCode: atRuleStatementUnsupportedReasonCode(record),
183
197
  hash: record.atRuleHash
184
198
  });
185
199
  }
@@ -194,7 +208,15 @@ function conflict(id, sourcePath, code, reasonCode, details = {}) {
194
208
 
195
209
  function sameContractChange(left, right) { return (left.after?.hash ?? '') === (right.after?.hash ?? '') && left.kind === right.kind; }
196
210
  function contractChangeDetails(change) { return { kind: change.kind, contractKind: (change.after ?? change.before)?.contractKind, name: (change.after ?? change.before)?.name, hash: change.after?.hash }; }
197
- function sourceShapeDetails(shape) { return shape ? { kind: shape.kind, selectors: shape.selectors, atRuleName: shape.atRuleName, conditionText: shape.conditionText, representedByDeclarations: shape.representedByDeclarations } : undefined; }
211
+ function sourceShapeDetails(shape) { return shape ? { kind: shape.kind, selectors: shape.selectors, atRuleName: shape.atRuleName, conditionText: shape.conditionText, statementText: shape.statementText, representedByDeclarations: shape.representedByDeclarations } : undefined; }
212
+ function sourceShapeChangeReason(before, after) {
213
+ if (!before && after?.kind === 'at-rule') return 'css-atrule-new-scope-unsupported';
214
+ if (before?.kind === 'at-rule' || after?.kind === 'at-rule') return after?.unsupportedReasonCode ?? before?.unsupportedReasonCode ?? 'css-atrule-condition-edit-unsupported';
215
+ if (before?.kind === 'at-rule-statement' || after?.kind === 'at-rule-statement') return after?.unsupportedReasonCode ?? before?.unsupportedReasonCode ?? 'css-atrule-statement-unsupported';
216
+ return 'css-source-shape-unsupported';
217
+ }
218
+ function atRuleUnsupportedReasonCode(record) { return record.atRuleName === 'layer' ? 'css-layer-name-edit-unsupported' : 'css-atrule-condition-edit-unsupported'; }
219
+ function atRuleStatementUnsupportedReasonCode(record) { return record.atRuleName === 'layer' ? 'css-layer-order-statement-unsupported' : 'css-atrule-statement-unsupported'; }
198
220
  function ruleIdentityKey(record) { return [...(record.scopes ?? []), record.selectors.join(',')].join('::'); }
199
221
  function unique(values) { return [...new Set(values.filter(Boolean))]; }
200
222
  function uniqueProofGaps(values) {
@@ -10,8 +10,6 @@ function safeMergeCssSource(input = {}, context = {}) {
10
10
  const head = input.headSourceText ?? base;
11
11
  if (typeof base !== 'string' || typeof worker !== 'string' || typeof head !== 'string') return blocked(id, sourcePath, 'css-source-text-missing');
12
12
  if (worker === head) return merged(id, sourcePath, worker, 'worker-head-identical', hash);
13
- if (worker === base) return merged(id, sourcePath, head, 'worker-unchanged', hash);
14
- if (head === base) return merged(id, sourcePath, worker, 'head-unchanged', hash);
15
13
  const sheets = {
16
14
  base: parseSheet(base, sheetOptions(input, 'base', sourcePath)),
17
15
  worker: parseSheet(worker, sheetOptions(input, 'worker', sourcePath)),
@@ -47,7 +45,17 @@ function safeMergeCssSource(input = {}, context = {}) {
47
45
  function declarationIndex(sheet) {
48
46
  const declarations = new Map();
49
47
  const order = [];
48
+ const statements = [];
50
49
  for (const record of sheet.records) {
50
+ if (record.kind === 'at-rule-statement') {
51
+ statements.push({
52
+ key: record.atRuleHash,
53
+ scopes: record.scopes ?? [],
54
+ statementText: record.statementText,
55
+ atRuleName: record.atRuleName,
56
+ conditionText: record.conditionText
57
+ });
58
+ }
51
59
  if (record.kind !== 'rule') continue;
52
60
  const ruleKey = ruleIdentityKey(record);
53
61
  for (const declaration of record.declarations ?? []) {
@@ -66,7 +74,7 @@ function declarationIndex(sheet) {
66
74
  order.push(entry.key);
67
75
  }
68
76
  }
69
- return { declarations, order: unique(order) };
77
+ return { declarations, order: unique(order), statements };
70
78
  }
71
79
 
72
80
  function changedDeclarations(baseIndex, currentIndex, side) {
@@ -143,25 +151,55 @@ function applyDeclarationChanges(index, changes) {
143
151
  if (!order.includes(change.key)) order.push(change.key);
144
152
  }
145
153
  }
146
- return { declarations, order: order.filter((key) => declarations.has(key)) };
154
+ return { declarations, order: order.filter((key) => declarations.has(key)), statements: index.statements ?? [] };
147
155
  }
148
156
 
149
157
  function renderDeclarationIndex(index) {
150
158
  const groups = new Map();
151
159
  for (const key of index.order) {
152
160
  const declaration = index.declarations.get(key);
153
- if (!declaration || declaration.scopes.length) continue;
161
+ if (!declaration) continue;
154
162
  groups.set(declaration.ruleKey, [...(groups.get(declaration.ruleKey) ?? []), declaration]);
155
163
  }
156
164
  const chunks = [];
165
+ for (const statement of index.statements ?? []) renderAtRuleStatement(chunks, statement);
157
166
  for (const declarations of groups.values()) {
158
- chunks.push(`${declarations[0].selectors.join(', ')} {`);
159
- for (const declaration of declarations) chunks.push(` ${declaration.property}: ${declaration.value};`);
160
- chunks.push('}', '');
167
+ renderDeclarationGroup(chunks, declarations);
161
168
  }
162
169
  return `${chunks.join('\n').trimEnd()}\n`;
163
170
  }
164
171
 
172
+ function renderAtRuleStatement(chunks, statement) {
173
+ let indent = 0;
174
+ for (const scope of statement.scopes ?? []) {
175
+ chunks.push(`${spaces(indent)}${scope} {`);
176
+ indent += 2;
177
+ }
178
+ chunks.push(`${spaces(indent)}${statement.statementText}`);
179
+ for (let index = (statement.scopes ?? []).length - 1; index >= 0; index -= 1) {
180
+ indent -= 2;
181
+ chunks.push(`${spaces(indent)}}`);
182
+ }
183
+ chunks.push('');
184
+ }
185
+
186
+ function renderDeclarationGroup(chunks, declarations) {
187
+ const first = declarations[0];
188
+ let indent = 0;
189
+ for (const scope of first.scopes) {
190
+ chunks.push(`${spaces(indent)}${scope} {`);
191
+ indent += 2;
192
+ }
193
+ chunks.push(`${spaces(indent)}${first.selectors.join(', ')} {`);
194
+ for (const declaration of declarations) chunks.push(`${spaces(indent + 2)}${declaration.property}: ${declaration.value};`);
195
+ chunks.push(`${spaces(indent)}}`);
196
+ for (let index = first.scopes.length - 1; index >= 0; index -= 1) {
197
+ indent -= 2;
198
+ chunks.push(`${spaces(indent)}}`);
199
+ }
200
+ chunks.push('');
201
+ }
202
+
165
203
  function merged(id, sourcePath, sourceText, operation, hash, extra = {}) {
166
204
  return result(id, sourcePath, 'merged', {
167
205
  operation,
@@ -209,6 +247,7 @@ function proofGapsForDeclaration(record, declaration) {
209
247
  return (record.proofGaps ?? []).filter((gap) => gap.code !== 'css-shorthand-expansion-unproved' || gap.summary.includes(` ${declaration.property} `));
210
248
  }
211
249
  function unique(values) { return [...new Set(values.filter(Boolean))]; }
250
+ function spaces(count) { return ' '.repeat(Math.max(0, count)); }
212
251
 
213
252
  function shorthandGroupForProperty(property) {
214
253
  if (ShorthandGroups.has(property)) return property;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shapeshift-labs/frontier-lang-css",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "CSS semantic merge evidence and projection adapter for Frontier Lang semantic source documents.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",