1- import * as yaml from 'js- yaml' ;
1+ import { isNode , isScalar , isMap , LineCounter , parseDocument , stringify } from 'yaml' ;
22import * as React from 'react' ;
3- import type { LineRange , TemplateResource } from '../api' ;
3+ import type { TemplateResource } from '../api' ;
44import { CodeViewer } from './CodeViewer' ;
55
66export interface TemplateViewerProps {
77 readonly jsonContent : string ;
88 readonly resources : Record < string , TemplateResource > ;
9- readonly highlightStart ?: number ;
10- readonly highlightEnd ?: number ;
9+ readonly highlightLogicalId ?: string ;
1110 readonly highlightColor ?: string ;
1211 readonly navCounter ?: number ;
13- readonly scrollToLine ?: number ;
14- readonly onLineDoubleClick ?: ( line : number ) => void ;
12+ readonly onResourceDoubleClick ?: ( logicalId : string ) => void ;
1513}
1614
1715type Format = 'yaml' | 'json' ;
@@ -25,12 +23,10 @@ interface ResourceSection {
2523export function TemplateViewer ( {
2624 jsonContent,
2725 resources,
28- highlightStart,
29- highlightEnd,
26+ highlightLogicalId,
3027 highlightColor,
3128 navCounter,
32- scrollToLine,
33- onLineDoubleClick,
29+ onResourceDoubleClick,
3430} : TemplateViewerProps ) : JSX . Element {
3531 const [ format , setFormat ] = React . useState < Format > ( 'yaml' ) ;
3632 const [ collapsedResources , setCollapsedResources ] = React . useState < Set < string > > ( new Set ( ) ) ;
@@ -43,25 +39,23 @@ export function TemplateViewer({
4339 sections : buildSections ( resources ) ,
4440 } ;
4541 }
46- return jsonToYaml ( jsonContent , resources ) ;
42+ return jsonToYaml ( jsonContent ) ;
4743 } , [ jsonContent , resources , format ] ) ;
4844
4945 const filteredContent = React . useMemo ( ( ) => {
5046 if ( collapsedResources . size === 0 ) return displayContent ;
5147 return collapseContent ( displayContent , sections , collapsedResources ) ;
5248 } , [ displayContent , sections , collapsedResources ] ) ;
5349
50+ // Resolve the highlight from the rendered format's own ranges (JSON block in
51+ // JSON view, YAML block in YAML view), so the highlight is always in the
52+ // coordinate system actually on screen. Derived, so toggling format re-resolves.
5453 const adjustedHighlight = React . useMemo ( ( ) => {
55- if ( highlightStart === undefined || highlightEnd === undefined ) return undefined ;
56- if ( collapsedResources . size === 0 ) return { start : highlightStart , end : highlightEnd } ;
57- return adjustLineNumbers ( highlightStart , highlightEnd , sections , collapsedResources ) ;
58- } , [ highlightStart , highlightEnd , sections , collapsedResources ] ) ;
59-
60- const adjustedScroll = React . useMemo ( ( ) => {
61- if ( scrollToLine === undefined ) return undefined ;
62- if ( collapsedResources . size === 0 ) return scrollToLine ;
63- return adjustSingleLine ( scrollToLine , sections , collapsedResources ) ;
64- } , [ scrollToLine , sections , collapsedResources ] ) ;
54+ const block = highlightLogicalId ? displayResources [ highlightLogicalId ] ?. block : undefined ;
55+ if ( ! block ) return undefined ;
56+ if ( collapsedResources . size === 0 ) return { start : block . startLine , end : block . endLine } ;
57+ return adjustLineNumbers ( block . startLine , block . endLine , sections , collapsedResources ) ;
58+ } , [ highlightLogicalId , displayResources , sections , collapsedResources ] ) ;
6559
6660 const toggleResource = React . useCallback ( ( logicalId : string ) => {
6761 setCollapsedResources ( ( prev ) => {
@@ -91,10 +85,15 @@ export function TemplateViewer({
9185 }
9286 }
9387
94- if ( onLineDoubleClick ) {
95- onLineDoubleClick ( originalLine ) ;
88+ if ( onResourceDoubleClick ) {
89+ // Map the clicked line back to the resource that owns it (display-coordinate
90+ // sections), so the reverse jump is identity-based like the forward one.
91+ const section = sections . find ( ( s ) => originalLine >= s . startLine && originalLine <= s . endLine ) ;
92+ if ( section ) {
93+ onResourceDoubleClick ( section . logicalId ) ;
94+ }
9695 }
97- } , [ filteredContent , sections , collapsedResources , onLineDoubleClick , toggleResource ] ) ;
96+ } , [ filteredContent , sections , collapsedResources , onResourceDoubleClick , toggleResource ] ) ;
9897
9998 const gutterDecorations = React . useMemo ( ( ) => {
10099 const decorations = new Map < number , { icon : string ; logicalId : string } > ( ) ;
@@ -141,7 +140,7 @@ export function TemplateViewer({
141140 highlightEnd = { adjustedHighlight ?. end }
142141 highlightColor = { highlightColor }
143142 navCounter = { navCounter }
144- scrollToLine = { adjustedScroll }
143+ scrollToLine = { adjustedHighlight ?. start }
145144 onLineDoubleClick = { handleDoubleClick }
146145 />
147146 </ div >
@@ -160,77 +159,40 @@ interface YamlResult {
160159 sections : ResourceSection [ ] ;
161160}
162161
163- function jsonToYaml ( jsonContent : string , jsonResources : Record < string , TemplateResource > ) : YamlResult {
162+ function jsonToYaml ( jsonContent : string ) : YamlResult {
164163 let parsed : unknown ;
165164 try {
166165 parsed = JSON . parse ( jsonContent ) ;
167166 } catch {
168- return { displayContent : jsonContent , displayResources : jsonResources , sections : buildSections ( jsonResources ) } ;
167+ return { displayContent : jsonContent , displayResources : { } , sections : [ ] } ;
169168 }
170169
171- const yamlContent = yaml . dump ( parsed , { indent : 2 , lineWidth : - 1 , noRefs : true , sortKeys : false } ) ;
172- const yamlResources = remapResources ( parsed as Record < string , unknown > , yamlContent ) ;
173-
174- return {
175- displayContent : yamlContent ,
176- displayResources : yamlResources ,
177- sections : buildSections ( yamlResources ) ,
178- } ;
179- }
180-
181- function remapResources ( template : Record < string , unknown > , yamlText : string ) : Record < string , TemplateResource > {
182- const resources = ( template as { Resources ?: Record < string , unknown > } ) . Resources ;
183- if ( ! resources ) return { } ;
184-
185- const lines = yamlText . split ( '\n' ) ;
186- const result : Record < string , TemplateResource > = { } ;
187-
188- for ( const logicalId of Object . keys ( resources ) ) {
189- const pattern = new RegExp ( `^ ${ escapeRegex ( logicalId ) } :` ) ;
190- const startIdx = lines . findIndex ( ( l ) => pattern . test ( l ) ) ;
191- if ( startIdx === - 1 ) continue ;
192-
193- let endIdx = startIdx + 1 ;
194- while ( endIdx < lines . length ) {
195- const line = lines [ endIdx ] ;
196- if ( line . length > 0 && ! line . startsWith ( ' ' ) && ! line . startsWith ( ' ' ) === false ) {
197- if ( / ^ \S / . test ( line ) && ! line . startsWith ( ' ' ) ) break ;
170+ // Render and measure with the same parser so the text and the ranges always
171+ // agree. lineWidth:0 disables wrapping (keeps one CFN value per line, like the
172+ // old serializer); aliasDuplicateObjects:false keeps CloudFormation's repeated
173+ // objects inline instead of emitting &anchor/*alias.
174+ const displayContent = stringify ( parsed , { indent : 2 , lineWidth : 0 , aliasDuplicateObjects : false } ) ;
175+ const lineCounter = new LineCounter ( ) ;
176+ const doc = parseDocument ( displayContent , { lineCounter } ) ;
177+
178+ const displayResources : Record < string , TemplateResource > = { } ;
179+ const resources = doc . get ( 'Resources' ) ;
180+ if ( isMap ( resources ) ) {
181+ for ( const pair of resources . items ) {
182+ const key = pair . key ;
183+ const value = pair . value ;
184+ if ( ! isScalar ( key ) || ! isNode ( value ) || key . range == null || value . range == null ) {
185+ continue ;
198186 }
199- if ( endIdx > startIdx && / ^ [ ^ \s ] / . test ( line ) ) break ;
200- endIdx ++ ;
187+ // range is [valueStart, valueEnd, nodeEnd]; a resource block spans from its
188+ // logical-id key line through the end of its value.
189+ const startLine = lineCounter . linePos ( key . range [ 0 ] ) . line ;
190+ const endLine = lineCounter . linePos ( value . range [ 1 ] ) . line ;
191+ displayResources [ String ( key . value ) ] = { block : { startLine, endLine } } ;
201192 }
202-
203- const block : LineRange = { startLine : startIdx + 1 , endLine : endIdx } ;
204-
205- const properties : Record < string , LineRange > = { } ;
206- const resourceObj = resources [ logicalId ] as Record < string , unknown > | undefined ;
207- if ( resourceObj ?. Properties && typeof resourceObj . Properties === 'object' ) {
208- for ( const propName of Object . keys ( resourceObj . Properties as Record < string , unknown > ) ) {
209- const propPattern = new RegExp ( `^ ${ escapeRegex ( propName ) } :` ) ;
210- for ( let pi = startIdx ; pi < endIdx ; pi ++ ) {
211- if ( propPattern . test ( lines [ pi ] ) ) {
212- let propEnd = pi + 1 ;
213- while ( propEnd < endIdx && lines [ propEnd ] ?. startsWith ( ' ' ) ) {
214- propEnd ++ ;
215- }
216- properties [ propName ] = { startLine : pi + 1 , endLine : propEnd } ;
217- break ;
218- }
219- }
220- }
221- }
222-
223- result [ logicalId ] = {
224- block,
225- ...( Object . keys ( properties ) . length > 0 && { properties } ) ,
226- } ;
227193 }
228194
229- return result ;
230- }
231-
232- function escapeRegex ( s : string ) : string {
233- return s . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, '\\$&' ) ;
195+ return { displayContent, displayResources, sections : buildSections ( displayResources ) } ;
234196}
235197
236198function collapseContent ( content : string , sections : ResourceSection [ ] , collapsed : Set < string > ) : string {
0 commit comments