Setting the file. One moment.
Tag Strategy · Impeccable · pbakaus/impeccable · Skills Docs
ContentsBack to the top of the page 174
export function patchCspMeta
— line 174
This file
Number 1.120
Position 120 of 144
Type JavaScript
Size 11 KB
Lines 247 scripts/live/frameworks/ tag-strategy.mjs
JavaScript · 247 lines · 11 KB
15
16 import { buildLiveScriptSrc } from './script-src.mjs' ;
17
18 export const MARKER_OPEN_TEXT = 'impeccable-live-start' ;
19 export const MARKER_CLOSE_TEXT = 'impeccable-live-end' ;
20
21 /** Markers that identify a file as still carrying our tag-strategy patch. */
22 export const TAG_PATCH_MARKERS = Object. freeze ([ MARKER_OPEN_TEXT , 'data-impeccable-csp-original' ]);
23
24 function commentOpen ( syntax ) { return syntax === 'jsx' ? '{/*' : '<!--' ; }
25 function commentClose ( syntax ) { return syntax === 'jsx' ? '*/}' : '-->' ; }
26
27 /**
28 * `scriptAttrs` is a pre-rendered attribute string (trailing space included)
29 * that the registry supplies for the target file. Astro is the only framework
30 * that uses it today: Astro processes `<script>` tags by default and rewrites
31 * src to its own bundled URL, so `is:inline ` opts out and the literal external
32 * src survives.
33 */
34 export function buildTagBlock ( syntax , port , token , scriptAttrs = '' ) {
35 const open = commentOpen (syntax);
36 const close = commentClose (syntax);
37 return (
38 open + ' ' + MARKER_OPEN_TEXT + ' ' + close + ' \n ' +
39 '<script ' + scriptAttrs + 'src="' + buildLiveScriptSrc (port, token) + '"></script> \n ' +
40 open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + ' \n '
41 );
42 }
43
44 function detectLineEnding ( content ) {
45 if (content. includes ( ' \r\n ' )) return ' \r\n ' ;
46 if (content. includes ( ' \r ' )) return ' \r ' ;
47 return ' \n ' ;
48 }
49
50 function normalizeLineEndings ( content , lineEnding ) {
51 return lineEnding === ' \n ' ? content : content. replace ( / \n / g , lineEnding);
52 }
53
54 function readLineEndingAt ( content , index ) {
55 if (content[index] === ' \r ' && content[index + 1 ] === ' \n ' ) return ' \r\n ' ;
56 if (content[index] === ' \n ' ) return ' \n ' ;
57 if (content[index] === ' \r ' ) return ' \r ' ;
58 return '' ;
59 }
60
61 export function insertTag ( content , config , port , token , scriptAttrs = '' ) {
62 const lineEnding = detectLineEnding (content);
63 const block = normalizeLineEndings ( buildTagBlock (config.commentSyntax, port, token, scriptAttrs), lineEnding);
64 // insertBefore: match the LAST occurrence. Anchors like `</body>` naturally
65 // belong at the end, and the same literal can appear earlier in code blocks
66 // within rendered documentation pages.
67 if (config.insertBefore) {
68 const idx = content. lastIndexOf (config.insertBefore);
69 if (idx === - 1 ) return content;
70 return content. slice ( 0 , idx) + block + content. slice (idx);
71 }
72 // insertAfter: match the FIRST occurrence — typical anchors like `<head>` or
73 // `<body>` open near the top of the document.
74 const idx = content. indexOf (config.insertAfter);
75 if (idx === - 1 ) return content;
76 const after = idx + config.insertAfter. length ;
77 // Preserve an existing trailing newline if the anchor already has one.
78 // Slice the remainder from the original anchor offset, not prefix.length:
79 // in the no-newline case prefix is one char longer than the anchor (the
80 // appended '\n'), so slicing by prefix.length would drop the first real
81 // character after the anchor (#227).
82 const existingNewline = readLineEndingAt (content, after);
83 const prefix = content. slice ( 0 , after) + (existingNewline || lineEnding);
84 const rest = content. slice (after + existingNewline. length );
85 return prefix + block + rest;
86 }
87
88 /**
89 * Remove the live script block. Matches either HTML or JSX comment markers
90 * regardless of config (so stale tags from a wrong config can still be cleaned).
91 *
92 * Indent-preserving: captures any whitespace immediately preceding the opener
93 * marker and re-emits it in place of the removed block. `insertTag` inserted
94 * the block *after* the original line's indent and *before* the anchor (e.g.
95 * `</body>`), which moved the indent onto the opener line and left the anchor
96 * unindented. Replacing the whole block (plus its trailing newline) with just
97 * the captured indent hands the indent back to the anchor that follows.
98 */
99 export function removeTag ( content , _syntax ) {
100 const patterns = [
101 /( [ \t] * )<!-- \s * impeccable-live-start \s * --> [\s\S] *? <!-- \s * impeccable-live-end \s * -->( [ \t] * (?: \r\n | \n | \r |$ ) ? )/ ,
102 /( [ \t] * ) \{\/\* \s * impeccable-live-start \s * \*\/\} [\s\S] *? \{\/\* \s * impeccable-live-end \s * \*\/\} ( [ \t] * (?: \r\n | \n | \r |$ ) ? )/ ,
103 ];
104 for ( const pat of patterns) {
105 let changed = false ;
106 let next = content;
107 do {
108 content = next;
109 next = content. replace (pat, ( _match , leadingIndent , trailing = '' ) => {
110 if ( / [\r\n] / . test (trailing)) return leadingIndent;
111 return leadingIndent || trailing || '' ;
112 });
113 if (next !== content) changed = true ;
114 } while (next !== content);
115 if (changed) return next;
116 }
117 return content;
118 }
119
120 // ---------------------------------------------------------------------------
121 // Content-Security-Policy meta-tag patcher
122 //
123 // When the user's HTML carries `<meta http-equiv="Content-Security-Policy">`,
124 // the cross-origin load of /live.js (and the SSE/POST connection back to
125 // localhost:PORT) is blocked unless the CSP explicitly allows that origin.
126 //
127 // On insert: append `http://localhost:PORT` to `script-src` and `connect-src`,
128 // and stash the original `content` value in a `data-impeccable-csp-original`
129 // attribute (base64) so revert is exact.
130 //
131 // On remove: detect the marker attribute, decode it, restore the original
132 // content value verbatim, drop the marker.
133 //
134 // Header-based CSP (Next.js headers, Nuxt routeRules, SvelteKit kit.csp,
135 // shared helpers) is NOT patched here — those need framework-specific config
136 // edits and are handled via the existing detect-csp.mjs reference output.
137 // Only the in-source meta-tag form gets the auto-patch.
138 // ---------------------------------------------------------------------------
139
140 const CSP_MARKER_ATTR = 'data-impeccable-csp-original' ;
141
142 function findCspMetaTags ( content ) {
143 const out = [];
144 const tagRe = /<meta \s + ( [ ^ >] *? ) \/ ? >/ gis ;
145 let m;
146 while ((m = tagRe. exec (content)) !== null ) {
147 const attrs = m[ 1 ];
148 if ( ! /(http-equiv | httpEquiv) \s * = \s * ( ['"] )Content-Security-Policy \2 / i . test (attrs)) continue ;
149 out. push ({ start: m.index, end: m.index + m[ 0 ]. length , full: m[ 0 ], attrs });
150 }
151 return out;
152 }
153
154 function getAttr ( attrs , name ) {
155 const re = new RegExp ( ` \\ b${ name } \\ s*= \\ s*(['"])([ \\ s \\ S]*?) \\ 1` , 'i' );
156 const m = attrs. match (re);
157 return m ? { quote: m[ 1 ], value: m[ 2 ], full: m[ 0 ] } : null ;
158 }
159
160 function appendOriginToDirective ( csp , directive , origin ) {
161 const re = new RegExp ( `(^|;)( \\ s*)(${ directive }) \\ s+([^;]*)` , 'i' );
162 const m = csp. match (re);
163 if (m) {
164 const tokens = m[ 4 ]. trim (). split ( / \s + / );
165 if (tokens. includes (origin)) return csp;
166 return csp. replace (re, `${ m [ 1 ] }${ m [ 2 ] }${ m [ 3 ] } ${ [ ... tokens , origin ]. join ( ' ' ) }` );
167 }
168 // Directive missing — add it. Use 'self' + origin so we don't inadvertently
169 // narrow the policy compared to the default-src fallback (most users with
170 // an explicit CSP have 'self' there).
171 return csp. trim (). replace ( /; ? \s *$ / , '' ) + `; ${ directive } 'self' ${ origin }` ;
172 }
173
174 export function patchCspMeta ( content , port ) {
175 const tags = findCspMetaTags (content);
176 if (tags. length === 0 ) return content;
177 const origin = `http://localhost:${ port }` ;
178
179 // Walk last-to-first so prior splices don't invalidate later indices.
180 let result = content;
181 for ( let i = tags. length - 1 ; i >= 0 ; i -- ) {
182 const tag = tags[i];
183 const attrs = tag.attrs;
184 if ( getAttr (attrs, CSP_MARKER_ATTR )) continue ; // already patched
185 const contentAttr = getAttr (attrs, 'content' );
186 if ( ! contentAttr) continue ;
187
188 const original = contentAttr.value;
189 let patched = original;
190 patched = appendOriginToDirective (patched, 'script-src' , origin);
191 patched = appendOriginToDirective (patched, 'connect-src' , origin);
192 // The shader overlay during 'generating' creates a screenshot via
193 // URL.createObjectURL, producing a `blob:` URL — img-src 'self' rejects
194 // those. Add `blob:` so the overlay doesn't throw a CSP violation.
195 patched = appendOriginToDirective (patched, 'img-src' , 'blob:' );
196 if (patched === original) continue ;
197
198 const newContentAttr = `content=${ contentAttr . quote }${ patched }${ contentAttr . quote }` ;
199 const marker = `${ CSP_MARKER_ATTR }="${ Buffer . from ( original , 'utf-8' ). toString ( 'base64' ) }"` ;
200 // The tagRe captures any whitespace between the last attribute and the
201 // closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
202 // a replace would land it BEFORE that trailing space, leaving a double
203 // space inside attrs and clobbering the space before `/>`. Split off
204 // the trailing whitespace, splice the marker into the attribute body,
205 // and re-append the original trailing whitespace so a self-closing
206 // `<meta … />` round-trips byte-for-byte.
207 const trailingWs = (attrs. match ( / [ \t] *$ / ) || [ '' ])[ 0 ];
208 const attrsBody = attrs. slice ( 0 , attrs. length - trailingWs. length );
209 const newAttrs = attrsBody. replace (contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
210 const newTag = tag.full. replace (attrs, newAttrs);
211
212 result = result. slice ( 0 , tag.start) + newTag + result. slice (tag.end);
213 }
214 return result;
215 }
216
217 export function revertCspMeta ( content ) {
218 const tags = findCspMetaTags (content);
219 if (tags. length === 0 ) return content;
220
221 let result = content;
222 for ( let i = tags. length - 1 ; i >= 0 ; i -- ) {
223 const tag = tags[i];
224 const origAttr = getAttr (tag.attrs, CSP_MARKER_ATTR );
225 if ( ! origAttr) continue ;
226 const contentAttr = getAttr (tag.attrs, 'content' );
227 if ( ! contentAttr) continue ;
228
229 let originalValue;
230 try { originalValue = Buffer. from (origAttr.value, 'base64' ). toString ( 'utf-8' ); }
231 catch { continue ; }
232
233 const newContentAttr = `content=${ contentAttr . quote }${ originalValue }${ contentAttr . quote }` ;
234 let newAttrs = tag.attrs. replace (contentAttr.full, newContentAttr);
235 // Drop the marker attribute and any single space immediately preceding it.
236 newAttrs = newAttrs. replace ( new RegExp ( ` \\ s*${ origAttr . full }` ), '' );
237 const newTag = tag.full. replace (tag.attrs, newAttrs);
238
239 result = result. slice ( 0 , tag.start) + newTag + result. slice (tag.end);
240 }
241 return result;
242 }
243
244 /** The journal's undo for a tag-strategy patch: drop the block, restore CSP. */
245 export function unpatchTagFile ( content ) {
246 return revertCspMeta ( removeTag (content));
247 }