1장. 테마 제작 준비
DXCMS 테마 제작은 PHP와 HTML/CSS 기본 지식만 있으면 시작할 수 있습니다. 복잡한 빌드 도구 없이 파일을 만들고 서버에 올리면 바로 동작합니다. 이 가이드는 처음부터 실제 동작하는 테마를 만드는 과정을 단계별로 설명합니다.
1.1 필요한 사전 지식
- PHP 기본 — if/foreach/echo, 함수 호출, 변수 사용 정도면 충분
- HTML/CSS — 페이지 마크업, Flexbox/Grid, CSS 변수(Custom Properties)
- DXCMS 구조 이해 — 테마 구조 가이드의 폴백 체인, $dx_content, $context 개념
1.2 필수 파일 2개
테마를 만들려면 최소 2개 파일이 필요합니다. 이 2개만 있어도 완전히 동작합니다.
| 파일 |
역할 |
| themes/{테마명}/theme.json |
테마 메타 정보 (이름·버전·옵션 정의). 없으면 폴더명이 그대로 표시됨 |
| themes/{테마명}/layout/main.php |
HTML 전체 골격 (DOCTYPE~</html>). $dx_content 위치가 본문 영역 |
1.3 테마 폴더 생성
- themes/ 폴더 안에 새 폴더 생성 (예: themes/my-theme/)
- 폴더명 규칙: 영소문자, 숫자, 하이픈, 언더스코어만 사용
- themes/my-theme/theme.json 파일 생성
- themes/my-theme/layout/ 폴더 생성
- themes/my-theme/layout/main.php 파일 생성
- 관리자 → 테마 관리 → 새 테마 활성화
💡 개발 환경 팁
로컬 환경에서 개발 후 FTP/SFTP로 서버에 업로드하는 방식이 가장 편리합니다.
PHP DX_DEBUG 모드를 켜두면 오류 메시지가 화면에 표시됩니다:
config.php에 define("DX_DEBUG", true); 추가
VSCode + PHP Intelephense 확장을 사용하면 자동완성이 지원됩니다.
2장. theme.json 작성
2.1 기본 theme.json
{
"name": "나의 테마",
"version": "1.0.0",
"author": "홍길동",
"description": "DXCMS용 커스텀 테마",
"preview": "assets/preview.png",
"supports": ["board", "page", "gallery"]
}
2.2 options 추가
options 블록을 추가하면 관리자가 색상•텍스트 등을 직접 바꿀 수 있는 UI가 자동 생성됩니다.
{
"name": "나의 테마",
"version": "1.0.0",
"author": "홍길동",
"description": "관리자 옵션 지원 테마",
"supports": ["board", "page"],
"options": {
"primary_color": {
"type": "color",
"label": "주요 색상",
"default": "#3b82f6"
},
"logo_text": {
"type": "text",
"label": "로고 텍스트 (비우면 사이트 이름 표시)",
"default": ""
},
"show_search": {
"type": "checkbox",
"label": "헤더 검색창 표시",
"default": "on"
},
"footer_text": {
"type": "textarea",
"label": "푸터 문구",
"default": "All rights reserved."
}
}
}
2.3 options 타입 완전 가이드
| type |
관리자 UI |
저장 값 예시 및 읽기 |
| "color" |
색상 피커 (색상 선택기) |
"#3b82f6" → dx_theme_option("primary_color") |
| "text" |
한 줄 텍스트 입력 |
"안녕하세요" → dx_theme_option("logo_text") |
| "textarea" |
여러 줄 텍스트 |
"한 줄 두 줄" → dx_theme_option("footer_text") |
| "checkbox" |
체크박스 (ON/OFF) |
"on" 또는 "" → dx_theme_option("show_search") === "on" |
| "select" |
드롭다운 선택 |
"choices" 배열 별도 정의 필요 (아래 예시 참고) |
select 타입 예시
"layout_style": {
"type": "select",
"label": "레이아웃 스타일",
"default": "wide",
"choices": {
"wide": "와이드 (1200px)",
"normal": "일반 (960px)",
"narrow": "좁게 (760px)"
}
}
// 테마 파일에서 읽기:
$layout = dx_theme_option("layout_style", "wide");
$maxWidth = ($layout === "wide") ? "1200px" : (($layout === "narrow") ? "760px" : "960px");
2.4 options 값 읽기
theme.json에 정의한 옵션은 테마 PHP 파일 어디서든 dx_theme_option()으로 읽을 수 있습니다.
// layout/main.php 또는 page/home.php 등 테마 파일에서
// color 타입 읽기 → CSS 변수에 주입
$primaryColor = dx_theme_option("primary_color", "#3b82f6");
// text 타입 읽기 → HTML에 출력
$logoText = dx_theme_option("logo_text", "");
$siteName = dx_config("site_name", "DXCMS");
$displayName = $logoText ? $logoText : $siteName;
// checkbox 타입 읽기 → 조건 분기
$showSearch = dx_theme_option("show_search", "on") === "on";
// select 타입 읽기 → 분기 처리
$layout = dx_theme_option("layout_style", "wide");
3장. layout/main.php 단계별 구현
layout/main.php는 테마의 뼈대 파일입니다. 모든 페이지가 이 파일을 통해 렌더링됩니다. 각 섹션을 단계별로 구현하는 방법을 설명합니다.
3.1 파일 시작부 — PHP 변수 준비
<?php
/**
* 나의 테마 — layout/main.php
*/
if (!defined("DX_CMS")) exit("Direct access not allowed.");
// ── 기본 사이트 정보 ──────────────────────────────
$siteName = dx_config("site_name", "내 사이트");
$siteDesc = dx_config("site_description", "");
$language = dx_config("language", "ko");
// ── 테마 옵션 ────────────────────────────────────
$primaryColor = dx_theme_option("primary_color", "#3b82f6");
$logoText = dx_theme_option("logo_text", "");
$showSearch = dx_theme_option("show_search", "on") === "on";
$footerText = dx_theme_option("footer_text", "All rights reserved.");
// ── 현재 페이지 컨텍스트 ─────────────────────────
$ctx = isset($context) ? $context : array();
$pageType = isset($ctx["type"]) ? $ctx["type"] : "home";
$isBoard = ($pageType === "board");
// ── 로그인 상태 ──────────────────────────────────
$isLogin = dx_is_login();
$isAdmin = dx_is_admin();
$authUser = $isLogin ? Auth::getInstance()->user() : null;
$userName = ($isLogin && $authUser) ? $authUser["name"] : "";
// ── SEO 타이틀 ──────────────────────────────────
$pageTitle = $siteName;
if (class_exists("DxSeo")) {
if (!DxSeo::get("title")) DxSeo::build($pageType, $ctx);
$pageTitle = DxSeo::get("title", $siteName);
}
// ── 메뉴 로드 ────────────────────────────────────
$db = Database::getInstance();
$menuGroup = dx_menu_group();
$menus = $db->findAll("menus",
array("menu_group"=>$menuGroup,"parent_id"=>0,"status"=>1),
"*", "sort_order ASC"
);
$allSubs = $db->findAll("menus",
array("menu_group"=>$menuGroup,"status"=>1),
"*", "sort_order ASC"
);
$subMap = array();
foreach ($allSubs as $s) {
if ((int)$s["parent_id"] > 0)
$subMap[(int)$s["parent_id"]][] = $s;
}
?>
3.2 HTML <head> 섹션
<!DOCTYPE html>
<html lang="<?php echo htmlspecialchars($language, ENT_QUOTES, "UTF-8"); ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title><?php echo htmlspecialchars($pageTitle, ENT_QUOTES, "UTF-8"); ?></title>
<?php if ($siteDesc): ?>
<meta name="description" content="<?php echo htmlspecialchars($siteDesc, ENT_QUOTES); ?>">
<?php endif; ?>
<?php if (class_exists("DxSeo")) DxSeo::renderHead(); ?>
<!-- ① 폰트 (Pretendard 한글 최적화 폰트) -->
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
<!-- ② Font Awesome 6 아이콘 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" crossorigin="anonymous">
<!-- ③ DX 유틸 CSS (dxb-css 대체, reflow 없음) -->
<link rel="stylesheet" href="<?php echo dx_base_url("assets/css/dx-utils.css"); ?>?v=<?php echo DX_VERSION; ?>">
<!-- ④ 테마 자체 CSS -->
<link rel="stylesheet" href="<?php echo dx_theme_asset("css/theme.css"); ?>?v=<?php echo DX_VERSION; ?>">
<!-- ⑤ CSS 변수 (--p 등 동적 색상은 PHP로 생성) -->
<style>
:root {
--p: <?php echo htmlspecialchars($primaryColor, ENT_QUOTES); ?>;
--bg-body: #f1f3f5;
--bg-card: #ffffff;
--bg-header: #ffffff;
--text-main: #1e293b;
--text-muted: #64748b;
--border: #e2e8f0;
--hover-row: #f8faff;
--font: "Pretendard", -apple-system, BlinkMacSystemFont, sans-serif;
}
body.dark {
--bg-body: #0f172a;
--bg-card: #1e293b;
--bg-header: #1e293b;
--text-main: #f1f5f9;
--text-muted: #94a3b8;
--border: #334155;
--hover-row: #243044;
}
body {
font-family: var(--font);
background: var(--bg-body);
color: var(--text-main);
min-height: 100vh;
}
</style>
<!-- ⑥ CSRF 토큰 (AJAX 요청에 필요) -->
<meta name="csrf-token" content="<?php echo dx_csrf_token(); ?>">
<!-- ⑦ 플러그인/확장 HEAD 훅 -->
<?php dx_run_hook("dx_head", isset($context) ? $context : array()); ?>
<!-- ⑧ Google Analytics (설정된 경우만) -->
<?php $ga = dx_config("google_analytics", ""); if ($ga): ?>
<script async src="https://www.googletagmanager.com/gtag/js?id=<?php echo htmlspecialchars($ga, ENT_QUOTES); ?>"></script>
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}
gtag("js",new Date());gtag("config","<?php echo htmlspecialchars($ga, ENT_QUOTES); ?>");</script>
<?php endif; ?>
</head>
3.3 헤더 (네비게이션)
<body>
<!-- ── 헤더 ─────────────────────────────────────── -->
<header style="background:var(--bg-header);border-bottom:1px solid var(--border);
position:sticky;top:0;z-index:100;box-shadow:0 1px 3px rgba(0,0,0,.06)">
<div style="max-width:1200px;margin:0 auto;padding:0 20px;
display:flex;align-items:center;gap:24px;height:60px">
<!-- 로고 -->
<a href="<?php echo dx_base_url(); ?>"
style="font-size:1.2rem;font-weight:800;color:var(--p);text-decoration:none;white-space:nowrap">
<?php echo htmlspecialchars($logoText ? $logoText : $siteName, ENT_QUOTES, "UTF-8"); ?>
</a>
<!-- 네비게이션 -->
<nav style="display:flex;align-items:center;gap:4px;flex:1;overflow:hidden">
<?php foreach ($menus as $menu):
$menuId = (int)$menu["id"];
$menuUrl = !empty($menu["url"]) ? $menu["url"] : "#";
$menuSubs = isset($subMap[$menuId]) ? $subMap[$menuId] : array();
$reqUri = strtok($_SERVER["REQUEST_URI"], "?");
$isActive = $menuUrl !== "#" && strpos($reqUri, rtrim($menuUrl, "/")) === 0;
?>
<div style="position:relative">
<a href="<?php echo dx_safe_url($menuUrl); ?>"
style="display:flex;align-items:center;gap:4px;padding:6px 12px;border-radius:8px;
font-size:.875rem;font-weight:600;text-decoration:none;transition:all .15s;
color:<?php echo $isActive ? "var(--p)" : "var(--text-main)"; ?>;
background:<?php echo $isActive ? "rgba(59,130,246,.08)" : "transparent"; ?>">
<?php echo htmlspecialchars($menu["title"], ENT_QUOTES, "UTF-8"); ?>
<?php if (!empty($menuSubs)): ?><i class="fa-solid fa-chevron-down" style="font-size:.55rem;opacity:.5"></i><?php endif; ?>
</a>
<?php if (!empty($menuSubs)): ?>
<div style="display:none;position:absolute;top:calc(100%+6px);left:0;
background:var(--bg-card);border:1px solid var(--border);border-radius:10px;
min-width:140px;box-shadow:0 8px 24px rgba(0,0,0,.1);z-index:200;
overflow:hidden;padding:4px 0"
class="nav-dropdown">
<?php foreach ($menuSubs as $sub):
$subUrl = !empty($sub["url"]) ? $sub["url"] : "#";
?>
<a href="<?php echo dx_safe_url($subUrl); ?>"
style="display:block;padding:9px 16px;font-size:.83rem;color:var(--text-main);
text-decoration:none;transition:background .12s"
<?php echo htmlspecialchars($sub["title"], ENT_QUOTES, "UTF-8"); ?>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</nav>
<!-- 검색창 (옵션) -->
<?php if ($showSearch): ?>
<form action="<?php echo dx_base_url("search"); ?>" method="get"
style="display:flex;align-items:center;gap:8px">
<input type="text" name="q" placeholder="검색..."
style="padding:7px 12px;border:1px solid var(--border);border-radius:8px;
font-size:.83rem;background:var(--bg-body);color:var(--text-main);
width:160px;outline:none">
<button type="submit"
style="background:var(--p);color:#fff;border:none;border-radius:8px;
padding:7px 12px;cursor:pointer">
<i class="fa-solid fa-magnifying-glass fa-sm"></i>
</button>
</form>
<?php endif; ?>
<!-- 로그인/회원 버튼 -->
<div style="display:flex;align-items:center;gap:8px;flex-shrink:0">
<?php if ($isLogin): ?>
<a href="<?php echo dx_base_url("auth/mypage"); ?>"
style="font-size:.83rem;font-weight:600;color:var(--text-main);text-decoration:none">
<?php echo htmlspecialchars($userName, ENT_QUOTES, "UTF-8"); ?>님
</a>
<a href="<?php echo dx_base_url("auth/logout"); ?>"
style="font-size:.83rem;padding:6px 14px;border:1px solid var(--border);
border-radius:8px;color:var(--text-muted);text-decoration:none">
로그아웃
</a>
<?php if ($isAdmin): ?>
<a href="<?php echo dx_base_url("admin"); ?>"
style="font-size:.83rem;padding:6px 14px;background:var(--p);color:#fff;
border-radius:8px;text-decoration:none;font-weight:600">
관리자
</a>
<?php endif; ?>
<?php else: ?>
<a href="<?php echo dx_base_url("auth/login"); ?>"
style="font-size:.83rem;padding:6px 14px;background:var(--p);color:#fff;
border-radius:8px;text-decoration:none;font-weight:600">
로그인
</a>
<?php endif; ?>
</div>
</div>
</header>
3.4 본문 영역 ($dx_content 출력)
본문은 $dx_content 변수를 출력하는 것이 핵심입니다. 사이드바 유무에 따라 레이아웃을 분기합니다.
<!-- ── 본문 ─────────────────────────────────────── -->
<main style="max-width:1200px;margin:0 auto;padding:24px 20px;flex:1">
<?php
// 사이드바 표시 여부 결정
// 게시판 페이지이고 카테고리가 있으면 사이드바 표시
$ctxCats = isset($ctx["categories"]) ? $ctx["categories"] : array();
$showSidebar = $isBoard && !empty($ctxCats);
?>
<?php if ($showSidebar): ?>
<!-- 2컬럼 레이아웃 (본문 + 사이드바) -->
<div style="display:flex;gap:24px;align-items:flex-start">
<!-- 본문 -->
<div style="flex:1;min-width:0">
<?php echo isset($dx_content) ? $dx_content : ""; ?>
</div>
<!-- 사이드바 -->
<aside style="width:220px;flex-shrink:0;position:sticky;top:80px;align-self:flex-start">
<!-- 카테고리 탭 -->
<?php if (!empty($ctxCats)): ?>
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:12px;overflow:hidden;margin-bottom:16px">
<div style="padding:12px 16px;border-bottom:1px solid var(--border);font-size:.85rem;font-weight:700;color:var(--text-main)">
<i class="fa-solid fa-tag" style="color:var(--p);margin-right:6px"></i>카테고리
</div>
<?php
$ctxBk = isset($ctx["slug"]) ? $ctx["slug"] : "";
$curCat = isset($ctx["currentCategory"]) ? $ctx["currentCategory"] : "";
?>
<ul style="list-style:none;padding:4px 0;margin:0">
<li>
<a href="<?php echo dx_base_url($ctxBk."/list"); ?>"
style="display:block;padding:8px 16px;font-size:.83rem;
color:<?php echo !$curCat?"var(--p)":"var(--text-main)"; ?>;
font-weight:<?php echo !$curCat?"700":"400"; ?>;
text-decoration:none">
전체
</a>
</li>
<?php foreach ($ctxCats as $cat):
$catName = isset($cat["name"]) ? $cat["name"] : "";
if (!$catName) continue;
$catDepth = isset($cat["depth"]) ? (int)$cat["depth"] : 0;
$catActive = ($curCat === $catName);
?>
<li>
<a href="<?php echo dx_base_url($ctxBk."/list")."?cat=".urlencode($catName); ?>"
style="display:block;padding:8px 16px;padding-left:<?php echo (16+$catDepth*14); ?>px;
font-size:<?php echo $catDepth>0?".78":".83"; ?>rem;
color:<?php echo $catActive?"var(--p)":"var(--text-main)"; ?>;
font-weight:<?php echo $catActive?"700":"400"; ?>;
text-decoration:none">
<?php echo htmlspecialchars($catName, ENT_QUOTES, "UTF-8"); ?>
</a>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
</aside>
</div>
<?php else: ?>
<!-- 단일 컬럼 레이아웃 -->
<?php echo isset($dx_content) ? $dx_content : ""; ?>
<?php endif; ?>
</main>
3.5 푸터
<!-- ── 푸터 ─────────────────────────────────────── -->
<footer style="border-top:1px solid var(--border);background:var(--bg-card);
padding:32px 20px;margin-top:auto">
<div style="max-width:1200px;margin:0 auto;text-align:center">
<!-- 사이트 이름 -->
<p style="font-size:1rem;font-weight:700;color:var(--text-main);margin:0 0 8px">
<?php echo htmlspecialchars($siteName, ENT_QUOTES, "UTF-8"); ?>
</p>
<!-- 푸터 문구 -->
<?php $footerText = dx_theme_option("footer_text", ""); if ($footerText): ?>
<p style="font-size:.83rem;color:var(--text-muted);margin:0 0 16px;line-height:1.6">
<?php echo htmlspecialchars($footerText, ENT_QUOTES, "UTF-8"); ?>
</p>
<?php endif; ?>
<!-- 링크 -->
<div style="display:flex;justify-content:center;gap:16px;flex-wrap:wrap;font-size:.8rem">
<?php $termsUrl = dx_config("footer_terms_url", "#"); if ($termsUrl && $termsUrl !== "#"): ?>
<a href="<?php echo htmlspecialchars($termsUrl, ENT_QUOTES); ?>"
style="color:var(--text-muted);text-decoration:none">이용약관</a>
<?php endif; ?>
<?php $privacyUrl = dx_config("footer_privacy_url", "#"); if ($privacyUrl && $privacyUrl !== "#"): ?>
<a href="<?php echo htmlspecialchars($privacyUrl, ENT_QUOTES); ?>"
style="color:var(--text-muted);text-decoration:none">개인정보처리방침</a>
<?php endif; ?>
</div>
<p style="font-size:.78rem;color:var(--text-muted);margin:12px 0 0">
© <?php echo date("Y"); ?> <?php echo htmlspecialchars($siteName, ENT_QUOTES, "UTF-8"); ?>.
Powered by <a href="https://designonex.com" style="color:var(--p)">DXCMS</a>
</p>
</div>
</footer>
3.6 바디 하단 (스크립트 훅)
<!-- jQuery (전역에서 사용) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js" crossorigin="anonymous"></script>
<!-- 드롭다운 메뉴 JS -->
<script>
// 마우스오버로 드롭다운 표시/숨김
document.querySelectorAll(".nav-dropdown").forEach(function(dd) {
var item = dd.parentElement;
item.addEventListener("mouseenter", function() { dd.style.display = "block"; });
item.addEventListener("mouseleave", function() { dd.style.display = "none"; });
});
</script>
<!-- ① 플러그인 푸터 스크립트 훅 -->
<?php dx_run_hook("dx_footer_scripts", array()); ?>
<!-- ② 바디 최하단 훅 (분석코드, 채팅위젯 등) -->
<?php dx_run_hook("dx_body_bottom"); ?>
</body>
</html>
4장. CSS 변수 시스템 완전 활용
DXCMS 테마는 CSS Custom Properties(변수)로 색상과 배경을 관리합니다. 이 변수를 사용하면 다크모드 전환이 자동으로 처리되고, 관리자의 primary_color 옵션 변경도 즉시 반영됩니다.
4.1 필수 CSS 변수 목록
| 변수명 |
라이트 기본값 |
다크 기본값 |
용도 |
| --p |
#1a73e8 (or 옵션) |
(동일) |
주요 강조색. primary_color 옵션으로 변경 |
| --bg-body |
#f1f3f5 |
#0f172a |
페이지 전체 배경 |
| --bg-card |
#ffffff |
#1e293b |
카드·패널 배경색 |
| --bg-header |
#ffffff |
#1e293b |
헤더 배경색 |
| --text-main |
#1e293b |
#f1f5f9 |
주요 본문 텍스트색 |
| --text-muted |
#64748b |
#94a3b8 |
보조·설명 텍스트색 |
| --border |
#e2e8f0 |
#334155 |
테두리·구분선 색 |
| --hover-row |
#f8faff |
#243044 |
행 호버 배경색 |
| --font |
Pretendard,... |
(동일) |
기본 폰트 패밀리 |
4.2 CSS 변수 활용 예시
/* themes/my-theme/assets/css/theme.css */
/* 기본 리셋 */ *, *::before, *::after { box-sizing: border-box; }
body { margin: 0; font-family: var(--font); background: var(--bg-body); color: var(--text-main); }
/* 카드 컴포넌트 — 다크모드 자동 대응 */ .my-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
transition: background-color .3s, border-color .3s;
}
/* 강조 버튼 */ .my-btn-primary {
background: var(--p);
color: #ffffff;
border: none;
padding: 10px 20px;
border-radius: 8px;
cursor: pointer;
}
.my-btn-primary:hover { filter: brightness(1.1); }
/* 다크모드 전용 추가 스타일 */ body.dark .my-special-img {
opacity: 0.85;
filter: brightness(.9);
}
/* 반응형 (모바일) */ @media (max-width: 768px) {
.my-sidebar { display: none; }
}
4.3 primary_color 옵션을 CSS에 적용
관리자가 primary_color를 변경하면 PHP가 CSS 변수 --p에 즉시 반영됩니다. 이 변수를 사용하는 모든 CSS가 자동으로 업데이트됩니다.
<!-- layout/main.php의 <style> 블록 -->
<style>
:root {
--p: <?php echo htmlspecialchars(dx_theme_option("primary_color", "#3b82f6"), ENT_QUOTES); ?>;
/* ... 다른 변수들 ... */
}
</style>
/* 이후 CSS에서 var(--p)를 사용하면 자동으로 반영됨 */
4.4 다크모드 지원
DXCMS는 localStorage의 "dx-theme" 키로 다크모드를 관리합니다. body 태그에 dark 클래스가 추가/제거됩니다. extend/top/01_darkmode_early.php 파일이 FOUC(깜빡임)를 방지합니다.
/* 다크모드 토글 버튼 예시 */ <button style="background:none;border:none;cursor:pointer;
color:var(--text-main);font-size:1.1rem">
<i class="fa-solid fa-moon"></i>
</button>
<script>
function toggleDarkMode() {
var isDark = document.body.classList.toggle("dark");
localStorage.setItem("dx-theme", isDark ? "dark" : "light");
}
// 초기 상태 적용
(function() {
var saved = localStorage.getItem("dx-theme");
var prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
if (saved === "dark" || (saved === null && prefersDark)) {
document.body.classList.add("dark");
}
})();
</script>
5장. 홈페이지 제작 (page/home.php)
themes/{테마}/page/home.php 파일이 있으면 홈페이지로 사용됩니다. 없으면 DB의 is_home=1 페이지가 사용됩니다. 이 파일 안에서 dx_board_latest()로 여러 게시판의 최신글을 자유롭게 배치합니다.
5.1 홈페이지 탐색 우선순위
| 순위 |
탐색 경로 |
| 1 |
themes/{현재테마}/page/home.php ← 있으면 항상 이 파일 사용 |
| 2 |
DB에서 현재 도메인(site_domain) is_home=1 페이지 |
| 3 |
DB에서 전체 공통(site_domain='') is_home=1 페이지 |
| 4 |
pages/home.php 기본 폴백 |
5.2 실전 홈페이지 구현
<?php
// themes/my-theme/page/home.php
if (!defined("DX_CMS")) exit;
?>
<!-- ── 히어로 섹션 ─────────────────────────────── -->
<div style="background:linear-gradient(135deg,var(--p),#6366f1);
color:#fff;padding:60px 24px;text-align:center;border-radius:16px;margin-bottom:32px">
<h1 style="font-size:2rem;font-weight:800;margin:0 0 12px">
<?php echo htmlspecialchars(dx_config("site_name",""), ENT_QUOTES, "UTF-8"); ?>
</h1>
<p style="font-size:1rem;opacity:.9;margin:0 0 24px">
<?php echo htmlspecialchars(dx_config("site_description",""), ENT_QUOTES, "UTF-8"); ?>
</p>
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap">
<?php if (dx_is_login()): ?>
<a href="<?php echo dx_base_url("auth/mypage"); ?>"
style="padding:12px 24px;background:rgba(255,255,255,.2);color:#fff;
border-radius:10px;text-decoration:none;font-weight:600">
마이페이지
</a>
<?php else: ?>
<a href="<?php echo dx_base_url("auth/login"); ?>"
style="padding:12px 24px;background:#fff;color:var(--p);
border-radius:10px;text-decoration:none;font-weight:700">
로그인하기
</a>
<a href="<?php echo dx_base_url("auth/register"); ?>"
style="padding:12px 24px;background:rgba(255,255,255,.2);color:#fff;
border-radius:10px;text-decoration:none;font-weight:600">
회원가입
</a>
<?php endif; ?>
</div>
</div>
<!-- ── 최신글 위젯 영역 ──────────────────────────── -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:24px;margin-bottom:32px">
<!-- 공지사항 -->
<div>
<?php dx_board_latest("notice", 5, "simple", "공지사항", "📢"); ?>
</div>
<!-- 자유게시판 -->
<div>
<?php dx_board_latest("free", 8, "simple", "자유게시판", "💬"); ?>
</div>
</div>
<!-- ── 갤러리 최신글 카드형 ─────────────────────── -->
<div style="margin-bottom:32px">
<?php dx_board_latest("gallery", 6, "card", "갤러리", "🖼️"); ?>
</div>
<!-- ── 모바일 반응형 처리 -->
<style>
@media (max-width: 640px) {
div[style*="grid-template-columns:1fr 1fr"] {
grid-template-columns: 1fr !important;
}
}
</style>
6장. 파셜(parts/) 제작 및 활용
파셜은 여러 파일에서 재사용되는 HTML 조각입니다. themes/{테마}/parts/ 폴더에 PHP 파일을 만들면 dx_include_part()로 어디서든 불러올 수 있습니다.
6.1 페이지네이션 파셜 (parts/pagination.php)
페이지네이션은 가장 자주 재사용되는 파셜입니다. default 테마에 이미 구현되어 있으며, 커스텀 테마에서 오버라이드할 수 있습니다.
<?php
// themes/my-theme/parts/pagination.php
if (!defined("DX_CMS")) exit;
// dx_include_part("pagination", [...])으로 전달받는 변수:
// $total — 전체 글 수
// $page — 현재 페이지
// $per_page — 페이지당 글 수 (기본 20)
// $url — 링크 기본 URL
// $range — 표시 페이지 수 (기본 10)
$total = isset($total) ? (int)$total : 0;
$page = isset($page) ? (int)$page : 1;
$per_page = isset($per_page) ? (int)$per_page : 20;
$url = isset($url) ? $url : dx_current_url();
$range = isset($range) ? (int)$range : 10;
if ($total <= 0 || $per_page <= 0) return;
$totalPages = (int)ceil($total / $per_page);
if ($totalPages <= 1) return;
$blockStart = (int)(floor(($page-1)/$range)*$range) + 1;
$blockEnd = min($blockStart + $range - 1, $totalPages);
function _myPageUrl($base, $p) {
$q = $_GET; unset($q["page"]);
$qs = http_build_query($q);
$sep = (strpos($base, "?") !== false) ? "&" : "?";
return $base . ($qs ? $sep.$qs."&page=".$p : $sep."page=".$p);
}
?>
<nav style="display:flex;justify-content:center;gap:4px;padding:20px 0;flex-wrap:wrap">
<?php if ($blockStart > 1): ?>
<a href="<?php echo htmlspecialchars(_myPageUrl($url,1),ENT_QUOTES); ?>"
style="<?php /* 버튼 공통 스타일 */ ?>
display:inline-flex;align-items:center;justify-content:center;
min-width:34px;height:34px;border-radius:8px;font-size:.82rem;
background:var(--bg-card);border:1px solid var(--border);
color:var(--text-muted);text-decoration:none">«</a>
<?php endif; ?>
<?php for ($i = $blockStart; $i <= $blockEnd; $i++): ?>
<a href="<?php echo htmlspecialchars(_myPageUrl($url,$i),ENT_QUOTES); ?>"
style="display:inline-flex;align-items:center;justify-content:center;
min-width:34px;height:34px;border-radius:8px;font-size:.82rem;
text-decoration:none;
<?php echo $i===$page
? "background:var(--p);color:#fff;border:1px solid var(--p)"
: "background:var(--bg-card);border:1px solid var(--border);color:var(--text-muted)";
?>"><?php echo $i; ?></a>
<?php endfor; ?>
<?php if ($blockEnd < $totalPages): ?>
<a href="<?php echo htmlspecialchars(_myPageUrl($url,$totalPages),ENT_QUOTES); ?>"
style="display:inline-flex;align-items:center;justify-content:center;
min-width:34px;height:34px;border-radius:8px;font-size:.82rem;
background:var(--bg-card);border:1px solid var(--border);
color:var(--text-muted);text-decoration:none">»</a>
<?php endif; ?>
</nav>
6.2 파셜 호출 방법
// board/list.php 또는 다른 테마 파일에서
// 기본 호출
dx_include_part("pagination", array(
"total" => $total,
"page" => $page,
"per_page" => $perPage,
"url" => dx_base_url($board["board_key"]."/list"),
));
// 검색어•카테고리 유지하며 호출
$paginationUrl = dx_base_url($board["board_key"]."/list");
if ($search) $paginationUrl .= "?s=".urlencode($search)."&sf=".urlencode($searchField);
if ($cat) $paginationUrl .= ($search ? "&" : "?")."cat=".urlencode($cat);
dx_include_part("pagination", array(
"total" => $total,
"page" => $page,
"per_page" => $perPage,
"url" => $paginationUrl,
"range" => 5, // 페이지 5개씩 표시
));
6.3 브레드크럼 파셜 (parts/breadcrumb.php)
<?php
// themes/my-theme/parts/breadcrumb.php
// dx_include_part("breadcrumb", array("items"=>[["label"=>"홈","url"=>"/"],[...]]]));
if (!defined("DX_CMS")) exit;
$items = isset($items) ? $items : array();
if (empty($items)) return;
?>
<nav style="display:flex;align-items:center;gap:6px;font-size:.8rem;
color:var(--text-muted);margin-bottom:12px;flex-wrap:wrap">
<?php foreach ($items as $i => $item):
$isLast = ($i === count($items) - 1);
$label = htmlspecialchars($item["label"], ENT_QUOTES, "UTF-8");
?>
<?php if (!$isLast && !empty($item["url"])): ?>
<a href="<?php echo htmlspecialchars($item["url"], ENT_QUOTES); ?>"
style="color:var(--text-muted);text-decoration:none;
transition:color .12s"
<?php echo $label; ?>
</a>
<span style="font-size:.6rem;color:var(--border)">›</span>
<?php else: ?>
<span style="color:var(--text-main);font-weight:600"><?php echo $label; ?></span>
<?php endif; ?>
<?php endforeach; ?>
</nav>
7장. 오류 페이지 제작 (404•403)
page/404.php와 page/403.php 파일을 만들면 커스텀 오류 페이지가 표시됩니다. 없으면 Dispatcher가 간단한 기본 오류 메시지를 출력합니다.
7.1 404 오류 페이지 (page/404.php)
<?php
// themes/my-theme/page/404.php
if (!defined("DX_CMS")) exit;
?>
<div style="display:flex;flex-direction:column;align-items:center;
justify-content:center;min-height:60vh;padding:40px;text-align:center">
<!-- 큰 숫자 -->
<div style="font-size:7rem;font-weight:900;
color:var(--border);line-height:1;margin-bottom:16px">
404
</div>
<!-- 메시지 -->
<h1 style="font-size:1.5rem;font-weight:700;color:var(--text-main);margin:0 0 8px">
페이지를 찾을 수 없습니다
</h1>
<p style="color:var(--text-muted);margin:0 0 32px;line-height:1.6">
요청하신 페이지가 존재하지 않거나<br>주소가 변경되었을 수 있습니다.
</p>
<!-- 버튼들 -->
<div style="display:flex;gap:12px;flex-wrap:wrap;justify-content:center">
<a href="blocked:history.back()"
style="padding:12px 24px;background:var(--bg-card);border:1px solid var(--border);
border-radius:10px;color:var(--text-main);text-decoration:none;font-weight:600">
← 이전 페이지
</a>
<a href="<?php echo dx_base_url(); ?>"
style="padding:12px 24px;background:var(--p);color:#fff;
border-radius:10px;text-decoration:none;font-weight:600">
홈으로 이동
</a>
</div>
</div>
7.2 403 접근 거부 페이지 (page/403.php)
<?php
// themes/my-theme/page/403.php
if (!defined("DX_CMS")) exit;
$isLogin = dx_is_login();
?>
<div style="display:flex;flex-direction:column;align-items:center;
justify-content:center;min-height:60vh;padding:40px;text-align:center">
<div style="font-size:4rem;margin-bottom:16px">🔒</div>
<h1 style="font-size:1.5rem;font-weight:700;color:var(--text-main);margin:0 0 8px">
접근이 거부되었습니다
</h1>
<p style="color:var(--text-muted);margin:0 0 32px">
이 페이지에 접근할 권한이 없습니다.
</p>
<div style="display:flex;gap:12px;flex-wrap:wrap;justify-content:center">
<?php if (!$isLogin): ?>
<a href="<?php echo dx_base_url("auth/login")."?redirect=".urlencode(dx_current_url()); ?>"
style="padding:12px 24px;background:var(--p);color:#fff;
border-radius:10px;text-decoration:none;font-weight:600">
로그인하기
</a>
<?php endif; ?>
<a href="<?php echo dx_base_url(); ?>"
style="padding:12px 24px;background:var(--bg-card);border:1px solid var(--border);
border-radius:10px;color:var(--text-main);text-decoration:none;font-weight:600">
홈으로 이동
</a>
</div>
</div>
8장. 게시판 뷰 오버라이드 (board/)
테마의 board/ 폴더에 파일을 만들면 기본 테마의 게시판 화면을 오버라이드합니다. 이 방법은 boards/skins/를 사용하지 않고 테마 안에서 게시판 레이아웃을 바꿀 때 유용합니다.
8.1 게시판 파일 폴백 체인
| 순위 |
경로 |
설명 |
| 1 |
boards/skins/{스킨명}/{액션}/handler.php |
완전 독립 핸들러 |
| 2 |
boards/skins/{스킨명}/{액션}.php |
스킨 독립 뷰 |
| 3 |
themes/{현재테마}/board/{스킨명}/{액션}.php |
★ 테마 내 스킨별 오버라이드 |
| 4 |
themes/{현재테마}/board/{액션}.php |
★ 테마 내 공통 오버라이드 |
| 5 |
themes/default/board/{스킨명}/{액션}.php |
default 테마 스킨 전용 |
| 6 |
themes/default/board/{액션}.php |
default 테마 공통 (최종 폴백) |
8.2 테마 내 게시판 목록 커스텀 (board/list.php)
themes/my-theme/board/list.php를 만들면 모든 게시판의 목록 화면이 바뀝니다. 아래는 최소한의 커스텀 목록 구현입니다.
<?php
// themes/my-theme/board/list.php
if (!defined("DX_CMS")) exit;
// handler.php가 주입한 변수들
$auth = Auth::getInstance();
$bk = $board["board_key"];
$bn = $board["board_name"];
$canWrite = ((int)$board["write_level"]===0)
|| ((int)$board["write_level"]===1 && $auth->isLoggedIn())
|| ((int)$board["write_level"]===9 && $auth->isAdmin());
?>
<!-- 게시판 헤더 -->
<div style="display:flex;align-items:center;justify-content:space-between;
margin-bottom:16px;padding-bottom:16px;border-bottom:2px solid var(--p)">
<h1 style="font-size:1.1rem;font-weight:700;color:var(--text-main);margin:0">
<?php echo htmlspecialchars($bn, ENT_QUOTES, "UTF-8"); ?>
<span style="font-size:.8rem;font-weight:400;color:var(--text-muted);margin-left:8px">
총 <?php echo number_format($total); ?>개
</span>
</h1>
<?php if ($canWrite): ?>
<a href="<?php echo dx_base_url($bk."/write"); ?>"
style="padding:8px 16px;background:var(--p);color:#fff;
border-radius:8px;text-decoration:none;font-size:.85rem;font-weight:600">
글쓰기
</a>
<?php endif; ?>
</div>
<!-- 공지글 -->
<?php foreach ($notices as $notice): ?>
<div style="padding:12px 16px;background:rgba(59,130,246,.05);
border-radius:10px;margin-bottom:8px;display:flex;align-items:center;gap:10px">
<span style="font-size:.7rem;font-weight:700;color:var(--p);
background:rgba(59,130,246,.1);padding:2px 8px;border-radius:4px">공지</span>
<a href="<?php echo dx_base_url($bk."/view/".$notice["id"]); ?>"
style="font-size:.9rem;color:var(--text-main);text-decoration:none;flex:1;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
<?php echo htmlspecialchars($notice["title"], ENT_QUOTES, "UTF-8"); ?>
</a>
<span style="font-size:.78rem;color:var(--text-muted);flex-shrink:0">
<?php echo dx_date($notice["created_at"], "m.d"); ?>
</span>
</div>
<?php endforeach; ?>
<!-- 일반 글 -->
<?php if (empty($posts)): ?>
<div style="padding:60px;text-align:center;color:var(--text-muted)">
등록된 글이 없습니다.
</div>
<?php else: ?>
<?php foreach ($posts as $post): ?>
<div style="padding:12px 0;border-bottom:1px solid var(--border);
display:flex;align-items:center;gap:10px"
<!-- 카테고리 배지 -->
<?php if (!empty($post["category"])): ?>
<span style="font-size:.72rem;font-weight:600;color:var(--p);
background:rgba(59,130,246,.1);padding:2px 8px;border-radius:4px;
flex-shrink:0">
<?php echo htmlspecialchars($post["category"], ENT_QUOTES, "UTF-8"); ?>
</span>
<?php endif; ?>
<!-- 제목 -->
<a href="<?php echo dx_base_url($bk."/view/".$post["id"]); ?>"
style="flex:1;font-size:.9rem;color:var(--text-main);text-decoration:none;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
<?php echo htmlspecialchars($post["title"], ENT_QUOTES, "UTF-8"); ?>
<?php if ((int)$post["comment_count"] > 0): ?>
<span style="font-size:.78rem;color:var(--p);margin-left:4px">
[<?php echo (int)$post["comment_count"]; ?>]
</span>
<?php endif; ?>
</a>
<!-- 메타 정보 -->
<div style="display:flex;gap:12px;font-size:.78rem;color:var(--text-muted);flex-shrink:0">
<span><?php echo htmlspecialchars($post["member_name"] ?: $post["author_name"] ?: "익명", ENT_QUOTES); ?></span>
<span><?php echo dx_date($post["created_at"], "Y.m.d"); ?></span>
<span><i class="fa-regular fa-eye" style="font-size:.65rem"></i> <?php echo number_format($post["view_count"]); ?></span>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
<!-- 페이지네이션 -->
<?php
dx_include_part("pagination", array(
"total" => $total,
"page" => $page,
"per_page" => $perPage,
"url" => dx_base_url($bk."/list"),
));
?>
9장. board_latest 위젯 스킨 직접 만들기
9.1 위젯 스킨 파일 위치
// 현재 테마 우선 (권장)
themes/my-theme/board_latest/timeline.php
// default 테마 (폴백)
themes/default/board_latest/timeline.php
// 호출
dx_board_latest("free", 5, "timeline", "최신글");
9.2 타임라인형 위젯 스킨
<?php
// themes/my-theme/board_latest/timeline.php
// 변수: $title, $icon, $more_url, $posts, $board_key, $show_excerpt
if (!defined("DX_CMS")) exit;
?>
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:14px;overflow:hidden">
<!-- 헤더 -->
<div style="display:flex;align-items:center;justify-content:space-between;
padding:14px 18px;border-bottom:1px solid var(--border)">
<span style="font-size:.9rem;font-weight:800;color:var(--text-main)">
<?php if ($icon): ?><span style="margin-right:6px"><?php echo htmlspecialchars($icon, ENT_QUOTES); ?></span><?php endif; ?>
<?php echo htmlspecialchars($title, ENT_QUOTES, "UTF-8"); ?>
</span>
<a href="<?php echo htmlspecialchars($more_url, ENT_QUOTES); ?>"
style="font-size:.75rem;color:var(--p);text-decoration:none;font-weight:600">
더보기 →
</a>
</div>
<!-- 타임라인 목록 -->
<?php if (empty($posts)): ?>
<p style="padding:24px;text-align:center;color:var(--text-muted);font-size:.83rem">
게시글이 없습니다.
</p>
<?php else: ?>
<div style="padding:8px 0">
<?php foreach ($posts as $post): ?>
<div style="display:flex;gap:12px;padding:10px 18px;
border-bottom:1px solid var(--border);align-items:flex-start">
<!-- 날짜 (타임라인 축) -->
<div style="flex-shrink:0;width:36px;text-align:center">
<span style="font-size:.75rem;font-weight:700;color:var(--p)">
<?php echo $post["date_short"]; ?>
</span>
</div>
<!-- 세로선 -->
<div style="flex-shrink:0;width:2px;background:var(--border);
border-radius:1px;margin-top:3px"></div>
<!-- 내용 -->
<div style="flex:1;min-width:0">
<a href="<?php echo htmlspecialchars($post["url"], ENT_QUOTES); ?>"
style="font-size:.875rem;color:var(--text-main);text-decoration:none;
font-weight:500;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"
<?php echo htmlspecialchars($post["title"], ENT_QUOTES, "UTF-8"); ?>
</a>
<div style="font-size:.75rem;color:var(--text-muted);margin-top:2px">
<?php echo htmlspecialchars($post["author"], ENT_QUOTES); ?>
<?php if ($post["comment_count"] > 0): ?>
• 댓글 <?php echo $post["comment_count"]; ?>
<?php endif; ?>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
10장. 관리자에서 테마 관리
10.1 테마 활성화 단계
- themes/ 폴더에 새 테마 폴더 업로드 (theme.json + layout/main.php 필수)
- 관리자 → 테마 관리 메뉴 클릭
- 테마 목록에서 새 테마 확인 (themes/ 폴더 자동 스캔)
- "활성화" 버튼 클릭 → settings 테이블의 theme 키 저장
- DxTheme::reset() + DxCache 초기화 자동 실행
- 사이트에서 새 테마 즉시 확인
10.2 테마 옵션 설정
- 관리자 → 테마 관리 → 활성 테마의 "옵션 편집" 클릭
- theme.json의 options에 정의된 항목이 입력 UI로 표시
- 값 입력 후 "저장" 클릭
- settings 테이블에 theme_{테마명}_{키} 형식으로 저장
- dx_theme_option()으로 즉시 읽기 가능
10.3 자주 발생하는 문제 해결
| 증상 |
원인 및 해결 |
| 테마 목록에 안 보임 |
themes/ 하위 폴더인지 확인. 폴더명에 대문자·특수문자 없는지 확인 |
| 화면이 깨지거나 흰 화면 |
PHP 오류 발생. DX_DEBUG=true 설정 후 오류 메시지 확인 |
| CSS가 적용 안 됨 |
dx_theme_asset() URL 확인. assets/ 경로·파일명 오타 확인. 브라우저 캐시 비우기 |
| $dx_content가 비어있음 |
layout/main.php에 echo isset($dx_content) 구문 확인. ob_start() 충돌 여부 확인 |
| 다크모드 깜빡임 |
extend/top/01_darkmode_early.php가 있는지 확인. FOUC 방지 스크립트 필요 |
| 사이드바가 안 나옴 |
$context 배열에 categories가 있는지, showSidebar 조건 로직 확인 |
| 메뉴가 안 나옴 |
DB의 menus 테이블에 데이터 있는지 확인. menu_group 값 일치 여부 확인 |
11장. 완성 테마 체크리스트
11.1 필수 요소 체크리스트
- [ ] themes/{테마명}/theme.json 존재 (name, version 포함)
- [ ] themes/{테마명}/layout/main.php 존재
- [ ] layout/main.php에 <!DOCTYPE html>~</html>완전한 HTML 구조
- [ ] $dx_content 출력 구문 포함
- [ ] <meta name="csrf-token" content="..."> 포함
- [ ] dx_run_hook("dx_head", ...) 포함
- [ ] dx_run_hook("dx_footer_scripts", ...) 포함
- [ ] dx_run_hook("dx_body_bottom") 포함
- [ ] CSS 변수(:root, body.dark) 정의
11.2 권장 요소 체크리스트
- [ ] page/home.php 작성 (홈페이지 커스텀)
- [ ] page/404.php 작성 (404 오류 페이지)
- [ ] page/403.php 작성 (403 접근 거부 페이지)
- [ ] parts/pagination.php 작성 (페이지네이션)
- [ ] 다크모드 지원 (body.dark CSS)
- [ ] 모바일 반응형 지원 (@media 쿼리)
- [ ] assets/preview.png 추가 (관리자 미리보기 이미지)
- [ ] theme.json에 options 정의 (관리자 커스텀 가능)
11.3 디버깅 체크리스트
- [ ] config.php에 define("DX_DEBUG", true) 추가 (개발 시만)
- [ ] data/error.log 파일 확인 (PHP 오류 로그)
- [ ] 브라우저 DevTools → Network 탭에서 CSS/JS 로드 확인
- [ ] PHP 구문 오류: php -l themes/my-theme/layout/main.php
- [ ] DX_DEBUG 끄기 (프로덕션 배포 전 반드시)