Compare commits
17 Commits
00e05f339e
...
8beca48705
| Author | SHA1 | Date | |
|---|---|---|---|
|
8beca48705
|
|||
|
7a0227244b
|
|||
|
fe45c44045
|
|||
|
4f7ee5f0d9
|
|||
|
0658459dfb
|
|||
|
7392ce4c58
|
|||
|
9db0e5d3e8
|
|||
|
0e571f3b8f
|
|||
|
6fde209464
|
|||
|
d32269abe3
|
|||
|
22866db191
|
|||
|
4b04afb912
|
|||
|
50d032ce62
|
|||
|
08a160a6a1
|
|||
|
7d488fbdf2
|
|||
|
675a5e95a9
|
|||
|
2523b5e7ef
|
@@ -1,3 +1,3 @@
|
|||||||
/node_modules
|
node_modules
|
||||||
/var
|
.astro
|
||||||
/vendor
|
.git
|
||||||
|
|||||||
@@ -4,16 +4,10 @@ root = true
|
|||||||
end_of_line = lf
|
end_of_line = lf
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
indent_style = space
|
indent_style = space
|
||||||
indent_size = 4
|
|
||||||
|
|
||||||
[*.{html,twig,yml,yaml,xml}]
|
|
||||||
indent_size = 2
|
indent_size = 2
|
||||||
|
|
||||||
[package.json]
|
|
||||||
indent_size = 2
|
|
||||||
|
|
||||||
[Makefile]
|
|
||||||
indent_style = tab
|
|
||||||
|
|
||||||
[*.md]
|
[*.md]
|
||||||
max_line_length = 80
|
max_line_length = 80
|
||||||
|
|
||||||
|
[Makefile]
|
||||||
|
indent_style = tab
|
||||||
|
|||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -4,10 +4,6 @@
|
|||||||
.config/
|
.config/
|
||||||
.home/
|
.home/
|
||||||
|
|
||||||
output_*
|
|
||||||
node_modules/
|
node_modules/
|
||||||
var/
|
dist/
|
||||||
vendor/
|
.astro/
|
||||||
|
|
||||||
.php_cs.cache
|
|
||||||
.php-cs-fixer.cache
|
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
/* vim: set ft=php: */
|
|
||||||
|
|
||||||
$finder = PhpCsFixer\Finder::create()
|
|
||||||
->in(__DIR__.'/app')
|
|
||||||
->in(__DIR__.'/bundle')
|
|
||||||
;
|
|
||||||
|
|
||||||
return (new PhpCsFixer\Config())
|
|
||||||
->setFinder($finder)
|
|
||||||
->setRules([
|
|
||||||
'@Symfony' => true,
|
|
||||||
'@PHP73Migration' => true,
|
|
||||||
'array_syntax' => ['syntax' => 'short'],
|
|
||||||
'no_useless_return' => true,
|
|
||||||
'ordered_imports' => true,
|
|
||||||
'phpdoc_order' => true,
|
|
||||||
'semicolon_after_instruction' => false,
|
|
||||||
'yoda_style' => false,
|
|
||||||
])
|
|
||||||
;
|
|
||||||
41
CLAUDE.md
Normal file
41
CLAUDE.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Personal homepage for [vakhrushev.me](https://vakhrushev.me) — a static site built with Astro 5, Tailwind CSS 4, and Vue 3. Russian-language content. All development runs inside Docker containers; npm is not used on the host.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
All commands use [Taskfile](https://taskfile.dev/) and run through Docker:
|
||||||
|
|
||||||
|
- `task build-docker` — build the Node Docker image (required first-time setup)
|
||||||
|
- `task install` — install npm dependencies (inside Docker)
|
||||||
|
- `task dev` — start dev server at `http://localhost:4321`
|
||||||
|
- `task build-prod` — production build (outputs to `dist/`)
|
||||||
|
- `task deploy` — full deploy pipeline: build → Docker nginx image → Ansible to server
|
||||||
|
- `task npm -- <args>` — run arbitrary npm commands inside Docker
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
**Static site generator**: Astro 5 with MDX, Vue, and Sitemap integrations. Tailwind CSS 4 via Vite plugin.
|
||||||
|
|
||||||
|
**Layouts** (three-level hierarchy):
|
||||||
|
- `BaseLayout.astro` — HTML shell, meta tags, Google Fonts, Yandex Metrika
|
||||||
|
- `InternalLayout.astro` → extends BaseLayout, adds navigation and page container
|
||||||
|
- `ArticleLayout.astro` → extends InternalLayout, adds article title and date
|
||||||
|
|
||||||
|
**Content**: Articles live in `src/content/articles/` as `.md`/`.mdx` files. Date is parsed from filename (`YYYY-MM-DD-slug.md`), not stored in frontmatter. Schema defined in `src/content.config.ts`.
|
||||||
|
|
||||||
|
**Pages**: `src/pages/` — index, articles list, `[slug]` dynamic article pages, gallery, 404, RSS feed (`rss.xml.ts`).
|
||||||
|
|
||||||
|
**Interactive component**: `PredictorDemo.vue` — Vue 3 island (`client:visible`) embedded in the predictor article via MDX. Uses `@anwinged/predictor` package.
|
||||||
|
|
||||||
|
**Utilities**: `src/utils/articles.ts` — `parseDateFromId()` extracts date from article filename ID.
|
||||||
|
|
||||||
|
**Styles**: `src/styles/global.css` — Tailwind 4 with custom theme (PT Serif font, 740px content width, GitHub-inspired colors).
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Production builds into a Docker nginx image (`docker/Dockerfile.nginx.prod`), deployed via Ansible from a separate `pet-project-server` repo. The deploy task tags images with `git-hash-timestamp`.
|
||||||
13
README.md
13
README.md
@@ -1,10 +1,17 @@
|
|||||||
Source for [vakhrushev.me](https://vakhrushev.me).
|
Source for [vakhrushev.me](https://vakhrushev.me).
|
||||||
|
|
||||||
Build:
|
Built with [Astro](https://astro.build/), Tailwind CSS, Vue 3.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
task build-docker
|
task build-docker
|
||||||
task build
|
task install
|
||||||
|
|
||||||
Deploy:
|
## Development
|
||||||
|
|
||||||
|
task dev
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
task build-prod
|
||||||
task deploy
|
task deploy
|
||||||
|
|||||||
74
Taskfile.yml
74
Taskfile.yml
@@ -9,103 +9,39 @@ vars:
|
|||||||
sh: id -g
|
sh: id -g
|
||||||
|
|
||||||
PROJECT: "homepage"
|
PROJECT: "homepage"
|
||||||
PHP_IMAGE: "{{.PROJECT}}-php"
|
|
||||||
NODE_IMAGE: "{{.PROJECT}}-node"
|
NODE_IMAGE: "{{.PROJECT}}-node"
|
||||||
|
|
||||||
DOCKER_COMMON_OPTS: >-
|
DOCKER_COMMON_OPTS: >-
|
||||||
--rm
|
--rm
|
||||||
--interactive
|
|
||||||
--tty
|
|
||||||
--user {{.USER_ID}}:{{.GROUP_ID}}
|
--user {{.USER_ID}}:{{.GROUP_ID}}
|
||||||
--volume /etc/passwd:/etc/passwd:ro
|
--volume /etc/passwd:/etc/passwd:ro
|
||||||
--volume /etc/group:/etc/group:ro
|
--volume /etc/group:/etc/group:ro
|
||||||
--volume "./:/srv/app"
|
--volume "./:/srv/app"
|
||||||
--workdir "/srv/app"
|
--workdir "/srv/app"
|
||||||
-e XDG_CONFIG_HOME=/srv/app/.config
|
|
||||||
-e XDG_CACHE_HOME=/srv/app/.cache
|
|
||||||
-e HOME=/srv/app/.home
|
-e HOME=/srv/app/.home
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
|
|
||||||
build-docker:
|
build-docker:
|
||||||
cmds:
|
cmds:
|
||||||
- docker build --file docker/php/Dockerfile --tag "{{.PHP_IMAGE}}" .
|
|
||||||
- docker build --file docker/node/Dockerfile --tag "{{.NODE_IMAGE}}" .
|
- docker build --file docker/node/Dockerfile --tag "{{.NODE_IMAGE}}" .
|
||||||
|
|
||||||
composer:
|
|
||||||
cmds:
|
|
||||||
- docker run {{.DOCKER_COMMON_OPTS}} "{{.PHP_IMAGE}}" composer {{.CLI_ARGS}}
|
|
||||||
|
|
||||||
npm:
|
npm:
|
||||||
cmds:
|
cmds:
|
||||||
- docker run {{.DOCKER_COMMON_OPTS}} "{{.NODE_IMAGE}}" npm {{.CLI_ARGS}}
|
- docker run {{.DOCKER_COMMON_OPTS}} "{{.NODE_IMAGE}}" npm {{.CLI_ARGS}}
|
||||||
|
|
||||||
sculpin:
|
install:
|
||||||
cmds:
|
cmds:
|
||||||
- docker run {{.DOCKER_COMMON_OPTS}} "{{.PHP_IMAGE}}" ./vendor/bin/sculpin {{.CLI_ARGS}}
|
|
||||||
|
|
||||||
shell-node:
|
|
||||||
cmds:
|
|
||||||
- docker run {{.DOCKER_COMMON_OPTS}} "{{.NODE_IMAGE}}" bash
|
|
||||||
|
|
||||||
install-dependencies:
|
|
||||||
cmds:
|
|
||||||
- task: composer
|
|
||||||
vars: { CLI_ARGS: "install" }
|
|
||||||
- task: npm
|
- task: npm
|
||||||
vars: { CLI_ARGS: "install" }
|
vars: { CLI_ARGS: "install" }
|
||||||
|
|
||||||
format-pages:
|
dev:
|
||||||
cmds:
|
cmds:
|
||||||
- task: npm
|
- docker run {{.DOCKER_COMMON_OPTS}} -p 4321:4321 "{{.NODE_IMAGE}}" npm run dev
|
||||||
vars: { CLI_ARGS: 'run format-md' }
|
|
||||||
|
|
||||||
format-assets:
|
|
||||||
cmds:
|
|
||||||
- task: npm
|
|
||||||
vars: { CLI_ARGS: 'run format-webpack' }
|
|
||||||
- task: npm
|
|
||||||
vars: { CLI_ARGS: 'run format-js' }
|
|
||||||
- task: npm
|
|
||||||
vars: { CLI_ARGS: 'run format-vue' }
|
|
||||||
- task: npm
|
|
||||||
vars: { CLI_ARGS: 'run format-style' }
|
|
||||||
|
|
||||||
format-php:
|
|
||||||
cmds:
|
|
||||||
- docker run {{.DOCKER_COMMON_OPTS}} "{{.PHP_IMAGE}}" php-cs-fixer fix
|
|
||||||
|
|
||||||
build-dev:
|
|
||||||
vars:
|
|
||||||
APP_OUTPUT_DIR: output_dev
|
|
||||||
NPM_SCRIPT: build
|
|
||||||
APP_ENV: dev
|
|
||||||
APP_URL: homepage.site
|
|
||||||
cmds:
|
|
||||||
- rm -rf ./{{.APP_OUTPUT_DIR}}/*
|
|
||||||
- task: npm
|
|
||||||
vars: { CLI_ARGS: 'run {{.NPM_SCRIPT}}' }
|
|
||||||
- task: sculpin
|
|
||||||
vars: { CLI_ARGS: 'generate --env="{{.APP_ENV}}" --url="{{.APP_URL}}" --no-interaction -vv' }
|
|
||||||
|
|
||||||
build-prod:
|
build-prod:
|
||||||
vars:
|
|
||||||
APP_OUTPUT_DIR: output_prod
|
|
||||||
NPM_SCRIPT: build-prod
|
|
||||||
APP_ENV: prod
|
|
||||||
APP_URL: https://vakhrushev.me
|
|
||||||
cmds:
|
cmds:
|
||||||
- rm -rf ./{{.APP_OUTPUT_DIR}}/*
|
- docker run {{.DOCKER_COMMON_OPTS}} "{{.NODE_IMAGE}}" npm run build
|
||||||
- task: npm
|
|
||||||
vars: { CLI_ARGS: 'run {{.NPM_SCRIPT}}' }
|
|
||||||
- task: sculpin
|
|
||||||
vars: { CLI_ARGS: 'generate --env="{{.APP_ENV}}" --url="{{.APP_URL}}" --no-interaction -vv' }
|
|
||||||
|
|
||||||
make-post:
|
|
||||||
vars:
|
|
||||||
POST_DATE:
|
|
||||||
sh: date +'%Y-%m-%d'
|
|
||||||
cmd: touch "source/_articles/{{.POST_DATE}}-new-post.md"
|
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
vars:
|
vars:
|
||||||
@@ -129,4 +65,4 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- yq --inplace '.homepage_nginx_image = "{{.DOCKER_IMAGE}}"' vars/homepage.images.yml
|
- yq --inplace '.homepage_nginx_image = "{{.DOCKER_IMAGE}}"' vars/homepage.images.yml
|
||||||
- "git commit vars/homepage.images.yml --message 'Homepage: release {{.DOCKER_IMAGE}}'"
|
- "git commit vars/homepage.images.yml --message 'Homepage: release {{.DOCKER_IMAGE}}'"
|
||||||
- ansible-playbook -i production.yml playbook-homepage-registry.yml playbook-homepage.yml
|
- uv run ansible-playbook -i production.yml playbook-homepage-registry.yml playbook-homepage.yml
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Homepage\HtmlPrettierBundle\HtmlPrettierBundle;
|
|
||||||
use Homepage\SiteMapBundle\SiteMapBundle;
|
|
||||||
use Homepage\TwigExtensionBundle\TwigExtensionBundle;
|
|
||||||
use Sculpin\Bundle\SculpinBundle\HttpKernel\AbstractKernel;
|
|
||||||
|
|
||||||
class SculpinKernel extends AbstractKernel
|
|
||||||
{
|
|
||||||
protected function getAdditionalSculpinBundles(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
TwigExtensionBundle::class,
|
|
||||||
SiteMapBundle::class,
|
|
||||||
HtmlPrettierBundle::class,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
sculpin:
|
|
||||||
ignore:
|
|
||||||
- _assets/
|
|
||||||
|
|
||||||
sculpin_content_types:
|
|
||||||
posts:
|
|
||||||
enabled: false
|
|
||||||
articles:
|
|
||||||
type: path
|
|
||||||
path: _articles
|
|
||||||
singular_name: article
|
|
||||||
layout: article
|
|
||||||
permalink: articles/:year-:month-:day-:basename/
|
|
||||||
publish_drafts: false
|
|
||||||
enabled: true
|
|
||||||
taxonomies:
|
|
||||||
- tags
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
---
|
|
||||||
title: Антон Вахрушев
|
|
||||||
author: Антон Вахрушев
|
|
||||||
email: anton@vakhrushev.me
|
|
||||||
24
astro.config.mjs
Normal file
24
astro.config.mjs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { defineConfig } from 'astro/config';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import mdx from '@astrojs/mdx';
|
||||||
|
import vue from '@astrojs/vue';
|
||||||
|
import sitemap from '@astrojs/sitemap';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
site: 'https://vakhrushev.me',
|
||||||
|
integrations: [mdx(), vue(), sitemap()],
|
||||||
|
markdown: {
|
||||||
|
shikiConfig: {
|
||||||
|
themes: {
|
||||||
|
light: 'github-light',
|
||||||
|
dark: 'github-dark',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
vite: {
|
||||||
|
plugins: [tailwindcss()],
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ['@anwinged/predictor'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Homepage\HtmlPrettierBundle\DependencyInjection;
|
|
||||||
|
|
||||||
use Symfony\Component\Config\FileLocator;
|
|
||||||
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
|
||||||
use Symfony\Component\DependencyInjection\Loader;
|
|
||||||
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
|
|
||||||
|
|
||||||
class HtmlPrettierExtension extends Extension
|
|
||||||
{
|
|
||||||
public function load(array $configs, ContainerBuilder $container)
|
|
||||||
{
|
|
||||||
$loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
|
|
||||||
$loader->load('services.yml');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Homepage\HtmlPrettierBundle;
|
|
||||||
|
|
||||||
use Sculpin\Core\Event\SourceSetEvent;
|
|
||||||
use Sculpin\Core\Sculpin;
|
|
||||||
use Sculpin\Core\Source\SourceInterface;
|
|
||||||
use Sculpin\Core\Source\SourceSet;
|
|
||||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
|
||||||
|
|
||||||
class HtmlPrettier implements EventSubscriberInterface
|
|
||||||
{
|
|
||||||
public static function getSubscribedEvents(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Sculpin::EVENT_AFTER_FORMAT => ['formatHtml', -100],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function formatHtml(SourceSetEvent $event): void
|
|
||||||
{
|
|
||||||
$config = [
|
|
||||||
'output-html' => true,
|
|
||||||
'drop-empty-elements' => false,
|
|
||||||
'indent' => true,
|
|
||||||
'wrap' => 120,
|
|
||||||
];
|
|
||||||
|
|
||||||
$sources = $this->filterSource($event->sourceSet());
|
|
||||||
|
|
||||||
/** @var SourceInterface $source */
|
|
||||||
foreach ($sources as $source) {
|
|
||||||
$html = $source->formattedContent();
|
|
||||||
$tidy = new \tidy();
|
|
||||||
$tidy->parseString($html, $config, 'utf8');
|
|
||||||
$tidy->cleanRepair();
|
|
||||||
$source->setFormattedContent((string) $tidy);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function filterSource(SourceSet $sourceSet): \Generator
|
|
||||||
{
|
|
||||||
/** @var SourceInterface $source */
|
|
||||||
foreach ($sourceSet->allSources() as $source) {
|
|
||||||
$filename = $source->filename();
|
|
||||||
|
|
||||||
$isSuitable = $this->endsWith($filename, '.md')
|
|
||||||
|| $this->endsWith($filename, '.html.twig')
|
|
||||||
;
|
|
||||||
|
|
||||||
if ($isSuitable) {
|
|
||||||
yield $source;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function endsWith($haystack, $needle): bool
|
|
||||||
{
|
|
||||||
$length = \strlen($needle);
|
|
||||||
|
|
||||||
return $length === 0 || (substr($haystack, -$length) === $needle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Homepage\HtmlPrettierBundle;
|
|
||||||
|
|
||||||
use Symfony\Component\HttpKernel\Bundle\Bundle;
|
|
||||||
|
|
||||||
class HtmlPrettierBundle extends Bundle
|
|
||||||
{
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
services:
|
|
||||||
homepage.html_prettier:
|
|
||||||
class: \Homepage\HtmlPrettierBundle\HtmlPrettier
|
|
||||||
tags:
|
|
||||||
- { name: kernel.event_subscriber }
|
|
||||||
public: yes
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Homepage\SiteMapBundle\DependencyInjection;
|
|
||||||
|
|
||||||
use Symfony\Component\Config\FileLocator;
|
|
||||||
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
|
||||||
use Symfony\Component\DependencyInjection\Loader;
|
|
||||||
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
|
|
||||||
|
|
||||||
class SiteMapExtension extends Extension
|
|
||||||
{
|
|
||||||
public function load(array $configs, ContainerBuilder $container)
|
|
||||||
{
|
|
||||||
$loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
|
|
||||||
$loader->load('services.yml');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
services:
|
|
||||||
homepage.site_map_generator:
|
|
||||||
class: \Homepage\SiteMapBundle\SiteMapGenerator
|
|
||||||
tags:
|
|
||||||
- { name: kernel.event_subscriber }
|
|
||||||
- { name: sculpin.data_provider, alias: sitemap }
|
|
||||||
public: yes
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Homepage\SiteMapBundle;
|
|
||||||
|
|
||||||
use Symfony\Component\HttpKernel\Bundle\Bundle;
|
|
||||||
|
|
||||||
class SiteMapBundle extends Bundle
|
|
||||||
{
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Homepage\SiteMapBundle;
|
|
||||||
|
|
||||||
use Sculpin\Core\DataProvider\DataProviderInterface;
|
|
||||||
use Sculpin\Core\Event\SourceSetEvent;
|
|
||||||
use Sculpin\Core\Sculpin;
|
|
||||||
use Sculpin\Core\Source\SourceInterface;
|
|
||||||
use Sculpin\Core\Source\SourceSet;
|
|
||||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
|
||||||
|
|
||||||
class SiteMapGenerator implements DataProviderInterface, EventSubscriberInterface
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @var array|null
|
|
||||||
*/
|
|
||||||
private $siteMap;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var SourceSet
|
|
||||||
*/
|
|
||||||
private $sources;
|
|
||||||
|
|
||||||
public static function getSubscribedEvents(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Sculpin::EVENT_BEFORE_RUN => 'saveSourceSet',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provide data.
|
|
||||||
*/
|
|
||||||
public function provideData(): array
|
|
||||||
{
|
|
||||||
$this->buildSiteMap();
|
|
||||||
|
|
||||||
return $this->siteMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Before run.
|
|
||||||
*
|
|
||||||
* @param SourceSetEvent $sourceSetEvent Source Set Event
|
|
||||||
*/
|
|
||||||
public function saveSourceSet(SourceSetEvent $sourceSetEvent)
|
|
||||||
{
|
|
||||||
$this->sources = $sourceSetEvent->sourceSet();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function buildSiteMap(): array
|
|
||||||
{
|
|
||||||
if ($this->siteMap !== null) {
|
|
||||||
return $this->siteMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->siteMap = $this->createSiteMap();
|
|
||||||
|
|
||||||
return $this->siteMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createSiteMap(): array
|
|
||||||
{
|
|
||||||
$siteMap = [];
|
|
||||||
|
|
||||||
/** @var \Sculpin\Core\Source\FileSource $source */
|
|
||||||
foreach ($this->sources->allSources() as $source) {
|
|
||||||
$url = $this->createSiteUrlFromSource($source);
|
|
||||||
if (!$url) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$siteMap[$url['loc']] = $url;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $siteMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createSiteUrlFromSource(SourceInterface $source): array
|
|
||||||
{
|
|
||||||
$data = $source->data()->export();
|
|
||||||
|
|
||||||
if (empty($data) || $source->useFileReference()) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (array_key_exists('draft', $data) && $data['draft']) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$siteMapData = $data['sitemap'] ?? [];
|
|
||||||
|
|
||||||
if (array_key_exists('_exclude', $siteMapData)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$loc = $data['canonical'] ?? $data['url'];
|
|
||||||
|
|
||||||
if (is_callable([$source, 'file'])) {
|
|
||||||
$lastmod = date(DATE_W3C, $source->file()->getMTime());
|
|
||||||
} else {
|
|
||||||
$lastmod = date(DATE_W3C);
|
|
||||||
}
|
|
||||||
|
|
||||||
$url = [
|
|
||||||
'loc' => $loc,
|
|
||||||
'lastmod' => $lastmod,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (isset($data['sitemap'])) {
|
|
||||||
$url = array_merge($url, $data['sitemap']);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $url;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Homepage\TwigExtensionBundle\DependencyInjection;
|
|
||||||
|
|
||||||
use Symfony\Component\Config\FileLocator;
|
|
||||||
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
|
||||||
use Symfony\Component\DependencyInjection\Loader;
|
|
||||||
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
|
|
||||||
|
|
||||||
class TwigExtensionExtension extends Extension
|
|
||||||
{
|
|
||||||
public function load(array $configs, ContainerBuilder $container)
|
|
||||||
{
|
|
||||||
$loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
|
|
||||||
$loader->load('services.yml');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
services:
|
|
||||||
homepage.twig_extension:
|
|
||||||
class: \Homepage\TwigExtensionBundle\TwigExtension
|
|
||||||
arguments:
|
|
||||||
- '%sculpin.output_dir%'
|
|
||||||
public: yes
|
|
||||||
tags:
|
|
||||||
- { name: twig.extension }
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Homepage\TwigExtensionBundle;
|
|
||||||
|
|
||||||
use Twig\Extension\AbstractExtension;
|
|
||||||
use Twig\TwigFunction;
|
|
||||||
|
|
||||||
class TwigExtension extends AbstractExtension
|
|
||||||
{
|
|
||||||
private $outputDir;
|
|
||||||
|
|
||||||
public function __construct(string $outputDir)
|
|
||||||
{
|
|
||||||
$this->outputDir = $outputDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getFunctions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
new TwigFunction('hashed_asset', [$this, 'createHashedFileLink']),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function createHashedFileLink(string $path): string
|
|
||||||
{
|
|
||||||
$fullPath = $this->join($this->outputDir, $path);
|
|
||||||
$realPath = realpath($fullPath);
|
|
||||||
|
|
||||||
if (!file_exists($realPath)) {
|
|
||||||
return sprintf('%s?v=%s', $path, time());
|
|
||||||
}
|
|
||||||
|
|
||||||
$hash = md5_file($realPath);
|
|
||||||
|
|
||||||
return sprintf('%s?v=%s', $path, $hash);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function join(string $base, string $path): string
|
|
||||||
{
|
|
||||||
return $path ? rtrim($base, '/').'/'.ltrim($path, '/') : $base;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Homepage\TwigExtensionBundle;
|
|
||||||
|
|
||||||
use Symfony\Component\HttpKernel\Bundle\Bundle;
|
|
||||||
|
|
||||||
class TwigExtensionBundle extends Bundle
|
|
||||||
{
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "anwinged/homepage",
|
|
||||||
"description": "Anton Vakhrushev homepage",
|
|
||||||
"type": "project",
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Anton Vakhrushev",
|
|
||||||
"email": "anwinged@ya.ru"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"require": {
|
|
||||||
"php": "^8.0",
|
|
||||||
"ext-tidy": "*"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"sculpin/sculpin": "^3.0"
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Homepage\\": "bundle/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"config": {
|
|
||||||
"allow-plugins": {
|
|
||||||
"sculpin/sculpin-theme-composer-plugin": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
3522
composer.lock
generated
3522
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
|||||||
FROM nginx:stable
|
FROM nginx:stable
|
||||||
|
|
||||||
COPY output_prod /usr/share/nginx/html
|
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY dist /usr/share/nginx/html
|
||||||
|
|||||||
25
docker/nginx.conf
Normal file
25
docker/nginx.conf
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# 404 fallback
|
||||||
|
error_page 404 /404.html;
|
||||||
|
|
||||||
|
# Static assets caching
|
||||||
|
location /_astro/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Redirect old article URLs (/articles/YYYY/MM/DD/slug/ -> /articles/YYYY-MM-DD-slug/)
|
||||||
|
location ~ "^/articles/(\d\d\d\d)/(\d\d)/(\d\d)/([^/]+)/?$" {
|
||||||
|
return 301 /articles/$1-$2-$3-$4/;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Redirect old atom feed
|
||||||
|
location = /atom.xml {
|
||||||
|
return 301 /rss.xml;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
FROM node:12
|
FROM node:22-slim
|
||||||
|
|
||||||
ENV npm_config_fund=false
|
|
||||||
ENV npm_config_update_notifier=false
|
|
||||||
|
|
||||||
WORKDIR /srv/app
|
WORKDIR /srv/app
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
FROM php:8.1-cli
|
|
||||||
|
|
||||||
ENV COMPOSER_FUND=0
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y \
|
|
||||||
curl \
|
|
||||||
git \
|
|
||||||
gnupg \
|
|
||||||
gzip \
|
|
||||||
libtidy-dev \
|
|
||||||
rsync \
|
|
||||||
zip \
|
|
||||||
;
|
|
||||||
|
|
||||||
RUN docker-php-ext-install tidy \
|
|
||||||
&& docker-php-ext-enable tidy
|
|
||||||
|
|
||||||
# Composer and required tools
|
|
||||||
RUN curl -sLO https://getcomposer.org/download/2.8.4/composer.phar \
|
|
||||||
&& mv composer.phar /usr/local/bin/composer \
|
|
||||||
&& chmod +x /usr/local/bin/composer
|
|
||||||
|
|
||||||
|
|
||||||
# PHP-CS-Fixer
|
|
||||||
RUN curl -sLO https://github.com/FriendsOfPHP/PHP-CS-Fixer/releases/download/v3.65.0/php-cs-fixer.phar \
|
|
||||||
&& mv php-cs-fixer.phar /usr/local/bin/php-cs-fixer \
|
|
||||||
&& chmod +x /usr/local/bin/php-cs-fixer
|
|
||||||
|
|
||||||
WORKDIR /srv/app
|
|
||||||
12852
package-lock.json
generated
12852
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
60
package.json
60
package.json
@@ -1,49 +1,23 @@
|
|||||||
{
|
{
|
||||||
"name": "homepage",
|
"name": "homepage",
|
||||||
"author": "Anton Vakhrushev",
|
"author": "Anton Vakhrushev",
|
||||||
"license": "",
|
"version": "2.0.0",
|
||||||
"version": "1.0.0",
|
"type": "module",
|
||||||
"description": "Homepage",
|
"description": "Anton Vakhrushev homepage",
|
||||||
"devDependencies": {
|
|
||||||
"@anwinged/predictor": "^0.2.1",
|
|
||||||
"@babel/core": "^7.14.6",
|
|
||||||
"@babel/plugin-proposal-class-properties": "^7.14.5",
|
|
||||||
"@babel/plugin-transform-runtime": "^7.14.5",
|
|
||||||
"@babel/preset-env": "^7.14.7",
|
|
||||||
"@babel/runtime": "^7.14.6",
|
|
||||||
"autoprefixer": "^9.8.6",
|
|
||||||
"babel-loader": "^8.2.2",
|
|
||||||
"css-loader": "^2.1.1",
|
|
||||||
"glob": "^7.1.7",
|
|
||||||
"mini-css-extract-plugin": "^0.6.0",
|
|
||||||
"node-sass": "^4.14.1",
|
|
||||||
"postcss-loader": "^3.0.0",
|
|
||||||
"prettier": "^1.19.1",
|
|
||||||
"sass-loader": "^7.3.1",
|
|
||||||
"style-loader": "^0.23.1",
|
|
||||||
"underscore": "^1.13.1",
|
|
||||||
"vue": "^2.6.14",
|
|
||||||
"vue-loader": "^15.9.7",
|
|
||||||
"vue-style-loader": "^4.1.3",
|
|
||||||
"vue-template-compiler": "^2.6.14",
|
|
||||||
"webpack": "^4.46.0",
|
|
||||||
"webpack-cli": "^3.3.12"
|
|
||||||
},
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"watch": "",
|
"dev": "astro dev --host 0.0.0.0",
|
||||||
"build": "webpack --config webpack.config.js --progress",
|
"build": "astro build",
|
||||||
"build-prod": "webpack --config webpack.config.js --env.production",
|
"preview": "astro preview --host 0.0.0.0"
|
||||||
"format-webpack": "prettier --single-quote --trailing-comma es5 --write \"./webpack.config.js\"",
|
|
||||||
"format-js": "prettier --single-quote --trailing-comma es5 --write \"./source/_assets/**/*.js\"",
|
|
||||||
"format-vue": "prettier --single-quote --trailing-comma es5 --write \"./source/_assets/**/*.vue\"",
|
|
||||||
"format-style": "prettier --single-quote --write \"source/_assets/**/*.scss\"",
|
|
||||||
"format-md": "prettier --print-width=80 --parser=markdown --write \"source/**/*.md\""
|
|
||||||
},
|
},
|
||||||
"dependencies": {},
|
"dependencies": {
|
||||||
"browserslist": [
|
"astro": "^5",
|
||||||
"last 5 version",
|
"@astrojs/vue": "^5",
|
||||||
"> 1%",
|
"@astrojs/sitemap": "^3",
|
||||||
"maintained node versions",
|
"@astrojs/rss": "^4",
|
||||||
"not dead"
|
"@astrojs/mdx": "^4",
|
||||||
]
|
"vue": "^3",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"@tailwindcss/vite": "^4",
|
||||||
|
"@anwinged/predictor": "^0.2.1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
public/robots.txt
Normal file
2
public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Sitemap: https://vakhrushev.me/sitemap-index.xml
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
---
|
|
||||||
layout: internal
|
|
||||||
title: Страница не найдена
|
|
||||||
description: Станица не найдена
|
|
||||||
styles:
|
|
||||||
- /static/index.css
|
|
||||||
---
|
|
||||||
|
|
||||||
# 404
|
|
||||||
|
|
||||||
Ой, страница не найдена.
|
|
||||||
|
|
||||||
Давайте посмотрим, что интересное есть на [главной](/).
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
@import 'vars';
|
|
||||||
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
font-size: $base-font-size;
|
|
||||||
font-family: $base-font-family;
|
|
||||||
color: $base-font-color;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
|
|
||||||
@media (max-width: $first-media-step) {
|
|
||||||
font-size: $base-font-size - 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h1,
|
|
||||||
h2,
|
|
||||||
h3,
|
|
||||||
h4,
|
|
||||||
h5,
|
|
||||||
h6 {
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:not(:visited) {
|
|
||||||
color: $link-color;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover,
|
|
||||||
a:focus,
|
|
||||||
a:active {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hr-line {
|
|
||||||
height: 0;
|
|
||||||
display: block;
|
|
||||||
border-bottom: 1px solid $rule-color;
|
|
||||||
margin: 2em 0;
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
@import '../vars';
|
|
||||||
|
|
||||||
$button-border-radius: 0.5em;
|
|
||||||
$button-background-color: desaturate(darken($link-color, 5%), 20%);
|
|
||||||
|
|
||||||
%button {
|
|
||||||
display: inline-block;
|
|
||||||
color: #fff;
|
|
||||||
background-color: $button-background-color;
|
|
||||||
padding: 0.6em 1.2em;
|
|
||||||
border: none;
|
|
||||||
font-size: 100%;
|
|
||||||
font-family: inherit;
|
|
||||||
border-radius: $button-border-radius;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: darken($button-background-color, 10%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
@import '../vars';
|
|
||||||
|
|
||||||
.social {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 2.4em 0;
|
|
||||||
display: block;
|
|
||||||
text-align: left;
|
|
||||||
|
|
||||||
&__item {
|
|
||||||
display: inline-block;
|
|
||||||
margin: 0.25em 0.5em;
|
|
||||||
font-size: 120%;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__link {
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__link:hover {
|
|
||||||
color: $link-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
@import '../vars';
|
|
||||||
@import '../base_style';
|
|
||||||
|
|
||||||
.content {
|
|
||||||
width: $preferred-width;
|
|
||||||
margin: 0 auto;
|
|
||||||
|
|
||||||
@media (max-width: $first-media-step) {
|
|
||||||
width: auto;
|
|
||||||
margin: {
|
|
||||||
left: $mobile-margin;
|
|
||||||
right: $mobile-margin;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@import 'social';
|
|
||||||
|
|
||||||
.name {
|
|
||||||
margin-top: 0.8em;
|
|
||||||
font-size: 2.4em;
|
|
||||||
margin-bottom: 0.6em;
|
|
||||||
font-weight: bold;
|
|
||||||
|
|
||||||
@media (max-width: $first-media-step) {
|
|
||||||
margin-top: $mobile-margin;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.info {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 0.8em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.year-title {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
@import '../vars';
|
|
||||||
|
|
||||||
p {
|
|
||||||
line-height: 1.5;
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
margin-top: 1.2em;
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
|
|
||||||
@media (max-width: $first-media-step) {
|
|
||||||
margin-top: 0.8em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pre {
|
|
||||||
font-family: $base-monospace-font-family;
|
|
||||||
font-size: 90%;
|
|
||||||
padding: 1em;
|
|
||||||
background-color: #f6f8fa; // from github
|
|
||||||
border-radius: 5px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
font-family: $base-monospace-font-family;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
hr {
|
|
||||||
color: $rule-color;
|
|
||||||
border-top: 1px solid $rule-color;
|
|
||||||
border-left: none;
|
|
||||||
border-right: none;
|
|
||||||
border-bottom: none;
|
|
||||||
margin-block-start: 1em;
|
|
||||||
margin-block-end: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
margin-bottom: 0.6em;
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
@import '../vars';
|
|
||||||
|
|
||||||
.navigation {
|
|
||||||
display: block;
|
|
||||||
border-bottom: 1px solid $rule-color;
|
|
||||||
margin-bottom: 1em;
|
|
||||||
|
|
||||||
&__actions {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__action {
|
|
||||||
display: inline-block;
|
|
||||||
margin: {
|
|
||||||
top: 1em;
|
|
||||||
bottom: 1em;
|
|
||||||
right: 1em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__link {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
@import '../vars';
|
|
||||||
@import '../base_style';
|
|
||||||
@import 'navigation';
|
|
||||||
|
|
||||||
.page {
|
|
||||||
width: $preferred-width;
|
|
||||||
margin: 0 auto;
|
|
||||||
|
|
||||||
@media (max-width: $first-media-step) {
|
|
||||||
width: auto;
|
|
||||||
margin-left: $mobile-margin;
|
|
||||||
margin-right: $mobile-margin;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
margin-bottom: 3em;
|
|
||||||
|
|
||||||
@import 'content-tags';
|
|
||||||
@import 'youtube-embed';
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
.youtube-embed-container {
|
|
||||||
position: relative;
|
|
||||||
padding-bottom: 56.25%;
|
|
||||||
height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
max-width: 100%;
|
|
||||||
|
|
||||||
iframe,
|
|
||||||
object,
|
|
||||||
embed {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="app" tabindex="0" v-on:keyup="press">
|
|
||||||
<div v-if="isHumanWin">
|
|
||||||
<p>Победа! Было очень сложно, но вы справились, поздравляю!</p>
|
|
||||||
<button class="restart-button" v-on:click.prevent="restart">
|
|
||||||
Заново
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="isRobotWin">
|
|
||||||
<p>
|
|
||||||
Упс, железяка победила. Оказывается, предсказать выбор человека
|
|
||||||
не так уж и сложно, да?
|
|
||||||
</p>
|
|
||||||
<button class="restart-button" v-on:click.prevent="restart">
|
|
||||||
Заново
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
<p class="score">{{ predictor.score }}</p>
|
|
||||||
<p class="step">Ход {{ step }}</p>
|
|
||||||
<div class="buttons">
|
|
||||||
<button
|
|
||||||
class="pass-button __left"
|
|
||||||
value="0"
|
|
||||||
v-on:click.prevent="click(0)"
|
|
||||||
>
|
|
||||||
Нечет
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="pass-button __right"
|
|
||||||
value="1"
|
|
||||||
v-on:click.prevent="click(1)"
|
|
||||||
>
|
|
||||||
Чет
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import Predictor from '@anwinged/predictor';
|
|
||||||
|
|
||||||
const MAX_SCORE = 50;
|
|
||||||
|
|
||||||
function make_predictor() {
|
|
||||||
return new Predictor({
|
|
||||||
base: 2,
|
|
||||||
daemons: [
|
|
||||||
{ human: 3, robot: 3 },
|
|
||||||
{ human: 4, robot: 4 },
|
|
||||||
{ human: 5, robot: 5 },
|
|
||||||
{ human: 6, robot: 6 },
|
|
||||||
{ human: 8, robot: 8 },
|
|
||||||
{ human: 12, robot: 12 },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
predictor: make_predictor(),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
isHumanWin() {
|
|
||||||
return this.predictor.score >= MAX_SCORE;
|
|
||||||
},
|
|
||||||
isRobotWin() {
|
|
||||||
return this.predictor.score <= -MAX_SCORE;
|
|
||||||
},
|
|
||||||
step() {
|
|
||||||
return this.predictor.stepCount() + 1;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
click(v) {
|
|
||||||
const value = v ? 1 : 0;
|
|
||||||
this.pass(value);
|
|
||||||
},
|
|
||||||
press(evt) {
|
|
||||||
const value = evt.key === '1' ? 0 : 1;
|
|
||||||
this.pass(value);
|
|
||||||
},
|
|
||||||
pass(value) {
|
|
||||||
const oldScore = this.predictor.score;
|
|
||||||
if (Math.abs(oldScore) >= MAX_SCORE) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* const prediction = */ this.predictor.pass(+value);
|
|
||||||
},
|
|
||||||
restart() {
|
|
||||||
this.predictor = make_predictor();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
@import '../vars';
|
|
||||||
@import '../components/button';
|
|
||||||
|
|
||||||
.app {
|
|
||||||
display: block;
|
|
||||||
margin: 2em auto;
|
|
||||||
padding: 2em;
|
|
||||||
text-align: center;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
|
|
||||||
@media (max-width: $first-media-step) {
|
|
||||||
padding: {
|
|
||||||
right: 0;
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: $rule-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.score {
|
|
||||||
font-size: 400%;
|
|
||||||
margin-top: 0.2em;
|
|
||||||
margin-bottom: 0.2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 3em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buttons {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.restart-button {
|
|
||||||
@extend %button;
|
|
||||||
padding: {
|
|
||||||
left: 1.4em;
|
|
||||||
right: 1.4em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.pass-button {
|
|
||||||
@extend %button;
|
|
||||||
flex-grow: 0;
|
|
||||||
min-width: 7em;
|
|
||||||
margin: 0.2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pass-button.__left {
|
|
||||||
border-top-right-radius: 0;
|
|
||||||
border-bottom-right-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pass-button.__right {
|
|
||||||
border-top-left-radius: 0;
|
|
||||||
border-bottom-left-radius: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import Vue from 'vue';
|
|
||||||
import Demo from './demo.vue';
|
|
||||||
|
|
||||||
new Vue({
|
|
||||||
el: '#app',
|
|
||||||
render: h => h(Demo),
|
|
||||||
});
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
$base-font-family: 'PT Serif', serif;
|
|
||||||
$base-monospace-font-family: 'Source Code Pro', monospace;
|
|
||||||
|
|
||||||
// Базовый размер шрифта
|
|
||||||
$base-font-size: 20px;
|
|
||||||
$base-font-color: #24292e; // from github
|
|
||||||
|
|
||||||
// Цвет ссылок
|
|
||||||
$link-color: #0366d6; // from github
|
|
||||||
|
|
||||||
// Ширина страницы
|
|
||||||
$preferred-width: 740px;
|
|
||||||
|
|
||||||
$first-media-step: $preferred-width - 1px;
|
|
||||||
|
|
||||||
$mobile-margin: 0.8em;
|
|
||||||
|
|
||||||
$rule-color: #e6e6e6;
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{% for year in range(2099, 2010, -1) %}
|
|
||||||
|
|
||||||
{% set items = [] %}
|
|
||||||
|
|
||||||
{% for article in data.articles %}
|
|
||||||
{% if article.date|date('Y') == year %}
|
|
||||||
{% set items = items|merge([article]) %}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
{% if items %}
|
|
||||||
<h2 class="year-title">{{ year }}</h2>
|
|
||||||
{% for article in items %}
|
|
||||||
<h3 class="article-title">
|
|
||||||
<a href="{{ article.url }}">{{ article.title }}</a>
|
|
||||||
</h3>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% endfor %}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<!-- Yandex.Metrika counter -->
|
|
||||||
<script type="text/javascript" >
|
|
||||||
(function (d, w, c) {
|
|
||||||
(w[c] = w[c] || []).push(function() {
|
|
||||||
try {
|
|
||||||
w.yaCounter41913764 = new Ya.Metrika({
|
|
||||||
id:41913764,
|
|
||||||
clickmap:true,
|
|
||||||
trackLinks:true,
|
|
||||||
accurateTrackBounce:true,
|
|
||||||
trackHash:true
|
|
||||||
});
|
|
||||||
} catch(e) { }
|
|
||||||
});
|
|
||||||
|
|
||||||
var n = d.getElementsByTagName("script")[0],
|
|
||||||
s = d.createElement("script"),
|
|
||||||
f = function () { n.parentNode.insertBefore(s, n); };
|
|
||||||
s.type = "text/javascript";
|
|
||||||
s.async = true;
|
|
||||||
s.src = "https://cdn.jsdelivr.net/npm/yandex-metrica-watch/watch.js";
|
|
||||||
|
|
||||||
if (w.opera == "[object Opera]") {
|
|
||||||
d.addEventListener("DOMContentLoaded", f, false);
|
|
||||||
} else { f(); }
|
|
||||||
})(document, window, "yandex_metrika_callbacks");
|
|
||||||
</script>
|
|
||||||
<noscript><div><img src="https://mc.yandex.ru/watch/41913764" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
|
|
||||||
<!-- /Yandex.Metrika counter -->
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.8.1/css/solid.css" integrity="sha384-QokYePQSOwpBDuhlHOsX0ymF6R/vLk/UQVz3WHa6wygxI5oGTmDTv8wahFOSspdm" crossorigin="anonymous">
|
|
||||||
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.8.1/css/brands.css" integrity="sha384-n9+6/aSqa9lBidZMRCQHTHKJscPq6NW4pCQBiMmHdUCvPN8ZOg2zJJTkC7WIezWv" crossorigin="anonymous">
|
|
||||||
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.8.1/css/fontawesome.css" integrity="sha384-vd1e11sR28tEK9YANUtpIOdjGW14pS87bUBuOIoBILVWLFnS+MCX9T6MMf0VdPGq" crossorigin="anonymous">
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
{# Charset #}
|
|
||||||
<meta charset="utf-8">
|
|
||||||
|
|
||||||
{# Title #}
|
|
||||||
{% if page.title is defined %}
|
|
||||||
<title>{{ page.title }} - {{ site.title }}</title>
|
|
||||||
{% else %}
|
|
||||||
<title>{{ site.title }}</title>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# Description #}
|
|
||||||
{% if page.description is defined %}
|
|
||||||
<meta name="description" content="{{ page.description }}">
|
|
||||||
{% elseif page.title is defined %}
|
|
||||||
<meta name="description" content="{{ page.title }}">
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if page.keywords is defined and page.keywords %}
|
|
||||||
{% if page.keywords is iterable %}
|
|
||||||
<meta name="keywords" content="{{ page.keywords|join(',') }}">
|
|
||||||
{% else %}
|
|
||||||
<meta name="keywords" content="{{ page.keywords }}">
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# Other meta fields #}
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<meta name="yandex-verification" content="eb6443fccb57d7d2" />
|
|
||||||
|
|
||||||
{# Social #}
|
|
||||||
<meta property="og:site_name" content="Anton Vakhrushev">
|
|
||||||
<meta property="og:title" content="{{ page.og.title ?? page.title ?? site.title }}">
|
|
||||||
<meta property="og:description" content="{{ page.og.description ?? page.description ?? site.description ?? '' }}">
|
|
||||||
<meta property="og:url" content="{{ page.og.url ?? (site.url | trim('/', side='right')) ~ (page.url != '/.' ? page.url : '') }}">
|
|
||||||
<meta property="og:locale" content="ru_RU">
|
|
||||||
|
|
||||||
{# Links and styles #}
|
|
||||||
<link href="https://fonts.googleapis.com/css?family=PT+Serif:400,700&subset=cyrillic" rel="stylesheet">
|
|
||||||
<link href="https://fonts.googleapis.com/css?family=Source+Code+Pro:400&display=swap&subset=cyrillic" rel="stylesheet">
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<nav class="navigation">
|
|
||||||
<ul class="navigation__actions">
|
|
||||||
<li class="navigation__action">
|
|
||||||
<a class="navigation__link" href="/">Главная</a>
|
|
||||||
</li>
|
|
||||||
<li class="navigation__action">
|
|
||||||
<a class="navigation__link" href="/articles/">Заметки</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{% extends 'internal.html.twig' %}
|
|
||||||
|
|
||||||
{% block content_wrapper %}
|
|
||||||
|
|
||||||
<h1>{{ page.title }}</h1>
|
|
||||||
|
|
||||||
{% block content %}{% endblock %}
|
|
||||||
|
|
||||||
<span class="hr-line"></span>
|
|
||||||
|
|
||||||
<p>{{ page.date|date('d.m.Y') }}, <a href="mailto:anton@vakhrushev.me">anton@vakhrushev.me</a></p>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
{% include 'head.twig' %}
|
|
||||||
{% block css %}{% endblock %}
|
|
||||||
{% for s in page.styles | default([]) %}
|
|
||||||
<link rel="stylesheet" href="{{ hashed_asset(s) }}">
|
|
||||||
{% endfor %}
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div><!-- Этот div сдается для вашей рекламы --></div>
|
|
||||||
|
|
||||||
{# block for main page content #}
|
|
||||||
{% block page_content %}{% endblock page_content %}
|
|
||||||
|
|
||||||
{# Analytics counters #}
|
|
||||||
{% include 'counters.twig' %}
|
|
||||||
|
|
||||||
{# Scripts #}
|
|
||||||
{% block js %}{% endblock %}
|
|
||||||
{% for s in page.scripts | default([]) %}
|
|
||||||
<script async src="{{ hashed_asset(s) }}"></script>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
{# Extra fonts #}
|
|
||||||
{% include 'font-awesome.twig' %}
|
|
||||||
|
|
||||||
<!-- Deployed wih Circle CI -->
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
{% extends 'base.html.twig' %}
|
|
||||||
|
|
||||||
{% block css %}
|
|
||||||
{{ parent() }}
|
|
||||||
<link rel="stylesheet" href="{{ hashed_asset('/static/layout_internal.css') }}">
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block js %}
|
|
||||||
{{ parent() }}
|
|
||||||
<script async src="{{ hashed_asset('/static/layout_internal.js') }}"></script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block page_content %}
|
|
||||||
<div class="page">
|
|
||||||
{% include 'navigation.twig' %}
|
|
||||||
{% block main %}
|
|
||||||
<main class="content">
|
|
||||||
{% block content_wrapper %}
|
|
||||||
{% block content %}{% endblock %}
|
|
||||||
{% endblock %}
|
|
||||||
</main>
|
|
||||||
{% endblock %}
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
layout: internal
|
|
||||||
title: Заметки
|
|
||||||
description: Заметки
|
|
||||||
use: [articles]
|
|
||||||
---
|
|
||||||
{% block content %}
|
|
||||||
|
|
||||||
{% include 'article_list.twig' %}
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
---
|
|
||||||
use: ["articles"]
|
|
||||||
permalink: none
|
|
||||||
sitemap:
|
|
||||||
_exclude: yes
|
|
||||||
---
|
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
|
||||||
<title><![CDATA[{{ site.title }}]]></title>
|
|
||||||
<link href="{{ site.url }}/atom.xml" rel="self"/>
|
|
||||||
<link href="{{ site.url }}/"/>
|
|
||||||
<updated>{{ site.calculated_date | date('c') }}</updated>
|
|
||||||
<id>{{ site.url }}/</id>
|
|
||||||
{% if site.author or site.email %}
|
|
||||||
<author>
|
|
||||||
{% if site.author %}<name><![CDATA[{{ site.author }}]]></name>{% endif %}
|
|
||||||
{% if site.email %}<email><![CDATA[{{ site.email }}]]></email>{% endif %}
|
|
||||||
</author>
|
|
||||||
{% endif %}
|
|
||||||
<generator uri="http://sculpin.io/">Sculpin</generator>
|
|
||||||
{% for article in data.articles|slice(0, 10) %}
|
|
||||||
<entry>
|
|
||||||
<title type="html"><![CDATA[{{ article.title }}]]></title>
|
|
||||||
<link href="{{ site.url }}{{ article.url }}"/>
|
|
||||||
<updated>{{ article.date|date('c') }}</updated>
|
|
||||||
<id>{{ site.url }}{{ article.url }}</id>
|
|
||||||
<content type="html"><![CDATA[{{ article.blocks.content|raw }}]]></content>
|
|
||||||
</entry>
|
|
||||||
{% endfor %}
|
|
||||||
</feed>
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
---
|
|
||||||
layout: base
|
|
||||||
description: Личный сайт Антона Вахрушева
|
|
||||||
use: [articles]
|
|
||||||
---
|
|
||||||
|
|
||||||
{% block css %}
|
|
||||||
{{ parent() }}
|
|
||||||
<link rel="stylesheet" href="{{ hashed_asset('/static/index.css') }}">
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block js %}
|
|
||||||
{{ parent() }}
|
|
||||||
<script async src="{{ hashed_asset('/static/index.js') }}"></script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block page_content %}
|
|
||||||
|
|
||||||
<main class="content">
|
|
||||||
|
|
||||||
<h1 class="name">Антон Вахрушев</h1>
|
|
||||||
|
|
||||||
<p class="info">
|
|
||||||
Я веб-программист.
|
|
||||||
Работаю в <a href="https://playkot.com/" target="_blank">Playkot</a>.
|
|
||||||
Пишу на PHP.
|
|
||||||
Разбираюсь в back-end, экспериментирую с front-end, интересуюсь функциональным программированием.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<span class="hr-line"></span>
|
|
||||||
|
|
||||||
{% include 'article_list.twig' %}
|
|
||||||
|
|
||||||
<span class="hr-line"></span>
|
|
||||||
|
|
||||||
<ul class="social">
|
|
||||||
<li class="social__item">
|
|
||||||
<a class="social__link" href="mailto:anton@vakhrushev.me" target="_blank" title="Написать на почту">
|
|
||||||
<i class="fas fa-envelope"></i>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="social__item">
|
|
||||||
<a class="social__link" href="https://git.vakhrushev.me" target="_blank" title="Код на Гитхабе">
|
|
||||||
<i class="fab fa-git-square"></i>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="social__item">
|
|
||||||
<a class="social__link" href="https://www.notion.so/6cbeaaa60dd146ba8d1d001a322e0139" target="_blank" title="Вишлист">
|
|
||||||
<i class="fas fa-socks"></i>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
---
|
|
||||||
permalink: none
|
|
||||||
sitemap:
|
|
||||||
_exclude: yes
|
|
||||||
---
|
|
||||||
User-agent: *
|
|
||||||
Sitemap: {{ site.url }}/sitemap.xml
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
---
|
|
||||||
permalink: none
|
|
||||||
use:
|
|
||||||
- sitemap
|
|
||||||
sitemap:
|
|
||||||
_exclude: yes
|
|
||||||
---
|
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
||||||
{% for url in data.sitemap %}
|
|
||||||
<url>
|
|
||||||
|
|
||||||
{# Last slash for pretty url #}
|
|
||||||
<loc>{{ site.url }}{{ url.loc != '/.' ? (url.loc|trim('/', side='right') ~ '/') : '' }}</loc>
|
|
||||||
|
|
||||||
<lastmod>{{ url.lastmod }}</lastmod>
|
|
||||||
|
|
||||||
{% if url.changefreq %}
|
|
||||||
<changefreq>{{ url.changefreq }}</changefreq>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if url.priority %}
|
|
||||||
<priority>{{ url.priority }}</priority>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
</url>
|
|
||||||
{% endfor %}
|
|
||||||
</urlset>
|
|
||||||
322
spec/migration.md
Normal file
322
spec/migration.md
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
# План миграции сайта vakhrushev.me с Sculpin на Astro
|
||||||
|
|
||||||
|
## Обзор текущего состояния
|
||||||
|
|
||||||
|
### Стек
|
||||||
|
- **Генератор**: Sculpin 3 (PHP 8)
|
||||||
|
- **Шаблоны**: Twig (base → internal → article, три уровня наследования)
|
||||||
|
- **Фронтенд**: Webpack 4, SCSS, Vue 2 (интерактивная демка гадалки)
|
||||||
|
- **Сборка**: Docker (PHP + Node контейнеры), Taskfile
|
||||||
|
- **Деплой**: Docker-образ nginx, Ansible на личный сервер
|
||||||
|
- **Аналитика**: Яндекс.Метрика
|
||||||
|
- **Шрифты**: Google Fonts (PT Serif, Source Code Pro), Font Awesome 5
|
||||||
|
- **Кастомные бандлы Sculpin**: HtmlPrettier, SiteMap, TwigExtension (hashed_asset)
|
||||||
|
|
||||||
|
### Контент
|
||||||
|
- 8 статей в markdown (2019-2020), русский язык
|
||||||
|
- 1 статья с интерактивным Vue-компонентом (гадалка Шеннона)
|
||||||
|
- Страницы: главная, список статей, 404
|
||||||
|
- Atom-лента (ручной шаблон)
|
||||||
|
- Sitemap (кастомный бандл + шаблон)
|
||||||
|
- robots.txt
|
||||||
|
|
||||||
|
### Структура стилей
|
||||||
|
- Переменные SCSS: шрифт PT Serif 20px, ширина 740px, цвета GitHub
|
||||||
|
- Базовые стили, стили главной, стили внутренних страниц, навигация
|
||||||
|
- BEM-подобная методология
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Принятые решения
|
||||||
|
|
||||||
|
- **URL статей**: формат `/articles/2019-05-01-predictor/`, slug = имя файла без расширения
|
||||||
|
- **Пакет `@anwinged/predictor`**: чистый TypeScript, без привязки к фреймворку — миграция на Vue 3 затрагивает только обёртку
|
||||||
|
- **Tailwind CSS**: используем; Tailwind 4 делает tree-shaking из коробки — в сборку попадают только используемые утилиты
|
||||||
|
- **Текст на главной**: переносим как есть, автор обновит сам
|
||||||
|
- **Галерея**: один альбом с фотографиями, хранение в `src/content/gallery/` (оптимизация через Astro Image)
|
||||||
|
- **Локальная разработка**: всё через Docker (и dev, и build), npm на хосте не используем
|
||||||
|
|
||||||
|
## Целевое состояние
|
||||||
|
|
||||||
|
### Стек
|
||||||
|
- **Генератор**: Astro
|
||||||
|
- **Стилизация**: Tailwind CSS 4
|
||||||
|
- **Интерактивность**: Vue 3 (islands, для гадалки и будущих компонентов)
|
||||||
|
- **Сборка**: Docker (один Node-контейнер), Taskfile
|
||||||
|
- **Деплой**: Docker-образ nginx, Ansible (без изменений)
|
||||||
|
|
||||||
|
### Разделы сайта
|
||||||
|
1. **Главная** — краткое описание, ссылки
|
||||||
|
2. **Блог** — статьи и эссе по программированию
|
||||||
|
3. **Галерея** — один альбом с избранными фотографиями
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Задачи
|
||||||
|
|
||||||
|
### Этап 1. Инициализация проекта Astro
|
||||||
|
|
||||||
|
#### 1.1. Создать проект Astro
|
||||||
|
- Инициализировать Astro в корне проекта (или в отдельной ветке)
|
||||||
|
- Настроить `astro.config.mjs`: site URL `https://vakhrushev.me`, язык `ru`
|
||||||
|
- Установить интеграции: `@astrojs/sitemap`, `@astrojs/rss`, `@astrojs/vue`, `@astrojs/tailwind`
|
||||||
|
- Настроить TypeScript (strict или relaxed — на усмотрение)
|
||||||
|
|
||||||
|
#### 1.2. Настроить Tailwind CSS 4
|
||||||
|
- Установить и настроить Tailwind 4 (tree-shaking из коробки — в сборку попадает только используемый CSS)
|
||||||
|
- Перенести дизайн-токены из SCSS-переменных в Tailwind-конфигурацию (через `@theme` в CSS):
|
||||||
|
- Шрифты: PT Serif (serif), Source Code Pro (mono)
|
||||||
|
- Цвета: `#24292e` (текст), `#0366d6` (ссылки), `#e6e6e6` (линии)
|
||||||
|
- Ширина контента: 740px
|
||||||
|
- Размер шрифта: 20px базовый
|
||||||
|
|
||||||
|
#### 1.3. Настроить Docker для сборки
|
||||||
|
- Заменить два Docker-образа (PHP + Node) одним Node-образом
|
||||||
|
- Использовать актуальную LTS-версию Node (22)
|
||||||
|
- Удалить PHP Dockerfile — он больше не нужен
|
||||||
|
- Сохранить `Dockerfile.nginx.prod`, обновить путь: `dist/` вместо `output_prod/`
|
||||||
|
|
||||||
|
#### 1.4. Обновить Taskfile
|
||||||
|
- Убрать задачи: `composer`, `sculpin`, `shell-node`, `format-php`
|
||||||
|
- Обновить задачи сборки:
|
||||||
|
- `build-prod`: `astro build`
|
||||||
|
- Добавить задачу `dev`: запуск `astro dev` для локальной разработки (через Docker с проброшенными портами)
|
||||||
|
- Обновить задачу `deploy`: путь к собранным файлам `dist/`
|
||||||
|
- Сохранить структуру Docker-команд через Taskfile
|
||||||
|
- Все npm/astro-команды выполняются внутри Docker-контейнера
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Этап 2. Лейауты и компоненты
|
||||||
|
|
||||||
|
#### 2.1. Создать базовый лейаут `BaseLayout.astro`
|
||||||
|
Аналог текущего `base.html.twig`. Должен включать:
|
||||||
|
- `<html lang="ru">`, мета-теги (charset, viewport, description, keywords)
|
||||||
|
- Open Graph мета-теги (og:site_name, og:title, og:description, og:url, og:locale)
|
||||||
|
- Подключение шрифтов Google Fonts (PT Serif, Source Code Pro)
|
||||||
|
- Яндекс.Метрика (вынести в отдельный компонент `YandexMetrika.astro`)
|
||||||
|
- Yandex verification мета-тег
|
||||||
|
- Слот для контента
|
||||||
|
|
||||||
|
Параметры (props): `title`, `description`, `keywords`
|
||||||
|
|
||||||
|
#### 2.2. Создать лейаут для внутренних страниц `InternalLayout.astro`
|
||||||
|
Аналог `internal.html.twig`:
|
||||||
|
- Использует `BaseLayout`
|
||||||
|
- Навигация (компонент `Navigation.astro`)
|
||||||
|
- Контейнер `.page` с ограничением ширины
|
||||||
|
|
||||||
|
#### 2.3. Создать лейаут для статей `ArticleLayout.astro`
|
||||||
|
Аналог `article.html.twig`:
|
||||||
|
- Использует `InternalLayout`
|
||||||
|
- Заголовок статьи `<h1>`
|
||||||
|
- Дата публикации и email внизу
|
||||||
|
- Слот для содержимого статьи
|
||||||
|
|
||||||
|
#### 2.4. Создать общие компоненты
|
||||||
|
- `Navigation.astro` — навигация (Главная, Блог, Галерея)
|
||||||
|
- `YandexMetrika.astro` — код счетчика аналитики
|
||||||
|
- `ArticleList.astro` — список статей, сгруппированных по годам
|
||||||
|
|
||||||
|
Особенность: Font Awesome больше не нужен (социальные ссылки удалены в коммите `63a324c`).
|
||||||
|
Если понадобятся иконки — использовать `astro-icon` или inline SVG.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Этап 3. Content Collections и перенос статей
|
||||||
|
|
||||||
|
#### 3.1. Настроить Content Collection для статей
|
||||||
|
- Создать `src/content/articles/` для markdown-файлов
|
||||||
|
- Определить схему в `src/content.config.ts`:
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
keywords: z.array(z.string()).optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
draft: z.boolean().default(false),
|
||||||
|
// date — парсится из имени файла (YYYY-MM-DD-slug.md), не хранится в frontmatter
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2. Перенести статьи
|
||||||
|
Перенести 8 markdown-файлов из `source/_articles/` в `src/content/articles/`.
|
||||||
|
|
||||||
|
Имена файлов сохраняют даты (формат `YYYY-MM-DD-slug.md`) для удобной навигации в файловой системе. Дата парсится из имени файла при сборке (в `content.config.ts` или в `getStaticPaths`), дублировать в frontmatter не нужно.
|
||||||
|
|
||||||
|
Необходимые изменения в каждом файле:
|
||||||
|
- Удалить Sculpin-специфичные поля frontmatter (`layout`, `styles`, `scripts`, `use`)
|
||||||
|
- Оставить `title`, `description`, `keywords`
|
||||||
|
- Опционально: добавить `tags` для будущей фильтрации
|
||||||
|
|
||||||
|
Список статей:
|
||||||
|
1. `2019-05-01-predictor.md` — **ОСОБЫЙ СЛУЧАЙ**, см. задачу 3.3
|
||||||
|
2. `2019-06-01-php-serialization.md`
|
||||||
|
3. `2019-06-28-storytelling.md`
|
||||||
|
4. `2019-08-08-yandex-disk-image-hosting.md`
|
||||||
|
5. `2019-09-26-highload-videos.md`
|
||||||
|
6. `2020-06-27-interesting-programming-blogs.md`
|
||||||
|
7. `2020-06-27-type-discriminant.md`
|
||||||
|
8. `2020-11-08-nullable-fields.md`
|
||||||
|
|
||||||
|
#### 3.3. Перенести статью с интерактивным компонентом (гадалка)
|
||||||
|
Статья `predictor.md` содержит встроенный Vue-компонент `<div id="app"></div>`.
|
||||||
|
|
||||||
|
Шаги:
|
||||||
|
- Переименовать файл в `.mdx`
|
||||||
|
- Обновить Vue-компонент `PredictorDemo.vue` до Vue 3 (Composition API или Options API)
|
||||||
|
- `@anwinged/predictor` — чистый TS, совместимость с Vue 3 не затронута
|
||||||
|
- Перенести scoped-стили компонента на Tailwind или оставить scoped CSS
|
||||||
|
- В MDX-файле: импортировать компонент и использовать `<PredictorDemo client:visible />`
|
||||||
|
- Удалить ручное подключение JS/CSS через frontmatter (`styles`, `scripts`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Этап 4. Страницы
|
||||||
|
|
||||||
|
#### 4.1. Главная страница `src/pages/index.astro`
|
||||||
|
- Использовать `BaseLayout`
|
||||||
|
- Имя, краткое описание (обновить текст — актуализировать информацию о себе)
|
||||||
|
- Ссылки: email, git, другие
|
||||||
|
- Список последних статей (через `getCollection('articles')`)
|
||||||
|
|
||||||
|
#### 4.2. Страница списка статей `src/pages/articles/index.astro`
|
||||||
|
- Использовать `InternalLayout`
|
||||||
|
- Список всех статей, сгруппированных по годам (как сейчас в `article_list.twig`)
|
||||||
|
- Сортировка по дате, от новых к старым
|
||||||
|
|
||||||
|
#### 4.3. Динамические страницы статей `src/pages/articles/[...slug].astro`
|
||||||
|
- Использовать `ArticleLayout`
|
||||||
|
- Рендеринг markdown/MDX содержимого через `render()`
|
||||||
|
- SEO мета-теги из frontmatter
|
||||||
|
|
||||||
|
#### 4.4. Страница 404 `src/pages/404.astro`
|
||||||
|
- Использовать `InternalLayout`
|
||||||
|
- Текст "Страница не найдена" и ссылка на главную
|
||||||
|
|
||||||
|
#### 4.5. Страница галереи `src/pages/gallery/index.astro`
|
||||||
|
- Использовать `InternalLayout`
|
||||||
|
- Content Collection `gallery` — фотографии с метаданными (title, описание, дата, порядок)
|
||||||
|
- Структура хранения:
|
||||||
|
```
|
||||||
|
src/content/gallery/
|
||||||
|
├── photo-001.md # frontmatter: title, image, order
|
||||||
|
├── photo-002.md
|
||||||
|
└── ...
|
||||||
|
public/photos/
|
||||||
|
├── photo-001.jpg # оригиналы фотографий
|
||||||
|
├── photo-002.jpg
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
- Сетка превью с оптимизацией через Astro Image (WebP/AVIF, responsive sizes)
|
||||||
|
- Просмотр полноразмерных фотографий: lightbox-библиотека (PhotoSwipe или GLightbox)
|
||||||
|
- Один альбом, без категорий
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Этап 5. RSS, Sitemap, SEO
|
||||||
|
|
||||||
|
#### 5.1. Настроить RSS-ленту
|
||||||
|
- Создать `src/pages/rss.xml.ts` с использованием `@astrojs/rss`
|
||||||
|
- Включить все статьи (title, description, date, link)
|
||||||
|
- URL: `/rss.xml`
|
||||||
|
- Для обратной совместимости: добавить редирект `/atom.xml` → `/rss.xml` (в nginx или через Astro)
|
||||||
|
|
||||||
|
#### 5.2. Настроить Sitemap
|
||||||
|
- Интеграция `@astrojs/sitemap` уже генерирует sitemap автоматически
|
||||||
|
- Убедиться, что 404 и служебные страницы исключены
|
||||||
|
|
||||||
|
#### 5.3. Настроить robots.txt
|
||||||
|
- Создать `public/robots.txt` со ссылкой на sitemap
|
||||||
|
|
||||||
|
#### 5.4. Open Graph и мета-теги
|
||||||
|
- Уже реализовано в BaseLayout (задача 2.1)
|
||||||
|
- Проверить корректность на каждом типе страниц
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Этап 6. Сборка и деплой
|
||||||
|
|
||||||
|
#### 6.1. Обновить Docker-сборку
|
||||||
|
- Новый `docker/node/Dockerfile` на Node 22 LTS
|
||||||
|
- PHP Dockerfile удалить
|
||||||
|
- Обновить `Dockerfile.nginx.prod`:
|
||||||
|
```dockerfile
|
||||||
|
FROM nginx:stable
|
||||||
|
COPY dist /usr/share/nginx/html
|
||||||
|
```
|
||||||
|
- Добавить конфиг nginx для SPA fallback (404.html) и кэширования статики
|
||||||
|
|
||||||
|
#### 6.2. Обновить Taskfile
|
||||||
|
Финальная версия задач:
|
||||||
|
```yaml
|
||||||
|
tasks:
|
||||||
|
dev: # astro dev (локальная разработка)
|
||||||
|
build-prod: # astro build
|
||||||
|
deploy: # build-prod → docker build → ansible
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6.3. Проверить деплой
|
||||||
|
- Собрать Docker-образ
|
||||||
|
- Проверить, что все страницы отдаются корректно
|
||||||
|
- Проверить 404
|
||||||
|
- Проверить RSS и sitemap
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Этап 7. Очистка
|
||||||
|
|
||||||
|
#### 7.1. Удалить файлы Sculpin
|
||||||
|
- `app/` — SculpinKernel, конфиги
|
||||||
|
- `bundle/` — кастомные бандлы (HtmlPrettier, SiteMap, TwigExtension)
|
||||||
|
- `source/` — старые шаблоны, ассеты, контент (после проверки, что всё перенесено)
|
||||||
|
- `composer.json`, `composer.lock`
|
||||||
|
- `docker/php/Dockerfile`
|
||||||
|
- `webpack.config.js`
|
||||||
|
- `.php-cs-fixer.php`
|
||||||
|
|
||||||
|
#### 7.2. Обновить package.json
|
||||||
|
- Удалить старые зависимости (webpack, babel, vue 2, node-sass, и т.д.)
|
||||||
|
- Удалить старые npm-скрипты
|
||||||
|
- Оставить только Astro и его зависимости
|
||||||
|
|
||||||
|
#### 7.3. Обновить .gitignore
|
||||||
|
- Убрать `output_dev/`, `output_prod/`, `vendor/`
|
||||||
|
- Добавить `dist/`, `.astro/`, `node_modules/`
|
||||||
|
|
||||||
|
#### 7.4. Обновить README.md
|
||||||
|
- Описание нового стека
|
||||||
|
- Инструкции по локальной разработке и деплою
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## URL-совместимость
|
||||||
|
|
||||||
|
URL статей: `/articles/2019-05-01-predictor/`.
|
||||||
|
Slug = имя файла без расширения, Astro использует его как есть.
|
||||||
|
|
||||||
|
Редиректы в nginx для старых URL (301):
|
||||||
|
```
|
||||||
|
/articles/2019/05/01/predictor/ → /articles/2019-05-01-predictor/
|
||||||
|
/articles/2019/06/01/php-serialization/ → /articles/2019-06-01-php-serialization/
|
||||||
|
/articles/2019/06/28/storytelling/ → /articles/2019-06-28-storytelling/
|
||||||
|
/articles/2019/08/08/yandex-disk-image-hosting/ → /articles/2019-08-08-yandex-disk-image-hosting/
|
||||||
|
/articles/2019/09/26/highload-videos/ → /articles/2019-09-26-highload-videos/
|
||||||
|
/articles/2020/06/27/interesting-programming-blogs/ → /articles/2020-06-27-interesting-programming-blogs/
|
||||||
|
/articles/2020/06/27/type-discriminant/ → /articles/2020-06-27-type-discriminant/
|
||||||
|
/articles/2020/11/08/nullable-fields/ → /articles/2020-11-08-nullable-fields/
|
||||||
|
```
|
||||||
|
|
||||||
|
Редирект `/atom.xml` → `/rss.xml` для совместимости RSS-подписок.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Порядок выполнения
|
||||||
|
|
||||||
|
```
|
||||||
|
Этап 1 (инициализация) → Этап 2 (лейауты) → Этап 3 (контент)
|
||||||
|
→ Этап 4 (страницы) → Этап 5 (RSS/sitemap) → Этап 6 (деплой) → Этап 7 (очистка)
|
||||||
|
```
|
||||||
|
|
||||||
|
Этапы 2 и 3 можно частично выполнять параллельно.
|
||||||
|
Этап 7 выполняется только после полной проверки нового сайта.
|
||||||
54
spec/predictor.md
Normal file
54
spec/predictor.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Модернизация пакета @anwinged/predictor
|
||||||
|
|
||||||
|
## Проблема
|
||||||
|
|
||||||
|
Пакет собран через webpack в UMD-формат с `eval()` внутри чанков. Это создает проблемы при использовании в современных проектах на Vite/Astro:
|
||||||
|
|
||||||
|
1. **UMD-обертка использует `window`** — падает в SSR/Node.js окружении.
|
||||||
|
2. **`eval()` в бандле** — нарушает Content Security Policy, мешает Vite анализировать зависимости.
|
||||||
|
3. **Нет ESM-экспорта** — в `package.json` только поле `"main"`, указывающее на UMD-бандл. Vite не может подключить пакет без принудительного пре-бандлинга (`optimizeDeps.include`).
|
||||||
|
4. **Нет поля `"exports"`** — современные бандлеры не могут автоматически выбрать подходящий формат.
|
||||||
|
5. **TypeScript-исходники не используются напрямую** — хотя `src/` лежит в пакете, точка входа ведет только на собранный `dist/predictor.js`.
|
||||||
|
|
||||||
|
## Что нужно изменить
|
||||||
|
|
||||||
|
### 1. Заменить webpack на tsc (или tsup/unbuild)
|
||||||
|
|
||||||
|
Исходники уже на TypeScript. Достаточно компилировать их напрямую:
|
||||||
|
|
||||||
|
- ESM-вариант: `dist/index.mjs`
|
||||||
|
- CJS-вариант (опционально): `dist/index.cjs`
|
||||||
|
- Типы: `dist/index.d.ts`
|
||||||
|
|
||||||
|
Webpack для библиотеки из пяти файлов без внешних зависимостей избыточен.
|
||||||
|
|
||||||
|
### 2. Обновить package.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.cjs",
|
||||||
|
"module": "dist/index.mjs",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./dist/index.mjs",
|
||||||
|
"require": "./dist/index.cjs",
|
||||||
|
"types": "./dist/index.d.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": ["dist"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Убрать привязку к `window`
|
||||||
|
|
||||||
|
Текущий UMD-бандл передает `window` как глобальный объект. При сборке через tsc/tsup это уходит автоматически.
|
||||||
|
|
||||||
|
### 4. Убрать `eval()`
|
||||||
|
|
||||||
|
Webpack в dev-режиме оборачивает модули в `eval()`. При переходе на tsc/tsup проблема исчезает.
|
||||||
|
|
||||||
|
## Результат
|
||||||
|
|
||||||
|
После этих изменений пакет будет работать в Vite/Astro/Next.js без дополнительной конфигурации (`optimizeDeps.include`, `ssr.noExternal` и т.п.).
|
||||||
39
src/components/ArticleList.astro
Normal file
39
src/components/ArticleList.astro
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
interface Article {
|
||||||
|
slug: string;
|
||||||
|
date: Date;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
articles: Article[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { articles } = Astro.props;
|
||||||
|
|
||||||
|
const sorted = [...articles].sort((a, b) => b.date.getTime() - a.date.getTime());
|
||||||
|
|
||||||
|
const byYear = new Map<number, Article[]>();
|
||||||
|
for (const article of sorted) {
|
||||||
|
const year = article.date.getFullYear();
|
||||||
|
if (!byYear.has(year)) {
|
||||||
|
byYear.set(year, []);
|
||||||
|
}
|
||||||
|
byYear.get(year)!.push(article);
|
||||||
|
}
|
||||||
|
---
|
||||||
|
{[...byYear.entries()].map(([year, items]) => (
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="font-bold text-2xl mb-3">{year}</h2>
|
||||||
|
<ul class="list-none p-0 m-0 flex flex-col gap-2">
|
||||||
|
{items.map((article) => (
|
||||||
|
<li class="flex flex-col content:flex-row content:items-baseline content:gap-3">
|
||||||
|
<time class="text-text-secondary text-sm shrink-0 w-12 tabular-nums" datetime={article.date.toISOString().slice(0, 10)}>
|
||||||
|
{String(article.date.getDate()).padStart(2, '0')}.{String(article.date.getMonth() + 1).padStart(2, '0')}
|
||||||
|
</time>
|
||||||
|
<a href={`/articles/${article.slug}/`} class="text-lg">{article.title}</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
9
src/components/Navigation.astro
Normal file
9
src/components/Navigation.astro
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
---
|
||||||
|
<nav class="my-4">
|
||||||
|
<ul class="flex gap-4 list-none p-0 m-0">
|
||||||
|
<li><a href="/">Главная</a></li>
|
||||||
|
<li><a href="/articles/">Блог</a></li>
|
||||||
|
<li><a href="/gallery/">Галерея</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
139
src/components/PredictorDemo.vue
Normal file
139
src/components/PredictorDemo.vue
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app" tabindex="0" @keyup="press">
|
||||||
|
<div v-if="!ready">
|
||||||
|
<p>Загрузка...</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="isHumanWin">
|
||||||
|
<p>Победа! Было очень сложно, но вы справились, поздравляю!</p>
|
||||||
|
<button class="btn" @click.prevent="restart">Заново</button>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="isRobotWin">
|
||||||
|
<p>
|
||||||
|
Упс, железяка победила. Оказывается, предсказать выбор человека
|
||||||
|
не так уж и сложно, да?
|
||||||
|
</p>
|
||||||
|
<button class="btn" @click.prevent="restart">Заново</button>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<p class="score">{{ predictor.score }}</p>
|
||||||
|
<p class="step">Ход {{ step }}</p>
|
||||||
|
<div class="buttons">
|
||||||
|
<button class="btn btn-left" @click.prevent="click(0)">Нечет</button>
|
||||||
|
<button class="btn btn-right" @click.prevent="click(1)">Чет</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
|
||||||
|
const MAX_SCORE = 50;
|
||||||
|
|
||||||
|
let Predictor: any;
|
||||||
|
|
||||||
|
async function makePredictor() {
|
||||||
|
if (!Predictor) {
|
||||||
|
const mod = await import('@anwinged/predictor');
|
||||||
|
Predictor = mod.default || mod;
|
||||||
|
}
|
||||||
|
return new Predictor({
|
||||||
|
base: 2,
|
||||||
|
daemons: [
|
||||||
|
{ human: 3, robot: 3 },
|
||||||
|
{ human: 4, robot: 4 },
|
||||||
|
{ human: 5, robot: 5 },
|
||||||
|
{ human: 6, robot: 6 },
|
||||||
|
{ human: 8, robot: 8 },
|
||||||
|
{ human: 12, robot: 12 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ready = ref(false);
|
||||||
|
const predictor = ref<any>(null);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
predictor.value = await makePredictor();
|
||||||
|
ready.value = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isHumanWin = computed(() => predictor.value.score >= MAX_SCORE);
|
||||||
|
const isRobotWin = computed(() => predictor.value.score <= -MAX_SCORE);
|
||||||
|
const step = computed(() => predictor.value.stepCount() + 1);
|
||||||
|
|
||||||
|
function pass(value: number) {
|
||||||
|
if (Math.abs(predictor.value.score) >= MAX_SCORE) return;
|
||||||
|
predictor.value.pass(+value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function click(v: number) {
|
||||||
|
pass(v ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function press(evt: KeyboardEvent) {
|
||||||
|
pass(evt.key === '1' ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restart() {
|
||||||
|
predictor.value = await makePredictor();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app {
|
||||||
|
display: block;
|
||||||
|
margin: 2em auto;
|
||||||
|
padding: 2em;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app:hover {
|
||||||
|
border-color: #e6e6e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score {
|
||||||
|
font-size: 400%;
|
||||||
|
margin-top: 0.2em;
|
||||||
|
margin-bottom: 0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
color: #fff;
|
||||||
|
background-color: #1b5fad;
|
||||||
|
padding: 0.6em 1.2em;
|
||||||
|
border: none;
|
||||||
|
font-size: 100%;
|
||||||
|
font-family: inherit;
|
||||||
|
border-radius: 0.5em;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 7em;
|
||||||
|
margin: 0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
background-color: #154a88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-left {
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-right {
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
15
src/components/YandexMetrika.astro
Normal file
15
src/components/YandexMetrika.astro
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
---
|
||||||
|
<!-- Yandex.Metrika counter -->
|
||||||
|
<script type="text/javascript">
|
||||||
|
(function(m,e,t,r,i,k,a){
|
||||||
|
m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
|
||||||
|
m[i].l=1*new Date();
|
||||||
|
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
|
||||||
|
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
|
||||||
|
})(window, document,'script','https://mc.webvisor.org/metrika/tag_ww.js', 'ym');
|
||||||
|
|
||||||
|
ym(41913764, 'init', {trackHash:true, clickmap:true, referrer: document.referrer, url: location.href, accurateTrackBounce:true, trackLinks:true});
|
||||||
|
</script>
|
||||||
|
<noscript><div><img src="https://mc.yandex.ru/watch/41913764" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
|
||||||
|
<!-- /Yandex.Metrika counter -->
|
||||||
15
src/content.config.ts
Normal file
15
src/content.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineCollection, z } from 'astro:content';
|
||||||
|
import { glob } from 'astro/loaders';
|
||||||
|
|
||||||
|
const articles = defineCollection({
|
||||||
|
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/articles' }),
|
||||||
|
schema: z.object({
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
keywords: z.array(z.string()).optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
draft: z.boolean().default(false),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const collections = { articles };
|
||||||
@@ -2,11 +2,8 @@
|
|||||||
title: Гадалка Шеннона
|
title: Гадалка Шеннона
|
||||||
description: Демо-версия электронной гадалки Шеннона
|
description: Демо-версия электронной гадалки Шеннона
|
||||||
keywords: [гадалка, угадыватель, шеннон, чет-нечет]
|
keywords: [гадалка, угадыватель, шеннон, чет-нечет]
|
||||||
styles:
|
|
||||||
- /static/predictor.css
|
|
||||||
scripts:
|
|
||||||
- /static/predictor.js
|
|
||||||
---
|
---
|
||||||
|
import PredictorDemo from '../../components/PredictorDemo.vue';
|
||||||
|
|
||||||
В студенческое время я наткнулся на интересную статью об [игре "Чет-нечет"][game]
|
В студенческое время я наткнулся на интересную статью об [игре "Чет-нечет"][game]
|
||||||
на домашней страничке пользователя [ltwood][ltwood].
|
на домашней страничке пользователя [ltwood][ltwood].
|
||||||
@@ -33,7 +30,7 @@ scripts:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<div id="app"></div>
|
<PredictorDemo client:only="vue" />
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
title: Организация доступа к nullable полям класса
|
title: Организация доступа к nullable полям класса
|
||||||
description: Заметка о том, лучше организовать доступ к полям класса, которые могут содержать значение null
|
description: Заметка о том, лучше организовать доступ к полям класса, которые могут содержать значение null
|
||||||
keywords: [чистый код, php, null, поля класса]
|
keywords: [чистый код, php, "null", поля класса]
|
||||||
---
|
---
|
||||||
|
|
||||||
Нередкая ситуация, когда в классе есть поле, которое может содержать `null`.
|
Нередкая ситуация, когда в классе есть поле, которое может содержать `null`.
|
||||||
27
src/layouts/ArticleLayout.astro
Normal file
27
src/layouts/ArticleLayout.astro
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
import InternalLayout from './InternalLayout.astro';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
keywords?: string[];
|
||||||
|
date: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, description, keywords, date } = Astro.props;
|
||||||
|
|
||||||
|
const formattedDate = date.toLocaleDateString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
---
|
||||||
|
<InternalLayout title={title} description={description} keywords={keywords}>
|
||||||
|
<h1 class="text-4xl font-bold mt-3 mb-4">{title}</h1>
|
||||||
|
<div class="prose">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-rule mt-8 pt-4">
|
||||||
|
<p>{formattedDate}, <a href="mailto:anton@vakhrushev.me">anton@vakhrushev.me</a></p>
|
||||||
|
</div>
|
||||||
|
</InternalLayout>
|
||||||
40
src/layouts/BaseLayout.astro
Normal file
40
src/layouts/BaseLayout.astro
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
import '../styles/global.css';
|
||||||
|
import YandexMetrika from '../components/YandexMetrika.astro';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
keywords?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const siteTitle = 'Антон Вахрушев';
|
||||||
|
const siteUrl = Astro.site?.origin ?? 'https://vakhrushev.me';
|
||||||
|
|
||||||
|
const { title, description, keywords } = Astro.props;
|
||||||
|
const pageTitle = title ? `${title} - ${siteTitle}` : siteTitle;
|
||||||
|
const pageDescription = description ?? title ?? '';
|
||||||
|
const pageUrl = `${siteUrl}${Astro.url.pathname}`;
|
||||||
|
---
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
<title>{pageTitle}</title>
|
||||||
|
{pageDescription && <meta name="description" content={pageDescription} />}
|
||||||
|
{keywords && keywords.length > 0 && <meta name="keywords" content={keywords.join(',')} />}
|
||||||
|
<meta name="yandex-verification" content="eb6443fccb57d7d2" />
|
||||||
|
<meta property="og:site_name" content={siteTitle} />
|
||||||
|
<meta property="og:title" content={title ?? siteTitle} />
|
||||||
|
<meta property="og:description" content={pageDescription} />
|
||||||
|
<meta property="og:url" content={pageUrl} />
|
||||||
|
<meta property="og:locale" content="ru_RU" />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=PT+Serif:wght@400;700&family=Source+Code+Pro:wght@400&display=swap&subset=cyrillic" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<YandexMetrika />
|
||||||
|
<slot />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
20
src/layouts/InternalLayout.astro
Normal file
20
src/layouts/InternalLayout.astro
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
import BaseLayout from './BaseLayout.astro';
|
||||||
|
import Navigation from '../components/Navigation.astro';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
keywords?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, description, keywords } = Astro.props;
|
||||||
|
---
|
||||||
|
<BaseLayout title={title} description={description} keywords={keywords}>
|
||||||
|
<div class="max-w-content mx-auto px-3 content:px-0">
|
||||||
|
<Navigation />
|
||||||
|
<main class="mb-12">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</BaseLayout>
|
||||||
8
src/pages/404.astro
Normal file
8
src/pages/404.astro
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
import InternalLayout from '../layouts/InternalLayout.astro';
|
||||||
|
---
|
||||||
|
<InternalLayout title="Страница не найдена" description="Страница не найдена">
|
||||||
|
<h1 class="text-4xl font-bold mt-3 mb-4">404</h1>
|
||||||
|
<p>Ой, страница не найдена.</p>
|
||||||
|
<p>Давайте посмотрим, что интересное есть на <a href="/">главной</a>.</p>
|
||||||
|
</InternalLayout>
|
||||||
25
src/pages/articles/[slug].astro
Normal file
25
src/pages/articles/[slug].astro
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
import { getCollection, render } from 'astro:content';
|
||||||
|
import ArticleLayout from '../../layouts/ArticleLayout.astro';
|
||||||
|
import { parseDateFromId } from '../../utils/articles';
|
||||||
|
|
||||||
|
export async function getStaticPaths() {
|
||||||
|
const articles = await getCollection('articles');
|
||||||
|
return articles.map((article) => ({
|
||||||
|
params: { slug: article.id },
|
||||||
|
props: { article },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { article } = Astro.props;
|
||||||
|
const { Content } = await render(article);
|
||||||
|
const date = parseDateFromId(article.id);
|
||||||
|
---
|
||||||
|
<ArticleLayout
|
||||||
|
title={article.data.title}
|
||||||
|
description={article.data.description}
|
||||||
|
keywords={article.data.keywords}
|
||||||
|
date={date}
|
||||||
|
>
|
||||||
|
<Content />
|
||||||
|
</ArticleLayout>
|
||||||
16
src/pages/articles/index.astro
Normal file
16
src/pages/articles/index.astro
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
import { getCollection } from 'astro:content';
|
||||||
|
import InternalLayout from '../../layouts/InternalLayout.astro';
|
||||||
|
import ArticleList from '../../components/ArticleList.astro';
|
||||||
|
import { parseDateFromId } from '../../utils/articles';
|
||||||
|
|
||||||
|
const allArticles = await getCollection('articles', ({ data }) => !data.draft);
|
||||||
|
const articles = allArticles.map((a) => ({
|
||||||
|
slug: a.id,
|
||||||
|
date: parseDateFromId(a.id),
|
||||||
|
title: a.data.title,
|
||||||
|
}));
|
||||||
|
---
|
||||||
|
<InternalLayout title="Заметки" description="Заметки">
|
||||||
|
<ArticleList articles={articles} />
|
||||||
|
</InternalLayout>
|
||||||
7
src/pages/gallery/index.astro
Normal file
7
src/pages/gallery/index.astro
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
import InternalLayout from '../../layouts/InternalLayout.astro';
|
||||||
|
---
|
||||||
|
<InternalLayout title="Галерея" description="Фотогалерея">
|
||||||
|
<h1 class="text-4xl font-bold mt-3 mb-4">Галерея</h1>
|
||||||
|
<p>Скоро здесь будут фотографии.</p>
|
||||||
|
</InternalLayout>
|
||||||
43
src/pages/index.astro
Normal file
43
src/pages/index.astro
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
import { getCollection } from 'astro:content';
|
||||||
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
|
import ArticleList from '../components/ArticleList.astro';
|
||||||
|
import { parseDateFromId } from '../utils/articles';
|
||||||
|
|
||||||
|
const allArticles = await getCollection('articles', ({ data }) => !data.draft);
|
||||||
|
const articles = allArticles.map((a) => ({
|
||||||
|
slug: a.id,
|
||||||
|
date: parseDateFromId(a.id),
|
||||||
|
title: a.data.title,
|
||||||
|
}));
|
||||||
|
---
|
||||||
|
<BaseLayout description="Личный сайт Антона Вахрушева">
|
||||||
|
<main class="max-w-content mx-auto px-3 content:px-0">
|
||||||
|
<h1 class="text-4xl font-bold mt-3 mb-4">Антон Вахрушев</h1>
|
||||||
|
<p>
|
||||||
|
Я веб-программист.
|
||||||
|
Работаю в <a href="https://playkot.com/" target="_blank">Playkot</a>.
|
||||||
|
Пишу на PHP.
|
||||||
|
Разбираюсь в back-end, экспериментирую с front-end, интересуюсь функциональным программированием.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="border-t border-rule my-8"></div>
|
||||||
|
|
||||||
|
<ArticleList articles={articles} />
|
||||||
|
|
||||||
|
<div class="border-t border-rule my-8"></div>
|
||||||
|
|
||||||
|
<ul class="list-none p-0 m-0 flex gap-6">
|
||||||
|
<li>
|
||||||
|
<a href="mailto:anton@vakhrushev.me" title="Написать на почту">
|
||||||
|
<svg class="size-8" fill="currentColor" viewBox="0 0 512 512"><path d="M48 64C21.5 64 0 85.5 0 112c0 15.1 7.1 29.3 19.2 38.4L236.8 313.6c11.4 8.5 27 8.5 38.4 0L492.8 150.4c12.1-9.1 19.2-23.3 19.2-38.4c0-26.5-21.5-48-48-48H48zM0 176V384c0 35.3 28.7 64 64 64H448c35.3 0 64-28.7 64-64V176L294.4 339.2c-22.8 17.1-54 17.1-76.8 0L0 176z"/></svg>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://git.vakhrushev.me" target="_blank" title="Код на Гитхабе">
|
||||||
|
<svg class="size-8" fill="currentColor" viewBox="0 0 496 512"><path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.8-14.9-112.8-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"/></svg>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</main>
|
||||||
|
</BaseLayout>
|
||||||
24
src/pages/rss.xml.ts
Normal file
24
src/pages/rss.xml.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import rss from '@astrojs/rss';
|
||||||
|
import { getCollection } from 'astro:content';
|
||||||
|
import { parseDateFromId } from '../utils/articles';
|
||||||
|
import type { APIContext } from 'astro';
|
||||||
|
|
||||||
|
export async function GET(context: APIContext) {
|
||||||
|
const articles = await getCollection('articles', ({ data }) => !data.draft);
|
||||||
|
|
||||||
|
const sorted = articles.sort(
|
||||||
|
(a, b) => parseDateFromId(b.id).getTime() - parseDateFromId(a.id).getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return rss({
|
||||||
|
title: 'Антон Вахрушев',
|
||||||
|
description: 'Блог о программировании',
|
||||||
|
site: context.site!.toString(),
|
||||||
|
items: sorted.map((article) => ({
|
||||||
|
title: article.data.title,
|
||||||
|
description: article.data.description,
|
||||||
|
pubDate: parseDateFromId(article.id),
|
||||||
|
link: `/articles/${article.id}/`,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
220
src/styles/global.css
Normal file
220
src/styles/global.css
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-serif: 'PT Serif', serif;
|
||||||
|
--font-mono: 'Source Code Pro', monospace;
|
||||||
|
|
||||||
|
--color-text: #24292e;
|
||||||
|
--color-text-secondary: #57606a;
|
||||||
|
--color-link: #0366d6;
|
||||||
|
--color-rule: #e6e6e6;
|
||||||
|
--color-bg: #ffffff;
|
||||||
|
--color-code-bg: #f6f8fa;
|
||||||
|
--color-code-text: #24292e;
|
||||||
|
--color-blockquote-border: #d0d7de;
|
||||||
|
--color-blockquote-text: #57606a;
|
||||||
|
|
||||||
|
--max-width-content: 740px;
|
||||||
|
--breakpoint-content: 740px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
html {
|
||||||
|
font-size: 20px;
|
||||||
|
color-scheme: light dark;
|
||||||
|
|
||||||
|
@media (max-width: theme(--breakpoint-content)) {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply font-serif text-text bg-bg;
|
||||||
|
transition: background-color 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
@apply text-link no-underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover, a:focus, a:active {
|
||||||
|
@apply underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--color-text: #c9d1d9;
|
||||||
|
--color-text-secondary: #8b949e;
|
||||||
|
--color-link: #58a6ff;
|
||||||
|
--color-rule: #30363d;
|
||||||
|
--color-bg: #0d1117;
|
||||||
|
--color-code-bg: #161b22;
|
||||||
|
--color-code-text: #c9d1d9;
|
||||||
|
--color-blockquote-border: #30363d;
|
||||||
|
--color-blockquote-text: #8b949e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.prose {
|
||||||
|
line-height: 1.7;
|
||||||
|
|
||||||
|
> * + * {
|
||||||
|
margin-top: 1.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.5em;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-top: 2em;
|
||||||
|
margin-bottom: 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.25em;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-top: 1.5em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-size: 1.1em;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-top: 1.25em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 + *, h3 + *, h4 + * {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-top: 1em;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul, ol {
|
||||||
|
margin-top: 1em;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-left: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style-type: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol {
|
||||||
|
list-style-type: decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-top: 0.35em;
|
||||||
|
}
|
||||||
|
|
||||||
|
li > ul, li > ol {
|
||||||
|
margin-top: 0.35em;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
margin-top: 1em;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding: 0.25em 1em;
|
||||||
|
border-left: 4px solid var(--color-blockquote-border);
|
||||||
|
color: var(--color-blockquote-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote > p:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.85em;
|
||||||
|
background-color: var(--color-code-bg);
|
||||||
|
color: var(--color-code-text);
|
||||||
|
padding: 0.15em 0.35em;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
margin-top: 1em;
|
||||||
|
margin-bottom: 0;
|
||||||
|
background-color: var(--color-code-bg);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1em;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 0.85em;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
font-size: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shiki dual-theme: light by default, dark when preferred */
|
||||||
|
pre.astro-code {
|
||||||
|
background-color: var(--shiki-light-bg, var(--color-code-bg)) !important;
|
||||||
|
color: var(--shiki-light, inherit) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre.astro-code span {
|
||||||
|
color: var(--shiki-light, inherit) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
pre.astro-code {
|
||||||
|
background-color: var(--shiki-dark-bg, var(--color-code-bg)) !important;
|
||||||
|
color: var(--shiki-dark, inherit) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre.astro-code span {
|
||||||
|
color: var(--shiki-dark, inherit) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--color-rule);
|
||||||
|
margin-top: 2em;
|
||||||
|
margin-bottom: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 1em;
|
||||||
|
margin-bottom: 0;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
border: 1px solid var(--color-rule);
|
||||||
|
padding: 0.5em 0.75em;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
font-weight: bold;
|
||||||
|
background-color: var(--color-code-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/utils/articles.ts
Normal file
10
src/utils/articles.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Парсит дату из ID статьи (формат: "2019-05-01-slug").
|
||||||
|
*/
|
||||||
|
export function parseDateFromId(id: string): Date {
|
||||||
|
const match = id.match(/^(\d{4})-(\d{2})-(\d{2})-/);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`Cannot parse date from article id: ${id}`);
|
||||||
|
}
|
||||||
|
return new Date(`${match[1]}-${match[2]}-${match[3]}`);
|
||||||
|
}
|
||||||
3
tsconfig.json
Normal file
3
tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict"
|
||||||
|
}
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
const path = require('path');
|
|
||||||
const glob = require('glob');
|
|
||||||
const autoprefixer = require('autoprefixer');
|
|
||||||
const VueLoaderPlugin = require('vue-loader/lib/plugin');
|
|
||||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
|
||||||
|
|
||||||
function collect_entries() {
|
|
||||||
const assets = glob.sync('./source/_assets/**/{index.js,style.[s]css}');
|
|
||||||
const entries = {};
|
|
||||||
assets.forEach(f => {
|
|
||||||
const parts = f.split('/');
|
|
||||||
const name = parts[parts.length - 2];
|
|
||||||
entries[name] = f;
|
|
||||||
});
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = (env = {}) => {
|
|
||||||
const is_prod = !!env.production;
|
|
||||||
const dist_dir = is_prod ? './output_prod' : './output_dev';
|
|
||||||
|
|
||||||
const STYLE_LOADER = { loader: 'style-loader' };
|
|
||||||
|
|
||||||
const CSS_LOADER = {
|
|
||||||
loader: 'css-loader',
|
|
||||||
options: {
|
|
||||||
importLoaders: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const POSTCSS_LOADER = {
|
|
||||||
loader: 'postcss-loader',
|
|
||||||
options: {
|
|
||||||
plugins: [autoprefixer()],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const SCSS_LOADER = { loader: 'sass-loader' };
|
|
||||||
|
|
||||||
const MINI_CSS_LOADER = MiniCssExtractPlugin.loader;
|
|
||||||
|
|
||||||
const BABEL_LOADER = {
|
|
||||||
loader: 'babel-loader',
|
|
||||||
options: {
|
|
||||||
presets: ['@babel/preset-env'],
|
|
||||||
plugins: [
|
|
||||||
'@babel/plugin-transform-runtime',
|
|
||||||
'@babel/plugin-proposal-class-properties',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const VUE_LOADER = {
|
|
||||||
loader: 'vue-loader',
|
|
||||||
options: {
|
|
||||||
loaders: {
|
|
||||||
css: [
|
|
||||||
MINI_CSS_LOADER, //'vue-style-loader',
|
|
||||||
CSS_LOADER,
|
|
||||||
POSTCSS_LOADER,
|
|
||||||
],
|
|
||||||
scss: [
|
|
||||||
MINI_CSS_LOADER, //'vue-style-loader',
|
|
||||||
CSS_LOADER,
|
|
||||||
POSTCSS_LOADER,
|
|
||||||
SCSS_LOADER,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
mode: is_prod ? 'production' : 'development',
|
|
||||||
entry: collect_entries(),
|
|
||||||
output: {
|
|
||||||
filename: '[name].js',
|
|
||||||
path: path.resolve(__dirname, `${dist_dir}/static`),
|
|
||||||
},
|
|
||||||
module: {
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
test: /\.js$/,
|
|
||||||
exclude: /node_modules/,
|
|
||||||
use: [BABEL_LOADER],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
test: /\.css$/,
|
|
||||||
use: [MINI_CSS_LOADER, CSS_LOADER, POSTCSS_LOADER],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
test: /\.scss$/,
|
|
||||||
use: [
|
|
||||||
MINI_CSS_LOADER,
|
|
||||||
CSS_LOADER,
|
|
||||||
POSTCSS_LOADER,
|
|
||||||
SCSS_LOADER,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
test: /\.vue$/,
|
|
||||||
use: [VUE_LOADER],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
plugins: [
|
|
||||||
new VueLoaderPlugin(),
|
|
||||||
new MiniCssExtractPlugin({
|
|
||||||
// Options similar to the same options in webpackOptions.output
|
|
||||||
// both options are optional
|
|
||||||
filename: '[name].css',
|
|
||||||
// chunkFilename: "[id].css"
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user