Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 25 additions & 6 deletions plugins/power-pages/scripts/launch-playwright-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,31 @@
// Self-contained — no external dependencies required.

const { spawn } = require('child_process');
const path = require('path');
const { detectBrowser } = require('./lib/detect-browser');

const browser = detectBrowser();
const child = spawn('npx', ['@playwright/mcp@latest', '--browser', browser, '--viewport-size', '1024,768'], {
stdio: 'inherit',
shell: true,
});
function buildMcpArgs(browser) {
return [
'@playwright/mcp@latest',
'--browser',
browser,
'--config',
path.join(__dirname, 'playwright-mcp-fullscreen.config.json'),
];
}

child.on('exit', (code) => process.exit(code || 0));
function launch({ browser = detectBrowser(), spawnFn = spawn, onExit = (code) => process.exit(code || 0) } = {}) {
const child = spawnFn('npx', buildMcpArgs(browser), {
stdio: 'inherit',
shell: true,
});

child.on('exit', onExit);
return child;
}

if (require.main === module) {
launch();
}

module.exports = { buildMcpArgs, launch };
20 changes: 17 additions & 3 deletions plugins/power-pages/scripts/lib/render-template.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ const path = require('path');
* @param {string} [options.dataPath] - Absolute path to a JSON data file. Ignored if dataObject is provided.
* @param {Object} [options.dataObject] - Data object passed directly. If provided, takes precedence over dataPath.
* @param {string[]} options.requiredKeys - Keys that must be present in the data
* @param {boolean} [options.escapeStringValues=false] - Escape string values for HTML text contexts
*/
function renderTemplate({ templatePath, outputPath, dataPath, dataObject, requiredKeys }) {
function renderTemplate({ templatePath, outputPath, dataPath, dataObject, requiredKeys, escapeStringValues = false }) {
// Validate inputs exist
if (!fs.existsSync(templatePath)) {
console.error(`Template not found: ${templatePath}`);
Expand All @@ -44,11 +45,17 @@ function renderTemplate({ templatePath, outputPath, dataPath, dataObject, requir
process.exit(1);
}

// Replace all __KEY__ placeholders with corresponding values from the data object
// Replace all __KEY__ placeholders with corresponding values from the data object.
// For non-string values (arrays/objects serialized to JSON), escape `<` as `\u003c`
// so a literal `</script>` inside string data cannot close a containing <script> tag.
// Templates that place string placeholders in HTML text contexts can opt in to
// string escaping with escapeStringValues.
let result = template;
for (const [key, value] of Object.entries(data)) {
const placeholder = `__${key}__`;
const replacement = typeof value === 'string' ? value : JSON.stringify(value);
const replacement = typeof value === 'string'
? (escapeStringValues ? escapeHtml(value) : value)
: JSON.stringify(value).replace(/</g, '\\u003c');
result = result.split(placeholder).join(replacement);
Comment thread
priyanshu92 marked this conversation as resolved.
}

Expand Down Expand Up @@ -93,6 +100,13 @@ function renderTemplate({ templatePath, outputPath, dataPath, dataObject, requir
console.log(JSON.stringify({ status: 'ok', output: outputPath }));
}

function escapeHtml(value) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}

function parseArgs(argv) {
const args = {};
for (let i = 2; i < argv.length; i++) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"browser": {
"launchOptions": {
"args": [
"--start-maximized",
"--start-fullscreen"
]
},
"contextOptions": {
"viewport": null
}
}
}
102 changes: 102 additions & 0 deletions plugins/power-pages/scripts/render-createsite-plan.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#!/usr/bin/env node
/**
* render-createsite-plan.js — Renders the create-site implementation plan HTML.
*
* Usage (inline JSON):
* node render-createsite-plan.js --output <path> --data-inline '<json>'
*
* Usage (file-based):
* node render-createsite-plan.js --output <path> --data <json-file>
*
* Required keys in the data:
* SITE_NAME, PLAN_TITLE, FRAMEWORK, AESTHETIC, MOOD, SUMMARY,
* TYPOGRAPHY_DATA, PALETTE_DATA, MOTION_DATA, BACKGROUNDS_DATA,
* PAGES_DATA, COMPONENTS_DATA, ROUTES_DATA, REVIEW_DATA, DEPLOYMENT_DATA
*/

const path = require('path');
const fs = require('fs');
const { renderTemplate, parseArgs } = require('./lib/render-template');

const args = parseArgs(process.argv);

if (!args.output || (!args['data-inline'] && !args.data)) {
console.error(
'Usage: node render-createsite-plan.js --output <path> --data-inline \'<json>\'\n' +
' node render-createsite-plan.js --output <path> --data <json-file>'
);
process.exit(1);
}

const templatePath = path.join(
__dirname,
'..',
'skills',
'create-site',
'assets',
'create-site-plan.html'
);

const requiredKeys = [
'SITE_NAME',
'PLAN_TITLE',
'FRAMEWORK',
'AESTHETIC',
'MOOD',
'SUMMARY',
'TYPOGRAPHY_DATA',
'PALETTE_DATA',
'MOTION_DATA',
'BACKGROUNDS_DATA',
'PAGES_DATA',
'COMPONENTS_DATA',
'ROUTES_DATA',
'REVIEW_DATA',
'DEPLOYMENT_DATA',
];

function withDerivedTemplateData(dataObject) {
return {
...dataObject,
SUMMARY_DATA: { text: String(dataObject.SUMMARY ?? '') },
};
}

if (args['data-inline']) {
let dataObject;
try {
dataObject = JSON.parse(args['data-inline']);
} catch {
console.error('Error: --data-inline value is not valid JSON');
process.exit(1);
}
renderTemplate({
templatePath,
outputPath: path.resolve(args.output),
dataObject: withDerivedTemplateData(dataObject),
requiredKeys,
escapeStringValues: true,
});
} else {
const dataPath = path.resolve(args.data);
if (!fs.existsSync(dataPath)) {
console.error(`Data file not found: ${dataPath}`);
process.exit(1);
}

let dataObject;
try {
dataObject = JSON.parse(fs.readFileSync(dataPath, 'utf8'));
} catch {
console.error('Error: --data file is not valid JSON');
process.exit(1);
}

renderTemplate({
templatePath,
outputPath: path.resolve(args.output),
dataObject: withDerivedTemplateData(dataObject),
requiredKeys,
escapeStringValues: true,
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');

const createSiteRoot = path.join(__dirname, '..', '..', 'skills', 'create-site', 'assets');
const loaderTemplates = [
'react/src/pages/Home.tsx',
'vue/src/pages/Home.vue',
'angular/src/app/pages/home.component.ts',
'astro/src/pages/index.astro',
];

test('create-site loader keeps awaiting-input banner persistent and dismissible across templates', () => {
for (const template of loaderTemplates) {
const content = fs.readFileSync(path.join(createSiteRoot, template), 'utf8');

assert.match(content, /id="inputBannerClose"/, template);
assert.match(content, /input-banner-close/, template);
assert.match(content, /aria-label="Dismiss notification"/, template);
assert.match(content, /dismissedPrompt/, template);
assert.match(content, /addEventListener\('click', dismissInputBanner\)/, template);
assert.match(content, /if \(banner\) banner\.hidden = !awaiting \|\| dismissedPrompt === prompt/, template);
assert.match(content, /if \(!awaiting\) dismissedPrompt = null/, template);
assert.match(content, /if \(!lastAwaiting\)/, template);
}
});
51 changes: 51 additions & 0 deletions plugins/power-pages/scripts/tests/launch-playwright-mcp.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const { EventEmitter } = require('node:events');

const {
buildMcpArgs,
launch,
} = require('../launch-playwright-mcp');

test('buildMcpArgs launches Playwright MCP with fullscreen config', () => {
const args = buildMcpArgs('chrome');
const configIndex = args.indexOf('--config');

assert.deepEqual(args.slice(0, 3), ['@playwright/mcp@latest', '--browser', 'chrome']);
assert.equal(args.includes('--viewport-size'), false);
assert.notEqual(configIndex, -1);
assert.equal(path.basename(args[configIndex + 1]), 'playwright-mcp-fullscreen.config.json');
});

test('fullscreen config maximizes the browser and uses the real viewport size', () => {
const configPath = path.join(__dirname, '..', 'playwright-mcp-fullscreen.config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));

assert.deepEqual(config.browser.launchOptions.args, ['--start-maximized', '--start-fullscreen']);
assert.equal(config.browser.contextOptions.viewport, null);
});

test('launch wires spawn and process exit handling', () => {
let spawnCall;
const child = new EventEmitter();

launch({
browser: 'msedge',
spawnFn(command, args, options) {
spawnCall = { command, args, options };
return child;
},
onExit(code) {
spawnCall.exitCode = code;
},
});

assert.equal(spawnCall.command, 'npx');
assert.deepEqual(spawnCall.args.slice(0, 3), ['@playwright/mcp@latest', '--browser', 'msedge']);
assert.deepEqual(spawnCall.options, { stdio: 'inherit', shell: true });

child.emit('exit', 7);
assert.equal(spawnCall.exitCode, 7);
});
Loading
Loading