feat: add full Zonemaster stack with Docker and Spanish UI
- Clone all 5 Zonemaster component repos (LDNS, Engine, CLI, Backend, GUI) - Dockerfile.backend: 8-stage multi-stage build LDNS→Engine→CLI→Backend - Dockerfile.gui: Astro static build served via nginx - docker-compose.yml: backend (internal) + frontend (port 5353) - nginx.conf: root redirects to /es/, /api/ proxied to backend - zonemaster-gui/config.ts: defaultLanguage set to 'es' (Spanish) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
51
zonemaster-gui/scripts/create_release.js
Normal file
51
zonemaster-gui/scripts/create_release.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import archiver from 'archiver';
|
||||
import { readFileSync } from 'fs';
|
||||
|
||||
// Read package.json to get version
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(new URL('../package.json', import.meta.url))
|
||||
);
|
||||
|
||||
export async function zipDirectory(sourceDir, outPath) {
|
||||
const output = fs.createWriteStream(outPath);
|
||||
const archive = archiver('zip', {
|
||||
zlib: { level: 9 }
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
output.on('close', () => {
|
||||
console.log(`Zipped ${archive.pointer()} total bytes`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
archive.on('error', err => reject(err));
|
||||
|
||||
archive.pipe(output);
|
||||
archive.directory(sourceDir, false); // `false` keeps only folder contents
|
||||
archive.finalize();
|
||||
});
|
||||
}
|
||||
|
||||
// Example usage
|
||||
const __dirname = path.dirname(new URL(import.meta.url).pathname);
|
||||
const inputDir = path.resolve(__dirname, '../public');
|
||||
const version = packageJson.version;
|
||||
const outputZip = path.resolve(__dirname, `../zonemaster_web_gui_${version}.zip`);
|
||||
|
||||
// Copy zonemaster.conf-example into public
|
||||
const file1Src = path.resolve(__dirname, '../zonemaster.conf-example');
|
||||
const file1Dest = path.resolve(inputDir, 'zonemaster.conf-example');
|
||||
fs.copyFileSync(file1Src, file1Dest);
|
||||
console.log('Include zonemaster.conf-example in distribution zip file');
|
||||
|
||||
// Copy LICENSE into public
|
||||
const file2Src = path.resolve(__dirname, '../LICENSE');
|
||||
const file2Dest = path.resolve(inputDir, 'LICENSE');
|
||||
fs.copyFileSync(file2Src, file2Dest);
|
||||
console.log('Include LICENSE in distribution zip file');
|
||||
|
||||
zipDirectory(inputDir, outputZip)
|
||||
.then(() => console.log('Zip complete'))
|
||||
.catch(err => console.error('Error zipping:', err));
|
||||
166
zonemaster-gui/scripts/generate-messages.ts
Normal file
166
zonemaster-gui/scripts/generate-messages.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Regex to find placeholders like {variableName}
|
||||
const placeholderRegex = /\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g;
|
||||
|
||||
interface Messages {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
interface AllMessages {
|
||||
[lang: string]: Messages;
|
||||
}
|
||||
|
||||
interface GenerateConfig {
|
||||
defaultLanguage: string;
|
||||
enabledLanguages: string[];
|
||||
}
|
||||
|
||||
function getFileHeader(): string {
|
||||
return `/* Auto-generated by vite-plugin-messages - DO NOT EDIT */\n/* Generated: ${new Date().toISOString()} */\n`;
|
||||
}
|
||||
|
||||
export function generateMessages(langDir: string, outDir: string, config: GenerateConfig) {
|
||||
const { defaultLanguage, enabledLanguages } = config;
|
||||
|
||||
if (!fs.existsSync(outDir)) {
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
}
|
||||
|
||||
const allMessages: AllMessages = {};
|
||||
|
||||
for (const lang of enabledLanguages) {
|
||||
const filePath = path.join(langDir, `${lang}.json`);
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
allMessages[lang] = JSON.parse(content);
|
||||
} catch (e) {
|
||||
console.warn(`⚠️ Failed to load ${lang}.json:`, e instanceof Error ? e.message : String(e));
|
||||
allMessages[lang] = {};
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(allMessages).length === 0) {
|
||||
console.warn('⚠️ No language files found');
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultMessages = allMessages[defaultLanguage] || {};
|
||||
const allKeys = Object.keys(defaultMessages);
|
||||
|
||||
for (const lang of enabledLanguages) {
|
||||
const messages = allMessages[lang] || {};
|
||||
const functions: string[] = [];
|
||||
const reExports: string[] = [];
|
||||
|
||||
for (const key of allKeys) {
|
||||
const hasTranslation = key in messages;
|
||||
|
||||
if (hasTranslation) {
|
||||
functions.push(createMessageFunction(key, messages[key]));
|
||||
} else if (lang !== defaultLanguage) {
|
||||
reExports.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
let content = getFileHeader();
|
||||
|
||||
if (reExports.length > 0) {
|
||||
content += `\nexport { ${reExports.join(', ')} } from './${defaultLanguage}.ts';\n`;
|
||||
}
|
||||
|
||||
if (functions.length > 0) {
|
||||
content += `\n${functions.join('\n\n')}\n`;
|
||||
}
|
||||
|
||||
const filePath = path.join(outDir, `${lang}.ts`);
|
||||
fs.writeFileSync(filePath, content, 'utf-8');
|
||||
console.log(`✅ Generated messages/${lang}.ts (${functions.length} translated, ${reExports.length} re-exported)`);
|
||||
}
|
||||
|
||||
generateIndexFile(outDir, allKeys, defaultMessages, config);
|
||||
}
|
||||
|
||||
function extractPlaceholders(value: string): string[] {
|
||||
const matches = value.matchAll(placeholderRegex);
|
||||
const placeholders = new Set<string>();
|
||||
for (const match of matches) {
|
||||
placeholders.add(match[1]);
|
||||
}
|
||||
return Array.from(placeholders);
|
||||
}
|
||||
|
||||
function escapeTemplateString(value: string): string {
|
||||
// Escape backticks and ${} that are not our placeholders
|
||||
return value
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/`/g, '\\`');
|
||||
}
|
||||
|
||||
function createMessageFunction(key: string, value: string): string {
|
||||
const placeholders = extractPlaceholders(value);
|
||||
const templateString = value.replace(placeholderRegex, '${params.$1}');
|
||||
const escapedTemplate = escapeTemplateString(templateString);
|
||||
|
||||
if (placeholders.length > 0) {
|
||||
const paramsType = placeholders.map(p => `${p}: string | number`).join(', ');
|
||||
return `export const ${key} = (params: { ${paramsType} }): string => \`${escapedTemplate}\`;`;
|
||||
}
|
||||
return `export const ${key} = (): string => \`${escapedTemplate}\`;`;
|
||||
}
|
||||
|
||||
function generateIndexFile(outDir: string, allKeys: string[], defaultMessages: Messages, config: GenerateConfig) {
|
||||
const { enabledLanguages } = config;
|
||||
|
||||
const langImports = enabledLanguages
|
||||
.map(lang => `import * as ${lang} from './${lang}.ts';`)
|
||||
.join('\n');
|
||||
|
||||
const langObject = enabledLanguages.join(', ');
|
||||
const languageType = enabledLanguages.map(l => `'${l}'`).join(' | ');
|
||||
const messageExports: string[] = [];
|
||||
|
||||
for (const key of allKeys) {
|
||||
const value = defaultMessages[key] || '';
|
||||
const placeholders = extractPlaceholders(value);
|
||||
const paramsType = placeholders.length > 0
|
||||
? `params: { ${placeholders.map(p => `${p}: string | number`).join(', ')} }`
|
||||
: '';
|
||||
const paramsArg = paramsType ? 'params' : '';
|
||||
|
||||
messageExports.push(
|
||||
`export const ${key} = (${paramsType}): string => allMessages[getLocale()].${key}(${paramsArg});`
|
||||
);
|
||||
}
|
||||
|
||||
const content = `${getFileHeader()}
|
||||
import config from '@/config';
|
||||
|
||||
${langImports}
|
||||
|
||||
const allMessages = { ${langObject} } as const;
|
||||
|
||||
export type Language = ${languageType};
|
||||
|
||||
export const defaultLanguage: Language = '${config.defaultLanguage}';
|
||||
export const enabledLanguages: readonly Language[] = ${JSON.stringify(config.enabledLanguages)} as const;
|
||||
|
||||
let _locale: Language = defaultLanguage;
|
||||
|
||||
export const getLocale = (): Language => _locale;
|
||||
export const isValidLocale = (locale: unknown): locale is Language => enabledLanguages.includes(locale as Language);
|
||||
export const setLocale = (locale: unknown): void => {
|
||||
if (isValidLocale(locale)) {
|
||||
_locale = locale as Language;
|
||||
}
|
||||
};
|
||||
|
||||
// Message functions
|
||||
${messageExports.join('\n\n')}
|
||||
`;
|
||||
|
||||
const filePath = path.join(outDir, 'index.ts');
|
||||
fs.writeFileSync(filePath, content, 'utf-8');
|
||||
console.log('✅ Generated messages/index.ts');
|
||||
}
|
||||
69
zonemaster-gui/scripts/messages-plugin.ts
Normal file
69
zonemaster-gui/scripts/messages-plugin.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import path from 'path';
|
||||
import { generateMessages } from './generate-messages.ts';
|
||||
|
||||
interface MessagesPluginConfig {
|
||||
defaultLanguage: string;
|
||||
enabledLanguages: string[];
|
||||
}
|
||||
|
||||
function validateConfig(config: any): asserts config is MessagesPluginConfig {
|
||||
const errors = [];
|
||||
if (!config?.defaultLanguage) errors.push('defaultLanguage is required');
|
||||
if (!Array.isArray(config?.enabledLanguages) || !config.enabledLanguages.length)
|
||||
errors.push('enabledLanguages must be a non-empty array');
|
||||
if (config?.enabledLanguages?.some((l: any) => typeof l !== 'string'))
|
||||
errors.push('enabledLanguages must contain only strings');
|
||||
|
||||
if (errors.length) throw new Error(`messagesPlugin: ${errors.join(', ')}`);
|
||||
}
|
||||
|
||||
export function messagesIntegration() {
|
||||
return {
|
||||
name: 'messages-integration',
|
||||
hooks: {
|
||||
'astro:config:setup': ({ injectScript }: any) => {
|
||||
injectScript(
|
||||
'before-hydration',
|
||||
`
|
||||
import { setLocale } from '@/messages';
|
||||
setLocale(document.documentElement.lang);
|
||||
`
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function messagesPlugin(config: MessagesPluginConfig) {
|
||||
validateConfig(config);
|
||||
|
||||
const langDir = path.resolve(process.cwd(), 'messages');
|
||||
const outDir = path.resolve(process.cwd(), 'src/messages');
|
||||
|
||||
const regenerate = () => {
|
||||
console.log('🔄 Generating message files...');
|
||||
generateMessages(langDir, outDir, config);
|
||||
};
|
||||
|
||||
return {
|
||||
name: 'vite-plugin-messages',
|
||||
|
||||
buildStart() {
|
||||
regenerate();
|
||||
},
|
||||
|
||||
configureServer(server: any) {
|
||||
server.watcher.add(langDir);
|
||||
},
|
||||
|
||||
handleHotUpdate({ file, server }: { file: string; server: any }) {
|
||||
if (file.startsWith(langDir) && file.endsWith('.json')) {
|
||||
console.log('♻️ Language file changed:', path.basename(file));
|
||||
regenerate();
|
||||
// Trigger a full reload to pick up the new messages
|
||||
server.ws.send({ type: 'full-reload' });
|
||||
return [];
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user