Skip to content

Commit 9142ab6

Browse files
Merge pull request #367 from emulsify-ds/codex/7.1-release-prep
release: prepare Emulsify Drupal 7.1.0
2 parents 5777995 + e11f437 commit 9142ab6

26 files changed

Lines changed: 1327 additions & 164 deletions
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
#!/usr/bin/env node
2+
3+
const fs = require('fs');
4+
const path = require('path');
5+
6+
const repoRoot = path.resolve(__dirname, '../..');
7+
8+
const CHECKS = [
9+
{
10+
relativePath: 'README.md',
11+
heading: 'Generate a child theme',
12+
packagePath: 'whisk/package.json',
13+
packageLabel: 'generated child themes',
14+
expectedScripts: ['develop'],
15+
},
16+
{
17+
relativePath: 'README.md',
18+
heading: 'Verify your generated child theme',
19+
packagePath: 'whisk/package.json',
20+
packageLabel: 'generated child themes',
21+
expectedScripts: ['build', 'storybook-build', 'test', 'a11y'],
22+
},
23+
{
24+
relativePath: 'UPGRADE.md',
25+
heading: 'Project Audit',
26+
packagePath: 'whisk/package.json',
27+
packageLabel: 'generated child themes',
28+
expectedScripts: ['audit', 'audit:twig-stories'],
29+
},
30+
{
31+
relativePath: 'README.md',
32+
heading: 'Release Readiness',
33+
packagePath: 'package.json',
34+
packageLabel: 'the root project',
35+
expectedScripts: ['release:check'],
36+
},
37+
{
38+
relativePath: 'docs/release-readiness.md',
39+
heading: 'Local validation',
40+
packagePath: 'package.json',
41+
packageLabel: 'the root project',
42+
includeInlineCode: true,
43+
expectedScripts: ['docs:check-commands', 'lint:php', 'release:check'],
44+
},
45+
];
46+
47+
function readFile(relativePath) {
48+
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
49+
}
50+
51+
function readJson(relativePath) {
52+
return JSON.parse(readFile(relativePath));
53+
}
54+
55+
function normalizeHeadingText(text) {
56+
return text.replace(/\s+#+\s*$/, '').trim();
57+
}
58+
59+
function extractMarkdownSection(relativePath, heading) {
60+
const lines = readFile(relativePath).split(/\r?\n/);
61+
62+
for (let index = 0; index < lines.length; index += 1) {
63+
const match = lines[index].match(/^(#{1,6})\s+(.+?)\s*$/);
64+
if (!match || normalizeHeadingText(match[2]) !== heading) {
65+
continue;
66+
}
67+
68+
const level = match[1].length;
69+
const bodyStart = index + 1;
70+
let bodyEnd = lines.length;
71+
for (let nextIndex = bodyStart; nextIndex < lines.length; nextIndex += 1) {
72+
const nextMatch = lines[nextIndex].match(/^(#{1,6})\s+/);
73+
if (nextMatch && nextMatch[1].length <= level) {
74+
bodyEnd = nextIndex;
75+
break;
76+
}
77+
}
78+
79+
return {
80+
text: lines.slice(bodyStart, bodyEnd).join('\n'),
81+
startLine: bodyStart + 1,
82+
};
83+
}
84+
85+
throw new Error(`${relativePath} is missing the "${heading}" documentation section.`);
86+
}
87+
88+
function extractShellFenceCommands(section) {
89+
const commands = [];
90+
const lines = section.text.split(/\r?\n/);
91+
let shellFence = null;
92+
93+
for (let index = 0; index < lines.length; index += 1) {
94+
const line = lines[index];
95+
const fenceMatch = line.match(/^```([A-Za-z0-9_-]*)\s*$/);
96+
97+
if (!shellFence && fenceMatch) {
98+
const language = fenceMatch[1].toLowerCase();
99+
shellFence = {
100+
collect: ['bash', 'sh', 'shell'].includes(language),
101+
lines: [],
102+
startLine: section.startLine + index + 1,
103+
};
104+
continue;
105+
}
106+
107+
if (shellFence && /^```\s*$/.test(line)) {
108+
if (shellFence.collect) {
109+
commands.push(...extractNpmRunCommands(shellFence.lines.join('\n'), shellFence.startLine));
110+
}
111+
shellFence = null;
112+
continue;
113+
}
114+
115+
if (shellFence && shellFence.collect) {
116+
shellFence.lines.push(line);
117+
}
118+
}
119+
120+
return commands;
121+
}
122+
123+
function extractInlineCommands(section) {
124+
const commands = [];
125+
for (const match of section.text.matchAll(/`([^`\n]*\bnpm\s+run\s+[^`]*)`/g)) {
126+
commands.push(...extractNpmRunCommands(match[1], section.startLine + lineOffsetForIndex(section.text, match.index)));
127+
}
128+
return commands;
129+
}
130+
131+
function extractNpmRunCommands(text, startLine) {
132+
const commands = [];
133+
const lines = text.split(/\r?\n/);
134+
for (let index = 0; index < lines.length; index += 1) {
135+
const line = lines[index].trim();
136+
if (line === '' || line.startsWith('#')) {
137+
continue;
138+
}
139+
140+
for (const match of line.matchAll(/\bnpm\s+run\s+([A-Za-z0-9:_-]+)/g)) {
141+
commands.push({
142+
script: match[1],
143+
line: startLine + index,
144+
});
145+
}
146+
}
147+
148+
return commands;
149+
}
150+
151+
function lineOffsetForIndex(text, index) {
152+
return text.slice(0, index).split(/\r?\n/).length - 1;
153+
}
154+
155+
function unique(values) {
156+
return [...new Set(values)];
157+
}
158+
159+
function validateScope(scope) {
160+
const section = extractMarkdownSection(scope.relativePath, scope.heading);
161+
const packageJson = readJson(scope.packagePath);
162+
const scripts = packageJson.scripts || {};
163+
const commands = [
164+
...extractShellFenceCommands(section),
165+
...(scope.includeInlineCode ? extractInlineCommands(section) : []),
166+
];
167+
const documentedScripts = unique(commands.map((command) => command.script)).sort();
168+
const errors = [];
169+
170+
if (commands.length === 0) {
171+
errors.push(`${scope.relativePath}#${scope.heading} does not document any npm run commands for ${scope.packageLabel}.`);
172+
}
173+
174+
for (const expectedScript of scope.expectedScripts || []) {
175+
if (!documentedScripts.includes(expectedScript)) {
176+
errors.push(`${scope.relativePath}#${scope.heading} should document npm run ${expectedScript} for ${scope.packageLabel}.`);
177+
}
178+
}
179+
180+
for (const command of commands) {
181+
if (!scripts[command.script]) {
182+
errors.push(`${scope.relativePath}:${command.line} documents npm run ${command.script} for ${scope.packageLabel}, but ${scope.packagePath} has no "${command.script}" script.`);
183+
}
184+
}
185+
186+
return {
187+
documentedScripts,
188+
errors,
189+
};
190+
}
191+
192+
const errors = [];
193+
const summaries = [];
194+
195+
for (const scope of CHECKS) {
196+
const result = validateScope(scope);
197+
errors.push(...result.errors);
198+
summaries.push(`${scope.relativePath}#${scope.heading} -> ${scope.packagePath}: ${result.documentedScripts.join(', ')}`);
199+
}
200+
201+
if (errors.length > 0) {
202+
for (const error of errors) {
203+
console.error(error);
204+
}
205+
process.exit(1);
206+
}
207+
208+
console.log(`Validated documented npm scripts in ${CHECKS.length} documentation sections.`);
209+
for (const summary of summaries) {
210+
console.log(`- ${summary}`);
211+
}

.github/scripts/favicon-portability-smoke.php

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,19 @@ function emulsify_favicon_assert_invalid(callable $callback, string $expected_me
9090
emulsify_favicon_fail(sprintf('Expected InvalidArgumentException containing "%s".', $expected_message));
9191
}
9292

93+
/**
94+
* Returns whether source analysis includes a diagnostic warning fragment.
95+
*/
96+
function emulsify_favicon_analysis_has_warning(array $analysis, string $expected_message): bool {
97+
foreach (($analysis['warnings'] ?? []) as $warning) {
98+
if (is_string($warning) && str_contains($warning, $expected_message)) {
99+
return TRUE;
100+
}
101+
}
102+
103+
return FALSE;
104+
}
105+
93106
/**
94107
* Loads normalized theme settings.
95108
*/
@@ -144,6 +157,7 @@ function emulsify_favicon_run_sanitizer_matrix(FaviconPackageGenerator $generato
144157
// Dangerous markup should be stripped without rejecting usable SVGs.
145158
$analysis = $generator->validateSourceSvg('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><script>alert(1)</script><rect width="64" height="64"/></svg>', FALSE);
146159
emulsify_favicon_assert(!str_contains((string) $analysis['sanitized_svg'], '<script'), 'Script tags should be stripped from sanitized SVG output.');
160+
emulsify_favicon_assert(emulsify_favicon_analysis_has_warning($analysis, 'Unsafe SVG markup was removed'), 'Sanitized SVG cleanup should be reported as a warning.');
147161

148162
$analysis = $generator->validateSourceSvg('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><foreignObject><div>bad</div></foreignObject><rect width="64" height="64"/></svg>', FALSE);
149163
emulsify_favicon_assert(!str_contains(strtolower((string) $analysis['sanitized_svg']), 'foreignobject'), 'foreignObject nodes should be stripped from sanitized SVG output.');
@@ -161,6 +175,33 @@ function emulsify_favicon_run_sanitizer_matrix(FaviconPackageGenerator $generato
161175
emulsify_favicon_assert(!str_contains(strtolower((string) $analysis['sanitized_svg']), 'onclick='), 'Inline event handlers should be stripped.');
162176
emulsify_favicon_assert(!str_contains(strtolower((string) $analysis['sanitized_svg']), 'onload='), 'Root event handlers should be stripped.');
163177

178+
$style_element_svg = <<<'SVG'
179+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
180+
<style>@import url("https://example.com/favicon.css"); rect { fill: url("javascript:alert(1)"); }</style>
181+
<rect width="64" height="64"/>
182+
</svg>
183+
SVG;
184+
$analysis = $generator->validateSourceSvg($style_element_svg, FALSE);
185+
$sanitized_svg = strtolower((string) $analysis['sanitized_svg']);
186+
emulsify_favicon_assert(!str_contains($sanitized_svg, '<style'), 'Style elements should be stripped from sanitized SVG output.');
187+
emulsify_favicon_assert(!str_contains($sanitized_svg, '@import'), 'CSS @import rules should be stripped from sanitized SVG output.');
188+
emulsify_favicon_assert(!str_contains($sanitized_svg, 'https://example.com/favicon.css'), 'External CSS imports should be stripped from sanitized SVG output.');
189+
emulsify_favicon_assert(!str_contains($sanitized_svg, 'javascript:'), 'CSS javascript: URLs should be stripped from sanitized SVG output.');
190+
191+
$analysis = $generator->validateSourceSvg('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" style="fill:url(https://example.com/icon.svg#mark);stroke:url(javascript:alert(1))"/></svg>', FALSE);
192+
$sanitized_svg = strtolower((string) $analysis['sanitized_svg']);
193+
emulsify_favicon_assert(!str_contains($sanitized_svg, 'style='), 'Inline style attributes should be stripped from sanitized SVG output.');
194+
emulsify_favicon_assert(!str_contains($sanitized_svg, 'https://example.com/icon.svg'), 'External style attribute URLs should be stripped from sanitized SVG output.');
195+
emulsify_favicon_assert(!str_contains($sanitized_svg, 'javascript:'), 'javascript: style attribute URLs should be stripped from sanitized SVG output.');
196+
197+
$analysis = $generator->validateSourceSvg('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><defs><linearGradient id="g"><stop offset="0%" stop-color="#000"/><stop offset="100%" stop-color="#fff"/></linearGradient></defs><rect width="64" height="64" fill="url(#g)" filter="url(https://example.com/filter.svg#f)"/></svg>', FALSE);
198+
$sanitized_svg = (string) $analysis['sanitized_svg'];
199+
emulsify_favicon_assert(!str_contains($sanitized_svg, 'https://example.com/filter.svg'), 'External CSS url() attribute references should be stripped from sanitized SVG output.');
200+
emulsify_favicon_assert(str_contains($sanitized_svg, 'fill="url(#g)"'), 'Local CSS url() fragment references should be preserved.');
201+
202+
$analysis = $generator->validateSourceSvg('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" filter="url(javascript:alert(1))"/></svg>', FALSE);
203+
emulsify_favicon_assert(!str_contains(strtolower((string) $analysis['sanitized_svg']), 'javascript:'), 'javascript: CSS url() attribute references should be stripped from sanitized SVG output.');
204+
164205
$analysis = $generator->validateSourceSvg('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 32"><rect width="64" height="32"/></svg>', FALSE);
165206
emulsify_favicon_assert(
166207
($analysis['view_box'] ?? []) === [0.0, -16.0, 64.0, 64.0],

0 commit comments

Comments
 (0)