Now.js Framework Documentation

Now.js Framework Documentation

SPA ในโฟลเดอร์ย่อย — คู่มือติดตั้งฉบับสมบูรณ์

TH 22 Feb 2026 02:36

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.htmladmin/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.html

Bug 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 ระดับการซ้อน