first commit
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import { mkdir, readdir, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join, relative, resolve, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const appRoot = join(projectRoot, 'app');
|
||||
const outputFile = join(projectRoot, 'public', 'meshkee', 'sitemap-config.json');
|
||||
const pageNames = new Set(['page.js','page.jsx','page.ts','page.tsx']);
|
||||
|
||||
async function findPages(directory) {
|
||||
const entries = await readdir(directory,{withFileTypes:true});
|
||||
const pages=[];
|
||||
for(const entry of entries){
|
||||
const full=join(directory,entry.name);
|
||||
if(entry.isDirectory()) pages.push(...await findPages(full));
|
||||
else if(pageNames.has(entry.name)) pages.push(full);
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
function toRoute(file) {
|
||||
const segments=relative(appRoot,dirname(file)).split(sep).filter(Boolean);
|
||||
if(segments.some(segment=>segment.startsWith('[')||segment.startsWith('(')||segment==='api'))return null;
|
||||
return segments.length?`/${segments.join('/')}`:'/';
|
||||
}
|
||||
|
||||
const priorities={ '/':1, '/portfolios':.9, '/ourservices':.8, '/blog':.8, '/aboutus':.7, '/contactus':.6 };
|
||||
const routes=(await findPages(appRoot)).map(toRoute).filter(Boolean).sort((a,b)=>a==='/'?-1:a.localeCompare(b));
|
||||
const staticPages=[...new Set(routes)].map(path=>({path,...(path==='/'?{changefreq:'weekly'}:{}),priority:priorities[path]??.5}));
|
||||
await mkdir(dirname(outputFile),{recursive:true});
|
||||
await writeFile(outputFile,`${JSON.stringify({baseUrl:'https://sanazgoodarzi.ir',staticPages},null,2)}\n`,'utf8');
|
||||
console.log(`Generated Meshkee sitemap config with ${staticPages.length} static routes.`);
|
||||
@@ -0,0 +1,5 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { nodeRuntime } from './runtime.mjs';
|
||||
const child = spawn(nodeRuntime(), ['node_modules/eslint/bin/eslint.js', '.'], {stdio:'inherit'});
|
||||
child.on('error', error => {console.error(error.message);process.exit(1)});
|
||||
child.on('exit', code => process.exit(code ?? 1));
|
||||
@@ -0,0 +1,6 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { nodeRuntime } from './runtime.mjs';
|
||||
if (process.argv[2] === 'build') await import('./generate-meshkee-config.mjs');
|
||||
const child = spawn(nodeRuntime(), ['node_modules/next/dist/bin/next', ...process.argv.slice(2)], { stdio: 'inherit', env: process.env });
|
||||
child.on('error', error => { console.error(error.message); process.exit(1); });
|
||||
child.on('exit', code => process.exit(code ?? 1));
|
||||
@@ -0,0 +1,13 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// Laragon may expose Node 18. Prefer the current runtime, with the desktop's
|
||||
// already-installed Node as a local development fallback; never download one.
|
||||
export function nodeRuntime() {
|
||||
const [major, minor] = process.versions.node.split('.').map(Number);
|
||||
if (major > 20 || (major === 20 && minor >= 9)) return process.execPath;
|
||||
const bundled = join(homedir(), '.cache', 'codex-runtimes', 'codex-primary-runtime', 'dependencies', 'node', 'bin', 'node.exe');
|
||||
if (process.platform === 'win32' && existsSync(bundled)) return bundled;
|
||||
throw new Error('Next.js requires Node.js 20.9 or newer. Use Node.js 24 LTS.');
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createServer } from 'node:http';
|
||||
import { createReadStream, existsSync } from 'node:fs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { resolve, sep, extname } from 'node:path';
|
||||
const root=resolve('out');
|
||||
if(!existsSync(root)){console.error('Run npm run build before npm start.');process.exit(1)}
|
||||
const types={'.html':'text/html; charset=utf-8','.js':'text/javascript; charset=utf-8','.css':'text/css; charset=utf-8','.json':'application/json','.txt':'text/plain; charset=utf-8','.png':'image/png','.jpg':'image/jpeg','.jpeg':'image/jpeg','.svg':'image/svg+xml','.woff':'font/woff','.woff2':'font/woff2','.ico':'image/x-icon'};
|
||||
createServer(async(req,res)=>{
|
||||
if(!['GET','HEAD'].includes(req.method)){res.writeHead(405,{Allow:'GET, HEAD'});res.end();return}
|
||||
try{
|
||||
const pathname=decodeURIComponent(new URL(req.url,'http://localhost').pathname);
|
||||
let file=resolve(root,`.${pathname}`);
|
||||
if(file!==root&&!file.startsWith(root+sep)){res.writeHead(403);res.end();return}
|
||||
let info=await stat(file).catch(()=>null);
|
||||
if(info?.isDirectory()){file=resolve(file,'index.html');info=await stat(file).catch(()=>null)}
|
||||
const status=info?.isFile()?200:404;
|
||||
if(status===404){file=resolve(root,'404.html');info=await stat(file)}
|
||||
res.writeHead(status,{'Content-Type':types[extname(file)]??'application/octet-stream','Content-Length':info.size});
|
||||
if(req.method==='HEAD')res.end();else createReadStream(file).pipe(res);
|
||||
}catch{res.writeHead(400);res.end('Bad request')}
|
||||
}).listen(Number(process.env.PORT??3000),'127.0.0.1',()=>console.log(`Static site: http://localhost:${process.env.PORT??3000}`));
|
||||
@@ -0,0 +1,30 @@
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { resolve, join } from 'node:path';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const root=resolve('out');
|
||||
const routes=['/','/aboutus/','/contactus/','/ourservices/','/portfolios/','/blog/',...['residential','beauty','commercial','landscape','facade'].map(slug=>`/portfolios/${slug}/`)];
|
||||
const assets=new Set();
|
||||
for(const route of routes){
|
||||
const html=await readFile(join(root,route,'index.html'),'utf8');
|
||||
assert.match(html,/<html[^>]*lang="fa"/);
|
||||
assert.match(html,/<html[^>]*dir="rtl"/);
|
||||
assert.ok(html.includes('گودرزی'),`Missing brand: ${route}`);
|
||||
if (route === '/') assert.ok(!html.includes('class="site-footer"'), 'Home must not render a footer');
|
||||
else assert.ok(html.includes('https://meshkee.com'),`Missing Meshkee credit: ${route}`);
|
||||
for(const match of html.matchAll(/(?:src|href)="(\/[^"?#]*)/g)){
|
||||
const url=decodeURIComponent(match[1]);
|
||||
if(url.startsWith('//'))continue;
|
||||
assets.add(url.endsWith('/')?`${url}index.html`:url);
|
||||
}
|
||||
const response=await fetch(`http://localhost:3000${route}`);
|
||||
assert.equal(response.status,200,route);
|
||||
}
|
||||
for(const asset of assets){assert.ok((await stat(join(root,asset))).isFile(),asset)}
|
||||
for(const font of ['iranyekanweblightfanum.woff','iranyekanwebboldfanum.woff']){
|
||||
assert.equal((await readFile(join(root,'fonts',font))).subarray(0,4).toString(),'wOFF');
|
||||
}
|
||||
const missing=await fetch('http://localhost:3000/this-page-does-not-exist/');
|
||||
assert.equal(missing.status,404);
|
||||
assert.ok((await missing.text()).includes('این صفحه پیدا نشد'));
|
||||
console.log(`PASS: ${routes.length} routes, ${assets.size} linked local files, both IRANYekan fonts, Persian RTL, Meshkee credit, and real HTTP 404.`);
|
||||
@@ -0,0 +1,23 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { access, readFile } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
const output=resolve('out');
|
||||
const routes=['/','/aboutus/','/blog/','/contactus/','/ourservices/','/portfolios/'];
|
||||
const titles=new Set(); const descriptions=new Set();
|
||||
for(const route of routes){
|
||||
const html=await readFile(join(output,route,'index.html'),'utf8');
|
||||
const title=html.match(/<title>([^<]+)<\/title>/)?.[1];
|
||||
const description=html.match(/<meta name="description" content="([^"]+)"/)?.[1];
|
||||
assert.ok(title,`Missing title: ${route}`); assert.ok(description,`Missing description: ${route}`);
|
||||
assert.ok(!titles.has(title),`Duplicate title: ${title}`); assert.ok(!descriptions.has(description),`Duplicate description: ${description}`);
|
||||
titles.add(title); descriptions.add(description);
|
||||
assert.equal((html.match(/<h1(?:\s|>)/g)||[]).length,1,`Expected one H1: ${route}`);
|
||||
assert.match(html,/<link rel="canonical" href="https:\/\/sanazgoodarzi\.ir\//,`Missing canonical: ${route}`);
|
||||
for(const image of html.matchAll(/<img\b[^>]*>/g)) assert.match(image[0],/\salt="[^"]+"/,`Missing meaningful image alt in ${route}`);
|
||||
}
|
||||
const sitemap=JSON.parse(await readFile(join(output,'meshkee','sitemap-config.json'),'utf8'));
|
||||
assert.equal(sitemap.baseUrl,'https://sanazgoodarzi.ir');
|
||||
assert.deepEqual(sitemap.staticPages.map(page=>page.path),['/','/aboutus','/blog','/contactus','/ourservices','/portfolios']);
|
||||
for(const forbidden of ['sitemap.xml','robots.txt']){try{await access(join(output,forbidden));assert.fail(`${forbidden} must be proxied, not shipped`)}catch(error){if(error.code!=='ENOENT')throw error}}
|
||||
console.log(`PASS: SEO metadata, one H1, canonical URLs and image alt text on ${routes.length} crawlable static pages; sitemap config contains ${sitemap.staticPages.length} routes.`);
|
||||
Reference in New Issue
Block a user