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>
528 lines
17 KiB
PHP
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}";
|
|
}
|
|
}
|