Now.js Framework Documentation
SPA ในโฟลเดอร์ย่อย — คู่มือติดตั้งฉบับสมบูรณ์
SPA ในโฟลเดอร์ย่อย — คู่มือติดตั้งฉบับสมบูรณ์
วิธีติดตั้ง Now.js admin SPA ภายใน subdirectory (เช่น /nowjs-gcms/admin/)
โดยที่ public CMS อยู่ที่ domain root
ที่มาของปัญหา
การรัน history-mode SPA จาก subfolder มักพังใน 3 จุด:
| อาการ | สาเหตุ |
|---|---|
CSS/JS 404 เมื่อ refresh จาก deep route (/admin/widgets/facebook) |
ไม่มี <base href> → browser resolve relative path จาก URL ปัจจุบัน |
currentDir ผิดเมื่อ refresh จาก deep route |
window.location.pathname คืน /admin/widgets/facebook ไม่ใช่ /admin/ |
| Admin deep route คืนหน้า public CMS แทน | Root .htaccess catch-all ดักจับ admin/* ก่อนที่ admin/.htaccess จะทำงาน |
ขั้นตอนที่ 1 — PHP entry point พร้อม <base href> แบบ dynamic
แปลง admin/index.html → admin/index.php
PHP สร้าง <base href> จาก $_SERVER['SCRIPT_NAME'] ซึ่งชี้ไปที่ script ที่รันอยู่เสมอ
ไม่ขึ้นกับ URL rewriting
<?php
// ทำงานกับทุก install path ไม่ต้อง hardcode
// dirname($_SERVER['SCRIPT_NAME']) = /nowjs-gcms/admin
$base = rtrim(str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'])), '/') . '/';
?>
<!DOCTYPE html>
<html lang="th">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<base href="<?= htmlspecialchars($base, ENT_QUOTES) ?>">
<title>Admin Panel</title>
<!-- src/href ด้านล่างนี้ทั้งหมด relative กับ base แล้ว -->
<link rel="stylesheet" href="css/app.css">
</head>
<body>
<div id="app"></div>
<script src="../Now/dist/now.core.min.js"></script>
<script src="js/main.js"></script>
</body>
</html>ทำไมต้องใช้ $_SERVER['SCRIPT_NAME'] ไม่ใช่ $_SERVER['REQUEST_URI']:
| ตัวแปร | ค่าเมื่อเข้า /admin/widgets/facebook |
|---|---|
$_SERVER['REQUEST_URI'] |
/nowjs-gcms/admin/widgets/facebook ← ผิด |
$_SERVER['SCRIPT_NAME'] |
/nowjs-gcms/admin/index.php ← ถูกต้องเสมอ |
ขั้นตอนที่ 2 — admin/.htaccess
Rewrite ทุก request ที่ไม่ใช่ไฟล์จริงไปที่ index.php (SPA shell):
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [L,QSA]ขั้นตอนที่ 3 — Root .htaccess
Root .htaccess ถูกประมวลผล ก่อน rule ของ subdirectory
catch-all แบบ generic (^(.*)$ index.php) จะดักจับ /admin/widgets/facebook
และ serve public CMS แทน admin SPA
เพิ่ม admin rule เฉพาะ เหนือ public catch-all:
<IfModule mod_rewrite.c>
RewriteEngine On
# 1. ไฟล์ static — serve ตามปกติ
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
# 2. Admin SPA — ต้องมาก่อน public CMS catch-all
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^admin/(.*)$ admin/index.php [L,QSA]
# 3. Public CMS catch-all
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [L,QSA]
</IfModule>ขั้นตอนที่ 4 — ตรวจจับ currentDir ใน main.js
window.location.pathname คืน URL path ปัจจุบัน ไม่ใช่ script directory
เมื่อ refresh จาก deep route จะผิด ให้อ่านจาก attribute src ของ script แทน:
// ❌ พังเมื่อ refresh จาก /admin/widgets/facebook
const currentDir = window.location.pathname.substring(
0, window.location.pathname.lastIndexOf('/') + 1
);
// → '/nowjs-gcms/admin/widgets/' ← ผิด
// ✅ ถูกต้องเสมอ — directory ที่มี main.js
const mainScriptEl = document.querySelector('script[src*="js/main.js"]');
let currentDir;
if (mainScriptEl) {
const scriptUrl = new URL(mainScriptEl.src);
// ตัด '/js/main.js' suffix → เก็บ '/nowjs-gcms/admin/'
currentDir = scriptUrl.pathname.replace(/\/js\/main\.js$/, '') + '/';
} else {
// Fallback สำหรับกรณีพิเศษ
const p = window.location.pathname;
currentDir = p.substring(0, p.lastIndexOf('/') + 1);
}ส่ง currentDir เข้า Now.init:
await Now.init({
paths: {
templates: `${currentDir}templates`,
components: `${currentDir}components`,
translations: `${currentDir}../language`
},
router: {
mode: 'history',
base: currentDir,
...
}
});ขั้นตอนที่ 5 — Templates นอก admin/ (รูปแบบ Widget)
Now.resolvePath() ตัด ../ ทั้งหมดออกเพื่อความปลอดภัย — ไม่สามารถหนีออกจากpaths.templates ด้วย relative traversal ได้
คำนวณ http:// URL แบบเต็ม สำหรับ directory นอก admin/:
// currentDir = '/nowjs-gcms/admin/'
// ตัด last segment ("admin/") เพื่อได้ project root แล้วต่อ Widgets/
const widgetsDir =
window.location.origin +
currentDir.replace(/[^/]+\/$/, '') +
'Widgets/';
// widgetsDir = 'http://localhost/nowjs-gcms/Widgets/'ใช้ใน route config กับ parameter :module:
routes: {
'/widgets/:module': {
template: `${widgetsDir}:module/index.html`,
// เช่น → http://localhost/nowjs-gcms/Widgets/facebook/index.html
title: '{LNG_Widget}',
menuPath: '/widgets',
requireAuth: true,
beforeEnter: requireAdmin
}
}โครงสร้าง Widget directory:
nowjs-gcms/
├── admin/
│ ├── index.php ← SPA shell (PHP, dynamic <base href>)
│ ├── .htaccess ← rewrite ไป index.php
│ ├── js/
│ │ └── main.js ← คำนวณ currentDir + widgetsDir ที่นี่
│ └── templates/ ← admin templates ปกติ
└── Widgets/
├── facebook/
│ └── index.html
├── textlinks/
│ └── index.html
└── share/
└── index.htmlBug 2 จุดใน TemplateManager — เรื่องราวทั้งหมด
เมื่อใช้ http:// URL ครั้งแรก มันกระตุ้น bug แยกกัน 2 จุดใน TemplateManager:
Bug 1 — else if prepend ไม่ตรวจ absolute URL
// โค้ดเดิม (มี bug)
} else if (Now.config?.paths?.templates
&& !path.trim().startsWith('<')
&& !path.startsWith(Now.config.paths.templates)) { // ← ไม่ตรวจ http://
path = Now.config.paths.templates + path;
// → '/nowjs-gcms/admin/templates' + 'http://...'
// → 'templateshttp://...' ← ผิด!
}วิธีแก้: เพิ่ม !/^(https?:)?\/\//i.test(path) ในเงื่อนไข เพื่อให้ absolute URL ผ่านไปได้โดยไม่ถูก prepend
Bug 2 — isValidPath ปฏิเสธ path ที่ไม่ขึ้นต้นด้วย /
// โค้ดเดิม (มี bug)
isValidPath(path) {
if (!path.startsWith('/')) return false; // ← ปฏิเสธ http://
...
}
// → throw 'Invalid template path format: http://...'วิธีแก้: เพิ่ม branch สำหรับ absolute URL:
if (/^https?:\/\//i.test(path)) {
return this.isValidUrl(path) &&
this.config.security.allowedExtensions.some(ext => path.toLowerCase().endsWith(ext));
}ทั้งสองจุดได้รับการแก้ไขแล้วใน Now/js/TemplateManager.js และ Now/dist/now.core.min.js
หมายเหตุด้านความปลอดภัย
| ชั้น | หน้าที่ |
|---|---|
isValidPath |
ตรวจสอบ URL protocol (http:/https:) + extension ของไฟล์ (.html/.htm) |
validateRequest |
บังคับ allowedOrigins — default = window.location.origin URL ข้าม domain จะถูกปฏิเสธ |
processContent |
ทำความสะอาด HTML ที่โหลดมาด้วย sanitizeElement (ปรับได้ผ่าน security.sanitize) |
เอกสารที่เกี่ยวข้อง
- RouterManager — routing,
base, ตัวอย่าง.htaccess - TemplateManager — การ resolve เส้นทาง,
isValidPath, directives - MenuManager — ข้อจำกัด 2 ระดับการซ้อน