@monoes/monodesign 1.2.20 → 1.2.23

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.
Files changed (95) hide show
  1. package/README.md +2 -2
  2. package/cli/engine/cli/main.mjs +21 -1
  3. package/cli/engine/design-system-checks.mjs +381 -0
  4. package/cli/engine/design-system-parse.mjs +433 -0
  5. package/cli/engine/design-system.mjs +20 -736
  6. package/cli/engine/detect-antipatterns-browser.js +28 -5
  7. package/cli/engine/detect-antipatterns.mjs +1 -0
  8. package/cli/engine/engines/regex/detect-text-matchers.mjs +371 -0
  9. package/cli/engine/engines/regex/detect-text-run.mjs +186 -0
  10. package/cli/engine/engines/regex/detect-text.mjs +11 -523
  11. package/cli/engine/engines/static-html/css-cascade-color.mjs +219 -0
  12. package/cli/engine/engines/static-html/css-cascade-static-data.mjs +138 -0
  13. package/cli/engine/engines/static-html/css-cascade-static-dom.mjs +132 -0
  14. package/cli/engine/engines/static-html/css-cascade-static-parse.mjs +393 -0
  15. package/cli/engine/engines/static-html/css-cascade.mjs +24 -837
  16. package/cli/engine/engines/static-html/detect-html.mjs +6 -1
  17. package/cli/engine/findings-group.mjs +94 -0
  18. package/cli/engine/fix/index.mjs +1 -26
  19. package/cli/engine/fix/unified-diff.mjs +107 -0
  20. package/cli/engine/rules/checks-background.mjs +17 -3
  21. package/cli/engine/rules/checks-element.mjs +2 -0
  22. package/cli/engine/rules/checks-pure.mjs +9 -2
  23. package/cli/lib/monodesign-config-consent.mjs +93 -0
  24. package/cli/lib/monodesign-config-core.mjs +37 -0
  25. package/cli/lib/monodesign-config-detection.mjs +133 -0
  26. package/cli/lib/monodesign-config-filter.mjs +198 -0
  27. package/cli/lib/monodesign-config-ignore-value.mjs +212 -0
  28. package/cli/lib/monodesign-config.mjs +21 -623
  29. package/package.json +2 -2
  30. package/skill/scripts/context-fields.mjs +65 -0
  31. package/skill/scripts/context-resolve.mjs +425 -0
  32. package/skill/scripts/context-update.mjs +151 -0
  33. package/skill/scripts/context-utils.mjs +68 -0
  34. package/skill/scripts/context-workspace.mjs +245 -0
  35. package/skill/scripts/context.mjs +17 -891
  36. package/skill/scripts/hook-admin-config.mjs +152 -0
  37. package/skill/scripts/hook-admin-ignore.mjs +131 -0
  38. package/skill/scripts/hook-admin-manifest.mjs +292 -0
  39. package/skill/scripts/hook-admin.mjs +8 -541
  40. package/skill/scripts/hook-lib-cache.mjs +145 -0
  41. package/skill/scripts/hook-lib-config.mjs +292 -0
  42. package/skill/scripts/hook-lib-detector.mjs +72 -0
  43. package/skill/scripts/hook-lib-events.mjs +182 -0
  44. package/skill/scripts/hook-lib-findings.mjs +201 -0
  45. package/skill/scripts/hook-lib-ignore.mjs +205 -0
  46. package/skill/scripts/hook-lib-render.mjs +273 -0
  47. package/skill/scripts/hook-lib-run.mjs +281 -0
  48. package/skill/scripts/hook-lib-scan-targets.mjs +128 -0
  49. package/skill/scripts/hook-lib.mjs +71 -1747
  50. package/skill/scripts/lib/design-parser-colors.mjs +182 -0
  51. package/skill/scripts/lib/design-parser-extract.mjs +333 -0
  52. package/skill/scripts/lib/design-parser-sections.mjs +214 -0
  53. package/skill/scripts/lib/design-parser-yaml.mjs +135 -0
  54. package/skill/scripts/lib/design-parser.mjs +10 -783
  55. package/skill/scripts/live/manual-apply-chunk.mjs +151 -0
  56. package/skill/scripts/live/manual-apply-compact.mjs +122 -0
  57. package/skill/scripts/live/manual-apply-evidence.mjs +115 -0
  58. package/skill/scripts/live/manual-apply-log.mjs +179 -0
  59. package/skill/scripts/live/manual-apply-transaction.mjs +122 -0
  60. package/skill/scripts/live/manual-apply.mjs +65 -640
  61. package/skill/scripts/live/svelte-component-accept.mjs +248 -0
  62. package/skill/scripts/live/svelte-component-css.mjs +230 -0
  63. package/skill/scripts/live/svelte-component-markup.mjs +104 -0
  64. package/skill/scripts/live/svelte-component-scaffold.mjs +118 -0
  65. package/skill/scripts/live/svelte-component-session.mjs +158 -0
  66. package/skill/scripts/live/svelte-component.mjs +32 -792
  67. package/skill/scripts/live-accept-mutate.mjs +152 -0
  68. package/skill/scripts/live-accept-parse.mjs +370 -0
  69. package/skill/scripts/live-accept-scrub.mjs +59 -0
  70. package/skill/scripts/live-accept-search.mjs +58 -0
  71. package/skill/scripts/live-accept.mjs +15 -631
  72. package/skill/scripts/live-commit-manual-edits-paths.mjs +27 -0
  73. package/skill/scripts/live-commit-manual-edits-repair.mjs +166 -0
  74. package/skill/scripts/live-commit-manual-edits-rollback.mjs +231 -0
  75. package/skill/scripts/live-commit-manual-edits-utils.mjs +167 -0
  76. package/skill/scripts/live-commit-manual-edits-verify.mjs +356 -0
  77. package/skill/scripts/live-commit-manual-edits.mjs +24 -881
  78. package/skill/scripts/live-copy-edit-agent.mjs +16 -551
  79. package/skill/scripts/live-copy-edit-compact.mjs +137 -0
  80. package/skill/scripts/live-copy-edit-post-apply.mjs +175 -0
  81. package/skill/scripts/live-copy-edit-runners.mjs +297 -0
  82. package/skill/scripts/live-inject-csp.mjs +132 -0
  83. package/skill/scripts/live-inject-files.mjs +229 -0
  84. package/skill/scripts/live-inject-tag.mjs +114 -0
  85. package/skill/scripts/live-inject.mjs +13 -440
  86. package/skill/scripts/live-server-poll.mjs +232 -0
  87. package/skill/scripts/live-server-routes.mjs +449 -0
  88. package/skill/scripts/live-server-state.mjs +361 -0
  89. package/skill/scripts/live-server.mjs +10 -946
  90. package/skill/scripts/live-wrap-css.mjs +132 -0
  91. package/skill/scripts/live-wrap-manual-edit.mjs +153 -0
  92. package/skill/scripts/live-wrap-search.mjs +247 -0
  93. package/skill/scripts/live-wrap.mjs +21 -484
  94. package/skill/scripts/palette-seeds.mjs +401 -0
  95. package/skill/scripts/palette.mjs +1 -395
@@ -0,0 +1,132 @@
1
+ // Content-Security-Policy meta-tag patcher: appends the live server's
2
+ // localhost origin to script-src/connect-src (and img-src for the shader
3
+ // overlay's blob: screenshots) on insert, and reverts it exactly on remove.
4
+ // Split out of live-inject.mjs (file-size sweep — pure move, no behaviour
5
+ // change).
6
+ //
7
+ // When the user's HTML carries `<meta http-equiv="Content-Security-Policy">`,
8
+ // the cross-origin load of /live.js (and the SSE/POST connection back to
9
+ // localhost:PORT) is blocked unless the CSP explicitly allows that origin.
10
+ //
11
+ // On insert: append `http://localhost:PORT` to `script-src` and `connect-src`,
12
+ // and stash the original `content` value in a `data-monodesign-csp-original`
13
+ // attribute (base64) so revert is exact.
14
+ //
15
+ // On remove: detect the marker attribute, decode it, restore the original
16
+ // content value verbatim, drop the marker.
17
+ //
18
+ // Header-based CSP (Next.js headers, Nuxt routeRules, SvelteKit kit.csp,
19
+ // shared helpers) is NOT patched here — those need framework-specific config
20
+ // edits and are handled via the existing detect-csp.mjs reference output.
21
+ // Only the in-source meta-tag form gets the auto-patch.
22
+
23
+ const CSP_MARKER_ATTR = 'data-monodesign-csp-original';
24
+
25
+ function findCspMetaTags(content) {
26
+ const out = [];
27
+ const tagRe = /<meta\s+([^>]*?)\/?>/gis;
28
+ let m;
29
+ while ((m = tagRe.exec(content)) !== null) {
30
+ const attrs = m[1];
31
+ if (!/(http-equiv|httpEquiv)\s*=\s*(['"])Content-Security-Policy\2/i.test(attrs)) continue;
32
+ out.push({ start: m.index, end: m.index + m[0].length, full: m[0], attrs });
33
+ }
34
+ return out;
35
+ }
36
+
37
+ function getAttr(attrs, name) {
38
+ const re = new RegExp(`\\b${name}\\s*=\\s*(['"])([\\s\\S]*?)\\1`, 'i');
39
+ const m = attrs.match(re);
40
+ return m ? { quote: m[1], value: m[2], full: m[0] } : null;
41
+ }
42
+
43
+ function appendOriginToDirective(csp, directive, origin) {
44
+ const re = new RegExp(`(^|;)(\\s*)(${directive})\\s+([^;]*)`, 'i');
45
+ const m = csp.match(re);
46
+ if (m) {
47
+ const tokens = m[4].trim().split(/\s+/);
48
+ if (tokens.includes(origin)) return csp;
49
+ return csp.replace(re, `${m[1]}${m[2]}${m[3]} ${[...tokens, origin].join(' ')}`);
50
+ }
51
+ // Directive missing — add it. Use 'self' + origin so we don't inadvertently
52
+ // narrow the policy compared to the default-src fallback (most users with
53
+ // an explicit CSP have 'self' there).
54
+ return `${csp.trim().replace(/;?\s*$/, '')}; ${directive} 'self' ${origin}`;
55
+ }
56
+
57
+ export function patchCspMeta(content, port) {
58
+ const tags = findCspMetaTags(content);
59
+ if (tags.length === 0) return content;
60
+ const origin = `http://localhost:${port}`;
61
+
62
+ // Walk last-to-first so prior splices don't invalidate later indices.
63
+ let result = content;
64
+ for (let i = tags.length - 1; i >= 0; i--) {
65
+ const tag = tags[i];
66
+ const attrs = tag.attrs;
67
+ if (getAttr(attrs, CSP_MARKER_ATTR)) continue; // already patched
68
+ const contentAttr = getAttr(attrs, 'content');
69
+ if (!contentAttr) continue;
70
+
71
+ const original = contentAttr.value;
72
+ let patched = original;
73
+ patched = appendOriginToDirective(patched, 'script-src', origin);
74
+ patched = appendOriginToDirective(patched, 'connect-src', origin);
75
+ // The shader overlay during 'generating' creates a screenshot via
76
+ // URL.createObjectURL, producing a `blob:` URL — img-src 'self' rejects
77
+ // those. Add `blob:` so the overlay doesn't throw a CSP violation.
78
+ patched = appendOriginToDirective(patched, 'img-src', 'blob:');
79
+ if (patched === original) continue;
80
+
81
+ const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
82
+ const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
83
+ // The tagRe captures any whitespace between the last attribute and the
84
+ // closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
85
+ // a replace would land it BEFORE that trailing space, leaving a double
86
+ // space inside attrs and clobbering the space before `/>`. Split off
87
+ // the trailing whitespace, splice the marker into the attribute body,
88
+ // and re-append the original trailing whitespace so a self-closing
89
+ // `<meta … />` round-trips byte-for-byte.
90
+ const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
91
+ const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
92
+ const newAttrs = `${attrsBody.replace(contentAttr.full, newContentAttr)} ${marker}${trailingWs}`;
93
+ const newTag = tag.full.replace(attrs, newAttrs);
94
+
95
+ result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
96
+ }
97
+ return result;
98
+ }
99
+
100
+ export function revertCspMeta(content) {
101
+ const tags = findCspMetaTags(content);
102
+ if (tags.length === 0) return content;
103
+
104
+ let result = content;
105
+ for (let i = tags.length - 1; i >= 0; i--) {
106
+ const tag = tags[i];
107
+ const origAttr = getAttr(tag.attrs, CSP_MARKER_ATTR);
108
+ if (!origAttr) continue;
109
+ const contentAttr = getAttr(tag.attrs, 'content');
110
+ if (!contentAttr) continue;
111
+
112
+ let originalValue;
113
+ try { originalValue = Buffer.from(origAttr.value, 'base64').toString('utf-8'); }
114
+ catch { continue; }
115
+
116
+ const newContentAttr = `content=${contentAttr.quote}${originalValue}${contentAttr.quote}`;
117
+ let newAttrs = tag.attrs.replace(contentAttr.full, newContentAttr);
118
+ // Drop the marker attribute and any single space immediately preceding it.
119
+ newAttrs = newAttrs.replace(new RegExp(`\\s*${origAttr.full}`), '');
120
+ const newTag = tag.full.replace(tag.attrs, newAttrs);
121
+
122
+ result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
123
+ }
124
+ return result;
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Auto-execute
129
+ // ---------------------------------------------------------------------------
130
+
131
+
132
+ export { findCspMetaTags, getAttr, appendOriginToDirective };
@@ -0,0 +1,229 @@
1
+ // Config file resolution (glob expansion + exclude filtering) and the
2
+ // project .gitignore / git info-exclude management for monodesign-live's
3
+ // own runtime files. Split out of live-inject.mjs (file-size sweep — pure
4
+ // move, no behaviour change).
5
+
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+
9
+ const IGNORE_MARKER_OPEN = '# monodesign-live-ignore-start';
10
+ const IGNORE_MARKER_CLOSE = '# monodesign-live-ignore-end';
11
+
12
+ export const LIVE_IGNORE_PATTERNS = Object.freeze([
13
+ '.monodesign/hook.cache.json',
14
+ '.monodesign/hook.pending.json',
15
+ '.monodesign/config.local.json',
16
+ '.monodesign/live/server.json',
17
+ '.monodesign/live/sessions/',
18
+ '.monodesign/live/previews/',
19
+ '.monodesign/live/annotations/',
20
+ '.monodesign/live/cache/',
21
+ '.monodesign/live/manual-edit-apply-transaction.json',
22
+ '.monodesign/live/manual-edit-events.jsonl',
23
+ '.monodesign/live/manual-edit-evidence/',
24
+ '.monodesign/live/pending-manual-edits.json',
25
+ '.monodesign/live/deferred-svelte-component-accepts.json',
26
+ '.monodesign-live.json',
27
+ '.monodesign-live/',
28
+ 'node_modules/.monodesign-live/',
29
+ 'src/lib/monodesign/MonodesignLiveRoot.svelte',
30
+ 'src/lib/monodesign/__runtime.js',
31
+ 'src/lib/monodesign/[0-9a-f]*/',
32
+ ]);
33
+
34
+ /**
35
+ * Hard-excluded directory patterns. These are NEVER user-facing pages and
36
+ * matching them would silently inject tracking scripts into third-party
37
+ * code. The user cannot turn these off via config — they are the floor.
38
+ */
39
+ const HARD_EXCLUDES = [
40
+ '**/node_modules/**',
41
+ '**/.git/**',
42
+ ];
43
+
44
+ export function ensureLiveGitIgnores(cwd = process.cwd()) {
45
+ const target = resolveIgnoreTarget(cwd);
46
+ const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : '';
47
+ const block = [
48
+ IGNORE_MARKER_OPEN,
49
+ ...LIVE_IGNORE_PATTERNS,
50
+ IGNORE_MARKER_CLOSE,
51
+ ].join('\n');
52
+ const markerRe = new RegExp(`${escapeRegExp(IGNORE_MARKER_OPEN)}[\\s\\S]*?${escapeRegExp(IGNORE_MARKER_CLOSE)}`);
53
+
54
+ let updated;
55
+ if (markerRe.test(existing)) {
56
+ updated = existing.replace(markerRe, block);
57
+ } else {
58
+ const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
59
+ updated = `${prefix}${prefix.endsWith('\n\n') || prefix === '' ? '' : '\n'}${block}\n`;
60
+ }
61
+
62
+ if (updated !== existing) {
63
+ fs.mkdirSync(path.dirname(target.path), { recursive: true });
64
+ fs.writeFileSync(target.path, updated, 'utf-8');
65
+ }
66
+
67
+ return {
68
+ file: path.relative(cwd, target.path).split(path.sep).join('/'),
69
+ mode: target.mode,
70
+ changed: updated !== existing,
71
+ patterns: [...LIVE_IGNORE_PATTERNS],
72
+ };
73
+ }
74
+
75
+ function resolveIgnoreTarget(cwd) {
76
+ const gitExcludePath = resolveGitInfoExcludePath(cwd);
77
+ if (gitExcludePath) {
78
+ return { path: gitExcludePath, mode: 'git-info-exclude' };
79
+ }
80
+ return { path: path.join(cwd, '.gitignore'), mode: 'gitignore' };
81
+ }
82
+
83
+ function resolveGitInfoExcludePath(cwd) {
84
+ const dotGit = path.join(cwd, '.git');
85
+ if (!fs.existsSync(dotGit)) return null;
86
+
87
+ const stat = fs.statSync(dotGit);
88
+ if (stat.isDirectory()) return path.join(dotGit, 'info', 'exclude');
89
+ if (!stat.isFile()) return null;
90
+
91
+ const body = fs.readFileSync(dotGit, 'utf-8').trim();
92
+ const match = body.match(/^gitdir:\s*(.+)$/i);
93
+ if (!match) return null;
94
+ const gitDir = path.isAbsolute(match[1]) ? match[1] : path.resolve(cwd, match[1]);
95
+ return path.join(gitDir, 'info', 'exclude');
96
+ }
97
+
98
+ function escapeRegExp(value) {
99
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
100
+ }
101
+
102
+ /**
103
+ * Expand config.files (which may contain glob patterns) into a literal list
104
+ * of existing file paths relative to rootDir. Literal entries pass through;
105
+ * glob patterns are expanded via fs.globSync. HARD_EXCLUDES and config.exclude
106
+ * are applied as filters. Duplicates are removed. Order is preserved by
107
+ * first appearance.
108
+ */
109
+ export function resolveFiles(rootDir, config) {
110
+ const patterns = config.files;
111
+ const userExcludes = Array.isArray(config.exclude) ? config.exclude : [];
112
+ const allExcludes = [...HARD_EXCLUDES, ...userExcludes];
113
+ const excludeRegexes = allExcludes.map(globToRegex);
114
+
115
+ const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath));
116
+ const isGlob = (s) => /[*?[]/.test(s);
117
+
118
+ const seen = new Set();
119
+ const out = [];
120
+ for (const pat of patterns) {
121
+ if (!isGlob(pat)) {
122
+ // Literal path — include even if it doesn't exist yet; the caller
123
+ // reports file_not_found per-entry. Exclude list doesn't apply to
124
+ // explicit literal entries (user named it on purpose).
125
+ if (!seen.has(pat)) {
126
+ seen.add(pat);
127
+ out.push(pat);
128
+ }
129
+ continue;
130
+ }
131
+ let matches;
132
+ try {
133
+ matches = fs.globSync(pat, { cwd: rootDir, withFileTypes: true });
134
+ } catch {
135
+ continue;
136
+ }
137
+ for (const ent of matches) {
138
+ if (!ent.isFile?.()) continue;
139
+ const abs = path.join(ent.parentPath || ent.path || rootDir, ent.name);
140
+ const rel = path.relative(rootDir, abs).split(path.sep).join('/');
141
+ if (isExcluded(rel)) continue;
142
+ if (seen.has(rel)) continue;
143
+ seen.add(rel);
144
+ out.push(rel);
145
+ }
146
+ }
147
+ return out;
148
+ }
149
+
150
+ /**
151
+ * Convert a glob pattern to a RegExp. Supports:
152
+ * ** → any number of path segments (including zero)
153
+ * * → any chars except `/`
154
+ * ? → any single char except `/`
155
+ * Paths are normalized to forward slashes before matching.
156
+ */
157
+ function globToRegex(pattern) {
158
+ let re = '';
159
+ let i = 0;
160
+ while (i < pattern.length) {
161
+ const c = pattern[i];
162
+ if (c === '*') {
163
+ if (pattern[i + 1] === '*') {
164
+ // ** — any number of segments, including zero. Handle the common
165
+ // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`.
166
+ if (pattern[i + 2] === '/') {
167
+ re += '(?:.*/)?';
168
+ i += 3;
169
+ } else {
170
+ re += '.*';
171
+ i += 2;
172
+ }
173
+ } else {
174
+ re += '[^/]*';
175
+ i += 1;
176
+ }
177
+ } else if (c === '?') {
178
+ re += '[^/]';
179
+ i += 1;
180
+ } else if (/[.+^${}()|[\]\\]/.test(c)) {
181
+ re += `\\${c}`;
182
+ i += 1;
183
+ } else {
184
+ re += c;
185
+ i += 1;
186
+ }
187
+ }
188
+ return new RegExp(`^${re}$`);
189
+ }
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // Core operations
193
+ // ---------------------------------------------------------------------------
194
+
195
+ function validateConfig(cfg) {
196
+ if (!cfg || typeof cfg !== 'object') throw new Error('config.json must be an object');
197
+ if (!Array.isArray(cfg.files) || cfg.files.length === 0) {
198
+ throw new Error('config.files (non-empty string array) required');
199
+ }
200
+ if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) {
201
+ throw new Error('config.files must contain only non-empty strings');
202
+ }
203
+ if (cfg.exclude !== undefined) {
204
+ if (!Array.isArray(cfg.exclude)) {
205
+ throw new Error('config.exclude, if present, must be a string array');
206
+ }
207
+ if (!cfg.exclude.every((f) => typeof f === 'string' && f.length > 0)) {
208
+ throw new Error('config.exclude must contain only non-empty strings');
209
+ }
210
+ }
211
+ if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') {
212
+ throw new Error('config.insertBefore or config.insertAfter (string) required');
213
+ }
214
+ if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') {
215
+ throw new Error("config.commentSyntax must be 'html' or 'jsx'");
216
+ }
217
+ if (cfg.cspChecked !== undefined && typeof cfg.cspChecked !== 'boolean') {
218
+ throw new Error("config.cspChecked, if present, must be a boolean");
219
+ }
220
+ }
221
+
222
+
223
+ export {
224
+ resolveIgnoreTarget,
225
+ resolveGitInfoExcludePath,
226
+ escapeRegExp,
227
+ globToRegex,
228
+ validateConfig,
229
+ };
@@ -0,0 +1,114 @@
1
+ // Building, inserting, and removing the injected live script-tag block in
2
+ // an HTML/JSX entry file, with line-ending-preserving splicing. Split out of
3
+ // live-inject.mjs (file-size sweep — pure move, no behaviour change).
4
+
5
+ const MARKER_OPEN_TEXT = 'monodesign-live-start';
6
+ const MARKER_CLOSE_TEXT = 'monodesign-live-end';
7
+ // Query param name live-server.mjs checks on every protected route.
8
+ const AUTH_QUERY_KEY = 'token';
9
+
10
+ function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : '<!--'; }
11
+ function commentClose(syntax) { return syntax === 'jsx' ? '*/}' : '-->'; }
12
+
13
+ function buildTagBlock(syntax, port, authCred, filePath) {
14
+ const open = commentOpen(syntax);
15
+ const close = commentClose(syntax);
16
+ // Astro processes <script> tags by default and rewrites src to its own
17
+ // bundled URL. is:inline opts out so the literal external src survives.
18
+ const isAstro = typeof filePath === 'string' && filePath.endsWith('.astro');
19
+ const scriptAttrs = isAstro ? 'is:inline ' : '';
20
+ const authQuery = new URLSearchParams();
21
+ authQuery.set(AUTH_QUERY_KEY, authCred);
22
+ return (
23
+ open + ' ' + MARKER_OPEN_TEXT + ' ' + close + '\n' +
24
+ '<script ' + scriptAttrs + 'src="http://localhost:' + port + '/live.js?' + authQuery.toString() + '"></script>\n' +
25
+ open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n'
26
+ );
27
+ }
28
+
29
+ function detectLineEnding(content) {
30
+ if (content.includes('\r\n')) return '\r\n';
31
+ if (content.includes('\r')) return '\r';
32
+ return '\n';
33
+ }
34
+
35
+ function normalizeLineEndings(content, lineEnding) {
36
+ return lineEnding === '\n' ? content : content.replace(/\n/g, lineEnding);
37
+ }
38
+
39
+ function readLineEndingAt(content, index) {
40
+ if (content[index] === '\r' && content[index + 1] === '\n') return '\r\n';
41
+ if (content[index] === '\n') return '\n';
42
+ if (content[index] === '\r') return '\r';
43
+ return '';
44
+ }
45
+
46
+ function insertTag(content, config, port, authCred, filePath) {
47
+ const lineEnding = detectLineEnding(content);
48
+ const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, authCred, filePath), lineEnding);
49
+ // insertBefore: match the LAST occurrence. Anchors like `</body>` naturally
50
+ // belong at the end, and the same literal can appear earlier in code blocks
51
+ // within rendered documentation pages.
52
+ if (config.insertBefore) {
53
+ const idx = content.lastIndexOf(config.insertBefore);
54
+ if (idx === -1) return content;
55
+ return content.slice(0, idx) + block + content.slice(idx);
56
+ }
57
+ // insertAfter: match the FIRST occurrence — typical anchors like `<head>` or
58
+ // `<body>` open near the top of the document.
59
+ const idx = content.indexOf(config.insertAfter);
60
+ if (idx === -1) return content;
61
+ const after = idx + config.insertAfter.length;
62
+ // Preserve an existing trailing newline if the anchor already has one.
63
+ // Slice the remainder from the original anchor offset, not prefix.length:
64
+ // in the no-newline case prefix is one char longer than the anchor (the
65
+ // appended '\n'), so slicing by prefix.length would drop the first real
66
+ // character after the anchor (#227).
67
+ const existingNewline = readLineEndingAt(content, after);
68
+ const prefix = content.slice(0, after) + (existingNewline || lineEnding);
69
+ const rest = content.slice(after + existingNewline.length);
70
+ return prefix + block + rest;
71
+ }
72
+
73
+ /**
74
+ * Remove the live script block. Matches either HTML or JSX comment markers
75
+ * regardless of config (so stale tags from a wrong config can still be cleaned).
76
+ *
77
+ * Indent-preserving: captures any whitespace immediately preceding the opener
78
+ * marker and re-emits it in place of the removed block. `insertTag` inserted
79
+ * the block *after* the original line's indent and *before* the anchor (e.g.
80
+ * `</body>`), which moved the indent onto the opener line and left the anchor
81
+ * unindented. Replacing the whole block (plus its trailing newline) with just
82
+ * the captured indent hands the indent back to the anchor that follows.
83
+ */
84
+ function removeTag(content, _syntax) {
85
+ const patterns = [
86
+ /([ \t]*)<!--\s*monodesign-live-start\s*-->[\s\S]*?<!--\s*monodesign-live-end\s*-->([ \t]*(?:\r\n|\n|\r|$)?)/,
87
+ /([ \t]*)\{\/\*\s*monodesign-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*monodesign-live-end\s*\*\/\}([ \t]*(?:\r\n|\n|\r|$)?)/,
88
+ ];
89
+ for (const pat of patterns) {
90
+ let changed = false;
91
+ let next = content;
92
+ do {
93
+ content = next;
94
+ next = content.replace(pat, (_match, leadingIndent, trailing = '') => {
95
+ if (/[\r\n]/.test(trailing)) return leadingIndent;
96
+ return leadingIndent || trailing || '';
97
+ });
98
+ if (next !== content) changed = true;
99
+ } while (next !== content);
100
+ if (changed) return next;
101
+ }
102
+ return content;
103
+ }
104
+
105
+ export {
106
+ commentOpen,
107
+ commentClose,
108
+ buildTagBlock,
109
+ detectLineEnding,
110
+ normalizeLineEndings,
111
+ readLineEndingAt,
112
+ insertTag,
113
+ removeTag,
114
+ };