diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..3bcca5f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = tab +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{json,yml,yaml}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..48b15a5 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,20 @@ +{ + "root": true, + "extends": [ + "plugin:@wordpress/eslint-plugin/recommended", + "plugin:@typescript-eslint/recommended" + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 2020, + "sourceType": "module", + "project": "./tsconfig.json" + }, + "plugins": ["@typescript-eslint"], + "rules": { + "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], + "@typescript-eslint/explicit-function-return-type": "warn", + "no-console": "warn" + }, + "ignorePatterns": ["vendor/", "node_modules/", "assets/", "dist/"] +} diff --git a/.gitignore b/.gitignore index 6704566..674fedb 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,25 @@ dist # TernJS port file .tern-port + +# ----------------------------------------------- +# CyyWordpress additions +# ----------------------------------------------- +# Build output +assets/js/ +assets/css/ +assets/.vite/ +*.map + +# Packaged theme zip +*.zip + +# OS / editor +.DS_Store +Thumbs.db +.idea/ +.vscode/settings.json + +# Composer: vendor is committed for deployment, but generated files are not +composer.lock + diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..209e3ef --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20 diff --git a/.stylelintrc.json b/.stylelintrc.json index 959130a..86cae24 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -1,16 +1,13 @@ { - "extends": [ - "stylelint-config-wordpress/scss" - ], - "ignoreFiles": [ - "sass/_normalize.scss" - ], - "rules": { - "font-family-no-missing-generic-family-keyword": null, - "no-descending-specificity": null, - "block-no-empty": null, - "no-duplicate-selectors": null, - "font-family-no-duplicate-names": null, - "selector-class-pattern": null - } + "extends": ["stylelint-config-standard-scss"], + "rules": { + "color-named": "never", + "declaration-no-important": true, + "max-nesting-depth": 3, + "selector-class-pattern": "^[a-z][a-z0-9-_]*(__[a-z0-9-_]+)?(--[a-z0-9-_]+)?$", + "scss/dollar-variable-pattern": "^[a-z][a-z0-9-_]*$", + "scss/at-mixin-pattern": "^[a-z][a-z0-9-_]*$" + }, + "ignoreFiles": ["node_modules/**", "vendor/**", "assets/**"] } + diff --git a/README.md b/README.md index eb64a1a..e9b75d3 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,125 @@ # CyyWordpress -一个自用的Wordpress主题 + +一个基于 **Bulma 1.x** + **Underscores (_s)** 的现代 WordPress 主题,专为中文博客场景设计。 + +--- + +## 环境要求 + +| 依赖 | 最低版本 | +|------|---------| +| PHP | 8.1 | +| WordPress | 6.0 | +| Node.js | 20(见 `.nvmrc`)| +| npm | 10+ | + +--- + +## 本地开发环境配置 + +```bash +# 1. 安装正确的 Node.js 版本(推荐使用 nvm) +nvm use # 读取 .nvmrc 自动切换到 Node 20 + +# 2. 安装前端依赖 +npm install + +# 3. 启动 Vite 开发服务器(HMR) +npm run dev + +# 4. 生产构建(含 contenthash 文件指纹) +npm run build + +# 5. 生成 RTL 样式 +npm run build:rtl +``` + +> **注意:** 开发时,将 WordPress 的 `WP_HOME` 指向 `http://localhost:3000`, +> Vite 会代理所有请求到本地 WordPress 实例并启用热更新。 + +--- + +## 构建流程说明 + +| 命令 | 说明 | +|------|------| +| `npm run dev` | 启动 Vite 开发服务器(端口 3000,支持 HMR)| +| `npm run build` | 生产构建,输出到 `assets/`(含 contenthash)| +| `npm run build:rtl` | 生成 `assets/css/main-rtl.css` | +| `npm run lint` | 运行 ESLint + StyleLint | +| `npm run type-check` | TypeScript 类型检查(不生成文件)| + +构建产物由 `inc/class-cyywordpress-assets.php` 读取 `assets/.vite/manifest.json` +自动解析带 hash 的文件名注入到 `wp_enqueue_*`。 + +--- + +## SCSS 变量覆盖(子主题) + +在子主题的入口 SCSS 文件中,在 `@use 'cyywordpress/scss/abstracts/variables'` **之前** +重新声明你希望覆盖的 CSS 自定义属性: + +```scss +:root { + --color-primary: #6366f1; /* 换成紫色主题 */ + --color-primary-dark: #4f46e5; +} +``` + +所有组件均使用 `var(--color-primary)` 引用,覆盖后全局生效,无需修改源文件。 + +--- + +## Customizer 可配置项 + +后台路径:**外观 → 自定义 → 首页横幅** + +| 选项 | 说明 | 默认值 | +|------|------|--------| +| 欢迎横幅文字 | 首页/文章列表顶部的通知文字 | `欢迎来到这!Hello everyone.` | +| 显示欢迎横幅 | 是否在首页显示横幅 | 勾选 | + +--- + +## 导航菜单位置 + +| 位置标识 | 显示位置 | +|---------|---------| +| `menu-1` | 顶部主导航 | +| `menu-2` | 页脚导航 | +| `social` | 页脚社交媒体图标链接 | + +--- + +## Widget 区域 + +| 区域 ID | 位置 | +|---------|------| +| `sidebar-1` | 文章/页面侧边栏 | +| `sidebar-footer-1` | 页脚左列 | +| `sidebar-footer-2` | 页脚中列 | +| `sidebar-footer-3` | 页脚右列 | +| `sidebar-header` | 导航栏右侧 | + +--- + +## 块模式 (Block Patterns) + +注册在 `patterns/` 目录,可在编辑器 **插入块 → 模式 → CyyWordpress** 中使用: + +- **Hero 横幅** — 全宽渐变横幅 + CTA 按钮 +- **文章卡片网格** — 三列响应式卡片布局 +- **行动号召区域** — 居中标题 + 双按钮 + +--- + +## PHP 代码规范检查 + +```bash +composer install +./vendor/bin/phpcs +./vendor/bin/phpcbf # 自动修复 +``` + +规范基于 WordPress Coding Standards + PHPCompatibility(PHP 8.1+)。 + diff --git a/composer.json b/composer.json index a89123f..386d807 100644 --- a/composer.json +++ b/composer.json @@ -15,13 +15,14 @@ } ], "require": { - "php": ">=5.6" + "php": ">=8.1" }, "require-dev": { - "dealerdirect/phpcodesniffer-composer-installer": "^0.7.0", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0.0", "wptrt/wpthemereview": "^0.2.1", - "php-parallel-lint/php-parallel-lint": "^1.2.0", - "wp-cli/i18n-command": "^2.2.5" + "php-parallel-lint/php-parallel-lint": "^1.3.0", + "wp-cli/i18n-command": "^2.6.0", + "phpcompatibility/phpcompatibility-wp": "^2.1" }, "scripts": { "lint:wpcs": "@php ./vendor/squizlabs/php_codesniffer/bin/phpcs", diff --git a/footer.php b/footer.php index 80e0080..4b42b5c 100644 --- a/footer.php +++ b/footer.php @@ -2,7 +2,8 @@ /** * The template for displaying the footer * - * Contains the closing of the #content div and all content after. + * Three-column layout: site info | footer nav (menu-2) | social links (social). + * Dynamic copyright year. All hardcoded external links removed. * * @link https://developer.wordpress.org/themes/basics/template-files/#template-partials * @@ -10,75 +11,107 @@ */ ?> + - + + - - - - - - + diff --git a/functions.php b/functions.php index cddf3e2..069cbc5 100644 --- a/functions.php +++ b/functions.php @@ -7,57 +7,81 @@ * @package CyyWordpress */ -if ( ! defined( '_S_VERSION' ) ) { - // Replace the version number of the theme on each release. - define( '_S_VERSION', '1.0.0' ); +// --------------------------------------------------------------------------- +// PHP & WordPress version guards +// --------------------------------------------------------------------------- +if ( version_compare( PHP_VERSION, '8.1', '<' ) ) { + add_action( + 'admin_notices', + function (): void { + echo '

' + . esc_html__( 'CyyWordpress 主题需要 PHP 8.1 或更高版本。', 'cyywordpress' ) + . '

'; + } + ); + return; // Do not load theme in unsupported environments. } +global $wp_version; +if ( version_compare( $wp_version, '6.0', '<' ) ) { + add_action( + 'admin_notices', + function (): void { + echo '

' + . esc_html__( 'CyyWordpress 主题需要 WordPress 6.0 或更高版本。', 'cyywordpress' ) + . '

'; + } + ); + return; +} + +// --------------------------------------------------------------------------- +// Version constant +// --------------------------------------------------------------------------- +if ( ! defined( '_S_VERSION' ) ) { + define( '_S_VERSION', '2.0.0' ); +} + +// --------------------------------------------------------------------------- +// Includes – each file owns its own domain +// --------------------------------------------------------------------------- +require get_template_directory() . '/inc/custom-header.php'; +require get_template_directory() . '/inc/template-tags.php'; +require get_template_directory() . '/inc/template-functions.php'; +require get_template_directory() . '/inc/customizer.php'; +require get_template_directory() . '/inc/bulmapress_navwalker.php'; +require get_template_directory() . '/inc/search.php'; +require get_template_directory() . '/inc/pagination.php'; +require get_template_directory() . '/inc/block-patterns.php'; +require get_template_directory() . '/inc/class-cyywordpress-assets.php'; +require get_template_directory() . '/inc/class-cyywordpress-seo.php'; +require get_template_directory() . '/inc/class-cyywordpress-schema.php'; + +if ( defined( 'JETPACK__VERSION' ) ) { + require get_template_directory() . '/inc/jetpack.php'; +} + +// --------------------------------------------------------------------------- +// Theme setup +// --------------------------------------------------------------------------- if ( ! function_exists( 'cyywordpress_setup' ) ) : /** - * Sets up theme defaults and registers support for various WordPress features. - * - * Note that this function is hooked into the after_setup_theme hook, which - * runs before the init hook. The init hook is too late for some features, such - * as indicating support for post thumbnails. + * Sets up theme defaults and registers WordPress feature support. */ - function cyywordpress_setup() { - /* - * Make theme available for translation. - * Translations can be filed in the /languages/ directory. - * If you're building a theme based on CyyWordpress, use a find and replace - * to change 'cyywordpress' to the name of your theme in all the template files. - */ + function cyywordpress_setup(): void { load_theme_textdomain( 'cyywordpress', get_template_directory() . '/languages' ); - // Add default posts and comments RSS feed links to head. add_theme_support( 'automatic-feed-links' ); - - /* - * Let WordPress manage the document title. - * By adding theme support, we declare that this theme does not use a - * hard-coded tag in the document head, and expect WordPress to - * provide it for us. - */ add_theme_support( 'title-tag' ); - - /* - * Enable support for Post Thumbnails on posts and pages. - * - * @link https://developer.wordpress.org/themes/functionality/featured-images-post-thumbnails/ - */ add_theme_support( 'post-thumbnails' ); + add_theme_support( 'responsive-embeds' ); + add_theme_support( 'align-wide' ); + add_theme_support( 'widgets-block-editor' ); - // This theme uses wp_nav_menu() in one location. - register_nav_menus( - array( - 'menu-1' => esc_html__( 'Primary', 'cyywordpress' ), - ) - ); + // Editor styles – keeps back-end visually consistent with the front end. + add_theme_support( 'editor-styles' ); + add_editor_style( 'assets/css/editor-style.css' ); - /* - * Switch default core markup for search form, comment form, and comments - * to output valid HTML5. - */ add_theme_support( 'html5', array( @@ -71,7 +95,6 @@ if ( ! function_exists( 'cyywordpress_setup' ) ) : ) ); - // Set up the WordPress core custom background feature. add_theme_support( 'custom-background', apply_filters( @@ -83,116 +106,140 @@ if ( ! function_exists( 'cyywordpress_setup' ) ) : ) ); - // Add theme support for selective refresh for widgets. add_theme_support( 'customize-selective-refresh-widgets' ); - /** - * Add support for core custom logo. - * - * @link https://codex.wordpress.org/Theme_Logo - */ add_theme_support( 'custom-logo', array( - 'height' => 250, - 'width' => 250, + 'height' => 80, + 'width' => 80, 'flex-width' => true, 'flex-height' => true, ) ); + + // Navigation menus. + register_nav_menus( + array( + 'menu-1' => esc_html__( '主导航', 'cyywordpress' ), + 'menu-2' => esc_html__( '页脚导航', 'cyywordpress' ), + 'social' => esc_html__( '社交媒体链接', 'cyywordpress' ), + ) + ); + + // Custom image sizes. + add_image_size( 'card-thumbnail', 800, 450, true ); + add_image_size( 'card-thumbnail-sm', 400, 225, true ); } endif; add_action( 'after_setup_theme', 'cyywordpress_setup' ); -/** - * Set the content width in pixels, based on the theme's design and stylesheet. - * - * Priority 0 to make it available to lower priority callbacks. - * - * @global int $content_width - */ -function cyywordpress_content_width() { - $GLOBALS['content_width'] = apply_filters( 'cyywordpress_content_width', 640 ); +// --------------------------------------------------------------------------- +// Content width +// --------------------------------------------------------------------------- +function cyywordpress_content_width(): void { + $GLOBALS['content_width'] = apply_filters( 'cyywordpress_content_width', 760 ); } add_action( 'after_setup_theme', 'cyywordpress_content_width', 0 ); -/** - * Register widget area. - * - * @link https://developer.wordpress.org/themes/functionality/sidebars/#registering-a-sidebar - */ -function cyywordpress_widgets_init() { -// register_sidebar( -// array( -// 'name' => esc_html__( 'Sidebar', 'cyywordpress' ), -// 'id' => 'sidebar-1', -// 'description' => esc_html__( 'Add widgets here.', 'cyywordpress' ), -// 'before_widget' => '<section id="%1$s" class="widget %2$s">', -// 'after_widget' => '</section>', -// 'before_title' => '<h2 class="widget-title">', -// 'after_title' => '</h2>', -// ) -// ); +// --------------------------------------------------------------------------- +// Widget areas +// --------------------------------------------------------------------------- +function cyywordpress_widgets_init(): void { + register_sidebar( + array( + 'name' => esc_html__( '文章侧边栏', 'cyywordpress' ), + 'id' => 'sidebar-1', + 'description' => esc_html__( '出现在文章/页面侧边的 Widget 区域。', 'cyywordpress' ), + 'before_widget' => '<div id="%1$s" class="widget card %2$s" style="margin-bottom:1rem;">', + 'after_widget' => '</div>', + 'before_title' => '<div class="card-header"><p class="card-header-title">', + 'after_title' => '</p></div><div class="card-content">', + ) + ); - register_sidebar( - array( - 'name' => esc_html__( 'Sidebar', 'cyywordpress' ), - 'id' => 'sidebar-1', - 'description' => esc_html__( 'Add widgets here.', 'cyywordpress' ), + register_sidebar( + array( + 'name' => esc_html__( '页脚 Widget 1', 'cyywordpress' ), + 'id' => 'sidebar-footer-1', + 'description' => esc_html__( '页脚第一列 Widget 区域。', 'cyywordpress' ), + 'before_widget' => '<div id="%1$s" class="widget %2$s">', + 'after_widget' => '</div>', + 'before_title' => '<h3 class="footer-widget-title">', + 'after_title' => '</h3>', + ) + ); - 'before_widget' => '<div class="column is-3 %2$s" id="%1$s" >', - 'after_widget' => '</div>', - 'before_title' => '<h2 class="widget-title">', - 'after_title' => '</h2>', - ) - ); + register_sidebar( + array( + 'name' => esc_html__( '页脚 Widget 2', 'cyywordpress' ), + 'id' => 'sidebar-footer-2', + 'description' => esc_html__( '页脚第二列 Widget 区域。', 'cyywordpress' ), + 'before_widget' => '<div id="%1$s" class="widget %2$s">', + 'after_widget' => '</div>', + 'before_title' => '<h3 class="footer-widget-title">', + 'after_title' => '</h3>', + ) + ); + + register_sidebar( + array( + 'name' => esc_html__( '页脚 Widget 3', 'cyywordpress' ), + 'id' => 'sidebar-footer-3', + 'description' => esc_html__( '页脚第三列 Widget 区域。', 'cyywordpress' ), + 'before_widget' => '<div id="%1$s" class="widget %2$s">', + 'after_widget' => '</div>', + 'before_title' => '<h3 class="footer-widget-title">', + 'after_title' => '</h3>', + ) + ); + + register_sidebar( + array( + 'name' => esc_html__( '导航栏右侧', 'cyywordpress' ), + 'id' => 'sidebar-header', + 'description' => esc_html__( '出现在导航栏右侧的 Widget 区域(搜索等)。', 'cyywordpress' ), + 'before_widget' => '<div id="%1$s" class="widget navbar-item %2$s">', + 'after_widget' => '</div>', + 'before_title' => '<span class="screen-reader-text">', + 'after_title' => '</span>', + ) + ); } add_action( 'widgets_init', 'cyywordpress_widgets_init' ); -/** - * Enqueue scripts and styles. - */ -function cyywordpress_scripts() { - wp_enqueue_style( 'cyywordpress-style', get_stylesheet_uri(), array(), _S_VERSION ); - wp_style_add_data( 'cyywordpress-style', 'rtl', 'replace' ); - - wp_enqueue_script( 'cyywordpress-navigation', get_template_directory_uri() . '/js/navigation.js', array(), _S_VERSION, true ); - - if ( is_singular() && comments_open() && get_option( 'thread_comments' ) ) { - wp_enqueue_script( 'comment-reply' ); +// --------------------------------------------------------------------------- +// Initialise asset, SEO and schema classes +// --------------------------------------------------------------------------- +add_action( + 'after_setup_theme', + function (): void { + CyyWordpress_Assets::init(); + CyyWordpress_SEO::init(); + CyyWordpress_Schema::init(); } -} -add_action( 'wp_enqueue_scripts', 'cyywordpress_scripts' ); +); + +// --------------------------------------------------------------------------- +// Lazy-load attributes on all attachment images +// --------------------------------------------------------------------------- +add_filter( + 'wp_get_attachment_image_attributes', + function ( array $attr ): array { + $attr['loading'] = 'lazy'; + $attr['decoding'] = 'async'; + return $attr; + } +); + +// --------------------------------------------------------------------------- +// Remove WordPress emoji CDN (performance) +// --------------------------------------------------------------------------- +remove_action( 'wp_head', 'print_emoji_detection_script', 7 ); +remove_action( 'wp_print_styles', 'print_emoji_styles' ); +remove_action( 'admin_print_scripts', 'print_emoji_detection_script' ); +remove_action( 'admin_print_styles', 'print_emoji_styles' ); -/** - * Implement the Custom Header feature. - */ -require get_template_directory() . '/inc/custom-header.php'; -/** - * Custom template tags for this theme. - */ -require get_template_directory() . '/inc/template-tags.php'; - -/** - * Functions which enhance the theme by hooking into WordPress. - */ -require get_template_directory() . '/inc/template-functions.php'; - -/** - * Customizer additions. - */ -require get_template_directory() . '/inc/customizer.php'; - -/** - * Load Jetpack compatibility file. - */ -if ( defined( 'JETPACK__VERSION' ) ) { - require get_template_directory() . '/inc/jetpack.php'; -} - -require get_template_directory() . '/inc/bulmapress_navwalker.php'; -require get_template_directory() . '/inc/search.php'; -require get_template_directory() . '/inc/pagination.php'; diff --git a/header.php b/header.php index 74a0708..5623049 100644 --- a/header.php +++ b/header.php @@ -2,7 +2,8 @@ /** * The header for our theme * - * This is the template that displays all of the <head> section and everything up until <div id="content"> + * Displays the <head>, skip-link, navbar with custom logo, primary nav, + * search widget, dark-mode toggle, and back-to-top button. * * @link https://developer.wordpress.org/themes/basics/template-files/#template-partials * @@ -16,83 +17,137 @@ <meta charset="<?php bloginfo( 'charset' ); ?>"> <meta name="viewport" content="width=device-width, initial-scale=1"> <link rel="profile" href="https://gmpg.org/xfn/11"> - <link crossorigin="anonymous" integrity="sha512-6eqPOYQmUqGh2hFSAKha1dbQlMq1OaxityCVG80dkvGmmLdsAdrAUkgBKDV4fpAT/xOUdkB6uupudSbCwyoJPQ==" href="https://lib.baomitu.com/bulma/0.9.1/css/bulma.css" rel="stylesheet"> - <link crossorigin="anonymous" integrity="sha512-+4zCK9k+qNFUR5X+cKL9EIR+ZOhtIloNl9GIKS57V1MyNsYpYcUrUeQc9vNfzsWfV28IaLL3i96P9sdNyeRssA==" href="https://lib.baomitu.com/font-awesome/5.15.1/css/all.min.css" rel="stylesheet"> - - <?php wp_head(); ?> + <?php wp_head(); ?> </head> <body <?php body_class(); ?>> <?php wp_body_open(); ?> + <div id="page" class="site"> -<!-- <a class="skip-link screen-reader-text" href="#primary">--><?php //esc_html_e( 'Skip to content', 'cyywordpress' ); ?><!--</a>--> - <button onclick="topFunction()" id="myBtn" title="回顶部">返回顶部</button> + <a class="skip-link screen-reader-text" href="#primary"> + <?php esc_html_e( '跳到内容', 'cyywordpress' ); ?> + </a> - <nav class="navbar" style="box-shadow: 0 1px 3px 0 rgba(0, 0, 0, .1), 0 1px 2px 0 rgba(0, 0, 0, .06) !important;" role="navigation" aria-label="main navigation"> - <div class="navbar-brand"> - <a class="navbar-item" href="<?php echo esc_url( home_url( '/' ) ); ?>"> - <img src="https://s3.ax1x.com/2021/02/26/6SEr4g.jpg" width="28" height="28"> - </a> - <p class="subtitle is-4" style=" margin-top: 10px;margin-bottom: 5px;"><a href="<?php echo esc_url( home_url( '/' ) ); ?>" rel="home"><?php bloginfo( 'name' ); ?></a></p> - <a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="navbarBasicExample"> - <span aria-hidden="true"></span> - <span aria-hidden="true"></span> - <span aria-hidden="true"></span> - </a> - </div> + <!-- Back-to-top button (hidden until scrolled 300px) --> + <button + id="back-to-top" + class="back-to-top" + aria-label="<?php esc_attr_e( '返回顶部', 'cyywordpress' ); ?>" + hidden + > + <i class="fa-solid fa-arrow-up" aria-hidden="true"></i> + </button> - <div id="navbarBasicExample" class="navbar-menu"> + <nav + id="site-navigation" + class="navbar is-spaced site-navbar" + role="navigation" + aria-label="<?php esc_attr_e( '主导航', 'cyywordpress' ); ?>" + > + <div class="navbar-brand"> + <?php if ( has_custom_logo() ) : ?> + <a class="navbar-item navbar-logo" href="<?php echo esc_url( home_url( '/' ) ); ?>"> + <?php the_custom_logo(); ?> + </a> + <?php else : ?> + <a class="navbar-item navbar-site-name" href="<?php echo esc_url( home_url( '/' ) ); ?>" rel="home"> + <span class="site-title-initial"> + <?php echo esc_html( mb_substr( get_bloginfo( 'name' ), 0, 1 ) ); ?> + </span> + <span class="site-title-text"><?php bloginfo( 'name' ); ?></span> + </a> + <?php endif; ?> + <button + class="navbar-burger" + id="navbar-burger" + aria-label="<?php esc_attr_e( '菜单', 'cyywordpress' ); ?>" + aria-expanded="false" + aria-controls="navbar-menu" + > + <span aria-hidden="true"></span> + <span aria-hidden="true"></span> + <span aria-hidden="true"></span> + </button> + </div><!-- .navbar-brand --> - <div class="navbar-start"> - <?php - wp_nav_menu( array( - 'theme_location' => 'menu-1', - 'menu_id' => 'primary-menu', - 'depth' => 0, - 'container' => 'div id="navigation"', - 'menu_class' => 'navbar-end', - 'fallback_cb' => 'bulmapress_navwalker::fallback', - 'walker' => new bulmapress_navwalker() - ) - ); - ?> - <div class="navbar-item has-dropdown is-hoverable"> - <a class="navbar-link"> - 更多 - </a> + <div id="navbar-menu" class="navbar-menu"> + <div class="navbar-start"> + <?php + wp_nav_menu( + array( + 'theme_location' => 'menu-1', + 'menu_id' => 'primary-menu', + 'depth' => 3, + 'container' => false, + 'menu_class' => 'navbar-nav-list', + 'fallback_cb' => array( 'Bulmapress_Navwalker', 'fallback' ), + 'walker' => new Bulmapress_Navwalker(), + ) + ); + ?> + </div><!-- .navbar-start --> - <div class="navbar-dropdown"> - <a class="navbar-item" href="https://kaifa.baidu.com/" target="_blank"> - 开发者搜索(Baidu) - </a> - <a class="navbar-item" href="https://learnku.com/" target="_blank"> - 编程者论坛(LearnKu) - </a> - <a class="navbar-item" href="http://www.oschina.net/" target="_blank"> - 程序员资讯(osChina) - </a> - <hr class="navbar-divider"> - <a class="navbar-item" href="http://yaoyuan.io" target="_blank"> - 更多(chuyaoyuan) - </a> - </div> - </div> - </div> + <div class="navbar-end"> + <!-- Expandable search --> + <div class="navbar-item navbar-search-wrap"> + <button + class="navbar-search-toggle" + aria-label="<?php esc_attr_e( '搜索', 'cyywordpress' ); ?>" + aria-expanded="false" + aria-controls="navbar-search-form" + > + <i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i> + </button> + <form + id="navbar-search-form" + class="navbar-search-form" + role="search" + method="get" + action="<?php echo esc_url( home_url( '/' ) ); ?>" + hidden + > + <label class="screen-reader-text" for="navbar-search-input"> + <?php esc_html_e( '搜索', 'cyywordpress' ); ?> + </label> + <input + id="navbar-search-input" + class="input is-small" + type="search" + name="s" + placeholder="<?php esc_attr_e( '搜索…', 'cyywordpress' ); ?>" + value="<?php echo esc_attr( get_search_query() ); ?>" + > + </form> + </div> + + <!-- Dark-mode toggle --> + <div class="navbar-item"> + <button + id="theme-toggle" + class="theme-toggle" + aria-label="<?php esc_attr_e( '切换深色模式', 'cyywordpress' ); ?>" + > + <i class="fa-solid fa-moon theme-toggle__dark-icon" aria-hidden="true"></i> + <i class="fa-solid fa-sun theme-toggle__light-icon" aria-hidden="true"></i> + </button> + </div> + + <?php if ( is_active_sidebar( 'sidebar-header' ) ) : ?> + <?php dynamic_sidebar( 'sidebar-header' ); ?> + <?php endif; ?> + + <?php if ( is_user_logged_in() ) : ?> + <div class="navbar-item"> + <a class="button is-link is-light is-small" href="<?php echo esc_url( admin_url() ); ?>"> + <strong><?php esc_html_e( '后台', 'cyywordpress' ); ?></strong> + </a> + </div> + <?php endif; ?> + </div><!-- .navbar-end --> + </div><!-- #navbar-menu --> + </nav><!-- #site-navigation --> + + <div id="content" class="site-content container"> - <div class="navbar-end"> - <div class="navbar-item"> - <div class="buttons"> - <a class="button is-link is-light" href="http://chuyaoyuan.com/wp-admin/"> - <strong>后台登陆</strong> - </a> - <!-- <a class="button is-light"> - 退出 - </a>--> - </div> - </div> - </div> - </div> - </nav> - <div class="container"> diff --git a/inc/block-patterns.php b/inc/block-patterns.php new file mode 100644 index 0000000..ba85c00 --- /dev/null +++ b/inc/block-patterns.php @@ -0,0 +1,34 @@ +<?php +/** + * Register block patterns for CyyWordpress. + * + * @package CyyWordpress + */ + +if ( ! function_exists( 'cyywordpress_register_block_patterns' ) ) : + /** + * Register all theme block patterns. + */ + function cyywordpress_register_block_patterns(): void { + // Register pattern category. + register_block_pattern_category( + 'cyywordpress', + array( 'label' => esc_html__( 'CyyWordpress', 'cyywordpress' ) ) + ); + + // Load pattern files. + $pattern_files = array( + 'hero-banner', + 'post-card-grid', + 'cta-section', + ); + + foreach ( $pattern_files as $pattern ) { + $file = get_template_directory() . "/patterns/{$pattern}.php"; + if ( file_exists( $file ) ) { + require $file; + } + } + } +endif; +add_action( 'init', 'cyywordpress_register_block_patterns' ); diff --git a/inc/bulmapress_navwalker.php b/inc/bulmapress_navwalker.php index eb3bd64..17bd2ae 100644 --- a/inc/bulmapress_navwalker.php +++ b/inc/bulmapress_navwalker.php @@ -1,61 +1,102 @@ <?php /** - * Bulma-Navwalker + * Bulma Navwalker – secure, PHP 8.1 compatible rewrite * - * @package Bulma-Navwalker + * @package CyyWordpress */ -/** - * Class Name: Navwalker - * Plugin Name: Bulma Navwalker - * Plugin URI: https://github.com/Poruno/Bulma-Navwalker - * Description: An extended Wordpress Navwalker object that displays Bulma framework's Navbar https://bulma.io/ in Wordpress. - * Author: Carlo Operio - https://www.linkedin.com/in/carlooperio/, Bulma-Framework - * Author URI: https://github.com/wp-bootstrap - * License: GPL-3.0+ - * License URI: https://github.com/Poruno/Bulma-Navwalker/blob/master/LICENSE - */ +if ( ! class_exists( 'Bulmapress_Navwalker' ) ) : -class bulmapress_navwalker extends Walker_Nav_Menu { + /** + * Custom Bulma nav-walker for WordPress menus. + */ + class Bulmapress_Navwalker extends Walker_Nav_Menu { - public function start_lvl( &$output, $depth = 0, $args = array() ) { - - $output .= "<div class='navbar-dropdown'>"; - } - - public function start_el( &$output, $item, $depth = 0, $args = array(), $id = 0 ) { - - $liClasses = 'navbar-item '.$item->title; - - $hasChildren = $args->walker->has_children; - $liClasses .= $hasChildren? " has-dropdown is-hoverable": ""; - - if($hasChildren){ - $output .= "<div class='".$liClasses."'>"; - $output .= "\n<a class='navbar-link' href='".$item->url."'>".$item->title."</a>"; - } - else { - $output .= "<a class='".$liClasses."' href='".$item->url."'>".$item->title; + /** + * Opens a dropdown container. + * + * @param string $output Used to append additional content (passed by reference). + * @param int $depth Depth of menu item. + * @param stdClass|null $args An object of wp_nav_menu() arguments. + */ + public function start_lvl( string &$output, int $depth = 0, ?stdClass $args = null ): void { + $output .= '<div class="navbar-dropdown">'; } - // Adds has_children class to the item so end_el can determine if the current element has children - if ( $hasChildren ) { - $item->classes[] = 'has_children'; + /** + * Opens a menu item element and its link. + * + * @param string $output Used to append additional content (passed by reference). + * @param WP_Post $item Menu item data object. + * @param int $depth Depth of menu item. + * @param stdClass|null $args An object of wp_nav_menu() arguments. + * @param int $id Current item ID. + */ + public function start_el( string &$output, \WP_Post $item, int $depth = 0, ?stdClass $args = null, int $id = 0 ): void { + $has_children = isset( $args->walker ) && $args->walker->has_children; + + if ( $has_children ) { + // Mark the item so end_el knows it needs a </div> instead of </a>. + $item->classes[] = 'has_children'; + + $output .= '<div class="navbar-item has-dropdown is-hoverable">'; + $output .= '<a class="navbar-link" href="' . esc_url( $item->url ) . '">' + . esc_html( $item->title ) + . '</a>'; + } else { + $output .= '<a class="navbar-item" href="' . esc_url( $item->url ) . '">' + . esc_html( $item->title ); + } + } + + /** + * Closes a menu item element. + * + * @param string $output Used to append additional content (passed by reference). + * @param WP_Post $item Menu item data object. + * @param int $depth Depth of menu item. + * @param stdClass|null $args An object of wp_nav_menu() arguments. + */ + public function end_el( string &$output, \WP_Post $item, int $depth = 0, ?stdClass $args = null ): void { + if ( in_array( 'has_children', (array) $item->classes, true ) ) { + // Dropdown wrapper opened with <div>; close the div (inner <a> was already closed). + $output .= '</div>'; + } else { + // Regular item opened with <a>; close it. + $output .= '</a>'; + } + } + + /** + * Closes a dropdown container. + * + * @param string $output Used to append additional content (passed by reference). + * @param int $depth Depth of menu item. + * @param stdClass|null $args An object of wp_nav_menu() arguments. + */ + public function end_lvl( string &$output, int $depth = 0, ?stdClass $args = null ): void { + $output .= '</div>'; // .navbar-dropdown + } + + /** + * Fallback: output a link to the home page when no menu is assigned. + * + * @param array $args Arguments from wp_nav_menu(). + */ + public static function fallback( array $args ): void { + if ( current_user_can( 'manage_options' ) ) { + echo '<a class="navbar-item" href="' . esc_url( admin_url( 'nav-menus.php' ) ) . '">' + . esc_html__( '请添加菜单', 'cyywordpress' ) + . '</a>'; + } else { + echo '<a class="navbar-item" href="' . esc_url( home_url( '/' ) ) . '">' + . esc_html__( '首页', 'cyywordpress' ) + . '</a>'; + } } } - - public function end_el(&$output, $item, $depth = 0, $args = array(), $id = 0 ){ - if(in_array("has_children", $item->classes)) { + // Back-compat alias so existing wp_nav_menu() calls still work. + class_alias( 'Bulmapress_Navwalker', 'bulmapress_navwalker' ); - $output .= "</div>"; - } - $output .= "</a>"; - } - - public function end_lvl (&$output, $depth = 0, $args = array()) { - - $output .= "</div>"; - } -} -?> \ No newline at end of file +endif; diff --git a/inc/class-cyywordpress-assets.php b/inc/class-cyywordpress-assets.php new file mode 100644 index 0000000..e641aaf --- /dev/null +++ b/inc/class-cyywordpress-assets.php @@ -0,0 +1,150 @@ +<?php +/** + * Asset registration and enqueueing class. + * + * Reads the Vite manifest (assets/.vite/manifest.json) in production + * and falls back to direct paths in development mode. + * + * @package CyyWordpress + */ + +if ( ! class_exists( 'CyyWordpress_Assets' ) ) : + + /** + * Handles all script / style enqueueing for the theme. + */ + class CyyWordpress_Assets { + + /** Absolute path to the compiled assets directory. */ + private const ASSETS_DIR = __DIR__ . '/../assets'; + + /** URL to the compiled assets directory. */ + private static string $assets_url = ''; + + /** Parsed Vite manifest. */ + private static array $manifest = []; + + /** + * Registers hooks. + */ + public static function init(): void { + self::$assets_url = get_template_directory_uri() . '/assets'; + self::load_manifest(); + + add_action( 'wp_enqueue_scripts', array( static::class, 'enqueue_frontend' ) ); + add_action( 'admin_enqueue_scripts', array( static::class, 'enqueue_admin' ) ); + add_filter( 'script_loader_tag', array( static::class, 'add_defer_attr' ), 10, 2 ); + } + + /** + * Loads and caches the Vite manifest if it exists. + */ + private static function load_manifest(): void { + $manifest_path = self::ASSETS_DIR . '/.vite/manifest.json'; + + if ( file_exists( $manifest_path ) ) { + $json = file_get_contents( $manifest_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions + if ( $json ) { + self::$manifest = json_decode( $json, true ) ?? []; + } + } + } + + /** + * Returns the hashed asset URL from the Vite manifest + * or falls back to the raw path. + * + * @param string $entry The entry key (e.g. "src/ts/main.ts"). + * @param string $type 'js' or 'css'. + */ + private static function asset_url( string $entry, string $type = 'js' ): string { + if ( ! empty( self::$manifest[ $entry ][ 'file' ] ) ) { + return self::$assets_url . '/' . self::$manifest[ $entry ][ 'file' ]; + } + + // Dev fallback. + return self::$assets_url . "/$type/" . basename( $entry, '.ts' ) . '.' . $type; + } + + /** + * Returns the filemtime-based version string for a local file, + * used for cache-busting in development. + * + * @param string $path Relative path from assets dir. + */ + private static function file_version( string $path ): string { + $abs = self::ASSETS_DIR . '/' . ltrim( $path, '/' ); + return file_exists( $abs ) ? (string) filemtime( $abs ) : _S_VERSION; + } + + /** + * Enqueue front-end styles and scripts. + */ + public static function enqueue_frontend(): void { + // Main CSS. + $css_url = self::asset_url( 'src/ts/main.ts', 'css' ); + wp_enqueue_style( + 'cyywordpress-main', + $css_url, + array(), + _S_VERSION + ); + wp_style_add_data( 'cyywordpress-main', 'rtl', 'replace' ); + + // Legacy style.css (theme header required by WP). + wp_enqueue_style( + 'cyywordpress-style', + get_stylesheet_uri(), + array( 'cyywordpress-main' ), + _S_VERSION + ); + + // Main JS. + $js_url = self::asset_url( 'src/ts/main.ts', 'js' ); + wp_enqueue_script( + 'cyywordpress-main', + $js_url, + array(), + _S_VERSION, + true + ); + + // Font Awesome (local). + wp_enqueue_style( + 'font-awesome', + get_template_directory_uri() . '/node_modules/@fortawesome/fontawesome-free/css/all.min.css', + array(), + '6.5.2' + ); + + // Comment reply: only when needed. + if ( is_singular() && comments_open() && get_option( 'thread_comments' ) ) { + wp_enqueue_script( 'comment-reply' ); + } + } + + /** + * Enqueue admin / Customizer scripts. + */ + public static function enqueue_admin(): void { + // Customizer preview script is handled via customize_preview_init hook. + } + + /** + * Add `defer` attribute to all theme JS files. + * + * @param string $tag The <script> HTML tag. + * @param string $handle The registered script handle. + */ + public static function add_defer_attr( string $tag, string $handle ): string { + $defer_handles = array( 'cyywordpress-main' ); + + if ( in_array( $handle, $defer_handles, true ) ) { + return str_replace( ' src=', ' defer src=', $tag ); + } + + return $tag; + } + } + +endif; diff --git a/inc/class-cyywordpress-schema.php b/inc/class-cyywordpress-schema.php new file mode 100644 index 0000000..10b50f4 --- /dev/null +++ b/inc/class-cyywordpress-schema.php @@ -0,0 +1,154 @@ +<?php +/** + * JSON-LD Schema.org structured data class. + * + * Outputs inline <script type="application/ld+json"> on relevant pages. + * Skips output when a dedicated SEO plugin is active. + * + * @package CyyWordpress + */ + +if ( ! class_exists( 'CyyWordpress_Schema' ) ) : + + /** + * Handles JSON-LD output. + */ + class CyyWordpress_Schema { + + /** + * Register hooks. + */ + public static function init(): void { + // Bail early if a popular SEO plugin already handles schema. + if ( + defined( 'WPSEO_VERSION' ) + || defined( 'RANKMATH_VERSION' ) + || class_exists( 'All_in_One_SEO_Pack' ) + ) { + return; + } + + add_action( 'wp_head', array( static::class, 'output_schema' ) ); + } + + /** + * Determine page type and output the correct schema. + */ + public static function output_schema(): void { + $schema = null; + + if ( is_front_page() || is_home() ) { + $schema = self::website_schema(); + } elseif ( is_singular( 'post' ) ) { + $schema = self::article_schema(); + } elseif ( is_author() ) { + $schema = self::person_schema(); + } + + if ( $schema ) { + // json_encode with JSON_UNESCAPED_UNICODE for readable Chinese output. + echo '<script type="application/ld+json">' + . wp_json_encode( $schema, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT ) + . '</script>' . PHP_EOL; + } + } + + /** + * WebSite schema with SearchAction for Sitelink Search Box. + * + * @return array<string, mixed> + */ + private static function website_schema(): array { + return array( + '@context' => 'https://schema.org', + '@type' => 'WebSite', + 'name' => get_bloginfo( 'name' ), + 'url' => home_url( '/' ), + 'potentialAction' => array( + '@type' => 'SearchAction', + 'target' => array( + '@type' => 'EntryPoint', + 'urlTemplate' => home_url( '/?s={search_term_string}' ), + ), + 'query-input' => 'required name=search_term_string', + ), + ); + } + + /** + * Article schema for single posts. + * + * @return array<string, mixed> + */ + private static function article_schema(): array { + global $post; + + $author_id = (int) get_the_author_meta( 'ID' ); + $author_name = get_the_author_meta( 'display_name', $author_id ); + $author_url = get_author_posts_url( $author_id ); + $image_url = ''; + + if ( has_post_thumbnail() ) { + $src = wp_get_attachment_image_src( (int) get_post_thumbnail_id(), 'large' ); + if ( $src ) { + $image_url = $src[0]; + } + } + + $schema = array( + '@context' => 'https://schema.org', + '@type' => 'Article', + 'headline' => get_the_title(), + 'url' => (string) get_permalink(), + 'datePublished' => get_the_date( DATE_W3C ), + 'dateModified' => get_the_modified_date( DATE_W3C ), + 'author' => array( + '@type' => 'Person', + 'name' => $author_name, + 'url' => $author_url, + ), + 'publisher' => array( + '@type' => 'Organization', + 'name' => get_bloginfo( 'name' ), + 'url' => home_url( '/' ), + ), + 'description' => has_excerpt() + ? get_the_excerpt() + : wp_trim_words( wp_strip_all_tags( $post->post_content ), 30 ), + ); + + if ( $image_url ) { + $schema['image'] = $image_url; + } + + return $schema; + } + + /** + * Person schema for author archive pages. + * + * @return array<string, mixed> + */ + private static function person_schema(): array { + $author = get_queried_object(); + $author_id = ( $author instanceof WP_User ) ? $author->ID : 0; + $author_name = get_the_author_meta( 'display_name', $author_id ); + $author_url = get_author_posts_url( $author_id ); + $bio = get_the_author_meta( 'description', $author_id ); + + $schema = array( + '@context' => 'https://schema.org', + '@type' => 'Person', + 'name' => $author_name, + 'url' => $author_url, + ); + + if ( $bio ) { + $schema['description'] = $bio; + } + + return $schema; + } + } + +endif; diff --git a/inc/class-cyywordpress-seo.php b/inc/class-cyywordpress-seo.php new file mode 100644 index 0000000..03475f0 --- /dev/null +++ b/inc/class-cyywordpress-seo.php @@ -0,0 +1,124 @@ +<?php +/** + * SEO / Meta-tags class. + * + * Outputs Open Graph and Twitter Card meta tags. + * Does NOT run if a dedicated SEO plugin (Yoast, RankMath, AIOSEO) is active. + * + * @package CyyWordpress + */ + +if ( ! class_exists( 'CyyWordpress_SEO' ) ) : + + /** + * Manages Open Graph + Twitter Card meta output. + */ + class CyyWordpress_SEO { + + /** + * Register hooks. + */ + public static function init(): void { + // Bail early if a popular SEO plugin is active. + if ( + defined( 'WPSEO_VERSION' ) // Yoast SEO + || defined( 'RANKMATH_VERSION' ) // RankMath + || class_exists( 'All_in_One_SEO_Pack' ) + ) { + return; + } + + add_action( 'wp_head', array( static::class, 'output_meta_tags' ), 5 ); + } + + /** + * Output all meta tags. + */ + public static function output_meta_tags(): void { + $description = self::get_description(); + $image = self::get_image(); + $type = is_singular() ? 'article' : 'website'; + $url = esc_url( self::get_canonical_url() ); + $title = esc_attr( wp_get_document_title() ); + + echo "\n<!-- CyyWordpress SEO Meta -->\n"; + + // Open Graph. + printf( '<meta property="og:title" content="%s">' . PHP_EOL, esc_attr( $title ) ); + printf( '<meta property="og:description" content="%s">' . PHP_EOL, esc_attr( $description ) ); + printf( '<meta property="og:url" content="%s">' . PHP_EOL, $url ); + printf( '<meta property="og:type" content="%s">' . PHP_EOL, esc_attr( $type ) ); + printf( '<meta property="og:site_name" content="%s">' . PHP_EOL, esc_attr( get_bloginfo( 'name' ) ) ); + + if ( $image ) { + printf( '<meta property="og:image" content="%s">' . PHP_EOL, esc_url( $image ) ); + printf( '<meta property="og:image:alt" content="%s">' . PHP_EOL, esc_attr( $title ) ); + } + + // Twitter Card. + $card_type = $image ? 'summary_large_image' : 'summary'; + printf( '<meta name="twitter:card" content="%s">' . PHP_EOL, esc_attr( $card_type ) ); + printf( '<meta name="twitter:title" content="%s">' . PHP_EOL, esc_attr( $title ) ); + printf( '<meta name="twitter:description" content="%s">' . PHP_EOL, esc_attr( $description ) ); + + if ( $image ) { + printf( '<meta name="twitter:image" content="%s">' . PHP_EOL, esc_url( $image ) ); + } + + echo "<!-- /CyyWordpress SEO Meta -->\n\n"; + } + + /** + * Returns a cleaned excerpt / description. + */ + private static function get_description(): string { + if ( is_singular() ) { + global $post; + $excerpt = has_excerpt() ? get_the_excerpt() : wp_trim_words( wp_strip_all_tags( $post->post_content ), 30 ); + return sanitize_text_field( $excerpt ); + } + + $tagline = get_bloginfo( 'description' ); + return sanitize_text_field( $tagline ); + } + + /** + * Returns the OG image URL. + */ + private static function get_image(): string { + if ( is_singular() && has_post_thumbnail() ) { + $src = wp_get_attachment_image_src( (int) get_post_thumbnail_id(), 'large' ); + if ( $src ) { + return $src[0]; + } + } + + // Site-wide fallback: custom logo. + $logo_id = get_theme_mod( 'custom_logo' ); + if ( $logo_id ) { + $src = wp_get_attachment_image_src( (int) $logo_id, 'full' ); + if ( $src ) { + return $src[0]; + } + } + + return ''; + } + + /** + * Returns the canonical URL for the current page. + */ + private static function get_canonical_url(): string { + if ( is_singular() ) { + return (string) get_permalink(); + } + + if ( is_home() ) { + return home_url( '/' ); + } + + return home_url( add_query_arg( array() ) ); + } + } + +endif; diff --git a/inc/customizer.php b/inc/customizer.php index 06b5e7d..7ab2289 100644 --- a/inc/customizer.php +++ b/inc/customizer.php @@ -6,13 +6,13 @@ */ /** - * Add postMessage support for site title and description for the Theme Customizer. + * Add postMessage support and custom options for the Theme Customizer. * * @param WP_Customize_Manager $wp_customize Theme Customizer object. */ -function cyywordpress_customize_register( $wp_customize ) { - $wp_customize->get_setting( 'blogname' )->transport = 'postMessage'; - $wp_customize->get_setting( 'blogdescription' )->transport = 'postMessage'; +function cyywordpress_customize_register( WP_Customize_Manager $wp_customize ): void { + $wp_customize->get_setting( 'blogname' )->transport = 'postMessage'; + $wp_customize->get_setting( 'blogdescription' )->transport = 'postMessage'; $wp_customize->get_setting( 'header_textcolor' )->transport = 'postMessage'; if ( isset( $wp_customize->selective_refresh ) ) { @@ -31,31 +31,82 @@ function cyywordpress_customize_register( $wp_customize ) { ) ); } + + // ----------------------------------------------------------------------- + // Section: Front Page Banner + // ----------------------------------------------------------------------- + $wp_customize->add_section( + 'cyywordpress_frontpage', + array( + 'title' => esc_html__( '首页横幅', 'cyywordpress' ), + 'priority' => 130, + ) + ); + + $wp_customize->add_setting( + 'cyywordpress_welcome_text', + array( + // 存原始字符串,输出时再转义。 + 'default' => __( '欢迎来到这!Hello everyone.', 'cyywordpress' ), + 'sanitize_callback' => 'sanitize_text_field', + 'transport' => 'postMessage', + ) + ); + + $wp_customize->add_control( + 'cyywordpress_welcome_text', + array( + 'label' => esc_html__( '欢迎横幅文字', 'cyywordpress' ), + 'section' => 'cyywordpress_frontpage', + 'type' => 'text', + ) + ); + + $wp_customize->add_setting( + 'cyywordpress_show_welcome_banner', + array( + 'default' => true, + 'sanitize_callback' => 'rest_sanitize_boolean', + 'transport' => 'refresh', + ) + ); + + $wp_customize->add_control( + 'cyywordpress_show_welcome_banner', + array( + 'label' => esc_html__( '显示欢迎横幅', 'cyywordpress' ), + 'section' => 'cyywordpress_frontpage', + 'type' => 'checkbox', + ) + ); } add_action( 'customize_register', 'cyywordpress_customize_register' ); /** * Render the site title for the selective refresh partial. - * - * @return void */ -function cyywordpress_customize_partial_blogname() { +function cyywordpress_customize_partial_blogname(): void { bloginfo( 'name' ); } /** * Render the site tagline for the selective refresh partial. - * - * @return void */ -function cyywordpress_customize_partial_blogdescription() { +function cyywordpress_customize_partial_blogdescription(): void { bloginfo( 'description' ); } /** * Binds JS handlers to make Theme Customizer preview reload changes asynchronously. */ -function cyywordpress_customize_preview_js() { - wp_enqueue_script( 'cyywordpress-customizer', get_template_directory_uri() . '/js/customizer.js', array( 'customize-preview' ), _S_VERSION, true ); +function cyywordpress_customize_preview_js(): void { + wp_enqueue_script( + 'cyywordpress-customizer', + get_template_directory_uri() . '/js/customizer.js', + array( 'customize-preview' ), + _S_VERSION, + true + ); } add_action( 'customize_preview_init', 'cyywordpress_customize_preview_js' ); + diff --git a/index.php b/index.php index 48e68c0..f2b4e6b 100644 --- a/index.php +++ b/index.php @@ -2,10 +2,8 @@ /** * The main template file * - * This is the most generic template file in a WordPress theme - * and one of the two required files for a theme (the other being style.css). - * It is used to display a page when nothing more specific matches a query. - * E.g., it puts together the home page when no home.php file exists. + * Responsive 1/2/3-column post grid, sticky hero for top-pinned posts, + * customiser-driven welcome banner, and pagination. * * @link https://developer.wordpress.org/themes/basics/template-hierarchy/ * @@ -14,69 +12,89 @@ get_header(); ?> - <section class="section"> - <div class="container"> - <!-- <h1 class="title"> - <?php /*bloginfo( 'name' ); */?> - </h1>--> - <p class="subtitle"> - <?php bloginfo( 'description' ); ?> - </p> - </div> - </section> - <br> - <div class="notification is-primary"> - 欢迎来到这! <strong>Hello everyone.</strong> - </div> +<div id="primary" class="content-area"> + <main id="main" class="site-main" role="main"> - <nav class="breadcrumb" aria-label="breadcrumbs"> - <ul> - <li> - <a href="<?php echo esc_url( home_url( '/' ) ); ?>" > - <span class="icon is-small"> - <i class="fas fa-home" aria-hidden="true"></i> - </span> - <span>首页</span> - </a> - </li> - </ul> - </nav> + <?php + // Welcome banner – controlled via Customizer. + $show_banner = get_theme_mod( 'cyywordpress_show_welcome_banner', true ); + $welcome_text = get_theme_mod( 'cyywordpress_welcome_text', __( '欢迎来到这!Hello everyone.', 'cyywordpress' ) ); + if ( $show_banner && ( is_home() || is_front_page() ) ) : + ?> + <div class="notification is-primary welcome-banner" data-customizer="cyywordpress_welcome_text"> + <?php echo esc_html( $welcome_text ); ?> + </div> + <?php + endif; + ?> + <nav class="breadcrumb" aria-label="<?php esc_attr_e( '面包屑导航', 'cyywordpress' ); ?>"> + <ul> + <li> + <a href="<?php echo esc_url( home_url( '/' ) ); ?>"> + <span class="icon is-small"><i class="fa-solid fa-house" aria-hidden="true"></i></span> + <span><?php esc_html_e( '首页', 'cyywordpress' ); ?></span> + </a> + </li> + <?php if ( is_home() && ! is_front_page() ) : ?> + <li class="is-active"> + <a href="#" aria-current="page"><?php single_post_title(); ?></a> + </li> + <?php endif; ?> + </ul> + </nav> - <?php - if ( have_posts() ) : + <?php if ( have_posts() ) : ?> - if ( is_home() && ! is_front_page() ) : - ?> - <p class="title is-4"><?php single_post_title(); ?></p>article - <?php - endif; + <?php + // Separate sticky posts for hero treatment. + $sticky_ids = get_option( 'sticky_posts', array() ); + $hero_printed = false; + ?> - /* Start the Loop */ - while ( have_posts() ) : - the_post(); + <div class="posts-grid columns is-multiline"> + <?php + while ( have_posts() ) : + the_post(); - /* - * Include the Post-Type-specific template for the content. - * If you want to override this in a child theme, then include a file - * called content-___.php (where ___ is the Post Type name) and that will be used instead. - */ - get_template_part( 'template-parts/content', get_post_type() ); + // Sticky → hero card (full-width), only first sticky. + if ( is_sticky() && ! $hero_printed && is_home() && ! is_paged() ) : + $hero_printed = true; + echo '<div class="column is-12 post-card--hero">'; + get_template_part( 'template-parts/content', get_post_type() ); + echo '</div>'; + else : + echo '<div class="column is-12-mobile is-6-tablet is-4-desktop">'; + get_template_part( 'template-parts/content', get_post_type() ); + echo '</div>'; + endif; - endwhile; + endwhile; + ?> + </div><!-- .posts-grid --> + <div class="posts-pagination"> + <?php the_posts_pagination( + array( + 'mid_size' => 2, + 'prev_text' => '<i class="fa-solid fa-chevron-left" aria-hidden="true"></i> ' . esc_html__( '上一页', 'cyywordpress' ), + 'next_text' => esc_html__( '下一页', 'cyywordpress' ) . ' <i class="fa-solid fa-chevron-right" aria-hidden="true"></i>', + ) + ); ?> + </div> - the_posts_navigation(); + <?php else : ?> + <?php get_template_part( 'template-parts/content', 'none' ); ?> + <?php endif; ?> - else : - - get_template_part( 'template-parts/content', 'none' ); - - endif; - ?> + </main><!-- #main --> +</div><!-- #primary --> +<?php +get_sidebar(); +get_footer(); <br> <br> diff --git a/package.json b/package.json index ab2b654..08c763b 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,13 @@ { - "name": "underscores", - "version": "1.0.0", - "description": "Hi. I'm a starter theme called _s, or underscores, if you like. I'm a theme meant for hacking so don't use me as a Parent Theme. Instead try turning me into the next, most awesome, WordPress theme out there. That's what I'm here for.", - "author": "Automattic Theme Team", + "name": "cyywordpress", + "version": "2.0.0", + "description": "CyyWordpress – a modern WordPress theme built on Bulma 1.x and Underscores.", + "author": "Chuyaoyuan", "license": "GPL-2.0-or-later", "keywords": [ "WordPress", - "Theme" + "Theme", + "Bulma" ], "homepage": "https://github.com/Automattic/_s#readme", "repository": { @@ -17,30 +18,34 @@ "url": "https://github.com/Automattic/_s/issues" }, "devDependencies": { - "@wordpress/scripts": "^12.1.0", + "@fortawesome/fontawesome-free": "^6.5.2", + "@typescript-eslint/eslint-plugin": "^7.0.0", + "@typescript-eslint/parser": "^7.0.0", + "@wordpress/eslint-plugin": "^21.0.0", + "@wordpress/scripts": "^30.0.0", + "autoprefixer": "^10.4.19", + "bulma": "^1.0.2", + "cssnano": "^7.0.0", "dir-archiver": "^1.1.1", - "node-sass": "^4.14.1", - "rtlcss": "^2.5.0" - }, - "rtlcssConfig": { - "options": { - "autoRename": false, - "autoRenameStrict": false, - "blacklist": {}, - "clean": true, - "greedy": false, - "processUrls": false, - "stringMap": [] - }, - "plugins": [], - "map": false + "eslint": "^8.57.0", + "postcss": "^8.4.38", + "rtlcss": "^4.3.0", + "sass": "^1.77.0", + "stylelint": "^16.6.0", + "stylelint-config-standard-scss": "^14.0.0", + "typescript": "^5.4.5", + "vite": "^5.2.11", + "vite-plugin-sass-glob-import": "^1.0.0" }, "scripts": { - "watch": "node-sass sass/ -o ./ --source-map true --output-style expanded --indent-type tab --indent-width 1 -w", - "compile:css": "node-sass sass/ -o ./ && stylelint '*.css' --fix || true && stylelint '*.css' --fix", - "compile:rtl": "rtlcss style.css style-rtl.css", - "lint:scss": "wp-scripts lint-style 'sass/**/*.scss'", - "lint:js": "wp-scripts lint-js 'js/*.js'", - "bundle": "dir-archiver --src . --dest ../_s.zip --exclude .DS_Store .stylelintrc.json .eslintrc .git .gitattributes .github .gitignore README.md composer.json composer.lock node_modules vendor package-lock.json package.json .travis.yml phpcs.xml.dist sass style.css.map" + "dev": "vite", + "build": "vite build", + "build:rtl": "rtlcss assets/css/main.css assets/css/main-rtl.css", + "lint": "eslint 'src/**/*.{ts,js}' && stylelint 'scss/**/*.scss'", + "lint:js": "wp-scripts lint-js 'src/**/*.ts'", + "lint:scss": "stylelint 'scss/**/*.scss'", + "type-check": "tsc --noEmit", + "bundle": "dir-archiver --src . --dest ../cyywordpress.zip --exclude .DS_Store .stylelintrc.json .eslintrc.json .git .gitattributes .github .gitignore composer.json composer.lock node_modules vendor package-lock.json package.json phpcs.xml.dist scss src tsconfig.json vite.config.ts postcss.config.js" } } + diff --git a/patterns/cta-section.php b/patterns/cta-section.php new file mode 100644 index 0000000..58c59fc --- /dev/null +++ b/patterns/cta-section.php @@ -0,0 +1,39 @@ +<?php +/** + * Block Pattern: CTA Section + * + * @package CyyWordpress + */ + +register_block_pattern( + 'cyywordpress/cta-section', + array( + 'title' => esc_html__( '行动号召区域', 'cyywordpress' ), + 'description' => esc_html__( '中心对齐的行动号召区域,含标题、描述和按钮。', 'cyywordpress' ), + 'categories' => array( 'cyywordpress' ), + 'content' => '<!-- wp:group {"align":"full","style":{"spacing":{"padding":{"top":"var:preset|spacing|70","bottom":"var:preset|spacing|70"}},"color":{"background":"var:preset|color|light"}},"layout":{"type":"constrained"}} --> +<div class="wp-block-group alignfull has-background" style="background-color:var(--wp--preset--color--light);padding-top:var(--wp--preset--spacing--70);padding-bottom:var(--wp--preset--spacing--70)"> +<!-- wp:group {"layout":{"type":"flex","orientation":"vertical","justifyContent":"center"}} --> +<div class="wp-block-group"> +<!-- wp:heading {"textAlign":"center","level":2} --> +<h2 class="wp-block-heading has-text-align-center">' . esc_html__( '准备好开始了吗?', 'cyywordpress' ) . '</h2> +<!-- /wp:heading --> +<!-- wp:paragraph {"align":"center","style":{"typography":{"fontSize":"1.125rem"}}} --> +<p class="has-text-align-center" style="font-size:1.125rem">' . esc_html__( '加入我们,探索更多精彩内容。', 'cyywordpress' ) . '</p> +<!-- /wp:paragraph --> +<!-- wp:buttons {"layout":{"type":"flex","justifyContent":"center"},"style":{"spacing":{"margin":{"top":"var:preset|spacing|40"}}}} --> +<div class="wp-block-buttons" style="margin-top:var(--wp--preset--spacing--40)"> +<!-- wp:button {"backgroundColor":"primary","textColor":"white","style":{"border":{"radius":"6px"}}} --> +<div class="wp-block-button"><a class="wp-block-button__link has-primary-background-color has-white-color has-text-color has-background wp-element-button" style="border-radius:6px">' . esc_html__( '立即开始', 'cyywordpress' ) . '</a></div> +<!-- /wp:button --> +<!-- wp:button {"className":"is-style-outline","style":{"border":{"radius":"6px"}}} --> +<div class="wp-block-button is-style-outline"><a class="wp-block-button__link wp-element-button" style="border-radius:6px">' . esc_html__( '了解更多', 'cyywordpress' ) . '</a></div> +<!-- /wp:button --> +</div> +<!-- /wp:buttons --> +</div> +<!-- /wp:group --> +</div> +<!-- /wp:group -->', + ) +); diff --git a/patterns/hero-banner.php b/patterns/hero-banner.php new file mode 100644 index 0000000..4fbd9bb --- /dev/null +++ b/patterns/hero-banner.php @@ -0,0 +1,36 @@ +<?php +/** + * Block Pattern: Hero Banner + * + * @package CyyWordpress + */ + +register_block_pattern( + 'cyywordpress/hero-banner', + array( + 'title' => esc_html__( 'Hero 横幅', 'cyywordpress' ), + 'description' => esc_html__( '大标题 + 副标题 + 行动号召按钮的 Hero 区域。', 'cyywordpress' ), + 'categories' => array( 'cyywordpress' ), + 'content' => '<!-- wp:group {"align":"full","style":{"spacing":{"padding":{"top":"var:preset|spacing|70","bottom":"var:preset|spacing|70"}},"color":{"gradient":"linear-gradient(135deg,#00d1b2 0%,#3e8ed0 100%)"}},"layout":{"type":"constrained"}} --> +<div class="wp-block-group alignfull has-background" style="padding-top:var(--wp--preset--spacing--70);padding-bottom:var(--wp--preset--spacing--70)"> +<!-- wp:group {"layout":{"type":"flex","orientation":"vertical","justifyContent":"center"}} --> +<div class="wp-block-group"> +<!-- wp:heading {"textAlign":"center","level":1,"style":{"color":{"text":"#ffffff"},"typography":{"fontSize":"clamp(2rem,5vw,3.5rem)","fontWeight":"700"}}} --> +<h1 class="wp-block-heading has-text-align-center" style="color:#ffffff;font-size:clamp(2rem,5vw,3.5rem);font-weight:700">' . esc_html__( '欢迎来到我的博客', 'cyywordpress' ) . '</h1> +<!-- /wp:heading --> +<!-- wp:paragraph {"align":"center","style":{"color":{"text":"rgba(255,255,255,0.9)"},"typography":{"fontSize":"1.25rem"}}} --> +<p class="has-text-align-center" style="color:rgba(255,255,255,0.9);font-size:1.25rem">' . esc_html__( '分享技术、生活与思考', 'cyywordpress' ) . '</p> +<!-- /wp:paragraph --> +<!-- wp:buttons {"layout":{"type":"flex","justifyContent":"center"},"style":{"spacing":{"margin":{"top":"var:preset|spacing|40"}}}} --> +<div class="wp-block-buttons" style="margin-top:var(--wp--preset--spacing--40)"> +<!-- wp:button {"backgroundColor":"white","textColor":"primary","style":{"border":{"radius":"6px"}}} --> +<div class="wp-block-button"><a class="wp-block-button__link has-white-background-color has-primary-color has-text-color has-background wp-element-button" style="border-radius:6px">' . esc_html__( '浏览文章', 'cyywordpress' ) . '</a></div> +<!-- /wp:button --> +</div> +<!-- /wp:buttons --> +</div> +<!-- /wp:group --> +</div> +<!-- /wp:group -->', + ) +); diff --git a/patterns/post-card-grid.php b/patterns/post-card-grid.php new file mode 100644 index 0000000..64e0bdb --- /dev/null +++ b/patterns/post-card-grid.php @@ -0,0 +1,92 @@ +<?php +/** + * Block Pattern: Post Card Grid + * + * @package CyyWordpress + */ + +register_block_pattern( + 'cyywordpress/post-card-grid', + array( + 'title' => esc_html__( '文章卡片网格', 'cyywordpress' ), + 'description' => esc_html__( '响应式三列文章卡片网格布局。', 'cyywordpress' ), + 'categories' => array( 'cyywordpress' ), + 'content' => '<!-- wp:group {"align":"wide","layout":{"type":"constrained"}} --> +<div class="wp-block-group alignwide"> +<!-- wp:heading {"textAlign":"center","style":{"spacing":{"margin":{"bottom":"var:preset|spacing|50"}}}} --> +<h2 class="wp-block-heading has-text-align-center">' . esc_html__( '最新文章', 'cyywordpress' ) . '</h2> +<!-- /wp:heading --> +<!-- wp:columns {"isStackedOnMobile":true,"style":{"spacing":{"blockGap":{"top":"var:preset|spacing|40","left":"var:preset|spacing|40"}}}} --> +<div class="wp-block-columns is-layout-flex wp-container-core-columns"> +<!-- wp:column --> +<div class="wp-block-column"> +<!-- wp:group {"style":{"border":{"radius":"8px","width":"1px","color":"var:preset|color|light"},"spacing":{"padding":{"top":"0","bottom":"var:preset|spacing|40","left":"0","right":"0"}}},"layout":{"type":"constrained"}} --> +<div class="wp-block-group" style="border-color:var(--wp--preset--color--light);border-style:solid;border-width:1px;border-radius:8px;padding-bottom:var(--wp--preset--spacing--40)"> +<!-- wp:image {"sizeSlug":"card-thumbnail","style":{"border":{"radius":{"topLeft":"8px","topRight":"8px"}}}} --> +<figure class="wp-block-image size-card-thumbnail" style="border-top-left-radius:8px;border-top-right-radius:8px"><img alt="" /></figure> +<!-- /wp:image --> +<!-- wp:group {"style":{"spacing":{"padding":{"left":"var:preset|spacing|40","right":"var:preset|spacing|40"}}}} --> +<div class="wp-block-group" style="padding-left:var(--wp--preset--spacing--40);padding-right:var(--wp--preset--spacing--40)"> +<!-- wp:heading {"level":3,"style":{"spacing":{"margin":{"top":"var:preset|spacing|30"}}}} --> +<h3 class="wp-block-heading" style="margin-top:var(--wp--preset--spacing--30)">' . esc_html__( '文章标题', 'cyywordpress' ) . '</h3> +<!-- /wp:heading --> +<!-- wp:paragraph {"style":{"color":{"text":"#6b7280"}}} --> +<p style="color:#6b7280">' . esc_html__( '文章摘要文字,简要介绍内容。', 'cyywordpress' ) . '</p> +<!-- /wp:paragraph --> +<!-- wp:read-more {"content":"' . esc_html__( '阅读全文 →', 'cyywordpress' ) . '"} /--> +</div> +<!-- /wp:group --> +</div> +<!-- /wp:group --> +</div> +<!-- /wp:column --> +<!-- wp:column --> +<div class="wp-block-column"> +<!-- wp:group {"style":{"border":{"radius":"8px","width":"1px","color":"var:preset|color|light"},"spacing":{"padding":{"top":"0","bottom":"var:preset|spacing|40","left":"0","right":"0"}}},"layout":{"type":"constrained"}} --> +<div class="wp-block-group" style="border-color:var(--wp--preset--color--light);border-style:solid;border-width:1px;border-radius:8px;padding-bottom:var(--wp--preset--spacing--40)"> +<!-- wp:image {"sizeSlug":"card-thumbnail","style":{"border":{"radius":{"topLeft":"8px","topRight":"8px"}}}} --> +<figure class="wp-block-image size-card-thumbnail" style="border-top-left-radius:8px;border-top-right-radius:8px"><img alt="" /></figure> +<!-- /wp:image --> +<!-- wp:group {"style":{"spacing":{"padding":{"left":"var:preset|spacing|40","right":"var:preset|spacing|40"}}}} --> +<div class="wp-block-group" style="padding-left:var(--wp--preset--spacing--40);padding-right:var(--wp--preset--spacing--40)"> +<!-- wp:heading {"level":3,"style":{"spacing":{"margin":{"top":"var:preset|spacing|30"}}}} --> +<h3 class="wp-block-heading" style="margin-top:var(--wp--preset--spacing--30)">' . esc_html__( '文章标题', 'cyywordpress' ) . '</h3> +<!-- /wp:heading --> +<!-- wp:paragraph {"style":{"color":{"text":"#6b7280"}}} --> +<p style="color:#6b7280">' . esc_html__( '文章摘要文字,简要介绍内容。', 'cyywordpress' ) . '</p> +<!-- /wp:paragraph --> +<!-- wp:read-more {"content":"' . esc_html__( '阅读全文 →', 'cyywordpress' ) . '"} /--> +</div> +<!-- /wp:group --> +</div> +<!-- /wp:group --> +</div> +<!-- /wp:column --> +<!-- wp:column --> +<div class="wp-block-column"> +<!-- wp:group {"style":{"border":{"radius":"8px","width":"1px","color":"var:preset|color|light"},"spacing":{"padding":{"top":"0","bottom":"var:preset|spacing|40","left":"0","right":"0"}}},"layout":{"type":"constrained"}} --> +<div class="wp-block-group" style="border-color:var(--wp--preset--color--light);border-style:solid;border-width:1px;border-radius:8px;padding-bottom:var(--wp--preset--spacing--40)"> +<!-- wp:image {"sizeSlug":"card-thumbnail","style":{"border":{"radius":{"topLeft":"8px","topRight":"8px"}}}} --> +<figure class="wp-block-image size-card-thumbnail" style="border-top-left-radius:8px;border-top-right-radius:8px"><img alt="" /></figure> +<!-- /wp:image --> +<!-- wp:group {"style":{"spacing":{"padding":{"left":"var:preset|spacing|40","right":"var:preset|spacing|40"}}}} --> +<div class="wp-block-group" style="padding-left:var(--wp--preset--spacing--40);padding-right:var(--wp--preset--spacing--40)"> +<!-- wp:heading {"level":3,"style":{"spacing":{"margin":{"top":"var:preset|spacing|30"}}}} --> +<h3 class="wp-block-heading" style="margin-top:var(--wp--preset--spacing--30)">' . esc_html__( '文章标题', 'cyywordpress' ) . '</h3> +<!-- /wp:heading --> +<!-- wp:paragraph {"style":{"color":{"text":"#6b7280"}}} --> +<p style="color:#6b7280">' . esc_html__( '文章摘要文字,简要介绍内容。', 'cyywordpress' ) . '</p> +<!-- /wp:paragraph --> +<!-- wp:read-more {"content":"' . esc_html__( '阅读全文 →', 'cyywordpress' ) . '"} /--> +</div> +<!-- /wp:group --> +</div> +<!-- /wp:group --> +</div> +<!-- /wp:column --> +</div> +<!-- /wp:columns --> +</div> +<!-- /wp:group -->', + ) +); diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 1941312..ea93499 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -61,7 +61,7 @@ Multiple valid text domains can be provided as a comma-delimited list. --> <rule ref="WordPress.WP.I18n"> <properties> - <property name="text_domain" type="array" value="_s"/> + <property name="text_domain" type="array" value="cyywordpress"/> </properties> </rule> @@ -76,7 +76,11 @@ <!-- Set the minimum supported WP version. This is used by several sniffs. The minimum version set here should be in line with the minimum WP version as set in the "Requires at least" tag in the readme.txt file. --> - <config name="minimum_supported_wp_version" value="4.5"/> + <config name="minimum_supported_wp_version" value="6.0"/> + + <!-- PHP 8.1 compatibility check. --> + <config name="testVersion" value="8.1-"/> + <rule ref="PHPCompatibilityWP"/> <rule ref="WordPress.Arrays.MultipleStatementAlignment"> <properties> @@ -93,7 +97,7 @@ Multiple valid prefixes can be provided as a comma-delimited list. --> <rule ref="WordPress.NamingConventions.PrefixAllGlobals"> <properties> - <property name="prefixes" type="array" value="_s" /> + <property name="prefixes" type="array" value="cyywordpress,CyyWordpress,Bulmapress" /> </properties> </rule> diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..a09412b --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,9 @@ +/** @type {import('postcss-load-config').Config} */ +module.exports = { + plugins: [ + require( 'autoprefixer' ), + ...( process.env.NODE_ENV === 'production' + ? [ require( 'cssnano' )( { preset: 'default' } ) ] + : [] ), + ], +}; diff --git a/scss/abstracts/_functions.scss b/scss/abstracts/_functions.scss new file mode 100644 index 0000000..6d1aca5 --- /dev/null +++ b/scss/abstracts/_functions.scss @@ -0,0 +1,27 @@ +// ============================================================================= +// abstracts/_functions.scss +// Pure SCSS helper functions. +// ============================================================================= + +// Convert px value to rem. +@function rem($px, $base: 16) { + @return #{$px / $base}rem; +} + +// Strip units from a value. +@function strip-unit($value) { + @return $value / ($value * 0 + 1); +} + +// Fluid type scale using clamp(). +// Usage: font-size: fluid-type(16px, 24px); +@function fluid-type($min-size, $max-size, $min-vw: 375px, $max-vw: 1280px) { + $min-rem: rem(strip-unit($min-size)); + $max-rem: rem(strip-unit($max-size)); + + @return clamp( + #{$min-rem}, + calc(#{$min-rem} + (#{strip-unit($max-size)} - #{strip-unit($min-size)}) * ((100vw - #{$min-vw}) / (#{strip-unit($max-vw)} - #{strip-unit($min-vw)}))), + #{$max-rem} + ); +} diff --git a/scss/abstracts/_mixins.scss b/scss/abstracts/_mixins.scss new file mode 100644 index 0000000..914adbf --- /dev/null +++ b/scss/abstracts/_mixins.scss @@ -0,0 +1,100 @@ +// ============================================================================= +// abstracts/_mixins.scss +// Responsive helpers and reusable patterns. +// ============================================================================= + +// Bulma-matching breakpoint mixins. +@mixin mobile { + @media screen and (max-width: 768px) { @content; } +} + +@mixin tablet { + @media screen and (min-width: 769px) { @content; } +} + +@mixin tablet-only { + @media screen and (min-width: 769px) and (max-width: 1023px) { @content; } +} + +@mixin desktop { + @media screen and (min-width: 1024px) { @content; } +} + +@mixin widescreen { + @media screen and (min-width: 1216px) { @content; } +} + +// Truncate text to N lines using -webkit-line-clamp. +@mixin line-clamp($lines: 2) { + display: -webkit-box; + -webkit-line-clamp: $lines; + -webkit-box-orient: vertical; + overflow: hidden; +} + +// Visually hidden (accessible). +@mixin visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +// Aspect-ratio box. +@mixin aspect-ratio($w, $h) { + position: relative; + + &::before { + content: ''; + display: block; + padding-top: percentage($h / $w); + } + + > * { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + } +} + +// Focus visible ring (WCAG 2.1). +@mixin focus-ring($color: var(--color-primary)) { + &:focus-visible { + outline: 3px solid $color; + outline-offset: 2px; + } +} + +// Card elevation with hover lift. +@mixin card-lift { + transition: transform var(--transition-base), box-shadow var(--transition-base); + + &:hover { + transform: translateY(-4px); + box-shadow: var(--card-shadow-hover); + } +} + +// Skeleton loading shimmer. +@mixin skeleton-shimmer { + background: linear-gradient( + 90deg, + var(--color-surface) 25%, + rgba(255, 255, 255, 0.5) 50%, + var(--color-surface) 75% + ); + background-size: 200% 100%; + animation: skeleton-shimmer 1.5s infinite; +} + +@keyframes skeleton-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} diff --git a/scss/abstracts/_variables.scss b/scss/abstracts/_variables.scss new file mode 100644 index 0000000..d76da77 --- /dev/null +++ b/scss/abstracts/_variables.scss @@ -0,0 +1,79 @@ +// ============================================================================= +// abstracts/_variables.scss +// CSS Custom Properties + SCSS variables for the 7-1 architecture. +// Bulma 1.x is built on CSS variables, so we map our design tokens here. +// ============================================================================= + +// --- Colour tokens ----------------------------------------------------------- +:root { + --color-primary: #00d1b2; + --color-primary-dark: #00a896; + --color-dark: #363636; + --color-light: #f5f5f5; + --color-white: #ffffff; + --color-danger: #f14668; + --color-warning: #ffe08a; + --color-info: #3e8ed0; + --color-success: #48c78e; + + // Semantic surface tokens (flipped in dark mode). + --color-background: #ffffff; + --color-text: #363636; + --color-surface: #f5f5f5; + --color-border: #dbdbdb; + --color-shadow: rgba(0, 0, 0, 0.1); + + // Navbar + --navbar-bg: rgba(255, 255, 255, 0.85); + --navbar-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04); + + // Card + --card-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + --card-shadow-hover: 0 8px 24px rgba(0, 0, 0, 0.14); + --card-radius: 8px; + + // Typography + --font-family-base: 'Noto Sans SC', system-ui, -apple-system, sans-serif; + --font-size-base: 1rem; + --line-height-base: 1.7; + + // Spacing scale (mirrors theme.json) + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 1rem; + --space-4: 1.5rem; + --space-5: 2rem; + --space-6: 3rem; + --space-7: 4.5rem; + + // Breakpoints (Bulma defaults) + --bp-mobile: 768px; + --bp-tablet: 769px; + --bp-desktop: 1024px; + --bp-widescreen: 1216px; + + // Transitions + --transition-base: 0.3s ease; +} + +// Dark mode overrides via data-attribute or media query. +[data-theme='dark'], +@media (prefers-color-scheme: dark) { + :root { + --color-background: #1a1a2e; + --color-text: #e0e0e0; + --color-surface: #16213e; + --color-border: #444444; + --color-shadow: rgba(0, 0, 0, 0.4); + --navbar-bg: rgba(26, 26, 46, 0.9); + --card-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); + --card-shadow-hover:0 8px 24px rgba(0, 0, 0, 0.5); + } +} + +// SCSS-only convenience aliases (not emitted as CSS). +$primary: #00d1b2 !default; +$primary-dark: #00a896 !default; +$dark: #363636 !default; +$font-base: var(--font-family-base); +$transition: var(--transition-base); diff --git a/scss/base/_reset.scss b/scss/base/_reset.scss new file mode 100644 index 0000000..b651c8a --- /dev/null +++ b/scss/base/_reset.scss @@ -0,0 +1,56 @@ +// ============================================================================= +// base/_reset.scss +// Minimal modern reset on top of Bulma's built-in normalisation. +// ============================================================================= + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; + text-size-adjust: 100%; +} + +body { + background-color: var(--color-background); + color: var(--color-text); + font-family: var(--font-family-base); + font-size: var(--font-size-base); + line-height: var(--line-height-base); + transition: + background-color var(--transition-base), + color var(--transition-base); +} + +img, +video, +svg { + max-width: 100%; + height: auto; +} + +a { + color: var(--color-primary); + text-underline-offset: 0.2em; + + &:hover { + color: var(--color-primary-dark); + } + + @include focus-ring; +} + +button { + cursor: pointer; +} + +// Remove default list style for navigation lists. +nav ul, +nav ol { + list-style: none; + margin: 0; + padding: 0; +} diff --git a/scss/base/_typography.scss b/scss/base/_typography.scss new file mode 100644 index 0000000..57cda39 --- /dev/null +++ b/scss/base/_typography.scss @@ -0,0 +1,58 @@ +// ============================================================================= +// base/_typography.scss +// ============================================================================= + +h1, h2, h3, h4, h5, h6 { + line-height: 1.3; + font-weight: 700; + color: var(--color-dark); +} + +p { + margin-bottom: var(--space-3); +} + +code, +pre { + font-family: 'JetBrains Mono', 'Fira Code', 'Courier New', monospace; + font-size: 0.875em; +} + +pre { + overflow-x: auto; + padding: var(--space-3); + background: var(--color-surface); + border-radius: var(--card-radius); + border-left: 4px solid var(--color-primary); + + code { + background: transparent; + padding: 0; + } +} + +blockquote { + border-left: 4px solid var(--color-primary); + padding-left: var(--space-3); + margin-left: 0; + color: #6b7280; + font-style: italic; +} + +.screen-reader-text { + @include visually-hidden; + + &:focus { + background-color: var(--color-background); + clip: auto; + height: auto; + left: var(--space-3); + top: var(--space-3); + width: auto; + z-index: 100000; + padding: var(--space-2) var(--space-3); + border: 2px solid var(--color-primary); + border-radius: 4px; + font-weight: 600; + } +} diff --git a/scss/components/_button.scss b/scss/components/_button.scss new file mode 100644 index 0000000..79480a1 --- /dev/null +++ b/scss/components/_button.scss @@ -0,0 +1,38 @@ +// ============================================================================= +// components/_button.scss +// ============================================================================= + +// Extend Bulma's .button with our colour tokens. +.button.is-primary { + background-color: var(--color-primary); + border-color: transparent; + color: #fff; + + &:hover { + background-color: var(--color-primary-dark); + } +} + +// Copy-code button on code blocks. +.copy-code-btn { + position: absolute; + top: 0.5rem; + right: 0.5rem; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + color: #e0e0e0; + font-size: 0.75rem; + padding: 0.2rem 0.5rem; + cursor: pointer; + transition: background var(--transition-base); + + &:hover { + background: rgba(255, 255, 255, 0.2); + } + + &.is-copied { + color: var(--color-success); + border-color: var(--color-success); + } +} diff --git a/scss/components/_card.scss b/scss/components/_card.scss new file mode 100644 index 0000000..fbe1ecd --- /dev/null +++ b/scss/components/_card.scss @@ -0,0 +1,168 @@ +// ============================================================================= +// components/_card.scss +// Post card component. +// ============================================================================= + +.post-card { + border-radius: var(--card-radius); + overflow: hidden; + background: var(--color-background); + border: 1px solid var(--color-border); + box-shadow: var(--card-shadow); + margin-bottom: var(--space-4); + transition: + transform var(--transition-base), + box-shadow var(--transition-base); + + &:hover { + transform: translateY(-4px); + box-shadow: var(--card-shadow-hover); + } + + // Featured image wrapper. + &__image { + overflow: hidden; + + img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.5s ease; + } + } + + &:hover &__image img { + transform: scale(1.03); + } + + // Gradient placeholder when no featured image. + &__gradient-placeholder { + width: 100%; + height: 100%; + background: linear-gradient(135deg, var(--color-primary), var(--color-info)); + display: flex; + align-items: center; + justify-content: center; + } + + &__category-initial { + font-size: 2.5rem; + font-weight: 700; + color: rgba(255, 255, 255, 0.6); + } + + // Categories. + &__categories { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); + margin-bottom: var(--space-2); + } + + &__category-badge { + font-size: 0.75rem; + text-decoration: none; + border-radius: 4px; + transition: opacity var(--transition-base); + + &:hover { + opacity: 0.8; + } + } + + // Title. + &__title { + margin-bottom: var(--space-2) !important; + + a { + color: var(--color-text); + text-decoration: none; + transition: color var(--transition-base); + + &:hover { + color: var(--color-primary); + } + } + } + + // Meta row. + &__meta { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + color: #6b7280; + font-size: 0.85rem; + margin-bottom: var(--space-3) !important; + + a { + color: #6b7280; + + &:hover { + color: var(--color-primary); + } + } + + i { + margin-right: 0.25rem; + } + } + + // Footer row. + &__footer { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: var(--space-2); + margin-top: var(--space-3); + } + + // "Continue reading" button arrow animation. + &__read-more { + display: inline-flex; + align-items: center; + gap: 0.25rem; + text-decoration: none; + transition: + background-color var(--transition-base), + color var(--transition-base); + } + + &__read-more-arrow { + display: inline-block; + transition: transform var(--transition-base); + } + + &__read-more:hover &__read-more-arrow { + transform: translateX(4px); + } + + // Hero variant (sticky posts). + &--hero { + .post-card__image { + max-height: 400px; + } + + .post-card__title { + font-size: 1.75rem; + } + } + + // Skeleton loading state. + &--loading { + pointer-events: none; + + .post-card__image, + .post-card__title, + .post-card__meta, + .post-card__content { + border-radius: 4px; + @include skeleton-shimmer; + color: transparent; + } + + .post-card__image { + height: 180px; + } + } +} diff --git a/scss/components/_navigation.scss b/scss/components/_navigation.scss new file mode 100644 index 0000000..2b4869c --- /dev/null +++ b/scss/components/_navigation.scss @@ -0,0 +1,89 @@ +// ============================================================================= +// components/_navigation.scss +// Bulma navbar customisation + mobile dropdown corrections. +// ============================================================================= + +// Navbar item links. +.navbar-item, +.navbar-link { + color: var(--color-text); + transition: color var(--transition-base), background-color var(--transition-base); + + &:hover, + &:focus-visible { + background-color: transparent; + color: var(--color-primary); + } +} + +// Dropdown. +.navbar-dropdown { + background: var(--color-background); + border: 1px solid var(--color-border); + border-top: none; + border-radius: 0 0 8px 8px; + box-shadow: var(--card-shadow); + + .navbar-item { + padding: 0.5rem 1rem; + + &:hover { + background-color: var(--color-surface); + } + } + + .navbar-divider { + background-color: var(--color-border); + } +} + +// Breadcrumb. +.breadcrumb { + margin: var(--space-3) 0; + font-size: 0.9rem; + + a { + color: var(--color-text); + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 0.25rem; + transition: color var(--transition-base); + + &:hover { + color: var(--color-primary); + } + } + + li.is-active a { + color: #6b7280; + } +} + +// Post navigation. +.nav-links { + .nav-previous, + .nav-next { + a { + display: inline-flex; + flex-direction: column; + text-decoration: none; + max-width: 300px; + color: var(--color-text); + transition: color var(--transition-base); + + &:hover { + color: var(--color-primary); + } + } + } + + .nav-subtitle { + font-size: 0.8rem; + color: #6b7280; + } + + .nav-title { + font-weight: 600; + } +} diff --git a/scss/components/_toc.scss b/scss/components/_toc.scss new file mode 100644 index 0000000..a0d1a6d --- /dev/null +++ b/scss/components/_toc.scss @@ -0,0 +1,66 @@ +// ============================================================================= +// components/_toc.scss +// Article Table of Contents sidebar widget. +// ============================================================================= + +.toc { + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: var(--card-radius); + position: sticky; + top: 80px; + max-height: calc(100vh - 120px); + overflow-y: auto; + scrollbar-width: thin; + + .card-header { + background: transparent; + border-bottom: 1px solid var(--color-border); + } + + .card-header-title { + font-size: 0.9rem; + color: var(--color-text); + } + + .card-content { + padding: var(--space-2); + } +} + +#toc-list { + ol { + list-style: decimal; + padding-left: var(--space-4); + margin: 0; + } + + li { + margin-bottom: var(--space-2); + font-size: 0.85rem; + } + + a { + color: var(--color-text); + text-decoration: none; + line-height: 1.4; + transition: color var(--transition-base); + display: block; + + &:hover, + &.toc-active { + color: var(--color-primary); + } + + &.toc-active { + font-weight: 600; + } + } + + // Second-level (h3). + .toc-level-3 { + padding-left: var(--space-3); + list-style: disc; + color: #6b7280; + } +} diff --git a/scss/layout/_footer.scss b/scss/layout/_footer.scss new file mode 100644 index 0000000..fa6823a --- /dev/null +++ b/scss/layout/_footer.scss @@ -0,0 +1,106 @@ +// ============================================================================= +// layout/_footer.scss +// ============================================================================= + +.site-footer { + margin-top: var(--space-6); + background-color: var(--color-surface); + border-top: 1px solid var(--color-border); + padding-top: var(--space-5); +} + +.footer-site-name { + font-size: 1.25rem; + font-weight: 700; + margin-bottom: var(--space-2); + + a { + color: var(--color-text); + text-decoration: none; + + &:hover { + color: var(--color-primary); + } + } +} + +.footer-site-description { + color: #6b7280; + font-size: 0.875rem; + line-height: 1.5; + margin-bottom: var(--space-3); +} + +.footer-menu-list { + list-style: none; + margin: 0; + padding: 0; + + li { + margin-bottom: var(--space-2); + } + + a { + color: var(--color-text); + text-decoration: none; + transition: color var(--transition-base); + + &:hover { + color: var(--color-primary); + } + } +} + +.social-menu-list { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + list-style: none; + margin: 0; + padding: 0; + + a { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.25rem; + height: 2.25rem; + border-radius: 50%; + background-color: var(--color-border); + color: var(--color-text); + transition: + background-color var(--transition-base), + color var(--transition-base); + + &:hover { + background-color: var(--color-primary); + color: #fff; + } + } +} + +.footer-widget-title { + font-size: 1rem; + font-weight: 700; + margin-bottom: var(--space-3); + color: var(--color-text); +} + +.footer-bottom { + margin-top: var(--space-5); + padding: var(--space-3) 0; + border-top: 1px solid var(--color-border); +} + +.footer-copyright { + color: #6b7280; + font-size: 0.875rem; + + a { + color: var(--color-text); + + &:hover { + color: var(--color-primary); + } + } +} diff --git a/scss/layout/_grid.scss b/scss/layout/_grid.scss new file mode 100644 index 0000000..64c9acf --- /dev/null +++ b/scss/layout/_grid.scss @@ -0,0 +1,66 @@ +// ============================================================================= +// layout/_grid.scss +// Site-wide layout helpers. +// ============================================================================= + +.site-content { + padding-top: var(--space-4); + padding-bottom: var(--space-6); +} + +// Primary content area + sidebar layout. +.content-area { + @include desktop { + display: flex; + gap: var(--space-5); + + #main { + flex: 1; + min-width: 0; // Prevent flex blowout. + } + } +} + +// Responsive post grid. +.posts-grid { + margin-bottom: var(--space-4); +} + +// Pagination. +.posts-pagination { + margin-top: var(--space-4); + display: flex; + justify-content: center; + + .nav-links { + display: flex; + gap: var(--space-2); + flex-wrap: wrap; + justify-content: center; + } + + a, + span { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2.25rem; + height: 2.25rem; + padding: 0 var(--space-2); + border: 1px solid var(--color-border); + border-radius: 6px; + color: var(--color-text); + text-decoration: none; + transition: + background-color var(--transition-base), + color var(--transition-base), + border-color var(--transition-base); + + &:hover, + &.current { + background-color: var(--color-primary); + color: #fff; + border-color: var(--color-primary); + } + } +} diff --git a/scss/layout/_header.scss b/scss/layout/_header.scss new file mode 100644 index 0000000..514a387 --- /dev/null +++ b/scss/layout/_header.scss @@ -0,0 +1,192 @@ +// ============================================================================= +// layout/_header.scss +// ============================================================================= + +.site-navbar { + position: sticky; + top: 0; + z-index: 100; + background-color: var(--navbar-bg); + box-shadow: var(--navbar-shadow); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + transition: box-shadow var(--transition-base), background-color var(--transition-base); + + &.is-scrolled { + box-shadow: 0 4px 12px var(--color-shadow); + } +} + +// Logo area. +.navbar-logo { + padding: 0.5rem 0.75rem; + + img { + max-height: 2.5rem; + width: auto; + } +} + +.navbar-site-name { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 700; + color: var(--color-dark) !important; + white-space: nowrap; +} + +.site-title-initial { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 50%; + background-color: var(--color-primary); + color: #fff; + font-weight: 700; + font-size: 1rem; +} + +// Hamburger burger – three-line → X animation. +.navbar-burger { + background: transparent; + border: none; + padding: 0.5rem; + transition: opacity var(--transition-base); + + span { + display: block; + width: 1.25rem; + height: 2px; + background: var(--color-text); + margin: 4px 0; + transition: + transform 0.3s ease, + opacity 0.3s ease; + transform-origin: center; + } + + &.is-active span:nth-child(1) { transform: translateY(6px) rotate(45deg); } + &.is-active span:nth-child(2) { opacity: 0; } + &.is-active span:nth-child(3) { transform: translateY(-6px) rotate(-45deg); } +} + +// Search widget. +.navbar-search-wrap { + position: relative; +} + +.navbar-search-toggle { + background: transparent; + border: none; + color: var(--color-text); + font-size: 1rem; + padding: 0.25rem 0.5rem; + transition: color var(--transition-base); + + &:hover { + color: var(--color-primary); + } + + @include focus-ring; +} + +.navbar-search-form { + position: absolute; + top: 110%; + right: 0; + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: 6px; + padding: 0.5rem; + box-shadow: var(--card-shadow); + min-width: 200px; + + .input { + border-color: var(--color-border); + background: var(--color-background); + color: var(--color-text); + } +} + +// Dark-mode toggle. +.theme-toggle { + background: transparent; + border: none; + font-size: 1rem; + color: var(--color-text); + padding: 0.25rem 0.5rem; + transition: color var(--transition-base); + line-height: 1; + + &:hover { + color: var(--color-primary); + } + + @include focus-ring; +} + +// Show only the relevant icon. +body:not([data-theme='dark']) .theme-toggle__light-icon { display: none; } +[data-theme='dark'] .theme-toggle__dark-icon { display: none; } + +// Back-to-top button. +.back-to-top { + position: fixed; + bottom: 2rem; + right: 2rem; + z-index: 200; + width: 3rem; + height: 3rem; + border-radius: 50%; + background-color: var(--color-primary); + color: #fff; + border: none; + font-size: 1.25rem; + display: flex; + align-items: center; + justify-content: center; + box-shadow: var(--card-shadow); + transition: + opacity var(--transition-base), + transform var(--transition-base), + box-shadow var(--transition-base); + opacity: 0; + transform: translateY(1rem); + pointer-events: none; + + &.is-visible { + opacity: 1; + transform: translateY(0); + pointer-events: auto; + } + + &:hover { + box-shadow: var(--card-shadow-hover); + } + + @include focus-ring; +} + +// Skip link. +.skip-link { + @include visually-hidden; + + &:focus { + position: fixed; + top: var(--space-2); + left: var(--space-2); + z-index: 9999; + background: var(--color-primary); + color: #fff; + padding: var(--space-2) var(--space-3); + border-radius: 4px; + clip: auto; + height: auto; + width: auto; + font-weight: 700; + text-decoration: none; + } +} diff --git a/scss/layout/_sidebar.scss b/scss/layout/_sidebar.scss new file mode 100644 index 0000000..7102581 --- /dev/null +++ b/scss/layout/_sidebar.scss @@ -0,0 +1,26 @@ +// ============================================================================= +// layout/_sidebar.scss +// ============================================================================= + +.widget-area { + .widget { + border-radius: var(--card-radius); + overflow: hidden; + + .card-content { + padding-top: var(--space-3); + } + } +} + +// Sticky sidebar on desktop. +@include desktop { + #secondary.widget-area { + position: sticky; + top: calc(var(--space-5) + 60px); // 60px = approx navbar height. + align-self: flex-start; + max-height: calc(100vh - 100px); + overflow-y: auto; + scrollbar-width: thin; + } +} diff --git a/scss/main.scss b/scss/main.scss new file mode 100644 index 0000000..5b76df2 --- /dev/null +++ b/scss/main.scss @@ -0,0 +1,35 @@ +// ============================================================================= +// main.scss – CyyWordpress 7-1 SCSS entry point +// ============================================================================= + +// 1. Abstracts (no CSS output, just variables / mixins / functions) +@use 'abstracts/variables'; +@use 'abstracts/mixins' as *; +@use 'abstracts/functions'; + +// 2. Third-party framework (selective Bulma import) +@use 'vendors/bulma'; + +// 3. Base styles +@use 'base/reset'; +@use 'base/typography'; + +// 4. Layout shells +@use 'layout/grid'; +@use 'layout/header'; +@use 'layout/footer'; +@use 'layout/sidebar'; + +// 5. Reusable components +@use 'components/card'; +@use 'components/navigation'; +@use 'components/button'; +@use 'components/toc'; + +// 6. Page-specific +@use 'pages/home'; +@use 'pages/single'; +@use 'pages/archive'; + +// 7. Themes (dark mode overrides) +@use 'themes/dark'; diff --git a/scss/pages/_archive.scss b/scss/pages/_archive.scss new file mode 100644 index 0000000..54aa359 --- /dev/null +++ b/scss/pages/_archive.scss @@ -0,0 +1,18 @@ +// ============================================================================= +// pages/_archive.scss +// ============================================================================= + +.archive-header { + margin-bottom: var(--space-4); + padding-bottom: var(--space-3); + border-bottom: 1px solid var(--color-border); + + .page-title { + font-size: 1.75rem; + font-weight: 700; + } + + .archive-description { + color: #6b7280; + } +} diff --git a/scss/pages/_home.scss b/scss/pages/_home.scss new file mode 100644 index 0000000..4f47f14 --- /dev/null +++ b/scss/pages/_home.scss @@ -0,0 +1,8 @@ +// ============================================================================= +// pages/_home.scss +// ============================================================================= + +.welcome-banner { + margin-bottom: var(--space-3); + border-radius: var(--card-radius); +} diff --git a/scss/pages/_single.scss b/scss/pages/_single.scss new file mode 100644 index 0000000..41a6cad --- /dev/null +++ b/scss/pages/_single.scss @@ -0,0 +1,181 @@ +// ============================================================================= +// pages/_single.scss +// ============================================================================= + +// Reading progress bar. +.reading-progress { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 3px; + z-index: 9999; + background: transparent; + pointer-events: none; + + &__bar { + height: 100%; + width: 0; + background: var(--color-primary); + transition: width 0.1s linear; + } +} + +// Hero featured image. +.single-hero { + position: relative; + width: 100%; + max-height: 450px; + overflow: hidden; + border-radius: var(--card-radius); + margin-bottom: var(--space-4); + + &__image { + width: 100%; + height: 450px; + object-fit: cover; + display: block; + loading: eager; + } + + &__overlay { + position: absolute; + inset: 0; + background: linear-gradient(to top, rgba(0, 0, 0, 0.65) 0%, transparent 60%); + display: flex; + align-items: flex-end; + padding: var(--space-5); + } + + &__title { + color: #fff; + font-size: clamp(1.5rem, 4vw, 2.5rem); + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + margin: 0; + } +} + +// Single article layout. +.single-layout { + align-items: flex-start; +} + +// Social share. +.social-share { + display: flex; + align-items: center; + gap: var(--space-2); + flex-wrap: wrap; + margin: var(--space-4) 0; + padding: var(--space-3); + background: var(--color-surface); + border-radius: var(--card-radius); + border: 1px solid var(--color-border); + + &__label { + font-size: 0.875rem; + color: #6b7280; + } + + &__btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.25rem; + height: 2.25rem; + border-radius: 50%; + background: var(--color-border); + color: var(--color-text); + border: none; + font-size: 1rem; + text-decoration: none; + transition: + background-color var(--transition-base), + color var(--transition-base); + + &:hover { + background-color: var(--color-primary); + color: #fff; + } + + &--weibo:hover { background-color: #e6162d; } + &--twitter:hover { background-color: #000; } + } +} + +// Author bio. +.author-bio { + margin: var(--space-4) 0; + border-radius: var(--card-radius); + border: 1px solid var(--color-border); + + .media { + align-items: flex-start; + } + + &__avatar { + border-radius: 50%; + } + + &__name { + display: block; + color: var(--color-text); + text-decoration: none; + margin-bottom: var(--space-2); + + &:hover { color: var(--color-primary); } + } + + &__description { + font-size: 0.9rem; + color: #6b7280; + line-height: 1.6; + } +} + +// Related posts. +.related-posts { + margin: var(--space-4) 0; + + &__title { + margin-bottom: var(--space-3); + } +} + +.related-post-card { + height: 100%; + border-radius: var(--card-radius); + border: 1px solid var(--color-border); + overflow: hidden; + transition: + transform var(--transition-base), + box-shadow var(--transition-base); + + &:hover { + transform: translateY(-2px); + box-shadow: var(--card-shadow-hover); + } + + &__image { + width: 100%; + height: 100%; + object-fit: cover; + } + + &__title { + display: block; + font-size: 0.9rem; + font-weight: 600; + color: var(--color-text); + text-decoration: none; + line-height: 1.4; + + &:hover { color: var(--color-primary); } + } +} + +// Code block copy button wrapper. +.entry-content pre, +.post-card__content pre { + position: relative; +} diff --git a/scss/themes/_dark.scss b/scss/themes/_dark.scss new file mode 100644 index 0000000..d9bb364 --- /dev/null +++ b/scss/themes/_dark.scss @@ -0,0 +1,42 @@ +// ============================================================================= +// themes/_dark.scss +// Dark mode – extra overrides beyond CSS custom properties. +// ============================================================================= + +[data-theme='dark'], +@media (prefers-color-scheme: dark) { + // Bulma component overrides. + .navbar { + background-color: var(--navbar-bg); + border-bottom: 1px solid var(--color-border); + } + + .card { + background-color: var(--color-surface); + border-color: var(--color-border); + } + + .notification.is-primary { + background-color: rgba(0, 209, 178, 0.15); + color: #00d1b2; + } + + .input, + .select select, + .textarea { + background-color: var(--color-surface); + border-color: var(--color-border); + color: var(--color-text); + } + + .footer { + background-color: var(--color-surface); + } + + // Code blocks. + pre, + code { + background-color: #0d1117; + color: #e6edf3; + } +} diff --git a/scss/vendors/_bulma.scss b/scss/vendors/_bulma.scss new file mode 100644 index 0000000..99b5700 --- /dev/null +++ b/scss/vendors/_bulma.scss @@ -0,0 +1,10 @@ +// ============================================================================= +// vendors/_bulma.scss +// Selective Bulma 1.x import – only import what the theme uses. +// Bulma 1.x exposes sass/ subdirectory; each module uses @use. +// ============================================================================= +@use 'bulma/sass' with ( + $primary: #00d1b2, + $dark: #363636, + $light: #f5f5f5 +); diff --git a/sidebar.php b/sidebar.php index 96ceea9..0b79907 100644 --- a/sidebar.php +++ b/sidebar.php @@ -1,6 +1,9 @@ <?php /** - * The sidebar containing the main widget area + * The sidebar containing the main widget area. + * + * Fixed: each widget is wrapped by the correct Bulma card structure + * registered in functions.php (before_widget / after_widget). * * @link https://developer.wordpress.org/themes/basics/template-files/#template-partials * @@ -12,7 +15,7 @@ if ( ! is_active_sidebar( 'sidebar-1' ) ) { } ?> -<div class="columns" style="margin-bottom: 10px;"> - <?php dynamic_sidebar( 'sidebar-1' ); ?> +<aside id="secondary" class="widget-area column is-3" role="complementary" aria-label="<?php esc_attr_e( '侧边栏', 'cyywordpress' ); ?>"> + <?php dynamic_sidebar( 'sidebar-1' ); ?> +</aside><!-- #secondary --> -</div> diff --git a/single.php b/single.php index 5530ced..48fe98d 100644 --- a/single.php +++ b/single.php @@ -1,6 +1,15 @@ <?php /** - * The template for displaying all single posts + * The template for displaying single posts + * + * Features: + * – Full-width hero featured image with title overlay + * – Reading progress bar (filled by reading-progress.ts) + * – Auto-generated TOC (filled by toc.ts) + * – Author bio card + * – Related posts (same category) + * – Social share buttons (no third-party SDK) + * – Breadcrumb, prev/next navigation, comments * * @link https://developer.wordpress.org/themes/basics/template-hierarchy/#single-post * @@ -9,56 +18,224 @@ get_header(); ?> - <br> - <div class="notification is-primary"> - 欢迎来到这! <strong>Hello everyone.</strong> - </div> - <nav class="breadcrumb" aria-label="breadcrumbs"> - <ul> - <li> - <a href="<?php echo esc_url( home_url( '/' ) ); ?>" > - <span class="icon is-small"> - <i class="fas fa-home" aria-hidden="true"></i> - </span> - <span>首页</span> - </a> - </li> - <li> - <a href="#"> - <span class="icon is-small"> - <i class="fas fa-book" aria-hidden="true"></i> - </span> - <span>文章页</span> - </a> - </li> - </ul> - </nav> +<!-- Reading progress bar --> +<div id="reading-progress" class="reading-progress" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" aria-label="<?php esc_attr_e( '阅读进度', 'cyywordpress' ); ?>"> + <div class="reading-progress__bar"></div> +</div> - <main id="primary" class="site-main"> +<div id="primary" class="content-area"> + <main id="main" class="site-main" role="main"> + + <nav class="breadcrumb" aria-label="<?php esc_attr_e( '面包屑导航', 'cyywordpress' ); ?>"> + <ul> + <li> + <a href="<?php echo esc_url( home_url( '/' ) ); ?>"> + <span class="icon is-small"><i class="fa-solid fa-house" aria-hidden="true"></i></span> + <span><?php esc_html_e( '首页', 'cyywordpress' ); ?></span> + </a> + </li> + <li class="is-active"> + <a href="#" aria-current="page"> + <span class="icon is-small"><i class="fa-solid fa-file-lines" aria-hidden="true"></i></span> + <span><?php esc_html_e( '文章', 'cyywordpress' ); ?></span> + </a> + </li> + </ul> + </nav> <?php while ( have_posts() ) : the_post(); + ?> - get_template_part( 'template-parts/content', get_post_type() ); + <?php if ( has_post_thumbnail() ) : ?> + <div class="single-hero"> + <?php + the_post_thumbnail( + 'full', + array( + 'class' => 'single-hero__image', + 'loading' => 'eager', + 'decoding'=> 'async', + 'alt' => the_title_attribute( array( 'echo' => false ) ), + ) + ); + ?> + <div class="single-hero__overlay"> + <?php the_title( '<h1 class="single-hero__title">', '</h1>' ); ?> + </div> + </div> + <?php endif; ?> - the_post_navigation( - array( - 'prev_text' => '<span class="nav-subtitle">' . esc_html__( '上一篇:', 'cyywordpress' ) . '</span> <span class="nav-title">%title</span>', - 'next_text' => '<span class="nav-subtitle">' . esc_html__( '下一篇:', 'cyywordpress' ) . '</span> <span class="nav-title">%title</span>', - ) - ); + <div class="columns single-layout"> + <!-- TOC sidebar (populated by toc.ts) --> + <aside class="column is-3-desktop is-hidden-touch single-toc" aria-label="<?php esc_attr_e( '目录', 'cyywordpress' ); ?>"> + <div id="toc" class="toc card"> + <div class="card-header"> + <p class="card-header-title"> + <i class="fa-solid fa-list" aria-hidden="true"></i> +  <?php esc_html_e( '目录', 'cyywordpress' ); ?> + </p> + </div> + <div class="card-content" id="toc-list"></div> + </div> + </aside> - // If comments are open or we have at least one comment, load up the comment template. - if ( comments_open() || get_comments_number() ) : - comments_template(); - endif; + <!-- Main article column --> + <div class="column is-9-desktop single-article"> + <?php get_template_part( 'template-parts/content', get_post_type() ); ?> - endwhile; // End of the loop. - ?> + <!-- Social share --> + <div class="social-share"> + <span class="social-share__label"><?php esc_html_e( '分享:', 'cyywordpress' ); ?></span> + <a + class="social-share__btn social-share__btn--weibo" + href="<?php echo esc_url( 'https://service.weibo.com/share/share.php?url=' . rawurlencode( get_permalink() ) . '&title=' . rawurlencode( get_the_title() ) ); ?>" + target="_blank" + rel="noopener noreferrer" + aria-label="<?php esc_attr_e( '分享到微博', 'cyywordpress' ); ?>" + > + <i class="fa-brands fa-weibo" aria-hidden="true"></i> + </a> + <a + class="social-share__btn social-share__btn--twitter" + href="<?php echo esc_url( 'https://twitter.com/intent/tweet?url=' . rawurlencode( get_permalink() ) . '&text=' . rawurlencode( get_the_title() ) ); ?>" + target="_blank" + rel="noopener noreferrer" + aria-label="<?php esc_attr_e( '分享到 Twitter', 'cyywordpress' ); ?>" + > + <i class="fa-brands fa-x-twitter" aria-hidden="true"></i> + </a> + <button + class="social-share__btn social-share__btn--copy" + data-url="<?php echo esc_attr( get_permalink() ); ?>" + aria-label="<?php esc_attr_e( '复制链接', 'cyywordpress' ); ?>" + > + <i class="fa-solid fa-link" aria-hidden="true"></i> + </button> + </div> + + <!-- Post navigation --> + <?php + the_post_navigation( + array( + 'prev_text' => '<span class="nav-subtitle"><i class="fa-solid fa-chevron-left" aria-hidden="true"></i> ' . esc_html__( '上一篇', 'cyywordpress' ) . '</span><span class="nav-title">%title</span>', + 'next_text' => '<span class="nav-subtitle">' . esc_html__( '下一篇', 'cyywordpress' ) . ' <i class="fa-solid fa-chevron-right" aria-hidden="true"></i></span><span class="nav-title">%title</span>', + ) + ); + ?> + + <!-- Author bio --> + <?php + $author_id = (int) get_the_author_meta( 'ID' ); + $author_bio = get_the_author_meta( 'description' ); + if ( $author_bio ) : + ?> + <div class="author-bio card"> + <div class="card-content"> + <div class="media"> + <div class="media-left"> + <?php + echo get_avatar( + $author_id, + 80, + '', + get_the_author(), + array( + 'class' => 'author-bio__avatar', + 'loading' => 'lazy', + ) + ); + ?> + </div> + <div class="media-content"> + <a class="author-bio__name title is-5" href="<?php echo esc_url( get_author_posts_url( $author_id ) ); ?>"> + <?php echo esc_html( get_the_author() ); ?> + </a> + <p class="author-bio__description"><?php echo esc_html( $author_bio ); ?></p> + </div> + </div> + </div> + </div><!-- .author-bio --> + <?php endif; ?> + + <!-- Related posts (same first category) --> + <?php + $cats = get_the_category(); + if ( $cats ) : + $related = get_posts( + array( + 'category__in' => array( $cats[0]->term_id ), + 'post__not_in' => array( get_the_ID() ), + 'posts_per_page' => 3, + 'orderby' => 'rand', + ) + ); + + if ( $related ) : + ?> + <div class="related-posts"> + <h3 class="related-posts__title title is-5"> + <?php esc_html_e( '相关文章', 'cyywordpress' ); ?> + </h3> + <div class="columns is-multiline"> + <?php + foreach ( $related as $related_post ) : + setup_postdata( $related_post ); + ?> + <div class="column is-4"> + <div class="card related-post-card"> + <?php if ( has_post_thumbnail( $related_post ) ) : ?> + <div class="card-image"> + <figure class="image is-16by9"> + <?php + echo get_the_post_thumbnail( + $related_post, + 'card-thumbnail-sm', + array( + 'loading' => 'lazy', + 'decoding' => 'async', + 'class' => 'related-post-card__image', + 'alt' => esc_attr( get_the_title( $related_post ) ), + ) + ); + ?> + </figure> + </div> + <?php endif; ?> + <div class="card-content"> + <a class="related-post-card__title" href="<?php echo esc_url( get_permalink( $related_post ) ); ?>"> + <?php echo esc_html( get_the_title( $related_post ) ); ?> + </a> + </div> + </div> + </div> + <?php + endforeach; + wp_reset_postdata(); + ?> + </div><!-- .columns --> + </div><!-- .related-posts --> + <?php endif; ?> + <?php endif; ?> + + <!-- Comments --> + <?php + if ( comments_open() || get_comments_number() ) : + comments_template(); + endif; + ?> + + </div><!-- .single-article --> + </div><!-- .columns.single-layout --> + + <?php endwhile; ?> </main><!-- #main --> +</div><!-- #primary --> + <?php get_sidebar(); get_footer(); + diff --git a/src/admin/customizer.ts b/src/admin/customizer.ts new file mode 100644 index 0000000..4f85076 --- /dev/null +++ b/src/admin/customizer.ts @@ -0,0 +1,45 @@ +/** + * customizer.ts (replaces js/customizer.js) + * Binds postMessage handlers for the WordPress Customizer live preview. + */ + +declare const wp: { + customize: { + bind( event: string, callback: () => void ): void; + ( settingId: string, callback: ( value: { get(): string; bind( cb: ( v: string ) => void ): void } ) => void ): void; + }; +}; + +( function () { + 'use strict'; + + wp.customize( 'blogname', ( value ) => { + value.bind( ( to: string ) => { + const el = document.querySelector<HTMLElement>( '.site-title a' ); + if ( el ) el.textContent = to; + } ); + } ); + + wp.customize( 'blogdescription', ( value ) => { + value.bind( ( to: string ) => { + const el = document.querySelector<HTMLElement>( '.site-description' ); + if ( el ) el.textContent = to; + } ); + } ); + + wp.customize( 'header_textcolor', ( value ) => { + value.bind( ( to: string ) => { + const els = document.querySelectorAll<HTMLElement>( '.site-title a, .site-description' ); + els.forEach( ( el ) => { + el.style.color = to === 'blank' ? 'transparent' : to; + } ); + } ); + } ); + + wp.customize( 'cyywordpress_welcome_text', ( value ) => { + value.bind( ( to: string ) => { + const el = document.querySelector<HTMLElement>( '.welcome-banner' ); + if ( el ) el.textContent = to; + } ); + } ); +} )(); diff --git a/src/ts/back-to-top.ts b/src/ts/back-to-top.ts new file mode 100644 index 0000000..37fd041 --- /dev/null +++ b/src/ts/back-to-top.ts @@ -0,0 +1,27 @@ +/** + * back-to-top.ts + * Shows a back-to-top button after scrolling 300 px; + * scrolls to top using smooth behaviour on click. + */ + +const SCROLL_THRESHOLD = 300; + +export function initBackToTop(): void { + const btn = document.getElementById( 'back-to-top' ) as HTMLButtonElement | null; + if ( ! btn ) return; + + // Remove the `hidden` attribute so CSS can control visibility via `.is-visible`. + btn.removeAttribute( 'hidden' ); + + const onScroll = (): void => { + btn.classList.toggle( 'is-visible', window.scrollY > SCROLL_THRESHOLD ); + }; + + window.addEventListener( 'scroll', onScroll, { passive: true } ); + onScroll(); + + btn.addEventListener( 'click', () => { + window.scrollTo( { top: 0, behavior: 'smooth' } ); + btn.blur(); + } ); +} diff --git a/src/ts/copy-code.ts b/src/ts/copy-code.ts new file mode 100644 index 0000000..c5ac7a0 --- /dev/null +++ b/src/ts/copy-code.ts @@ -0,0 +1,44 @@ +/** + * copy-code.ts + * Adds a "Copy" button to every <pre><code> block in the article content. + */ + +export function initCopyCode(): void { + const blocks = document.querySelectorAll<HTMLPreElement>( + '.entry-content pre, .post-card__content pre, .single-article pre' + ); + + if ( ! blocks.length ) return; + + blocks.forEach( ( pre ) => { + // Avoid adding duplicate buttons. + if ( pre.querySelector( '.copy-code-btn' ) ) return; + + const btn = document.createElement( 'button' ); + btn.className = 'copy-code-btn'; + btn.textContent = '复制'; + btn.setAttribute( 'aria-label', '复制代码' ); + btn.type = 'button'; + + btn.addEventListener( 'click', async () => { + const code = pre.querySelector( 'code' ); + const text = code?.textContent ?? pre.textContent ?? ''; + + try { + await navigator.clipboard.writeText( text ); + btn.textContent = '已复制!'; + btn.classList.add( 'is-copied' ); + setTimeout( () => { + btn.textContent = '复制'; + btn.classList.remove( 'is-copied' ); + }, 2000 ); + } catch { + btn.textContent = '失败'; + setTimeout( () => { btn.textContent = '复制'; }, 2000 ); + } + } ); + + pre.style.position = 'relative'; + pre.appendChild( btn ); + } ); +} diff --git a/src/ts/lazy-images.ts b/src/ts/lazy-images.ts new file mode 100644 index 0000000..e00b965 --- /dev/null +++ b/src/ts/lazy-images.ts @@ -0,0 +1,40 @@ +/** + * lazy-images.ts + * Custom lazy-loading via IntersectionObserver for images + * that use data-src / data-srcset fallback (legacy browsers or pre-rendered markup). + * Native `loading="lazy"` is used for all new images via PHP; + * this module handles any remaining cases. + */ + +export function initLazyImages(): void { + if ( ! ( 'IntersectionObserver' in window ) ) return; + + const images = document.querySelectorAll<HTMLImageElement>( 'img[data-src]' ); + if ( ! images.length ) return; + + const observer = new IntersectionObserver( + ( entries, obs ) => { + entries.forEach( ( entry ) => { + if ( ! entry.isIntersecting ) return; + + const img = entry.target as HTMLImageElement; + + if ( img.dataset.src ) { + img.src = img.dataset.src; + delete img.dataset.src; + } + + if ( img.dataset.srcset ) { + img.srcset = img.dataset.srcset; + delete img.dataset.srcset; + } + + img.classList.add( 'is-loaded' ); + obs.unobserve( img ); + } ); + }, + { rootMargin: '200px 0px' } + ); + + images.forEach( ( img ) => observer.observe( img ) ); +} diff --git a/src/ts/main.ts b/src/ts/main.ts new file mode 100644 index 0000000..2b42be0 --- /dev/null +++ b/src/ts/main.ts @@ -0,0 +1,51 @@ +/** + * main.ts + * Entry point – conditionally imports each module based on the page context. + * + * Import the SCSS entry point here so Vite bundles both together. + */ +import '@scss/main.scss'; + +import { initNavigation } from './navigation'; +import { initThemeToggle } from './theme-toggle'; +import { initBackToTop } from './back-to-top'; +import { initLazyImages } from './lazy-images'; + +// Single-post-only modules (guard by DOM presence to keep bundles lean). +async function loadSingleModules(): Promise<void> { + if ( ! document.querySelector( '.single-article' ) ) return; + + const [ { initReadingProgress }, { initToc }, { initCopyCode } ] = await Promise.all( [ + import( './reading-progress' ), + import( './toc' ), + import( './copy-code' ), + ] ); + + initReadingProgress(); + initToc(); + initCopyCode(); + + // Social share – copy link button. + const copyLinkBtns = document.querySelectorAll<HTMLButtonElement>( '.social-share__btn--copy' ); + copyLinkBtns.forEach( ( btn ) => { + btn.addEventListener( 'click', async () => { + const url = btn.dataset.url ?? window.location.href; + try { + await navigator.clipboard.writeText( url ); + btn.setAttribute( 'aria-label', '链接已复制!' ); + setTimeout( () => btn.setAttribute( 'aria-label', '复制链接' ), 2000 ); + } catch { + // Clipboard API unavailable – silent fail. + } + } ); + } ); +} + +document.addEventListener( 'DOMContentLoaded', () => { + // Apply saved theme before paint to avoid FOUC (also done inline in theme-toggle). + initThemeToggle(); + initNavigation(); + initBackToTop(); + initLazyImages(); + void loadSingleModules(); +} ); diff --git a/src/ts/navigation.ts b/src/ts/navigation.ts new file mode 100644 index 0000000..da24801 --- /dev/null +++ b/src/ts/navigation.ts @@ -0,0 +1,97 @@ +/** + * navigation.ts + * Handles the Bulma navbar burger toggle + keyboard nav for dropdowns. + */ + +interface NavbarBurger extends HTMLElement { + dataset: DOMStringMap & { target?: string }; +} + +export function initNavigation(): void { + // Hamburger burger toggle. + const burgers = document.querySelectorAll<NavbarBurger>( '.navbar-burger' ); + + burgers.forEach( ( burger ) => { + burger.addEventListener( 'click', () => { + const targetId = burger.dataset.target ?? burger.getAttribute( 'aria-controls' ) ?? ''; + const menu = document.getElementById( targetId ); + + if ( ! menu ) return; + + const isExpanded = burger.getAttribute( 'aria-expanded' ) === 'true'; + burger.setAttribute( 'aria-expanded', String( ! isExpanded ) ); + burger.classList.toggle( 'is-active' ); + menu.classList.toggle( 'is-active' ); + } ); + } ); + + // Keyboard support for dropdown menus (Tab / Escape). + const dropdowns = document.querySelectorAll<HTMLElement>( '.has-dropdown' ); + + dropdowns.forEach( ( dropdown ) => { + const trigger = dropdown.querySelector<HTMLElement>( '.navbar-link' ); + const menu = dropdown.querySelector<HTMLElement>( '.navbar-dropdown' ); + + if ( ! trigger || ! menu ) return; + + // Close when Escape is pressed inside the dropdown. + dropdown.addEventListener( 'keydown', ( e: KeyboardEvent ) => { + if ( e.key === 'Escape' ) { + trigger.focus(); + dropdown.classList.remove( 'is-active' ); + } + } ); + + // Close when focus leaves the dropdown. + dropdown.addEventListener( 'focusout', ( e: FocusEvent ) => { + if ( ! dropdown.contains( e.relatedTarget as Node ) ) { + dropdown.classList.remove( 'is-active' ); + } + } ); + + trigger.addEventListener( 'keydown', ( e: KeyboardEvent ) => { + if ( e.key === 'Enter' || e.key === ' ' ) { + e.preventDefault(); + dropdown.classList.toggle( 'is-active' ); + } + } ); + } ); + + // Sticky navbar scroll class. + const navbar = document.querySelector<HTMLElement>( '.site-navbar' ); + + if ( navbar ) { + const onScroll = (): void => { + navbar.classList.toggle( 'is-scrolled', window.scrollY > 10 ); + }; + + window.addEventListener( 'scroll', onScroll, { passive: true } ); + onScroll(); + } + + // Navbar search toggle. + const searchToggle = document.querySelector<HTMLButtonElement>( '.navbar-search-toggle' ); + const searchForm = document.getElementById( 'navbar-search-form' ); + + if ( searchToggle && searchForm ) { + searchToggle.addEventListener( 'click', () => { + const isHidden = searchForm.hidden; + searchForm.hidden = ! isHidden; + searchToggle.setAttribute( 'aria-expanded', String( isHidden ) ); + + if ( isHidden ) { + const input = searchForm.querySelector<HTMLInputElement>( 'input' ); + input?.focus(); + } + } ); + + // Close when clicking outside. + document.addEventListener( 'click', ( e: MouseEvent ) => { + const target = e.target as Node; + if ( ! searchToggle.contains( target ) && ! searchForm.contains( target ) ) { + searchForm.hidden = true; + searchToggle.setAttribute( 'aria-expanded', 'false' ); + } + } ); + } +} diff --git a/src/ts/reading-progress.ts b/src/ts/reading-progress.ts new file mode 100644 index 0000000..ec3d129 --- /dev/null +++ b/src/ts/reading-progress.ts @@ -0,0 +1,26 @@ +/** + * reading-progress.ts + * Fills a thin progress bar at the top of single-post pages + * based on how far down the user has scrolled. + */ + +export function initReadingProgress(): void { + const progressEl = document.querySelector<HTMLElement>( '.reading-progress' ); + const barEl = document.querySelector<HTMLElement>( '.reading-progress__bar' ); + if ( ! progressEl || ! barEl ) return; + + const article = document.querySelector<HTMLElement>( '.single-article' ) ?? document.body; + + const update = (): void => { + const articleTop = article.getBoundingClientRect().top + window.scrollY; + const articleHeight = article.offsetHeight; + const scrolled = window.scrollY - articleTop; + const progress = Math.min( 100, Math.max( 0, ( scrolled / articleHeight ) * 100 ) ); + + barEl.style.width = `${ progress }%`; + progressEl.setAttribute( 'aria-valuenow', String( Math.round( progress ) ) ); + }; + + window.addEventListener( 'scroll', update, { passive: true } ); + update(); +} diff --git a/src/ts/theme-toggle.ts b/src/ts/theme-toggle.ts new file mode 100644 index 0000000..93a45b8 --- /dev/null +++ b/src/ts/theme-toggle.ts @@ -0,0 +1,45 @@ +/** + * theme-toggle.ts + * Dark-mode toggle with localStorage persistence + system preference detection. + */ + +type Theme = 'light' | 'dark'; + +const STORAGE_KEY = 'cyywordpress-theme'; + +function getSystemTheme(): Theme { + return window.matchMedia( '(prefers-color-scheme: dark)' ).matches ? 'dark' : 'light'; +} + +function applyTheme( theme: Theme ): void { + document.documentElement.setAttribute( 'data-theme', theme ); +} + +function getSavedTheme(): Theme | null { + const saved = localStorage.getItem( STORAGE_KEY ); + return saved === 'dark' || saved === 'light' ? saved : null; +} + +export function initThemeToggle(): void { + // Apply saved or system theme immediately (before paint). + const initialTheme = getSavedTheme() ?? getSystemTheme(); + applyTheme( initialTheme ); + + const btn = document.getElementById( 'theme-toggle' ) as HTMLButtonElement | null; + if ( ! btn ) return; + + btn.addEventListener( 'click', () => { + const current = document.documentElement.getAttribute( 'data-theme' ) as Theme | null; + const next: Theme = current === 'dark' ? 'light' : 'dark'; + applyTheme( next ); + localStorage.setItem( STORAGE_KEY, next ); + btn.setAttribute( 'aria-label', next === 'dark' ? '切换浅色模式' : '切换深色模式' ); + } ); + + // Respond to OS-level change when user hasn't set a preference. + window.matchMedia( '(prefers-color-scheme: dark)' ).addEventListener( 'change', ( e ) => { + if ( getSavedTheme() === null ) { + applyTheme( e.matches ? 'dark' : 'light' ); + } + } ); +} diff --git a/src/ts/toc.ts b/src/ts/toc.ts new file mode 100644 index 0000000..a0ab852 --- /dev/null +++ b/src/ts/toc.ts @@ -0,0 +1,104 @@ +/** + * toc.ts + * Automatically generates a Table of Contents from h2/h3 headings + * in the article body and highlights the active heading while scrolling. + */ + +interface TocItem { + id: string; + text: string; + level: 2 | 3; +} + +export function initToc(): void { + const tocList = document.getElementById( 'toc-list' ); + const article = document.querySelector<HTMLElement>( '.single-article .entry-content, .single-article .post-card__content' ); + + if ( ! tocList || ! article ) return; + + const headings = Array.from( + article.querySelectorAll<HTMLHeadingElement>( 'h2, h3' ) + ); + + if ( headings.length < 2 ) { + // Hide TOC widget when there aren't enough headings. + const tocWidget = document.getElementById( 'toc' ); + if ( tocWidget ) tocWidget.hidden = true; + return; + } + + const items: TocItem[] = headings.map( ( heading ) => { + if ( ! heading.id ) { + // Generate a slug from heading text. + heading.id = heading.textContent + ?.trim() + .toLowerCase() + .replace( /[^\w\u4e00-\u9fa5\s-]/g, '' ) + .replace( /\s+/g, '-' ) + .slice( 0, 60 ) ?? `heading-${ Math.random().toString( 36 ).slice( 2 ) }`; + } + + return { + id: heading.id, + text: heading.textContent?.trim() ?? '', + level: heading.tagName === 'H2' ? 2 : 3, + }; + } ); + + // Build ordered list HTML. + const ol = document.createElement( 'ol' ); + let currentUl: HTMLUListElement | null = null; + + items.forEach( ( item ) => { + const li = document.createElement( 'li' ); + const link = document.createElement( 'a' ); + link.href = `#${ item.id }`; + link.textContent = item.text; + link.className = `toc-link toc-level-${ item.level }`; + + link.addEventListener( 'click', ( e ) => { + e.preventDefault(); + const target = document.getElementById( item.id ); + target?.scrollIntoView( { behavior: 'smooth', block: 'start' } ); + } ); + + li.appendChild( link ); + + if ( item.level === 2 ) { + currentUl = null; + ol.appendChild( li ); + } else { + // Nest h3 items under the parent h2 list item. + if ( ! currentUl ) { + currentUl = document.createElement( 'ul' ); + currentUl.className = 'toc-sub-list'; + const lastH2 = ol.querySelector( 'li:last-child' ); + lastH2?.appendChild( currentUl ); + } + currentUl.appendChild( li ); + } + } ); + + tocList.appendChild( ol ); + + // Intersection Observer – highlight active heading. + const links = tocList.querySelectorAll<HTMLAnchorElement>( '.toc-link' ); + + const observer = new IntersectionObserver( + ( entries ) => { + entries.forEach( ( entry ) => { + const id = entry.target.getAttribute( 'id' ) ?? ''; + const link = tocList.querySelector<HTMLAnchorElement>( `a[href="#${ id }"]` ); + if ( link ) { + link.classList.toggle( 'toc-active', entry.isIntersecting ); + } + } ); + }, + { + rootMargin: '-80px 0px -60% 0px', + threshold: 0, + } + ); + + headings.forEach( ( h ) => observer.observe( h ) ); +} diff --git a/style.css b/style.css index 5768fef..f8df28f 100644 --- a/style.css +++ b/style.css @@ -4,9 +4,9 @@ Theme URI: http://underscores.me/ Author: Underscores.me Author URI: http://underscores.me/ Description: Description -Version: 1.0.0 -Tested up to: 5.4 -Requires PHP: 5.6 +Version: 2.0.0 +Tested up to: 6.7 +Requires PHP: 8.1 License: GNU General Public License v2 or later License URI: LICENSE Text Domain: cyywordpress diff --git a/template-parts/content.php b/template-parts/content.php index 5bec209..79d3fa1 100644 --- a/template-parts/content.php +++ b/template-parts/content.php @@ -2,66 +2,176 @@ /** * Template part for displaying posts * + * Modernised card layout: + * - 16:9 featured image with lazy-load + * - Category colour badges + * - Relative publish time + hover absolute time + * - Reading-time estimate + * - Animated "Continue reading" button + * - Skeleton loading CSS hook `.card--loading` + * * @link https://developer.wordpress.org/themes/basics/template-hierarchy/ * * @package CyyWordpress */ +// Estimate reading time (200 wpm average). +$word_count = str_word_count( wp_strip_all_tags( get_the_content() ) ); +$reading_mins = max( 1, (int) ceil( $word_count / 200 ) ); ?> -<div id="post-<?php the_ID(); ?>" class="card" style=" margin-bottom: 25px;"> - <div class="card-content"> - <div class="media"> - <div class="media-content"> +<article id="post-<?php the_ID(); ?>" <?php post_class( 'post-card card' ); ?>> - <?php - if ( is_singular() ) : - the_title( '<p class="title is-4">', '</p>' ); - else : - the_title( '<p class="title is-4"><a href="' . esc_url( get_permalink() ) . '" rel="bookmark">', '</a></p>' ); - endif; + <?php if ( has_post_thumbnail() ) : ?> + <div class="post-card__image card-image"> + <figure class="image is-16by9"> + <?php + the_post_thumbnail( + 'card-thumbnail', + array( + 'class' => 'post-card__thumb', + 'loading' => 'lazy', + 'decoding'=> 'async', + 'alt' => the_title_attribute( array( 'echo' => false ) ), + ) + ); + ?> + </figure> + </div> + <?php else : ?> + <div class="post-card__image post-card__image--placeholder card-image" aria-hidden="true"> + <figure class="image is-16by9"> + <div class="post-card__gradient-placeholder"> + <?php + $cats = get_the_category(); + echo '<span class="post-card__category-initial">' + . esc_html( $cats ? mb_substr( $cats[0]->name, 0, 1 ) : mb_substr( get_bloginfo( 'name' ), 0, 1 ) ) + . '</span>'; + ?> + </div> + </figure> + </div> + <?php endif; ?> - if ( 'post' === get_post_type() ) : - ?> - <p class="subtitle is-6"> - <?php - cyywordpress_posted_on(); - cyywordpress_posted_by(); - ?> - </p><!-- .entry-meta --> - <?php endif; ?> - </div> - </div> + <div class="card-content"> - <div class="content"> - <?php - the_content( - sprintf( - wp_kses( - /* translators: %s: Name of current post. Only visible to screen readers */ -// __( 'Continue reading<span class="screen-reader-text"> "%s"</span>', 'cyywordpress' ), - __( '继续阅读 <span class="screen-reader-text"> "%s"</span>', 'cyywordpress' ), - array( - 'span' => array( - 'class' => array(), - ), - ) - ), - wp_kses_post( get_the_title() ) - ) - ); + <!-- Category badges --> + <?php + $categories = get_the_category(); + if ( $categories ) : + echo '<div class="post-card__categories">'; + foreach ( array_slice( $categories, 0, 3 ) as $cat ) { + printf( + '<a class="post-card__category-badge tag is-link is-light" href="%s">%s</a>', + esc_url( get_category_link( $cat->term_id ) ), + esc_html( $cat->name ) + ); + } + echo '</div>'; + endif; + ?> - wp_link_pages( - array( - 'before' => '<div class="page-links">' . esc_html__( 'Pages:', 'cyywordpress' ), - 'after' => '</div>', - ) - ); - ?> + <!-- Title --> + <?php + if ( is_singular() ) : + the_title( '<h1 class="post-card__title title is-4">', '</h1>' ); + else : + the_title( + '<h2 class="post-card__title title is-4"><a href="' . esc_url( get_permalink() ) . '" rel="bookmark">', + '</a></h2>' + ); + endif; + ?> - <footer class="entry-footer"> - <?php cyywordpress_entry_footer(); ?> - </footer><!-- .entry-footer --> - </div> - </div> -</div> \ No newline at end of file + <!-- Meta --> + <?php if ( 'post' === get_post_type() ) : ?> + <div class="post-card__meta subtitle is-6"> + <span class="post-card__date"> + <i class="fa-regular fa-calendar" aria-hidden="true"></i> + <time + class="entry-date published" + datetime="<?php echo esc_attr( get_the_date( DATE_W3C ) ); ?>" + title="<?php echo esc_attr( get_the_date() ); ?>" + > + <?php + echo esc_html( + sprintf( + /* translators: %s: human-readable time diff */ + __( '%s前', 'cyywordpress' ), + human_time_diff( (int) get_the_time( 'U' ), time() ) + ) + ); + ?> + </time> + </span> + + <span class="post-card__author"> + <i class="fa-regular fa-user" aria-hidden="true"></i> + <a href="<?php echo esc_url( get_author_posts_url( (int) get_the_author_meta( 'ID' ) ) ); ?>"> + <?php echo esc_html( get_the_author() ); ?> + </a> + </span> + + <span class="post-card__reading-time"> + <i class="fa-regular fa-clock" aria-hidden="true"></i> + <?php + printf( + /* translators: %d: reading time in minutes */ + esc_html__( '约 %d 分钟', 'cyywordpress' ), + (int) $reading_mins + ); + ?> + </span> + </div><!-- .post-card__meta --> + <?php endif; ?> + + <!-- Content / excerpt --> + <div class="post-card__content content"> + <?php + if ( is_singular() ) : + the_content( + sprintf( + wp_kses( + /* translators: %s: post title (screen-reader only) */ + __( '继续阅读<span class="screen-reader-text"> "%s"</span>', 'cyywordpress' ), + array( 'span' => array( 'class' => array() ) ) + ), + wp_kses_post( get_the_title() ) + ) + ); + + wp_link_pages( + array( + 'before' => '<div class="page-links">' . esc_html__( '页面:', 'cyywordpress' ), + 'after' => '</div>', + ) + ); + else : + the_excerpt(); + endif; + ?> + </div><!-- .post-card__content --> + + <!-- Footer: tags + read-more --> + <?php if ( ! is_singular() ) : ?> + <div class="post-card__footer"> + <?php cyywordpress_entry_footer(); ?> + + <a + class="post-card__read-more button is-link is-light is-small" + href="<?php echo esc_url( get_permalink() ); ?>" + rel="bookmark" + aria-label="<?php echo esc_attr( sprintf( __( '继续阅读 %s', 'cyywordpress' ), get_the_title() ) ); ?>" + > + <?php esc_html_e( '继续阅读', 'cyywordpress' ); ?> + <span class="post-card__read-more-arrow" aria-hidden="true"> →</span> + </a> + </div> + <?php else : ?> + <footer class="entry-footer"> + <?php cyywordpress_entry_footer(); ?> + </footer> + <?php endif; ?> + + </div><!-- .card-content --> +</article><!-- #post-<?php the_ID(); ?> --> diff --git a/theme.json b/theme.json new file mode 100644 index 0000000..40774ac --- /dev/null +++ b/theme.json @@ -0,0 +1,127 @@ +{ + "$schema": "https://schemas.wp.org/trunk/theme.json", + "version": 3, + "settings": { + "color": { + "defaultPalette": false, + "palette": [ + { "name": "Primary", "slug": "primary", "color": "#00d1b2" }, + { "name": "Primary Dark", "slug": "primary-dark", "color": "#00a896" }, + { "name": "Dark", "slug": "dark", "color": "#363636" }, + { "name": "Light", "slug": "light", "color": "#f5f5f5" }, + { "name": "White", "slug": "white", "color": "#ffffff" }, + { "name": "Danger", "slug": "danger", "color": "#f14668" }, + { "name": "Warning", "slug": "warning", "color": "#ffe08a" }, + { "name": "Info", "slug": "info", "color": "#3e8ed0" }, + { "name": "Success", "slug": "success", "color": "#48c78e" } + ] + }, + "typography": { + "fontFamilies": [ + { + "name": "Noto Sans SC", + "slug": "noto-sans-sc", + "fontFamily": "'Noto Sans SC', sans-serif", + "fontFace": [ + { + "src": [ "file:./assets/fonts/NotoSansSC-Regular.woff2" ], + "fontWeight": "400", + "fontStyle": "normal", + "fontDisplay": "swap" + }, + { + "src": [ "file:./assets/fonts/NotoSansSC-Bold.woff2" ], + "fontWeight": "700", + "fontStyle": "normal", + "fontDisplay": "swap" + } + ] + }, + { + "name": "System UI", + "slug": "system-ui", + "fontFamily": "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" + } + ], + "fontSizes": [ + { "name": "Small", "slug": "small", "size": "0.875rem", "fluid": false }, + { "name": "Normal", "slug": "normal", "size": "1rem", "fluid": false }, + { "name": "Medium", "slug": "medium", "size": "1.25rem", "fluid": false }, + { "name": "Large", "slug": "large", "size": "1.5rem", "fluid": { "min": "1.25rem", "max": "1.5rem" } }, + { "name": "X-Large", "slug": "x-large", "size": "2rem", "fluid": { "min": "1.5rem", "max": "2rem" } }, + { "name": "XX-Large","slug": "xx-large","size": "2.5rem", "fluid": { "min": "1.75rem", "max": "2.5rem" } } + ], + "lineHeight": true, + "letterSpacing": true + }, + "layout": { + "contentSize": "760px", + "wideSize": "1200px" + }, + "spacing": { + "spacingScale": { + "operator": "*", + "increment": 1.5, + "steps": 7, + "mediumStep": 1.5, + "unit": "rem" + }, + "spacingSizes": [ + { "name": "1", "slug": "10", "size": "0.25rem" }, + { "name": "2", "slug": "20", "size": "0.5rem" }, + { "name": "3", "slug": "30", "size": "1rem" }, + { "name": "4", "slug": "40", "size": "1.5rem" }, + { "name": "5", "slug": "50", "size": "2rem" }, + { "name": "6", "slug": "60", "size": "3rem" }, + { "name": "7", "slug": "70", "size": "4.5rem" } + ], + "padding": true, + "margin": true, + "blockGap": true + }, + "border": { + "color": true, + "radius": true, + "style": true, + "width": true + }, + "shadow": { + "defaultPresets": false, + "presets": [ + { "name": "Small", "slug": "sm", "shadow": "0 1px 3px rgba(0,0,0,.1)" }, + { "name": "Medium", "slug": "md", "shadow": "0 4px 6px rgba(0,0,0,.1)" }, + { "name": "Large", "slug": "lg", "shadow": "0 10px 20px rgba(0,0,0,.12)" } + ] + }, + "custom": { + "colorBackground": "#ffffff", + "colorText": "#363636", + "colorSurface": "#f5f5f5", + "colorBorder": "#dbdbdb" + } + }, + "styles": { + "color": { + "background": "var(--wp--preset--color--white)", + "text": "var(--wp--preset--color--dark)" + }, + "typography": { + "fontFamily": "var(--wp--preset--font-family--noto-sans-sc)", + "fontSize": "var(--wp--preset--font-size--normal)", + "lineHeight": "1.7" + }, + "spacing": { + "blockGap": "var(--wp--preset--spacing--40)" + }, + "elements": { + "link": { + "color": { "text": "var(--wp--preset--color--primary)" }, + ":hover": { "color": { "text": "var(--wp--preset--color--primary-dark)" } } + }, + "h1": { "typography": { "fontSize": "var(--wp--preset--font-size--xx-large)", "lineHeight": "1.2" } }, + "h2": { "typography": { "fontSize": "var(--wp--preset--font-size--x-large)", "lineHeight": "1.3" } }, + "h3": { "typography": { "fontSize": "var(--wp--preset--font-size--large)", "lineHeight": "1.4" } }, + "h4": { "typography": { "fontSize": "var(--wp--preset--font-size--medium)", "lineHeight": "1.5" } } + } + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a15d743 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "./assets/js", + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + "@scss/*": ["scss/*"] + } + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "vendor", "assets"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..47b256a --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,60 @@ +import { defineConfig } from 'vite'; +import { resolve } from 'path'; + +/** + * Vite config for CyyWordpress theme. + * + * Outputs pre-hashed assets to assets/js/ and assets/css/. + * Run `npm run dev` → HMR dev server on :3000 + * Run `npm run build` → production bundles with contenthash + */ +export default defineConfig( ( { command } ) => ( { + root: resolve( __dirname, 'src' ), + + resolve: { + alias: { + '@scss': resolve( __dirname, 'scss' ), + '@': resolve( __dirname, 'src' ), + }, + }, + + css: { + preprocessorOptions: { + scss: { + // Expose abstracts to every SCSS file without manual imports. + additionalData: `@use "@scss/abstracts/variables" as *; +@use "@scss/abstracts/mixins" as *; +`, + }, + }, + }, + + build: { + outDir: resolve( __dirname, 'assets' ), + emptyOutDir: false, + manifest: true, + rollupOptions: { + input: { + main: resolve( __dirname, 'src/ts/main.ts' ), + admin: resolve( __dirname, 'src/admin/customizer.ts' ), + }, + output: { + entryFileNames: 'js/[name].[hash].js', + chunkFileNames: 'js/[name].[hash].js', + assetFileNames: ( assetInfo ) => { + if ( assetInfo.name?.endsWith( '.css' ) ) { + return 'css/[name].[hash][extname]'; + } + return 'img/[name].[hash][extname]'; + }, + }, + }, + }, + + server: { + port: 3000, + // Proxy WordPress requests through the local server. + // Point WP_HOME to http://localhost:3000 in wp-config.php during dev. + cors: true, + }, +} ) );