Files
usher-manage-stack/app/Console/Commands/ImportHugoContent.php
gbanyan a30af8eaf7 Add headless CMS for official site content management
Integrate article and page management into the Laravel admin dashboard
to serve as a headless CMS for the Next.js frontend (usher-site).

Backend:
- 7 migrations: article_categories, article_tags, articles, pivots, attachments, pages
- 5 models with relationships: Article, ArticleCategory, ArticleTag, ArticleAttachment, Page
- 4 admin controllers: articles (with publish/archive/pin), categories, tags, pages
- Admin views with EasyMDE markdown editor, multi-select categories/tags
- Navigation section "官網管理" in admin sidebar

API (v1):
- GET /api/v1/articles (filtered by type, category, tag, search; paginated)
- GET /api/v1/articles/{slug} (with related articles)
- GET /api/v1/categories
- GET /api/v1/pages/{slug} (with children)
- GET /api/v1/homepage (aggregated homepage data)
- Attachment download endpoint
- CORS configured for usher.org.tw, vercel.app, localhost:3000

Content migration:
- ImportHugoContent command: imports Hugo markdown files as articles/pages
- Successfully imported 27 articles, 17 categories, 11 tags, 9 pages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 11:58:22 +08:00

528 lines
17 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Models\Article;
use App\Models\ArticleCategory;
use App\Models\ArticleTag;
use App\Models\Page;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Symfony\Component\Yaml\Yaml;
class ImportHugoContent extends Command
{
protected $signature = 'hugo:import
{--path= : Path to Hugo content/zh directory}
{--images= : Path to Hugo static/images directory}
{--dry-run : Preview import without writing to database}';
protected $description = 'Import Hugo site content (markdown files) into CMS articles and pages';
private int $articlesCreated = 0;
private int $pagesCreated = 0;
private int $categoriesCreated = 0;
private int $tagsCreated = 0;
private int $imagescopied = 0;
private int $skipped = 0;
public function handle(): int
{
$contentPath = $this->option('path') ?: base_path('../usher-site/content/zh');
$imagesPath = $this->option('images') ?: base_path('../usher-site/static/images');
// Expand tilde
$contentPath = str_replace('~', getenv('HOME'), $contentPath);
$imagesPath = str_replace('~', getenv('HOME'), $imagesPath);
if (! is_dir($contentPath)) {
$this->error("Content directory not found: {$contentPath}");
return self::FAILURE;
}
$isDryRun = $this->option('dry-run');
if ($isDryRun) {
$this->warn('DRY RUN — no changes will be written to database.');
}
$this->info("Importing Hugo content from: {$contentPath}");
$this->newLine();
// Get or create a system user for attribution
$systemUser = User::where('email', 'admin@test.com')->first()
?? User::first();
if (! $systemUser) {
$this->error('No user found to attribute content to. Please create a user first.');
return self::FAILURE;
}
$this->info("Attributing content to: {$systemUser->name} ({$systemUser->email})");
$this->newLine();
DB::beginTransaction();
try {
// Import articles by content type
$contentTypes = [
'blog' => Article::CONTENT_TYPE_BLOG,
'notice' => Article::CONTENT_TYPE_NOTICE,
'document' => Article::CONTENT_TYPE_DOCUMENT,
'related_news' => Article::CONTENT_TYPE_RELATED_NEWS,
];
foreach ($contentTypes as $dir => $contentType) {
$dirPath = "{$contentPath}/{$dir}";
if (is_dir($dirPath)) {
$this->importArticles($dirPath, $contentType, $systemUser, $isDryRun);
} else {
$this->warn("Directory not found, skipping: {$dir}/");
}
}
// Import static pages
$pageDirectories = [
'about' => '關於我們',
'message' => '理事長的話',
'structure' => '組織架構',
'mission' => '協會任務',
'contact' => '聯繫我們',
'logo_represent' => 'Logo象徵',
];
foreach ($pageDirectories as $dir => $fallbackTitle) {
$dirPath = "{$contentPath}/{$dir}";
if (is_dir($dirPath)) {
$this->importPage($dirPath, $dir, $fallbackTitle, $systemUser, $isDryRun);
} else {
$this->warn("Page directory not found, skipping: {$dir}/");
}
}
// Copy images
if (is_dir($imagesPath)) {
$this->copyImages($imagesPath, $isDryRun);
} else {
$this->warn("Images directory not found: {$imagesPath}");
}
if ($isDryRun) {
DB::rollBack();
$this->newLine();
$this->warn('DRY RUN complete — no changes were made.');
} else {
DB::commit();
}
} catch (\Exception $e) {
DB::rollBack();
$this->error("Import failed: {$e->getMessage()}");
$this->error($e->getTraceAsString());
return self::FAILURE;
}
$this->newLine();
$this->info('=== Import Summary ===');
$this->table(
['Item', 'Count'],
[
['Articles created', $this->articlesCreated],
['Pages created', $this->pagesCreated],
['Categories created', $this->categoriesCreated],
['Tags created', $this->tagsCreated],
['Images copied', $this->imagescopied],
['Skipped (_index.md etc.)', $this->skipped],
]
);
return self::SUCCESS;
}
private function importArticles(string $dirPath, string $contentType, User $user, bool $isDryRun): void
{
$this->info("Importing {$contentType} articles from: {$dirPath}");
$files = File::glob("{$dirPath}/*.md");
foreach ($files as $filePath) {
$filename = basename($filePath);
// Skip _index.md files (Hugo section pages, not content)
if ($filename === '_index.md') {
$this->skipped++;
continue;
}
$parsed = $this->parseMarkdownFile($filePath);
if (! $parsed) {
$this->warn(" Could not parse: {$filename}");
$this->skipped++;
continue;
}
$frontmatter = $parsed['frontmatter'];
$body = $parsed['body'];
// Skip drafts
if (! empty($frontmatter['draft']) && $frontmatter['draft'] === true) {
$this->line(" Skipping draft: {$filename}");
$this->skipped++;
continue;
}
$title = $frontmatter['title'] ?? pathinfo($filename, PATHINFO_FILENAME);
// Prefer filename-based slug (Hugo filenames are usually descriptive ASCII)
$filenameBase = pathinfo($filename, PATHINFO_FILENAME);
$slug = Str::slug($filenameBase);
if (empty($slug)) {
$slug = Str::slug($title);
}
if (empty($slug)) {
// For Chinese-only titles/filenames, use content_type + short hash
$slug = $contentType.'-'.substr(md5($title), 0, 8);
}
// Ensure unique slug
$originalSlug = $slug;
$counter = 1;
while (! $isDryRun && Article::withTrashed()->where('slug', $slug)->exists()) {
$slug = "{$originalSlug}-{$counter}";
$counter++;
}
$publishedAt = $this->parseDate($frontmatter['date'] ?? null);
$description = $frontmatter['description'] ?? null;
$summary = $frontmatter['summary'] ?? null;
$author = $frontmatter['author'] ?? null;
$image = $this->resolveImagePath($frontmatter['image'] ?? null);
$this->line(" [{$contentType}] {$title}{$slug}");
if (! $isDryRun) {
$article = Article::create([
'title' => $title,
'slug' => $slug,
'summary' => $summary,
'content' => $body,
'content_type' => $contentType,
'status' => Article::STATUS_PUBLISHED,
'access_level' => Article::ACCESS_LEVEL_PUBLIC,
'featured_image_path' => $image,
'author_name' => $author,
'meta_description' => $description ?: null,
'published_at' => $publishedAt ?? now(),
'created_by_user_id' => $user->id,
'view_count' => 0,
]);
// Sync categories
$categoryNames = $this->extractList($frontmatter, 'categories', 'category');
foreach ($categoryNames as $catName) {
$category = $this->findOrCreateCategory($catName);
$article->categories()->attach($category->id);
}
// Sync tags
$tagNames = $this->extractList($frontmatter, 'tags');
foreach ($tagNames as $tagName) {
$tag = $this->findOrCreateTag($tagName);
$article->tags()->attach($tag->id);
}
}
$this->articlesCreated++;
}
}
private function importPage(string $dirPath, string $slug, string $fallbackTitle, User $user, bool $isDryRun): void
{
$indexFile = "{$dirPath}/_index.md";
if (! file_exists($indexFile)) {
$this->warn(" No _index.md in {$slug}/, skipping.");
$this->skipped++;
return;
}
$parsed = $this->parseMarkdownFile($indexFile);
if (! $parsed) {
$this->warn(" Could not parse: {$slug}/_index.md");
$this->skipped++;
return;
}
$frontmatter = $parsed['frontmatter'];
$body = $parsed['body'];
$title = $frontmatter['title'] ?? $fallbackTitle;
$description = $frontmatter['description'] ?? null;
$this->info(" [page] {$title}{$slug}");
$parentPage = null;
if (! $isDryRun) {
// Check for existing page with same slug
if (Page::withTrashed()->where('slug', $slug)->exists()) {
$this->warn(" Page with slug '{$slug}' already exists, skipping.");
$this->skipped++;
return;
}
$parentPage = Page::create([
'title' => $title,
'slug' => $slug,
'content' => $body,
'status' => Page::STATUS_PUBLISHED,
'meta_description' => $description ?: null,
'published_at' => now(),
'created_by_user_id' => $user->id,
'sort_order' => 0,
]);
}
$this->pagesCreated++;
// Import child pages (non-_index.md files in the same directory)
$childFiles = File::glob("{$dirPath}/*.md");
$sortOrder = 1;
foreach ($childFiles as $childFile) {
$childFilename = basename($childFile);
if ($childFilename === '_index.md') {
continue;
}
$childParsed = $this->parseMarkdownFile($childFile);
if (! $childParsed) {
$this->skipped++;
continue;
}
$childFm = $childParsed['frontmatter'];
$childBody = $childParsed['body'];
$childTitle = $childFm['title'] ?? pathinfo($childFilename, PATHINFO_FILENAME);
$childSlug = Str::slug(pathinfo($childFilename, PATHINFO_FILENAME));
if (empty($childSlug)) {
$childSlug = Str::slug($childTitle);
}
if (empty($childSlug)) {
$childSlug = 'page-'.md5($childTitle);
}
$this->line(" [child] {$childTitle}{$childSlug}");
if (! $isDryRun) {
$originalChildSlug = $childSlug;
$counter = 1;
while (Page::withTrashed()->where('slug', $childSlug)->exists()) {
$childSlug = "{$originalChildSlug}-{$counter}";
$counter++;
}
Page::create([
'title' => $childTitle,
'slug' => $childSlug,
'content' => $childBody,
'status' => Page::STATUS_PUBLISHED,
'meta_description' => $childFm['description'] ?? null,
'parent_id' => $parentPage->id,
'sort_order' => $sortOrder,
'published_at' => now(),
'created_by_user_id' => $user->id,
]);
}
$this->pagesCreated++;
$sortOrder++;
}
}
private function copyImages(string $imagesPath, bool $isDryRun): void
{
$this->info("Copying images from: {$imagesPath}");
$destPath = storage_path('app/public/migrated-images');
if (! $isDryRun && ! is_dir($destPath)) {
File::makeDirectory($destPath, 0755, true);
}
$files = File::allFiles($imagesPath);
foreach ($files as $file) {
$relativePath = $file->getRelativePathname();
$destFile = "{$destPath}/{$relativePath}";
$this->line(" {$relativePath}");
if (! $isDryRun) {
$destDir = dirname($destFile);
if (! is_dir($destDir)) {
File::makeDirectory($destDir, 0755, true);
}
File::copy($file->getPathname(), $destFile);
}
$this->imagescopied++;
}
}
/**
* Parse a markdown file into frontmatter array and body string.
*/
private function parseMarkdownFile(string $filePath): ?array
{
$content = file_get_contents($filePath);
if ($content === false) {
return null;
}
// Match YAML frontmatter between --- delimiters
if (! preg_match('/\A---\s*\n(.*?)\n---\s*\n?(.*)\z/s', $content, $matches)) {
// No frontmatter — treat entire content as body
return [
'frontmatter' => [],
'body' => trim($content),
];
}
$yamlString = $matches[1];
$body = trim($matches[2]);
try {
// Strip YAML comments (lines starting with #) before parsing
$yamlLines = array_filter(
explode("\n", $yamlString),
fn ($line) => ! preg_match('/^\s*#/', $line)
);
$cleanYaml = implode("\n", $yamlLines);
$frontmatter = Yaml::parse($cleanYaml) ?? [];
} catch (\Exception $e) {
$this->warn(" YAML parse error in {$filePath}: {$e->getMessage()}");
$frontmatter = [];
}
return [
'frontmatter' => $frontmatter,
'body' => $body,
];
}
/**
* Parse a date value from Hugo frontmatter.
* YAML parser may convert ISO 8601 dates to Unix timestamps (int).
*
* @param string|int|null $dateValue
*/
private function parseDate(mixed $dateValue): ?\Carbon\Carbon
{
if ($dateValue === null || $dateValue === '') {
return null;
}
try {
if (is_int($dateValue) || is_float($dateValue)) {
return \Carbon\Carbon::createFromTimestamp((int) $dateValue);
}
return \Carbon\Carbon::parse((string) $dateValue);
} catch (\Exception $e) {
$this->warn(" Could not parse date: {$dateValue}");
return null;
}
}
/**
* Extract a list of values from frontmatter.
* Hugo uses both plural array (categories: ["a", "b"]) and singular string (category: "a").
*/
private function extractList(array $frontmatter, string $pluralKey, ?string $singularKey = null): array
{
$items = [];
if (! empty($frontmatter[$pluralKey])) {
$value = $frontmatter[$pluralKey];
$items = is_array($value) ? $value : [$value];
} elseif ($singularKey && ! empty($frontmatter[$singularKey])) {
$value = $frontmatter[$singularKey];
$items = is_array($value) ? $value : [$value];
}
// Clean up strings
return array_filter(array_map('trim', $items));
}
private function findOrCreateCategory(string $name): ArticleCategory
{
$slug = Str::slug($name) ?: 'category-'.mt_rand(100, 999);
$category = ArticleCategory::where('slug', $slug)->first();
if (! $category) {
$category = ArticleCategory::create([
'name' => $name,
'slug' => $slug,
'sort_order' => 0,
]);
$this->categoriesCreated++;
$this->line(" Created category: {$name}");
}
return $category;
}
private function findOrCreateTag(string $name): ArticleTag
{
$slug = Str::slug($name) ?: 'tag-'.mt_rand(100, 999);
$tag = ArticleTag::where('slug', $slug)->first();
if (! $tag) {
$tag = ArticleTag::create([
'name' => $name,
'slug' => $slug,
]);
$this->tagsCreated++;
$this->line(" Created tag: {$name}");
}
return $tag;
}
/**
* Convert Hugo image paths to migrated storage paths.
* Hugo: "images/blog/Update.jpg" → "migrated-images/blog/Update.jpg"
*/
private function resolveImagePath(?string $hugoPath): ?string
{
if (empty($hugoPath)) {
return null;
}
// Remove leading "images/" prefix
$relativePath = preg_replace('#^images/#', '', $hugoPath);
return "migrated-images/{$relativePath}";
}
}