764 lines
19 KiB
Markdown
764 lines
19 KiB
Markdown
# 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 Скрипт миграции
|
||
|
||
```python
|
||
#!/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''
|
||
|
||
if __name__ == "__main__":
|
||
migrate_content()
|
||
print("Миграция контента завершена!")
|
||
```
|
||
|
||
## 2. Настройка Hugo
|
||
|
||
### 2.1 Базовая конфигурация
|
||
|
||
```toml
|
||
# 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 Базовая тема
|
||
|
||
```html
|
||
<!-- 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>
|
||
```
|
||
|
||
```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 Стили
|
||
|
||
```scss
|
||
// 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 Производительность
|
||
|
||
```toml
|
||
# 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 оптимизация
|
||
|
||
```html
|
||
<!-- 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 Поиск
|
||
|
||
```javascript
|
||
// 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 Локальная разработка
|
||
|
||
```bash
|
||
#!/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 Тестирование сборки
|
||
|
||
```bash
|
||
#!/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
|
||
|
||
```bash
|
||
#!/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 конфигурация
|
||
|
||
```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;
|
||
}
|
||
``` |