Files
second-mind-aep/Идеи/Переезд на Hugo/Hugo миграция и настройка.md
2025-08-04 15:25:16 +04:00

19 KiB
Raw Blame History

Hugo миграция и настройка

1. Миграция контента

1.1 Структура контента

Текущая структура (Quartz):

content/
├── notes/
│   ├── Идеи/
│   ├── Мой сервер/
│   └── index.md
├── daily/
└── templates/

Новая структура (Hugo):

content/
├── notes/
│   ├── идеи/
│   │   ├── obsidian-telegram-bot/
│   │   │   ├── mvp-telegram-bota.md
│   │   │   └── telegram-bot-dlya-obsidian.md
│   │   └── pereezd-na-hugo/
│   │       ├── plan-pereezda-na-hugo.md
│   │       ├── webhook-server-na-go.md
│   │       └── hugo-migratsiya-i-nastrojka.md
│   ├── мой-сервер/
│   │   ├── authelia-authentication/
│   │   ├── git-service/
│   │   ├── second-mind-setup/
│   │   └── traefik-reverse-proxy/
│   └── _index.md
├── daily/
│   └── _index.md
└── templates/
    └── _index.md

1.2 Скрипт миграции

#!/usr/bin/env python3
# scripts/migrate_content.py

import os
import re
import shutil
from pathlib import Path

def slugify(text):
    """Преобразование текста в slug для URL"""
    text = text.lower()
    text = re.sub(r'[^\w\s-]', '', text)
    text = re.sub(r'[-\s]+', '-', text)
    return text.strip('-')

def migrate_content():
    """Миграция контента из Quartz в Hugo"""
    
    # Пути
    quartz_content = "quartz/content"
    hugo_content = "hugo-site/content"
    
    # Создание структуры Hugo
    os.makedirs(hugo_content, exist_ok=True)
    
    # Обработка каждой директории
    for root, dirs, files in os.walk(quartz_content):
        # Пропускаем служебные директории
        if any(skip in root for skip in ['.obsidian', '.git', '__pycache__']):
            continue
            
        # Определяем относительный путь
        rel_path = os.path.relpath(root, quartz_content)
        
        # Создаем соответствующую структуру в Hugo
        hugo_path = os.path.join(hugo_content, rel_path)
        os.makedirs(hugo_path, exist_ok=True)
        
        # Обрабатываем файлы
        for file in files:
            if file.endswith('.md'):
                migrate_markdown_file(
                    os.path.join(root, file),
                    os.path.join(hugo_path, file)
                )

def migrate_markdown_file(src_path, dst_path):
    """Миграция отдельного Markdown файла"""
    
    with open(src_path, 'r', encoding='utf-8') as f:
        content = f.read()
    
    # Обработка frontmatter
    content = process_frontmatter(content)
    
    # Обработка внутренних ссылок
    content = process_internal_links(content)
    
    # Обработка изображений
    content = process_images(content)
    
    # Сохранение файла
    with open(dst_path, 'w', encoding='utf-8') as f:
        f.write(content)

def process_frontmatter(content):
    """Обработка YAML frontmatter для Hugo"""
    
    # Проверяем наличие frontmatter
    if content.startswith('---'):
        # Извлекаем frontmatter
        parts = content.split('---', 2)
        if len(parts) >= 3:
            frontmatter = parts[1]
            body = parts[2]
            
            # Добавляем Hugo-специфичные поля
            frontmatter = add_hugo_frontmatter(frontmatter)
            
            return f"---\n{frontmatter}\n---\n{body}"
    
    # Если frontmatter отсутствует, создаем базовый
    title = extract_title_from_content(content)
    return f"""---
title: "{title}"
date: {get_current_date()}
draft: false
---

{content}"""

def add_hugo_frontmatter(frontmatter):
    """Добавление Hugo-специфичных полей в frontmatter"""
    
    # Добавляем базовые поля если их нет
    if 'draft' not in frontmatter:
        frontmatter += '\ndraft: false'
    
    if 'date' not in frontmatter:
        frontmatter += f'\ndate: {get_current_date()}'
    
    return frontmatter

def process_internal_links(content):
    """Обработка внутренних ссылок для Hugo"""
    
    # Заменяем [[wiki links]] на Hugo ссылки
    content = re.sub(r'\[\[([^\]]+)\]\]', r'[{{< ref "\1" >}}]', content)
    
    # Обрабатываем обычные ссылки
    content = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', process_link, content)
    
    return content

def process_link(match):
    """Обработка ссылок"""
    text = match.group(1)
    url = match.group(2)
    
    # Если это внутренняя ссылка на .md файл
    if url.endswith('.md'):
        # Преобразуем в Hugo ссылку
        slug = slugify(url.replace('.md', ''))
        return f'[{text}]({{{{< ref "{slug}" >}}}})'
    
    return match.group(0)

def process_images(content):
    """Обработка изображений"""
    
    # Перемещаем изображения в static директорию
    # и обновляем ссылки
    content = re.sub(r'!\[([^\]]*)\]\(([^)]+)\)', process_image, content)
    
    return content

def process_image(match):
    """Обработка изображений"""
    alt = match.group(1)
    src = match.group(2)
    
    # Если изображение локальное, перемещаем в static
    if not src.startswith(('http://', 'https://')):
        # Копируем изображение в static/images
        static_path = f"static/images/{os.path.basename(src)}"
        if os.path.exists(src):
            shutil.copy2(src, static_path)
        
        # Обновляем ссылку
        src = f"/images/{os.path.basename(src)}"
    
    return f'![{alt}]({src})'

if __name__ == "__main__":
    migrate_content()
    print("Миграция контента завершена!")

2. Настройка Hugo

2.1 Базовая конфигурация

# config.toml
baseURL = "https://aepif.ru"
languageCode = "ru-ru"
title = "Second Mind"
theme = "custom-theme"

[params]
  description = "Персональная база знаний и заметки"
  author = "AEP"
  github = "https://github.com/username"
  
  # Настройки поиска
  enableSearch = true
  searchResultsLength = 10
  
  # Настройки навигации
  showBreadcrumb = true
  showReadingTime = true
  showWordCount = true

[menu]
  [[menu.main]]
    identifier = "notes"
    name = "Заметки"
    url = "/notes/"
    weight = 1
  
  [[menu.main]]
    identifier = "daily"
    name = "Дневник"
    url = "/daily/"
    weight = 2
  
  [[menu.main]]
    identifier = "templates"
    name = "Шаблоны"
    url = "/templates/"
    weight = 3

[markup]
  [markup.goldmark]
    [markup.goldmark.renderer]
      unsafe = true
  
  [markup.highlight]
    style = "dracula"
    lineNos = true
    lineNumbersInTable = true

[outputs]
  home = ["HTML", "RSS", "JSON"]

[outputFormats]
  [outputFormats.JSON]
    mediaType = "application/json"
    baseName = "index"
    isPlainText = true
    notAlternative = true

2.2 Структура темы

themes/custom-theme/
├── assets/
│   ├── css/
│   │   ├── main.scss
│   │   └── _variables.scss
│   ├── js/
│   │   └── main.js
│   └── images/
├── layouts/
│   ├── _default/
│   │   ├── baseof.html
│   │   ├── list.html
│   │   └── single.html
│   ├── partials/
│   │   ├── head.html
│   │   ├── header.html
│   │   ├── footer.html
│   │   ├── navigation.html
│   │   ├── search.html
│   │   └── breadcrumb.html
│   ├── shortcodes/
│   │   ├── note.html
│   │   └── warning.html
│   └── index.html
├── static/
│   ├── css/
│   ├── js/
│   └── images/
└── theme.toml

2.3 Базовая тема

<!-- layouts/_default/baseof.html -->
<!DOCTYPE html>
<html lang="{{ .Site.LanguageCode }}">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{{ if .IsHome }}{{ .Site.Title }}{{ else }}{{ .Title }} | {{ .Site.Title }}{{ end }}</title>
    
    {{ $style := resources.Get "css/main.scss" | resources.ToCSS | resources.Minify }}
    <link rel="stylesheet" href="{{ $style.RelPermalink }}">
    
    {{ partial "head.html" . }}
</head>
<body>
    {{ partial "header.html" . }}
    
    <main class="container">
        {{ block "main" . }}{{ end }}
    </main>
    
    {{ partial "footer.html" . }}
    
    {{ $script := resources.Get "js/main.js" | resources.Minify }}
    <script src="{{ $script.RelPermalink }}"></script>
</body>
</html>
<!-- layouts/_default/single.html -->
{{ define "main" }}
<article class="note">
    <header class="note-header">
        <h1>{{ .Title }}</h1>
        {{ if .Params.date }}
        <time datetime="{{ .Date.Format "2006-01-02" }}">
            {{ .Date.Format "2 January 2006" }}
        </time>
        {{ end }}
    </header>
    
    {{ partial "breadcrumb.html" . }}
    
    <div class="note-content">
        {{ .Content }}
    </div>
    
    {{ if .Params.tags }}
    <footer class="note-footer">
        <div class="tags">
            {{ range .Params.tags }}
            <a href="{{ "tags/" | relLangURL }}{{ . | urlize }}" class="tag">{{ . }}</a>
            {{ end }}
        </div>
    </footer>
    {{ end }}
</article>
{{ end }}

2.4 Стили

// assets/css/main.scss
@import "variables";

// Базовые стили
body {
    font-family: $font-family-base;
    line-height: 1.6;
    color: $text-color;
    background-color: $bg-color;
}

.container {
    max-width: $container-width;
    margin: 0 auto;
    padding: 0 $spacing;
}

// Заметки
.note {
    background: $note-bg;
    border-radius: $border-radius;
    padding: $spacing * 2;
    margin-bottom: $spacing * 2;
    box-shadow: $box-shadow;
    
    &-header {
        border-bottom: 1px solid $border-color;
        padding-bottom: $spacing;
        margin-bottom: $spacing * 2;
        
        h1 {
            margin: 0 0 $spacing 0;
            color: $heading-color;
        }
        
        time {
            color: $muted-color;
            font-size: 0.9em;
        }
    }
    
    &-content {
        font-size: 1.1em;
        line-height: 1.7;
        
        h1, h2, h3, h4, h5, h6 {
            color: $heading-color;
            margin-top: $spacing * 2;
            margin-bottom: $spacing;
        }
        
        p {
            margin-bottom: $spacing;
        }
        
        code {
            background: $code-bg;
            padding: 0.2em 0.4em;
            border-radius: 3px;
            font-size: 0.9em;
        }
        
        pre {
            background: $code-bg;
            padding: $spacing;
            border-radius: $border-radius;
            overflow-x: auto;
            
            code {
                background: none;
                padding: 0;
            }
        }
    }
}

// Навигация
.navigation {
    background: $nav-bg;
    padding: $spacing;
    margin-bottom: $spacing * 2;
    border-radius: $border-radius;
    
    ul {
        list-style: none;
        padding: 0;
        margin: 0;
        display: flex;
        gap: $spacing;
    }
    
    a {
        color: $nav-link-color;
        text-decoration: none;
        
        &:hover {
            color: $nav-link-hover-color;
        }
    }
}

// Поиск
.search {
    margin-bottom: $spacing * 2;
    
    input {
        width: 100%;
        padding: $spacing;
        border: 1px solid $border-color;
        border-radius: $border-radius;
        font-size: 1em;
        
        &:focus {
            outline: none;
            border-color: $primary-color;
        }
    }
}

// Хлебные крошки
.breadcrumb {
    margin-bottom: $spacing;
    font-size: 0.9em;
    color: $muted-color;
    
    a {
        color: $link-color;
        text-decoration: none;
        
        &:hover {
            text-decoration: underline;
        }
    }
}

3. Оптимизации

3.1 Производительность

# config.toml (дополнительные настройки)
[build]
  writeStats = true

[imaging]
  quality = 85
  resampleFilter = "Lanczos"

[minify]
  [minify.tdewolff]
    [minify.tdewolff.css]
      keepCSS2 = true
      precision = 0
    [minify.tdewolff.html]
      keepConditionalComments = true
      keepDefaultDoctype = true
      keepDocumentTags = true
      keepEndTags = true
      keepQuotes = false
      keepWhitespace = false
    [minify.tdewolff.js]
      keepVarNames = false
      precision = 0
    [minify.tdewolff.json]
      keepNumbers = false
      precision = 0
    [minify.tdewolff.svg]
      keepComments = false
    [minify.tdewolff.xml]
      keepWhitespace = false

3.2 SEO оптимизация

<!-- layouts/partials/head.html -->
{{ if .IsHome }}
<meta name="description" content="{{ .Site.Params.description }}">
{{ else }}
<meta name="description" content="{{ .Summary }}">
{{ end }}

<meta name="author" content="{{ .Site.Params.author }}">
<meta name="robots" content="index, follow">

<!-- Open Graph -->
<meta property="og:title" content="{{ if .IsHome }}{{ .Site.Title }}{{ else }}{{ .Title }}{{ end }}">
<meta property="og:description" content="{{ if .IsHome }}{{ .Site.Params.description }}{{ else }}{{ .Summary }}{{ end }}">
<meta property="og:type" content="{{ if .IsHome }}website{{ else }}article{{ end }}">
<meta property="og:url" content="{{ .Permalink }}">

<!-- Twitter Card -->
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="{{ if .IsHome }}{{ .Site.Title }}{{ else }}{{ .Title }}{{ end }}">
<meta name="twitter:description" content="{{ if .IsHome }}{{ .Site.Params.description }}{{ else }}{{ .Summary }}{{ end }}">

<!-- Canonical URL -->
<link rel="canonical" href="{{ .Permalink }}">

3.3 Поиск

// assets/js/search.js
class Search {
    constructor() {
        this.searchIndex = null;
        this.searchInput = document.getElementById('search-input');
        this.searchResults = document.getElementById('search-results');
        
        this.init();
    }
    
    async init() {
        await this.loadSearchIndex();
        this.bindEvents();
    }
    
    async loadSearchIndex() {
        try {
            const response = await fetch('/index.json');
            this.searchIndex = await response.json();
        } catch (error) {
            console.error('Failed to load search index:', error);
        }
    }
    
    bindEvents() {
        this.searchInput.addEventListener('input', (e) => {
            this.search(e.target.value);
        });
    }
    
    search(query) {
        if (!this.searchIndex || !query.trim()) {
            this.hideResults();
            return;
        }
        
        const results = this.searchIndex.filter(item => {
            const searchText = `${item.title} ${item.content}`.toLowerCase();
            return searchText.includes(query.toLowerCase());
        });
        
        this.displayResults(results.slice(0, 10));
    }
    
    displayResults(results) {
        if (results.length === 0) {
            this.searchResults.innerHTML = '<p>Ничего не найдено</p>';
        } else {
            const html = results.map(item => `
                <div class="search-result">
                    <h3><a href="${item.url}">${item.title}</a></h3>
                    <p>${item.content.substring(0, 150)}...</p>
                </div>
            `).join('');
            this.searchResults.innerHTML = html;
        }
        
        this.showResults();
    }
    
    showResults() {
        this.searchResults.style.display = 'block';
    }
    
    hideResults() {
        this.searchResults.style.display = 'none';
    }
}

// Инициализация поиска
document.addEventListener('DOMContentLoaded', () => {
    new Search();
});

4. Тестирование

4.1 Локальная разработка

#!/bin/bash
# scripts/dev.sh

echo "Запуск Hugo в режиме разработки..."

# Установка зависимостей
hugo mod get

# Запуск сервера разработки
hugo server \
    --bind 0.0.0.0 \
    --port 1313 \
    --disableFastRender \
    --noHTTPCache \
    --buildDrafts \
    --buildFuture

4.2 Тестирование сборки

#!/bin/bash
# scripts/test-build.sh

set -e

echo "Тестирование сборки Hugo..."

# Очистка предыдущей сборки
rm -rf public/

# Сборка сайта
hugo --minify

# Проверка размера
echo "Размер сборки:"
du -sh public/

# Проверка количества страниц
echo "Количество страниц:"
find public/ -name "*.html" | wc -l

echo "Сборка завершена успешно!"

5. Развертывание

5.1 Скрипт сборки для production

#!/bin/bash
# scripts/build-production.sh

set -e

echo "Сборка Hugo для production..."

# Очистка
rm -rf public/

# Сборка с оптимизациями
hugo \
    --minify \
    --gc \
    --cleanDestinationDir \
    --environment production

# Оптимизация изображений
find public/ -name "*.jpg" -o -name "*.png" | xargs -I {} convert {} -strip -quality 85 {}

# Сжатие статических файлов
find public/ -name "*.css" -o -name "*.js" | xargs gzip -9

echo "Production сборка завершена!"

5.2 Nginx конфигурация

# configs/nginx.conf
server {
    listen 80;
    server_name aepif.ru www.aepif.ru;
    
    root /var/www/html;
    index index.html;
    
    # Gzip сжатие
    gzip on;
    gzip_vary on;
    gzip_min_length 1024;
    gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
    
    # Кэширование статических файлов
    location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
    
    # Обработка Hugo страниц
    location / {
        try_files $uri $uri/ $uri.html =404;
    }
    
    # Обработка 404 ошибок
    error_page 404 /404.html;
    
    # Безопасность
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;
}