Move symfony stuff to own folder

This commit is contained in:
2025-05-28 23:51:27 +01:00
parent 20e604cf68
commit 077d33d801
89 changed files with 24 additions and 26 deletions

41
symfony/.env Normal file
View File

@@ -0,0 +1,41 @@
# In all environments, the following files are loaded if they exist,
# the latter taking precedence over the former:
#
# * .env contains default values for the environment variables needed by the app
# * .env.local uncommitted file with local overrides
# * .env.$APP_ENV committed environment-specific defaults
# * .env.$APP_ENV.local uncommitted environment-specific overrides
#
# Real environment variables win over .env files.
#
# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES.
# https://symfony.com/doc/current/configuration/secrets.html
#
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
###> symfony/framework-bundle ###
APP_ENV=dev
APP_SECRET=
###< symfony/framework-bundle ###
###> doctrine/doctrine-bundle ###
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
#
DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
# DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
###< doctrine/doctrine-bundle ###
###> symfony/messenger ###
# Choose one of the transports below
# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages
# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
###< symfony/messenger ###
###> symfony/mailer ###
MAILER_DSN=null://null
###< symfony/mailer ###

4
symfony/.env.dev Normal file
View File

@@ -0,0 +1,4 @@
###> symfony/framework-bundle ###
APP_SECRET=59e8f6a1ec4b7c0d4c3126975ed96a64
###< symfony/framework-bundle ###

6
symfony/.env.test Normal file
View File

@@ -0,0 +1,6 @@
# define your env variables for the test env here
KERNEL_CLASS='App\Kernel'
APP_SECRET='$ecretf0rt3st'
SYMFONY_DEPRECATIONS_HELPER=999999
PANTHER_APP_ENV=panther
PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots

24
symfony/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
###> symfony/framework-bundle ###
.env.local
.env.local.php
.env.*.local
config/secrets/prod/prod.decrypt.private.php
public/bundles/
var/
vendor/
###< symfony/framework-bundle ###
###> phpunit/phpunit ###
phpunit.xml
.phpunit.result.cache
###< phpunit/phpunit ###
###> symfony/phpunit-bridge ###
.phpunit.result.cache
phpunit.xml
###< symfony/phpunit-bridge ###
###> symfony/asset-mapper ###
public/assets/
assets/vendor/
###< symfony/asset-mapper ###

10
symfony/assets/app.js Normal file
View File

@@ -0,0 +1,10 @@
import './bootstrap.js';
/*
* Welcome to your app's main JavaScript file!
*
* This file will be included onto the page via the importmap() Twig function,
* which should already be in your base.html.twig.
*/
import './styles/app.css';
console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉');

5
symfony/assets/bootstrap.js vendored Normal file
View File

@@ -0,0 +1,5 @@
import { startStimulusApp } from '@symfony/stimulus-bundle';
const app = startStimulusApp();
// register any custom, 3rd party controllers here
// app.register('some_controller_name', SomeImportedController);

View File

@@ -0,0 +1,15 @@
{
"controllers": {
"@symfony/ux-turbo": {
"turbo-core": {
"enabled": true,
"fetch": "eager"
},
"mercure-turbo-stream": {
"enabled": false,
"fetch": "eager"
}
}
},
"entrypoints": []
}

View File

@@ -0,0 +1,79 @@
const nameCheck = /^[-_a-zA-Z0-9]{4,22}$/;
const tokenCheck = /^[-_\/+a-zA-Z0-9]{24,}$/;
// Generate and double-submit a CSRF token in a form field and a cookie, as defined by Symfony's SameOriginCsrfTokenManager
document.addEventListener('submit', function (event) {
generateCsrfToken(event.target);
}, true);
// When @hotwired/turbo handles form submissions, send the CSRF token in a header in addition to a cookie
// The `framework.csrf_protection.check_header` config option needs to be enabled for the header to be checked
document.addEventListener('turbo:submit-start', function (event) {
const h = generateCsrfHeaders(event.detail.formSubmission.formElement);
Object.keys(h).map(function (k) {
event.detail.formSubmission.fetchRequest.headers[k] = h[k];
});
});
// When @hotwired/turbo handles form submissions, remove the CSRF cookie once a form has been submitted
document.addEventListener('turbo:submit-end', function (event) {
removeCsrfToken(event.detail.formSubmission.formElement);
});
export function generateCsrfToken (formElement) {
const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]');
if (!csrfField) {
return;
}
let csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value');
let csrfToken = csrfField.value;
if (!csrfCookie && nameCheck.test(csrfToken)) {
csrfField.setAttribute('data-csrf-protection-cookie-value', csrfCookie = csrfToken);
csrfField.defaultValue = csrfToken = btoa(String.fromCharCode.apply(null, (window.crypto || window.msCrypto).getRandomValues(new Uint8Array(18))));
csrfField.dispatchEvent(new Event('change', { bubbles: true }));
}
if (csrfCookie && tokenCheck.test(csrfToken)) {
const cookie = csrfCookie + '_' + csrfToken + '=' + csrfCookie + '; path=/; samesite=strict';
document.cookie = window.location.protocol === 'https:' ? '__Host-' + cookie + '; secure' : cookie;
}
}
export function generateCsrfHeaders (formElement) {
const headers = {};
const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]');
if (!csrfField) {
return headers;
}
const csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value');
if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) {
headers[csrfCookie] = csrfField.value;
}
return headers;
}
export function removeCsrfToken (formElement) {
const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]');
if (!csrfField) {
return;
}
const csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value');
if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) {
const cookie = csrfCookie + '_' + csrfField.value + '=0; path=/; samesite=strict; max-age=0';
document.cookie = window.location.protocol === 'https:' ? '__Host-' + cookie + '; secure' : cookie;
}
}
/* stimulusFetch: 'lazy' */
export default 'csrf-protection-controller';

View File

@@ -0,0 +1,16 @@
import { Controller } from '@hotwired/stimulus';
/*
* This is an example Stimulus controller!
*
* Any element with a data-controller="hello" attribute will cause
* this controller to be executed. The name "hello" comes from the filename:
* hello_controller.js -> "hello"
*
* Delete this file or adapt it for your use!
*/
export default class extends Controller {
connect() {
this.element.textContent = 'Hello Stimulus! Edit me in assets/controllers/hello_controller.js';
}
}

View File

@@ -0,0 +1,3 @@
body {
background-color: skyblue;
}

21
symfony/bin/console Executable file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env php
<?php
use App\Kernel;
use Symfony\Bundle\FrameworkBundle\Console\Application;
if (!is_dir(dirname(__DIR__).'/vendor')) {
throw new LogicException('Dependencies are missing. Try running "composer install".');
}
if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
}
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
$kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
return new Application($kernel);
};

23
symfony/bin/phpunit Executable file
View File

@@ -0,0 +1,23 @@
#!/usr/bin/env php
<?php
if (!ini_get('date.timezone')) {
ini_set('date.timezone', 'UTC');
}
if (is_file(dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit')) {
if (PHP_VERSION_ID >= 80000) {
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';
} else {
define('PHPUNIT_COMPOSER_INSTALL', dirname(__DIR__).'/vendor/autoload.php');
require PHPUNIT_COMPOSER_INSTALL;
PHPUnit\TextUI\Command::main();
}
} else {
if (!is_file(dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php')) {
echo "Unable to find the `simple-phpunit.php` script in `vendor/symfony/phpunit-bridge/bin/`.\n";
exit(1);
}
require dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php';
}

112
symfony/composer.json Normal file
View File

@@ -0,0 +1,112 @@
{
"type": "project",
"license": "proprietary",
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.2",
"ext-ctype": "*",
"ext-iconv": "*",
"doctrine/dbal": "^3",
"doctrine/doctrine-bundle": "^2.14",
"doctrine/doctrine-migrations-bundle": "^3.4",
"doctrine/orm": "^3.3",
"league/commonmark": "^2.7",
"league/html-to-markdown": "^5.1",
"phpdocumentor/reflection-docblock": "^5.6",
"phpstan/phpdoc-parser": "^2.1",
"symfony/asset": "7.2.*",
"symfony/asset-mapper": "7.2.*",
"symfony/console": "7.2.*",
"symfony/doctrine-messenger": "7.2.*",
"symfony/dotenv": "7.2.*",
"symfony/expression-language": "7.2.*",
"symfony/flex": "^2",
"symfony/form": "7.2.*",
"symfony/framework-bundle": "7.2.*",
"symfony/http-client": "7.2.*",
"symfony/intl": "7.2.*",
"symfony/mailer": "7.2.*",
"symfony/mime": "7.2.*",
"symfony/monolog-bundle": "^3.0",
"symfony/notifier": "7.2.*",
"symfony/process": "7.2.*",
"symfony/property-access": "7.2.*",
"symfony/property-info": "7.2.*",
"symfony/runtime": "7.2.*",
"symfony/security-bundle": "7.2.*",
"symfony/serializer": "7.2.*",
"symfony/stimulus-bundle": "^2.24",
"symfony/string": "7.2.*",
"symfony/translation": "7.2.*",
"symfony/twig-bundle": "7.2.*",
"symfony/ux-turbo": "^2.24",
"symfony/validator": "7.2.*",
"symfony/web-link": "7.2.*",
"symfony/yaml": "7.2.*",
"twig/extra-bundle": "^2.12|^3.0",
"twig/markdown-extra": "^3.21",
"twig/twig": "^2.12|^3.0"
},
"config": {
"allow-plugins": {
"php-http/discovery": true,
"symfony/flex": true,
"symfony/runtime": true
},
"bump-after-update": true,
"sort-packages": true
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
},
"replace": {
"symfony/polyfill-ctype": "*",
"symfony/polyfill-iconv": "*",
"symfony/polyfill-php72": "*",
"symfony/polyfill-php73": "*",
"symfony/polyfill-php74": "*",
"symfony/polyfill-php80": "*",
"symfony/polyfill-php81": "*",
"symfony/polyfill-php82": "*"
},
"scripts": {
"auto-scripts": {
"cache:clear": "symfony-cmd",
"assets:install %PUBLIC_DIR%": "symfony-cmd",
"importmap:install": "symfony-cmd"
},
"post-install-cmd": [
"@auto-scripts"
],
"post-update-cmd": [
"@auto-scripts"
]
},
"conflict": {
"symfony/symfony": "*"
},
"extra": {
"symfony": {
"allow-contrib": false,
"require": "7.2.*"
}
},
"require-dev": {
"phpunit/phpunit": "^9.5",
"symfony/browser-kit": "7.2.*",
"symfony/css-selector": "7.2.*",
"symfony/debug-bundle": "7.2.*",
"symfony/maker-bundle": "^1.63",
"symfony/phpunit-bridge": "^7.2",
"symfony/stopwatch": "7.2.*",
"symfony/web-profiler-bundle": "7.2.*"
}
}

10506
symfony/composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
<?php
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true],
Symfony\UX\Turbo\TurboBundle::class => ['all' => true],
Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
];

View File

@@ -0,0 +1,11 @@
framework:
asset_mapper:
# The paths to make available to the asset mapper.
paths:
- assets/
missing_import_mode: strict
when@prod:
framework:
asset_mapper:
missing_import_mode: warn

View File

@@ -0,0 +1,19 @@
framework:
cache:
# Unique name of your app: used to compute stable namespaces for cache keys.
#prefix_seed: your_vendor_name/app_name
# The "app" cache stores to the filesystem by default.
# The data in this cache should persist between deploys.
# Other options include:
# Redis
#app: cache.adapter.redis
#default_redis_provider: redis://localhost
# APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
#app: cache.adapter.apcu
# Namespaced pools use the above "app" backend by default
#pools:
#my.dedicated.cache: null

View File

@@ -0,0 +1,11 @@
# Enable stateless CSRF protection for forms and logins/logouts
framework:
form:
csrf_protection:
token_id: submit
csrf_protection:
stateless_token_ids:
- submit
- authenticate
- logout

View File

@@ -0,0 +1,5 @@
when@dev:
debug:
# Forwards VarDumper Data clones to a centralized server allowing to inspect dumps on CLI or in your browser.
# See the "server:dump" command to start a new server.
dump_destination: "tcp://%env(VAR_DUMPER_SERVER)%"

View File

@@ -0,0 +1,54 @@
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
#server_version: '16'
profiling_collect_backtrace: '%kernel.debug%'
use_savepoints: true
orm:
auto_generate_proxy_classes: true
enable_lazy_ghost_objects: true
report_fields_where_declared: true
validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
identity_generation_preferences:
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
auto_mapping: true
mappings:
App:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
controller_resolver:
auto_mapping: false
when@test:
doctrine:
dbal:
# "TEST_TOKEN" is typically set by ParaTest
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
when@prod:
doctrine:
orm:
auto_generate_proxy_classes: false
proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies'
query_cache_driver:
type: pool
pool: doctrine.system_cache_pool
result_cache_driver:
type: pool
pool: doctrine.result_cache_pool
framework:
cache:
pools:
doctrine.result_cache_pool:
adapter: cache.app
doctrine.system_cache_pool:
adapter: cache.system

View File

@@ -0,0 +1,6 @@
doctrine_migrations:
migrations_paths:
# namespace is arbitrary but should be different from App\Migrations
# as migrations classes should NOT be autoloaded
'DoctrineMigrations': '%kernel.project_dir%/migrations'
enable_profiler: false

View File

@@ -0,0 +1,15 @@
# see https://symfony.com/doc/current/reference/configuration/framework.html
framework:
secret: '%env(APP_SECRET)%'
# Note that the session will be started ONLY if you read or write from it.
session: true
#esi: true
#fragments: true
when@test:
framework:
test: true
session:
storage_factory_id: session.storage.factory.mock_file

View File

@@ -0,0 +1,3 @@
framework:
mailer:
dsn: '%env(MAILER_DSN)%'

View File

@@ -0,0 +1,29 @@
framework:
messenger:
failure_transport: failed
transports:
# https://symfony.com/doc/current/messenger.html#transport-configuration
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
options:
use_notify: true
check_delayed_interval: 60000
retry_strategy:
max_retries: 3
multiplier: 2
failed: 'doctrine://default?queue_name=failed'
# sync: 'sync://'
default_bus: messenger.bus.default
buses:
messenger.bus.default: []
routing:
Symfony\Component\Mailer\Messenger\SendEmailMessage: async
Symfony\Component\Notifier\Message\ChatMessage: async
Symfony\Component\Notifier\Message\SmsMessage: async
# Route your messages to the transports
# 'App\Message\YourMessage': async

View File

@@ -0,0 +1,62 @@
monolog:
channels:
- deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists
when@dev:
monolog:
handlers:
main:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
channels: ["!event"]
# uncomment to get logging in your browser
# you may have to allow bigger header sizes in your Web server configuration
#firephp:
# type: firephp
# level: info
#chromephp:
# type: chromephp
# level: info
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine", "!console"]
when@test:
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
channels: ["!event"]
nested:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
when@prod:
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
nested:
type: stream
path: php://stderr
level: debug
formatter: monolog.formatter.json
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine"]
deprecation:
type: stream
channels: [deprecation]
path: php://stderr
formatter: monolog.formatter.json

View File

@@ -0,0 +1,12 @@
framework:
notifier:
chatter_transports:
texter_transports:
channel_policy:
# use chat/slack, chat/telegram, sms/twilio or sms/nexmo
urgent: ['email']
high: ['email']
medium: ['email']
low: ['email']
admin_recipients:
- { email: admin@example.com }

View File

@@ -0,0 +1,10 @@
framework:
router:
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
#default_uri: http://localhost
when@prod:
framework:
router:
strict_requirements: null

View File

@@ -0,0 +1,32 @@
security:
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
providers:
app_user_provider:
memory:
users:
me@joeac.net:
password: '%env(resolve:JOEAC_PASSWORD)%'
roles: ['ROLE_EDITOR']
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: app_user_provider
form_login:
login_path: login
check_path: login
enable_csrf: true
logout:
path: logout
when@test:
security:
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: auto
cost: 4 # Lowest possible value for bcrypt
time_cost: 3 # Lowest possible value for argon
memory_cost: 10 # Lowest possible value for argon

View File

@@ -0,0 +1,7 @@
framework:
default_locale: en
translator:
default_path: '%kernel.project_dir%/translations'
fallbacks:
- en
providers:

View File

@@ -0,0 +1,6 @@
twig:
file_name_pattern: '*.twig'
when@test:
twig:
strict_variables: true

View File

@@ -0,0 +1,11 @@
framework:
validation:
# Enables validator auto-mapping support.
# For instance, basic validation constraints will be inferred from Doctrine's metadata.
#auto_mapping:
# App\Entity\: []
when@test:
framework:
validation:
not_compromised_password: false

View File

@@ -0,0 +1,11 @@
when@dev:
web_profiler:
toolbar: true
framework:
profiler:
collect_serializer_data: true
when@test:
framework:
profiler: { collect: false }

View File

@@ -0,0 +1,5 @@
<?php
if (file_exists(dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php')) {
require dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php';
}

View File

@@ -0,0 +1,5 @@
controllers:
resource:
path: ../src/Controller/
namespace: App\Controller
type: attribute

View File

@@ -0,0 +1,4 @@
when@dev:
_errors:
resource: '@FrameworkBundle/Resources/config/routing/errors.xml'
prefix: /_error

View File

@@ -0,0 +1,3 @@
_security_logout:
resource: security.route_loader.logout
type: service

View File

@@ -0,0 +1,8 @@
when@dev:
web_profiler_wdt:
resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml'
prefix: /_wdt
web_profiler_profiler:
resource: '@WebProfilerBundle/Resources/config/routing/profiler.xml'
prefix: /_profiler

View File

@@ -0,0 +1,25 @@
# This file is the entry point to configure your own services.
# Files in the packages/ subdirectory configure your dependencies.
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
services:
# default configuration for services in *this* file
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
# makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name
App\:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Interface/'
- '../src/Kernel.php'
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones

28
symfony/importmap.php Normal file
View File

@@ -0,0 +1,28 @@
<?php
/**
* Returns the importmap for this application.
*
* - "path" is a path inside the asset mapper system. Use the
* "debug:asset-map" command to see the full list of paths.
*
* - "entrypoint" (JavaScript only) set to true for any module that will
* be used as an "entrypoint" (and passed to the importmap() Twig function).
*
* The "importmap:require" command can be used to add new entries to this file.
*/
return [
'app' => [
'path' => './assets/app.js',
'entrypoint' => true,
],
'@hotwired/stimulus' => [
'version' => '3.2.2',
],
'@symfony/stimulus-bundle' => [
'path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js',
],
'@hotwired/turbo' => [
'version' => '7.3.0',
],
];

0
symfony/migrations/.gitignore vendored Normal file
View File

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250517200832 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
CREATE TABLE note (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, content VARCHAR(255) NOT NULL, published_date DATE NOT NULL)
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE messenger_messages (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, body CLOB NOT NULL, headers CLOB NOT NULL, queue_name VARCHAR(190) NOT NULL, created_at DATETIME NOT NULL --(DC2Type:datetime_immutable)
, available_at DATETIME NOT NULL --(DC2Type:datetime_immutable)
, delivered_at DATETIME DEFAULT NULL --(DC2Type:datetime_immutable)
)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_75EA56E0FB7336F0 ON messenger_messages (queue_name)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_75EA56E0E3BD61CE ON messenger_messages (available_at)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_75EA56E016BA31DB ON messenger_messages (delivered_at)
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
DROP TABLE note
SQL);
$this->addSql(<<<'SQL'
DROP TABLE messenger_messages
SQL);
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250517212750 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE note ADD COLUMN slug VARCHAR(255) NOT NULL
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
CREATE TEMPORARY TABLE __temp__note AS SELECT id, content, published_date FROM note
SQL);
$this->addSql(<<<'SQL'
DROP TABLE note
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE note (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, content VARCHAR(255) NOT NULL, published_date DATE NOT NULL)
SQL);
$this->addSql(<<<'SQL'
INSERT INTO note (id, content, published_date) SELECT id, content, published_date FROM __temp__note
SQL);
$this->addSql(<<<'SQL'
DROP TABLE __temp__note
SQL);
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20250519215136 extends AbstractMigration
{
public function getDescription(): string {
return '';
}
public function up(Schema $schema): void {}
public function down(Schema $schema): void {}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20250521000000 extends AbstractMigration
{
public function getDescription(): string {
return 'Migrating my links from my old website to notes in my new website';
}
public function up(Schema $schema): void {
$this->addSql(<<<'SQL'
INSERT INTO note(content, published_date, slug) VALUES
('[Is Stack Overflow Obsolete? An Empirical Study of the Characteristics of ChatGPT Answers to Stack Overflow Questions](https://dl.acm.org/doi/pdf/10.1145/3613904.3642596). ChatGPT 3.5 gives bad answers half the time, and programmers miss the mistakes almost half the time. Be careful out there, folks.', '2024-06-04', '2024-06-04'),
('[Primary and Secondary Values](https://www.colinmcginn.net/primary-and-secondary-values). Colin McGinn makes the case for primary and secondary moral values, just as there are primary and secondary qualities, apparently thereby managing to assert both moral realism and anti-realism at the same time without contradiction.', '2024-06-05', '2024-06-05'),
('[Pluralistic: They brick you because they can](https://pluralistic.net/2024/05/24/record-scratch/#autoenshittification). Cory Doctorow writes incessantly about the harms of monopolised markets. This essay is particularly good, because he collects many of monopolists greatest hits from recent years. Do keep reading to the end. It just gets better.', '2024-06-05', '2024-06-05-1'),
('[Parenting](https://johan.hal.se/wrote/2024/06/05/parenting). Once you find yourself in the position of being someones father youll quickly realize that youre not actually raising anyone here, you just happen to be the veteran in the trenches alongside them, showing them the ropes and hoping theyll survive and turn out okay.', '2024-06-05', '2024-06-05-2'),
('[Boys](https://www.bbc.co.uk/programmes/m001yshl). Catherine Carr did a fantastic job of unveiling how teenage boys are experiencing masculinity in Britain today. Plenty here to surprise, shock and inspire.', '2024-06-05', '2024-06-05-3'),
('[BCS manifesto](https://www.bcs.org/articles-opinion-and-research/the-computing-revolution-how-the-next-government-can-transform-society-with-ethics-education-and-equity-in-technology). Good for what it is. Good policies. Succinct. Should be the beginning (<em>not</em> the end) of some interesting conversations.', '2024-06-05', '2024-06-05-4'),
('[154 McDonald Road: Gone but not Forgotten](https://broughtonspurtle.org.uk/news/gone-not-forgotten). A superb tribute to the building and analysis of the failures of the planning system. This was published in my free local newsletter, and is worthy of any broadsheet newspaper.', '2024-06-07', '2024-06-07'),
('[Beware the cloud of hype](https://thehistoryoftheweb.com/beware-the-cloud-of-hype). Jay Hoffman spots some striking parallels between the current AI hype and the dot-com bubble.', '2024-06-07', '2024-06-07-1'),
('[Saving the News from Big Tech](https://eff.org/saving-the-news). Cory Doctorow, writing for the Electronic Frontier Foundation, argues that to save news media, we need to dismantle ad-tech monopolies, ban surveillance advertising, open up app stores and have an end-to-end web.', '2024-06-07', '2024-06-07-2'),
('[Instagram is training AI on your data. Its nearly impossible to opt out](https://www.fastcompany.com/91132854/instagram-training-ai-on-your-data-its-nearly-impossible-to-opt-out). Yuck, yuck, yuck. Makes me glad Im not on Instagram. For people already stuck there, though, this just sucks. Highly recommend either opting out of AI training or quitting Insta, if only to give the twits the middle finger they deserve.', '2024-06-14', '2024-06-14'),
('[State-based UI is an anti-pattern](https://gomakethings.com/state-based-ui-is-an-anti-pattern). Chris Ferdinandi has a hot take here. I would be keen to test this idea out one day: push the limits of how much complex state you can manage within the light DOM.', '2024-06-14', '2024-06-14-1'),
('[Big Data is Dead](https://motherduck.com/blog/big-data-is-dead). Did you know that most organisations store less than 100GB, and almost all analytics is run on the last 24h of data? I didnt. Though take it all with a pinch of salt: the guys writing on his company blog which sells traditional data warehouses.', '2024-06-17', '2024-06-17'),
('[Reverse Engineering TicketMaster''s Rotating Barcodes (SafeTix)](https://conduition.io/coding/ticketmaster). Who doesn''t like a classic David-and-Goliath hacker story? Also, if you''re American, please <a href="https://www.breakupticketmaster.com">break up TicketMaster</a>. If you''re in the UK, it''s not quite as bad, but <a href="https://assets.publishing.service.gov.uk/media/5519473540f0b61401000087/final_report.pdf">it''s still really bad</a>. Use alternatives where you can.', '2024-07-16', '2024-07-16'),
('[Dont use booleans](https://www.luu.io/posts/dont-use-booleans). A nice idea. But I think this advice only applies well when you''ve got many inter-dependent flags. If you have independent flags, re-writing those as enums will just end up with you re-implementing the boolean type for every parameter, and not getting much profit, I reckon.', '2024-07-16', '2024-07-16-1'),
('[Story points are wasting time](https://blog.scottlogic.com/2024/07/05/story-points-are-wasting-time.html). Pretty convincing to me. The biggest potential weakness in his argument is his claim that none of the most common reasons why devs disagree on story points exposes anything which ought to be resolved in an estimation meeting. If you can provide other common reasons besides the ones Dave considered, you could rebut his argument. I don''t feel experienced enough to judge this myself.', '2024-07-17', '2024-07-17'),
('[Goldman Sachs Top of the Mind, Issue 129](https://www.goldmansachs.com/intelligence/pages/gs-research/gen-ai-too-much-spend-too-little-benefit/report.pdf). Read the interviews. Economists give interesting, and diverse, opinions on the economic potential of LLMs.', '2024-07-18', '2024-07-18'),
('[The Human Cost Of Our AI-Driven Future](https://www.noemamag.com/the-human-cost-of-our-ai-driven-future). In case you''d forgotten: content moderation is still carried out by appalling worker exploitation. This is not news, but nonetheless an excellent and suitably chilling essay on the topic. Be aware that the essay describes some deeply traumatic content.', '2024-10-11', '2024-10-11'),
('[LLMs don''t ''hallucinate''](https://blog.scottlogic.com/2024/09/10/llms-dont-hallucinate.html). I posted on the Scott Logic blog a while ago about how the word ''hallucination'' doesn''t accurately capture how LLMs work.', '2024-10-11', '2024-10-11-1'),
('[Please just stop saying ''just''](https://sgringwe.com/2019/10/10/Please-just-stop-saying-just.html). A pretty good case for avoiding the word ''just'' in software engineering. I admit I''ve been guilty, too.', '2024-11-11', '2024-11-11'),
('[Sexual symmetry and asymmetry](https://alexanderpruss.blogspot.com/2024/11/sexual-symmetry-and-asymmetry.html). Alexander Pruss has a bizarre, but at first blush convincing, argument that complementarians about gender dont have to appeal to morally significant intrinsic differences between men and women.', '2024-12-17', '2024-12-17'),
('["Rules" that terminal programs follow](https://jvns.ca/blog/2024/11/26/terminal-rules). Handy for the next time you develop a CLI or TUI. Also handy as a user: now I know about <a href="https://readline.kablamo.org/emacs.html">readline key bindings</a>, which are everywhere apparently.', '2024-12-20', '2024-12-20'),
('[Bank of England''s ''Millenium of Macroeconomic Data''](https://www.bankofengland.co.uk/statistics/research-datasets). There was no long-term price inflation from 1200 (when these data begin) until 1550. WHAT?!', '2025-01-23', '2025-01-23'),
('[Free social media from billionaire control](https://freeourfeeds.com). I just donated $40. These guys are promising to do whatever it takes to make sure the AT Protocol is genuinely owned by everyone.', '2025-01-28', '2025-01-28')
SQL);
}
public function down(Schema $schema): void {
$this->addSql(<<<'SQL'
DELETE FROM note WHERE
slug = '2024-06-04' OR
slug = '2024-06-05' OR
slug = '2024-06-05-1' OR
slug = '2024-06-05-2' OR
slug = '2024-06-05-3' OR
slug = '2024-06-05-4' OR
slug = '2024-06-07' OR
slug = '2024-06-07-1' OR
slug = '2024-06-07-2' OR
slug = '2024-06-14' OR
slug = '2024-06-14-1' OR
slug = '2024-06-17' OR
slug = '2024-07-16' OR
slug = '2024-07-16-1' OR
slug = '2024-07-17' OR
slug = '2024-07-18' OR
slug = '2024-10-11' OR
slug = '2024-10-11-1' OR
slug = '2024-11-11' OR
slug = '2024-12-17' OR
slug = '2024-12-20' OR
slug = '2025-01-23' OR
slug = '2025-01-28'
SQL);
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250522212213 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
CREATE TABLE blog_post (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, slug VARCHAR(255) NOT NULL, published_date DATE NOT NULL, updated_date DATE DEFAULT NULL, content CLOB NOT NULL, title VARCHAR(255) NOT NULL, description VARCHAR(1024) NOT NULL)
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
DROP TABLE blog_post
SQL);
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use DateTime;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
use Symfony\Component\Yaml\Yaml;
final class Version20250522212300 extends AbstractMigration
{
private const BLOG_POST_SLUGS = [
'doctor_who_gayness_church',
'does_resurrection_ground_works',
'easter',
'euhwc_toast_to_the_lasses_2024',
'god_is_not_great',
'llms_do_not_understand_anything',
'my_feed_and_reading_list',
'no_more_youtube',
'open_questions_about_sex',
'paradox',
'sapiens_on_religion',
'science_and_philosophy',
'surprised_by_hope',
'tracking_pixels',
'who_consecrates_the_temple',
'word_hallucination_with_reference_to_llms',
];
public function getDescription(): string {
return 'Migrating my blog posts from my old website to notes in my new website';
}
public function up(Schema $schema): void {
foreach (self::BLOG_POST_SLUGS as $slug) {
$blogPostPath = 'scripts/blog-migrated/' . $slug . '.yaml';
$blogPost = Yaml::parseFile($blogPostPath);
$publishedDateTime = DateTime::createFromFormat('U', strval($blogPost['pubDate']));
$publishedDate = $publishedDateTime->format('Y-m-d');
$updatedDateTime = array_key_exists(array: $blogPost, key: 'updatedDate')
? DateTime::createFromFormat('U', strval($blogPost['updatedDate']))
: null;
$updatedDate = $updatedDateTime == null
? 'NULL'
: "'" . $updatedDateTime->format('Y-m-d') . "'";
$title = str_replace("'", "''", $blogPost['title']);
$description = str_replace("'", "''", $blogPost['description']);
$content = str_replace("'", "''", $blogPost['content']);
$this->addSql(<<<SQL
INSERT INTO blog_post(slug, published_date, updated_date, title, description, content)
VALUES('$slug', '$publishedDate', $updatedDate, '$title', '$description', '$content')
SQL);
}
}
public function down(Schema $schema): void {
foreach (self::BLOG_POST_SLUGS as $slug) {
$this->addSql(<<<SQL
DELETE FROM blog_post WHERE slug = '$slug'
SQL);
}
}
}

38
symfony/phpunit.xml.dist Normal file
View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
backupGlobals="false"
colors="true"
bootstrap="tests/bootstrap.php"
convertDeprecationsToExceptions="false"
>
<php>
<ini name="display_errors" value="1" />
<ini name="error_reporting" value="-1" />
<server name="APP_ENV" value="test" force="true" />
<server name="SHELL_VERBOSITY" value="-1" />
<server name="SYMFONY_PHPUNIT_REMOVE" value="" />
<server name="SYMFONY_PHPUNIT_VERSION" value="9.5" />
</php>
<testsuites>
<testsuite name="Project Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">src</directory>
</include>
</coverage>
<listeners>
<listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener" />
</listeners>
<extensions>
</extensions>
</phpunit>

316
symfony/public/css/base.css Normal file
View File

@@ -0,0 +1,316 @@
/** Variables */
:root {
--colour-primary-10: #060300;
--colour-primary-15: #150800;
--colour-primary-20: #1f1400;
--colour-primary-30: #3c2b00;
--colour-primary-40: #5c4300;
--colour-primary-50: #7f5d00;
--colour-primary-60: #a37800;
--colour-primary-70: #c89500;
--colour-primary-80: #efb300;
--colour-primary-90: #ffd98c;
--colour-primary-95: #ffecc8;
--colour-hyperlink-10: #000409;
--colour-hyperlink-20: #001829;
--colour-hyperlink-30: #00314d;
--colour-hyperlink-40: #004d75;
--colour-hyperlink-50: #006a9f;
--colour-hyperlink-60: #1388c9;
--colour-hyperlink-70: #41a8ea;
--colour-hyperlink-80: #78c7ff;
--colour-hyperlink-90: #bfe3ff;
--colour-hyperlink-95: #e0f1ff;
--colour-primary-fg: var(--colour-primary-90);
--colour-primary-fg-accent: var(--colour-primary-80);
--colour-primary-bg: var(--colour-primary-10);
--colour-primary-bg-accent: var(--colour-primary-20);
--colour-code-fg: var(--colour-primary-90);
--colour-code-bg: var(--colour-primary-15);
--colour-hyperlink: var(--colour-hyperlink-80);
--font-size-sm: 1rem;
--font-size-base: 1.125rem;
--font-size-md: 1.5rem;
--font-size-lg: 2rem;
--font-size-xl: 3rem;
--spacing-block-xs: 0.5rem;
--spacing-block-sm: 1.75rem;
--spacing-block-md: 2.5rem;
--spacing-block-lg: 3.5rem;
--spacing-block-xl: 5rem;
--spacing-inline-xs: 0.25rem;
--spacing-inline-sm: 0.5rem;
--spacing-inline-md: 1.5rem;
--spacing-inline-lg: 3rem;
--spacing-inline-xl: 6rem;
}
/** Light theme */
@media (prefers-color-scheme: light) {
:root {
--colour-primary-fg: var(--colour-primary-20);
--colour-primary-fg-accent: var(--colour-primary-40);
--colour-primary-bg: var(--colour-primary-95);
--colour-primary-bg-accent: var(--colour-primary-90);
--colour-hyperlink: var(--colour-hyperlink-40);
}
}
/** Base typography */
body {
font-size: var(--font-size-base);
color: var(--colour-primary-fg);
font-weight: light;
background-color: var(--colour-primary-bg);
line-height: 1.5;
/* Geometric Humanist stack from https://modernfontstacks.com */
font-family: Avenir, Montserrat, Corbel, 'URW Gothic', source-sans-pro, sans-serif;
}
small {
font-size: var(--font-size-sm);
}
:is(p, h1, h2, h3, h4, h5, h6, hr, img, figure, ul, ol) {
margin-block-start: var(--spacing-block-sm);
}
/** Base layout */
body {
--body-margin-inline-start: var(--spacing-inline-lg);
--body-margin-inline-end: var(--body-margin-inline-start);
--body-margin-block-end: var(--spacing-block-xl);
margin-inline: var(--body-margin-inline-start) var(--body-margin-inline-end);
margin-block-end: var(--body-margin-block-end);
}
:is(h1, h2, h3, h4, h5, h6) {
text-align: center;
}
img {
margin-inline: auto;
height: auto;
}
@media (min-width: 48rem) {
body {
display: grid;
grid-template-columns:
[media-start]
var(--grid-margin-inline)
[content-start]
minmax(var(--grid-max-content-width), auto)
[content-end];
column-gap: var(--spacing-block-sm);
max-width: var(--grid-total-width);
--body-margin-inline-end: 6rem;
--grid-margin-inline: 6rem;
--grid-total-width: 48rem;
--grid-max-content-width: calc(
var(--grid-total-width)
- var(--body-margin-inline-start)
- var(--grid-margin-inline)
- var(--spacing-block-sm)
- var(--grid-margin-inline)
);
}
:is(main, article, nav) {
display: grid;
grid-column: media-start / content-end;
grid-template-columns: subgrid;
}
:is(section, header, aside) {
grid-column: content-start / content-end;
}
:is(h1, h2, h3, h4, h5, h6) {
text-align: start;
}
}
/** Headings */
h1 {
font-size: var(--font-size-xl);
font-weight: 900;
margin-block-start: 0;
}
h2 {
font-size: var(--font-size-lg);
font-weight: 900;
margin-block-start: var(--spacing-block-xl);
}
h3, h4, h5, h6 {
font-size: var(--font-size-md);
font-weight: 600;
margin-block-start: var(--spacing-block-lg);
}
/** Hyperlinks */
:is(:link, :visited) {
color: var(--colour-hyperlink);
text-decoration: underline;
display: inline;
}
:hover {
text-decoration: wavy;
}
/** Definition lists */
dl {
margin-block-start: var(--spacing-block-md);
}
dl dt {
font-weight: bold;
}
dl dd + dt {
margin-block-start: var(--spacing-block-md);
}
/** figcaptions */
figcaption {
font-size: var(--font-size-sm);
font-style: italic;
text-align: center;
}
/** Lists */
:is(ol, ul) {
margin-inline-start: var(--spacing-inline-lg);
}
/** Navigation bar */
.navbar {
margin-block: var(--spacing-block-sm);
}
.navbar ul {
grid-column: media-start / content-end;
display: flex;
justify-content: center;
gap: var(--spacing-inline-md);
list-style: none;
margin: 0;
}
@media (min-width: 36rem) {
.navbar {
display: grid;
grid-template-columns: subgrid;
}
.navbar ul {
justify-content: end;
}
}
/** Emphasis */
strong {
font-weight: bold;
font-style: italic;
}
/** Blog feed */
.h-feed ul {
list-style: none;
margin-inline: 0;
}
/** Block quotes */
blockquote {
padding-inline-start: var(--spacing-inline-lg);
border-inline-start: 2px solid var(--colour-primary-fg);
font-style: italic;
}
blockquote footer {
font-style: initial;
}
blockquote :is(b, strong) {
font-style: italic;
font-weight: bold;
}
blockquote :is(i, em) {
font-style: normal;
}
/** Small caps */
.small-caps {
font-variant: small-caps;
}
/** Pre-formatted blocks */
pre {
border: 2px solid var(--colour-primary-fg);
border-radius: 2px;
background-color: var(--colour-code-bg) !important;
margin-block-start: var(--spacing-block-sm);
padding-inline: var(--spacing-inline-sm);
padding-block: var(--spacing-block-xs);
}
/** Code blocks */
code {
border: 2px solid var(--colour-primary-fg);
border-radius: 2px;
padding-inline: var(--spacing-inline-xs);
color: var(--colour-code-fg);
background-color: var(--colour-code-bg);
font-size: var(--font-size-sm);
}
pre code {
border: none;
border-radius: none;
padding: none;
}
/** Alerts */
.alert {
border: 2px solid var(--colour-primary-fg);
border-radius: 2px;
padding-inline: var(--spacing-inline-xs);
background-color: var(--color-primary-fg-accent);
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
border: 0;
padding: 0;
white-space: nowrap;
clip-path: inset(100%);
clip: rect(0 0 0 0);
overflow: hidden;
outline: none;
outline-offset: 0;
}

86
symfony/public/css/cv.css Normal file
View File

@@ -0,0 +1,86 @@
div:has(img) {
width: 6rem;
height: 6rem;
margin-inline: auto;
}
div img {
width: 6rem;
height: 6rem;
border-radius: 1rem;
filter: contrast(1.25);
}
div:has(img)::after {
/* Colour overlay */
background-color: var(--colour-primary-80);
opacity: 0.3;
/* Same size and shape as the img */
border-radius: 1rem;
width: 6rem;
height: 6rem;
/* Positioned on top of the img */
display: block;
position: relative;
top: -6rem;
/* A content value is needed to get the ::after to render */
content: '';
}
header {
display: grid;
grid-template-columns: 1fr 1fr;
text-align: center;
border-block-end: 1px solid var(--colour-primary-fg);
}
header h1 {
grid-column: 1 / 3;
text-align: center;
}
.technical-skills h3 {
text-align: start;
}
.technical-skills ul {
color: var(--colour-primary-fg-accent);
margin-inline-start: var(--spacing-inline-md);
}
@media (min-width: 46rem) {
.technical-skills section {
display: flex;
gap: var(--spacing-inline-sm);
align-items: baseline;
}
.technical-skills section h3::after {
content: '/';
margin-inline: var(--spacing-inline-sm);
}
.technical-skills section ul {
display: flex;
gap: var(--spacing-inline-sm);
list-style: none;
margin-inline-start: 0;
}
.technical-skills section ul li + li::before {
content: '•';
margin-inline-end: var(--spacing-inline-sm);
}
}
:is(.experience, .passions) :is(ol, ul) {
list-style: none;
margin-inline: 0;
}
:is(.experience, .passions) :is(ol, ul) li {
margin-block-start: var(--spacing-block-sm);
}

View File

@@ -0,0 +1,14 @@
p:has(.dt-published, .dt-updated) {
font-size: var(--font-size-sm);
font-style: italic;
margin-block-start: 0;
}
.p-summary {
font-style: italic;
font-size: var(--font-size-sm);
}
h1 {
margin-block-start: var(--spacing-block-md);
}

View File

@@ -0,0 +1,76 @@
h2 {
margin-block-start: var(--spacing-block-md);
}
h3 {
margin-block-start: var(--spacing-block-sm);
}
.h-entry {
background-color: var(--colour-primary-bg-accent);
border: 0.125rem solid var(--colour-primary-fg);
border-radius: 1rem;
margin-inline: -1rem;
margin-block-start: var(--spacing-block-sm);
transition-duration: 250ms;
&:is(:hover, :focus, :focus-within, :focus-visible) {
background-color: var(--colour-primary-fg-accent);
color: var(--colour-primary-bg-accent);
transition-duration: 100ms;
}
+ .h-entry {
margin-block-start: var(--spacing-block-xs);
}
.u-url {
color: inherit;
display: inline-block;
height: 100%;
padding: 1rem;
text-decoration: none;
width: 100%;
}
.p-name {
margin-block-start: 0;
+ * {
margin-block-start: var(--spacing-block-xs);
}
}
p:has(.dt-published, .dt-updated) {
font-size: var(--font-size-sm);
font-style: italic;
&:not(.p-name + *) {
margin-block-start: 0;
}
}
}
.skip-to {
display: inline-block;
margin-block-start: var(--spacing-block-xs);
:is(ul, li) {
display: inline-block;
list-style-type: disc;
}
ul {
margin-block-start: var(--spacing-block-xs);
}
li:last-of-type {
list-style: none;
}
}
@media (prefers-reduced-motion: no-preference) {
.h-entry {
transition-property: background-color, color;
}
}

View File

@@ -0,0 +1,57 @@
.h-card div:has(img) {
width: 6rem;
height: 6rem;
margin-inline: auto;
}
.h-card img {
width: 6rem;
height: 6rem;
border-radius: 1rem;
filter: contrast(1.25);
}
.h-card div:has(img)::after {
/* Colour overlay */
background-color: var(--colour-primary-80);
opacity: 0.3;
/* Same size and shape as the img */
border-radius: 1rem;
width: 6rem;
height: 6rem;
/* Positioned on top of the img */
display: block;
position: relative;
top: -6rem;
/* A content value is needed to get the ::after to render */
content: '';
}
@media (min-width: 36rem) {
.h-card {
grid-column: media-start / content-end;
display: grid;
grid-template-columns: subgrid; /** Subgrid of main column layout */
grid-template-rows: min-content 1fr;
grid-template-areas:
"empty heading"
"photo text";
}
.h-card div:has(img) {
grid-area: photo;
margin-block-start: var(--spacing-block-sm);
}
.h-card header {
grid-area: heading;
}
.h-card__text {
grid-area: text;
}
}

View File

@@ -0,0 +1,73 @@
/* Based on Andy Bells More Modern CSS Reset: https://piccalil.li/blog/a-more-modern-css-reset/ */
/* Box sizing rules */
*,
*::before,
*::after {
box-sizing: border-box;
}
/* Prevent font size inflation */
html {
-moz-text-size-adjust: none;
-webkit-text-size-adjust: none;
text-size-adjust: none;
}
/* Remove default margin in favour of better control in authored CSS */
body, h1, h2, h3, h4, p,
figure, blockquote, dl, dd {
margin-block: 0;
}
/* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */
ul[role='list'],
ol[role='list'] {
list-style: none;
}
/* Set core body defaults */
body {
min-height: 100vh;
}
/* Set shorter line heights on headings and interactive elements */
h1, h2, h3, h4,
button, input, label {
line-height: 1.1;
}
/* Balance text wrapping on headings */
h1, h2,
h3, h4 {
text-wrap: balance;
}
/* Make images easier to work with */
img,
picture {
max-width: 100%;
display: block;
}
/* Inherit fonts for inputs and buttons */
input, button,
textarea, select {
font: inherit;
}
/* Make sure textareas without a rows attribute are not tiny */
textarea:not([rows]) {
min-height: 10em;
}
/* Anything that has been anchored to should have extra scroll margin */
:target {
scroll-margin-block: 5ex;
}
* {
margin: 0;
padding: 0;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 949 KiB

9
symfony/public/index.php Normal file
View File

@@ -0,0 +1,9 @@
<?php
use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};

0
symfony/src/Controller/.gitignore vendored Normal file
View File

View File

@@ -0,0 +1,257 @@
<?php
namespace App\Controller;
use App\Entity\BlogPost;
use App\Entity\Note;
use App\Form\Type\NoteType;
use DateTime;
use DateTimeZone;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Twig\Attribute\Template;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use function Symfony\Component\HttpFoundation\Response;
const MONTH_NAMES = [
'01' => 'January',
'02' => 'February',
'03' => 'March',
'04' => 'April',
'05' => 'May',
'06' => 'June',
'07' => 'July',
'08' => 'August',
'09' => 'September',
'10' => 'October',
'11' => 'November',
'12' => 'December',
];
class GuiController extends AbstractController {
/**
* @return array<string,string>
*/
#[Route('/', name: 'index')]
#[Template('/index.html.twig')]
public function index(): array {
return [
'title' => 'Joe Carstairs',
'description' => 'Joe Carstairs\' personal website',
];
}
/**
* @return array<string,mixed>
*/
#[Route('/notes', name: 'notes')]
#[Template('/notes.html.twig')]
public function notes(
EntityManagerInterface $entityManager,
): array {
$notes = $entityManager->getRepository(Note::class)->findAllOrderedBySlugDesc();
$years = $this->getYears($notes);
$notesByYear = $this->groupByYear($notes);
$months = array_map(array: $years, callback: fn($year) => $this->getMonths($notesByYear[$year]));
$monthsByYear = array_combine(keys: $years, values: $months);
$groupByMonth = fn($notes) => $this->groupByMonth($notes);
$notesByYearAndMonth = array_map(
array: $notesByYear,
callback: $groupByMonth,
);
return [
'title' => 'Joe Carstairs\' notes',
'description' => 'Joe Carstairs\' notes',
'isFeed' => True,
'notes' => $notesByYearAndMonth,
'years' => $years,
'months' => $monthsByYear,
'monthNames' => MONTH_NAMES,
];
}
/**
* @return array<string,mixed>
*/
#[Route('/blog', name: 'blog_posts')]
#[Template('/blog_posts.html.twig')]
public function blogPosts(
EntityManagerInterface $entityManager,
): array {
$posts = $entityManager->getRepository(BlogPost::class)->findAllOrderedBySlugDesc();
$years = $this->getYears($posts);
$postsByYear = $this->groupByYear($posts);
$months = array_map(array: $years, callback: fn($year) => $this->getMonths($postsByYear[$year]));
$monthsByYear = array_combine(keys: $years, values: $months);
$groupByMonth = fn($posts) => $this->groupByMonth($posts);
$postsByYearAndMonth = array_map(
array: $postsByYear,
callback: $groupByMonth,
);
return [
'title' => 'Joe Carstairs\' blog',
'description' => 'Joe Carstairs\' blog',
'isFeed' => True,
'posts' => $postsByYearAndMonth,
'years' => $years,
'months' => $monthsByYear,
'monthNames' => MONTH_NAMES,
];
}
/**
* @@template T of \FeedEntry
* @param $entries T[]
* @return T[][]
*/
function groupByYear(array $entries): array {
$years = $this->getYears($entries);
$filterByYear = fn(string $year) =>
fn($entry) => ($entry->getPublishedDate()->format('Y') == $year);
$entriesForYear = fn(string $year) =>
array_filter(array: $entries, callback: $filterByYear($year));
$entriesByYear = array_map(
array: $years,
callback: $entriesForYear,
);
return array_combine(keys: $years, values: $entriesByYear);
}
/**
* @template T of \FeedEntry
* @param $entries T[]
* @return string[]
*/
function getYears(array $entries): array {
$getYear = fn($note): string => $note->getPublishedDate()->format('Y');
$years = array_map(array: $entries, callback: $getYear);
$years = array_unique($years);
arsort($years);
return $years;
}
/**
* @template T of \FeedEntry
* @param $entries T[]
* @return T[]
*/
function groupByMonth(array $entries): array {
$months = $this->getMonths($entries);
$filterByMonth = fn(string $month) =>
fn($entry) => ($entry->getPublishedDate()->format('m') == $month);
$entriesByMonth = array_map(
array: $months,
callback: fn(string $month) =>
array_filter(array: $entries, callback: $filterByMonth($month)),
);
return array_combine(keys: $months, values: $entriesByMonth);
}
/**
* @@template T of \FeedEntry
* @param $entries T[]
* @return string[]
*/
function getMonths(array $entries): array {
$getMonth = fn($entry) => $entry->getPublishedDate()->format('m');
$months = array_map(array: $entries, callback: $getMonth);
$months = array_unique($months);
arsort($months);
return $months;
}
/**
* @template T of \FeedEntry
* @param $entries T[]
* @return T[]
*/
function sortBySlug(array $entries): array {
$getSlug = fn($entry): string => $entry->getSlug();
$slugs = array_map(array: $entries, callback: $getSlug);
$entriesBySlug = array_combine(keys: $slugs, values: $entries);
krsort($entriesBySlug);
return array_values($entriesBySlug);
}
#[IsGranted('ROLE_EDITOR')]
#[Route('/notes/write')]
#[Template('/write_note.html.twig')]
public function writeNote(
EntityManagerInterface $entityManager,
Request $request,
): Response {
$note = new Note();
$form = $this->createForm(NoteType::class, $note);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$note = $form->getData();
$now = new DateTime('now');
$note->setPublishedDate($now);
$num_existing_notes_today = $entityManager->getRepository(Note::class)->countWherePublishedOnDate($now);
$note->getPublishedDate()->setTimezone(new DateTimeZone('Europe/London'));
$slug = $note->getPublishedDate()->format('Y-m-d');
if ($num_existing_notes_today > 0) {
$slug = $slug . '-' . $num_existing_notes_today;
}
$note->setSlug($slug);
$entityManager->persist($note);
$entityManager->flush();
return $this->redirectToRoute('note', ['slug' => $slug]);
}
return $this->render('/write_note.html.twig', [
'title' => 'Write for Joe Carstairs',
'description' => 'The authoring page for Joe Carstairs\' personal website',
'form' => $form,
]);
}
/**
* @return array<string,mixed>
*/
#[Route('/notes/{slug}', name: 'note')]
#[Template('/note.html.twig')]
public function note(
EntityManagerInterface $entityManager,
string $slug,
): array {
$repository = $entityManager->getRepository(Note::class);
$note = $repository->findOneBy(['slug' => $slug]);
return [
'title' => 'Joe Carstairs\' notes',
'description' => 'Joe Carstairs\' notes',
'isFeedEntry' => True,
'note' => $note,
'slug' => $slug,
];
}
/**
* @return array<string,mixed>
*/
#[Route('/blog/{slug}', name: 'blog_post')]
#[Template('/blog_post.html.twig')]
public function blogPost(
EntityManagerInterface $entityManager,
string $slug,
): array {
$repository = $entityManager->getRepository(BlogPost::class);
$post = $repository->findOneBy(['slug' => $slug]);
return [
'title' => $post->getTitle(),
'description' => $post->getDescription(),
'isFeedEntry' => True,
'post' => $post,
'slug' => $slug,
];
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
class SecurityController extends AbstractController
{
#[Route(path: '/login', name: 'login')]
public function login(AuthenticationUtils $authenticationUtils): Response
{
$error = $authenticationUtils->getLastAuthenticationError();
$lastUsername = $authenticationUtils->getLastUsername();
return $this->render('security/login.html.twig', [
'title' => 'Log in',
'description' => 'Log in to Joe Carstairs\' personal website',
'lastUsername' => $lastUsername,
'error' => $error,
]);
}
#[Route(path: '/logout', name: 'logout')]
public function logout(): void
{
throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.');
}
}

0
symfony/src/Entity/.gitignore vendored Normal file
View File

View File

@@ -0,0 +1,93 @@
<?php
namespace App\Entity;
use App\Interface\FeedEntry;
use App\Repository\BlogPostRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: BlogPostRepository::class)]
class BlogPost implements FeedEntry
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private ?string $slug = null;
#[ORM\Column(type: Types::DATE_MUTABLE)]
private ?\DateTime $publishedDate = null;
#[ORM\Column(type: Types::DATE_MUTABLE, nullable: true)]
private ?\DateTime $updatedDate = null;
#[ORM\Column(type: Types::TEXT)]
private ?string $content = null;
#[ORM\Column(length: 255)]
private ?string $title = null;
#[ORM\Column(length: 1024)]
private ?string $description = null;
public function getId(): ?int {
return $this->id;
}
public function getSlug(): ?string {
return $this->slug;
}
public function setSlug(string $slug): static {
$this->slug = $slug;
return $this;
}
public function getPublishedDate(): ?\DateTime {
return $this->publishedDate;
}
public function setPublishedDate(\DateTime $publishedDate): static {
$this->publishedDate = $publishedDate;
return $this;
}
public function getUpdatedDate(): ?\DateTime {
return $this->updatedDate;
}
public function setUpdatedDate(?\DateTime $updatedDate): static {
$this->updatedDate = $updatedDate;
return $this;
}
public function getContent(): ?string {
return $this->content;
}
public function setContent(string $content): static {
$this->content = $content;
return $this;
}
public function getTitle(): ?string {
return $this->title;
}
public function setTitle(string $title): static {
$this->title = $title;
return $this;
}
public function getDescription(): ?string {
return $this->description;
}
public function setDescription(string $description): static {
$this->description = $description;
return $this;
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Entity;
use App\Interface\FeedEntry;
use App\Repository\NoteRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: NoteRepository::class)]
class Note implements FeedEntry
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private ?string $content = null;
#[ORM\Column(type: Types::DATE_MUTABLE)]
private ?\DateTime $publishedDate = null;
#[ORM\Column(length: 255)]
private ?string $slug = null;
public function getId(): ?int
{
return $this->id;
}
public function getContent(): ?string
{
return $this->content;
}
public function setContent(string $content): static
{
$this->content = $content;
return $this;
}
public function getPublishedDate(): ?\DateTime
{
return $this->publishedDate;
}
public function setPublishedDate(\DateTime $publishedDate): static
{
$this->publishedDate = $publishedDate;
return $this;
}
public function getSlug(): ?string
{
return $this->slug;
}
public function setSlug(string $slug): static
{
$this->slug = $slug;
return $this;
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Form\Type;
use App\Entity\Note;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class NoteType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('content', TextType::class)
->add('post', SubmitType::class)
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Note::class,
]);
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Interface;
interface FeedEntry {
function getPublishedDate(): ?\DateTime;
function getSlug(): ?string;
}

11
symfony/src/Kernel.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
namespace App;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
class Kernel extends BaseKernel
{
use MicroKernelTrait;
}

0
symfony/src/Repository/.gitignore vendored Normal file
View File

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Repository;
use App\Entity\BlogPost;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<BlogPost>
*/
class BlogPostRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry) {
parent::__construct($registry, BlogPost::class);
}
/**
* @return BlogPost[]
*/
public function findAllOrderedBySlugDesc(): array
{
return $this->createQueryBuilder('n')
->orderBy('n.slug', 'DESC')
->getQuery()
->getResult();
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Repository;
use App\Entity\Note;
use DateInterval;
use DateTime;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Note>
*/
class NoteRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Note::class);
}
public function countWherePublishedOnDate(DateTime $date): int {
$dateStr = substr($date->format('c'), 0, 10);
$wherePublishedOnDate = $this->createQueryBuilder('n')
->andWhere('n.publishedDate = :date')
->setParameter('date', $dateStr)
->getQuery()
->execute();
return count($wherePublishedOnDate);
}
/**
* @return Note[] Returns an array of Note objects
*/
public function findAllOrderedBySlugDesc(): array
{
return $this->createQueryBuilder('n')
->orderBy('n.slug', 'DESC')
->getQuery()
->getResult();
}
}

315
symfony/symfony.lock Normal file
View File

@@ -0,0 +1,315 @@
{
"doctrine/doctrine-bundle": {
"version": "2.14",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.13",
"ref": "620b57f496f2e599a6015a9fa222c2ee0a32adcb"
},
"files": [
"config/packages/doctrine.yaml",
"src/Entity/.gitignore",
"src/Repository/.gitignore"
]
},
"doctrine/doctrine-migrations-bundle": {
"version": "3.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.1",
"ref": "1d01ec03c6ecbd67c3375c5478c9a423ae5d6a33"
},
"files": [
"config/packages/doctrine_migrations.yaml",
"migrations/.gitignore"
]
},
"phpunit/phpunit": {
"version": "9.6",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "9.6",
"ref": "6a9341aa97d441627f8bd424ae85dc04c944f8b4"
},
"files": [
".env.test",
"phpunit.xml.dist",
"tests/bootstrap.php"
]
},
"symfony/asset-mapper": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.4",
"ref": "5ad1308aa756d58f999ffbe1540d1189f5d7d14a"
},
"files": [
"assets/app.js",
"assets/styles/app.css",
"config/packages/asset_mapper.yaml",
"importmap.php"
]
},
"symfony/console": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "5.3",
"ref": "1781ff40d8a17d87cf53f8d4cf0c8346ed2bb461"
},
"files": [
"bin/console"
]
},
"symfony/debug-bundle": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "5.3",
"ref": "5aa8aa48234c8eb6dbdd7b3cd5d791485d2cec4b"
},
"files": [
"config/packages/debug.yaml"
]
},
"symfony/flex": {
"version": "2.5",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.4",
"ref": "52e9754527a15e2b79d9a610f98185a1fe46622a"
},
"files": [
".env",
".env.dev"
]
},
"symfony/form": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.2",
"ref": "7d86a6723f4a623f59e2bf966b6aad2fc461d36b"
},
"files": [
"config/packages/csrf.yaml"
]
},
"symfony/framework-bundle": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.2",
"ref": "105b334917611e8c8071a69749916dfff208f262"
},
"files": [
"config/packages/cache.yaml",
"config/packages/framework.yaml",
"config/preload.php",
"config/routes/framework.yaml",
"config/services.yaml",
"public/index.php",
"src/Controller/.gitignore",
"src/Kernel.php",
".editorconfig"
]
},
"symfony/mailer": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "4.3",
"ref": "09051cfde49476e3c12cd3a0e44289ace1c75a4f"
},
"files": [
"config/packages/mailer.yaml"
]
},
"symfony/maker-bundle": {
"version": "1.63",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
}
},
"symfony/messenger": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.0",
"ref": "ba1ac4e919baba5644d31b57a3284d6ba12d52ee"
},
"files": [
"config/packages/messenger.yaml"
]
},
"symfony/monolog-bundle": {
"version": "3.10",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.7",
"ref": "aff23899c4440dd995907613c1dd709b6f59503f"
},
"files": [
"config/packages/monolog.yaml"
]
},
"symfony/notifier": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "5.0",
"ref": "178877daf79d2dbd62129dd03612cb1a2cb407cc"
},
"files": [
"config/packages/notifier.yaml"
]
},
"symfony/phpunit-bridge": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.3",
"ref": "a411a0480041243d97382cac7984f7dce7813c08"
},
"files": [
".env.test",
"bin/phpunit",
"phpunit.xml.dist",
"tests/bootstrap.php"
]
},
"symfony/routing": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.0",
"ref": "21b72649d5622d8f7da329ffb5afb232a023619d"
},
"files": [
"config/packages/routing.yaml",
"config/routes.yaml"
]
},
"symfony/security-bundle": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.4",
"ref": "2ae08430db28c8eb4476605894296c82a642028f"
},
"files": [
"config/packages/security.yaml",
"config/routes/security.yaml"
]
},
"symfony/stimulus-bundle": {
"version": "2.24",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.20",
"ref": "3acc494b566816514a6873a89023a35440b6386d"
},
"files": [
"assets/bootstrap.js",
"assets/controllers.json",
"assets/controllers/csrf_protection_controller.js",
"assets/controllers/hello_controller.js"
]
},
"symfony/translation": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.3",
"ref": "e28e27f53663cc34f0be2837aba18e3a1bef8e7b"
},
"files": [
"config/packages/translation.yaml",
"translations/.gitignore"
]
},
"symfony/twig-bundle": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.4",
"ref": "cab5fd2a13a45c266d45a7d9337e28dee6272877"
},
"files": [
"config/packages/twig.yaml",
"templates/base.html.twig"
]
},
"symfony/ux-turbo": {
"version": "2.24",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.20",
"ref": "c85ff94da66841d7ff087c19cbcd97a2df744ef9"
}
},
"symfony/validator": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.0",
"ref": "8c1c4e28d26a124b0bb273f537ca8ce443472bfd"
},
"files": [
"config/packages/validator.yaml"
]
},
"symfony/web-profiler-bundle": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.1",
"ref": "8b51135b84f4266e3b4c8a6dc23c9d1e32e543b7"
},
"files": [
"config/packages/web_profiler.yaml",
"config/routes/web_profiler.yaml"
]
},
"symfony/webapp-pack": {
"version": "1.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "7d5c5e282f7e2c36a2c3bbb1504f78456c352407"
},
"files": [
"config/packages/messenger.yaml"
]
},
"twig/extra-bundle": {
"version": "v3.21.0"
}
}

View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en-GB">
<head>
{{ include('components/head/_meta.html.twig') }}
{{ include('components/head/_indie_web.html.twig') }}
{{ include('components/head/_stylesheets.html.twig') }}
{{ include('components/head/_feeds.html.twig') }}
{{ include('components/head/_open_graph.html.twig') }}
{{ include('components/head/_twitter.html.twig') }}
</head>
<body>
{{ include('components/_navbar.html.twig') }}
{% block main %}
<main>
{% block content %}{% endblock %}
</main>
{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,47 @@
{% extends 'base.html.twig' %}
{% block content %}
{% if post %}
<article class="h-entry">
<aside>
<span>
This is a blog post by
<a class="p-author h-card" href="/">Joe Carstairs</a>.
</span>
<p>
Published: <time class="dt-published">{{ post.publishedDate.format('c') }}
</p>
{% if post.updatedDate %}}
<p>
Updated: <time class="dt-updated">{{ post.updatedDate.format('c') }}
</p>
{% endif %}
<span hidden>
<a class="u-url uid" href="{{ url('blog_post', { slug: post.slug }) }}">
Permalink
</a>
</span>
</aside>
<header>
<h1 class="p-name">{% apply markdown_to_html %}{{ post.title }}{% endapply %}</h1>
<p class="p-summary">
{% apply markdown_to_html %}{{ post.description }}{% endapply %}
</p>
</header>
{% apply markdown_to_html %}
<section class="e-content">
{{ post.content }}
</section>
{% endapply %}
</article>
{% else %}
<section>
<h1>Post not found</h1>
<p>I don't have a blog post '{{ slug }}'.</p>
<p>Go back to <a href="{{ path('blog_posts') }}">Blog</a>.</p>
</section>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,81 @@
{% extends 'base.html.twig' %}
{% block content %}
<section class="h-feed">
<h1 class="p-name">Joe Carstairs' blog</h1>
<p hidden>
This blog is written by
<a class="p-author h-card" href="{{ url('index') }}">
Joe Carstairs
</a>.
</p>
<p hidden>
<a class="u-url" href="{{ url('blog_posts') }}">Permalink</a>
</p>
{% if years %}
<nav class="skip-to">
Skip to:
<ul>
{% for year in years %}
<li><a href="#{{ year }}">{{ year }}</a></li>
{% endfor %}
</ul>
</nav>
{% endif %}
{% for year in years %}
<h2 id="{{ year }}">{{ year }}</h2>
<nav class="skip-to">
Skip to:
<ul>
{% for month in months[year] %}
<li><a href="#{{ year }}-{{ month }}">
{{ monthNames[month] }} <span class="visually-hidden">{{ year }}</span>
</a></li>
{% endfor %}
</ul>
</nav>
{% for month in months[year] %}
<h3 id="{{ year }}-{{ month }}">{{ monthNames[month] }}</h3>
{% for post in posts[year][month] %}
<section class="h-entry">
<a class="u-url" href="{{ path('blog_post', { 'slug': post.slug }) }}">
<h4 class="p-name">
{{ post.title }}
</h4>
<p>
Added:
<time class="dt-published" datetime="{{ post.publishedDate.format('c') }}">
{{ post.publishedDate.format('j F Y') }}
</time>
</p>
{% if post.updatedDate %}
<p>
Updated:
<time class="dt-updated" datetime="{{ post.updatedDate.format('c') }}">
{{ post.updatedDate.format('j F Y') }}
</time>
</p>
{% endif %}
<section class="p-summary">
{% apply markdown_to_html %}
{{ post.description|markdown_to_html|striptags('<i><em><b><strong><sup><p>')|html_to_markdown }}
{% endapply %}
</section>
</a>
</section>
{% endfor %}
{% endfor %}
{% else %}
<p>I have no blog posts.</p>
{% endfor %}
</section>
{% endblock %}

View File

@@ -0,0 +1,13 @@
<nav class="navbar">
<ul>
<li>
<a href="/">Home</a>
</li>
<li>
<a href="{{ path('blog_posts') }}">Blog</a>
</li>
<li>
<a href="{{ path('notes') }}">Notes</a>
</li>
</ul>
</nav>

View File

@@ -0,0 +1,2 @@
<link rel="alternate" type="text/xml" title="Blog RSS" href="/blog/rss.xml">
<link rel="alternate" type="text/xml" title="Links RSS" href="/links/rss.xml">

View File

@@ -0,0 +1,3 @@
<link rel="authorization_endpoint" href="https://indieauth.com/auth">
<link rel="token_endpoint" href="https://tokens.indieauth.com/token">
<link rel="micropub" href="https://tasty-windows-lick.loca.lt">

View File

@@ -0,0 +1,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="canonical" href="https://joeac.net{{ path(app.current_route, app.current_route_parameters) }}" />
<title>{{ title }}</title>
<meta name="title" content="{{ title }}" />
<meta name="description" content="{{ description }}" />

View File

@@ -0,0 +1,5 @@
<meta property="og:type" content="website" />
<meta property="og:url" content="https://joeac.net{{ path(app.current_route, app.current_route_parameters) }}" />
<meta property="og:title" content="{{ title }}" />
<meta property="og:description" content="{{ description }}" />
<meta property="og:image" content="/images/headshot.webp" />

View File

@@ -0,0 +1,9 @@
<link rel="stylesheet" href="/css/reset.css" />
<link rel="stylesheet" href="/css/base.css" />
<link rel="stylesheet" href="/css/hcard.css" />
{% if isFeed|default(false) %}
<link rel="stylesheet" href="/css/feed.css" />
{% endif %}
{% if isFeedEntry|default(false) %}
<link rel="stylesheet" href="/css/feed-entry.css" />
{% endif %}

View File

@@ -0,0 +1,5 @@
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content="https://joeac.net{{ path(app.current_route, app.current_route_parameters) }}" />
<meta property="twitter:title" content="{{ title }}" />
<meta property="twitter:description" content="{{ description }}" />
<meta property="twitter:image" content="/images/headshot.webp" />

View File

@@ -0,0 +1,54 @@
{% extends 'base.html.twig' %}
{% block content %}
<section class="h-card">
<div>
<img class="u-photo" src="/images/headshot.webp" height="96" width="96" />
</div>
<header>
<h1>
Joe Carstairs
</h1>
<div hidden>
<a class="p-name u-url u-uid" href="https://joeac.net" rel="me">
Permalink
</a>
</div>
</header>
<div class="h-card__text">
<p>
Hi! 👋 My name is <span class="p-given-name">Joe</span>
<span class="p-family-name">Carstairs</span>. Im a
<span class="p-job-title">software developer</span> at
<a class="p-org" href="https://www.scottlogic.com">Scott Logic</a>, a
graduate of Philosophy and Mathematics at the University of Edinburgh,
a committed Christian and a pretty rubbish poet.
</p>
<p>
Im also the <span class="p-job-title">secretary</span> of the
<a class="p-org" href="https://scotsleidassocie.org">Scots Language Society</a>.
<a href="https://github.com/joeacarstairs/lallans-wabsteid-astro">Help me maintain our website!</a>
</p>
<p>
HMU with your thoughts on philosophy of science, Scots verse and
John the Evangelist.
</p>
<p>
<small>
Or get me on
<a href="https://www.facebook.com/joe.carstairs.5" rel="me">Facebook</a>,
<a href="https://mastodon.social/@joe_carstairs" rel="me">Mastodon</a>,
<a href="https://www.linkedin.com/in/joe-carstairs-0aa936277" rel="me">LinkedIn</a>,
<a href="https://bsky.app/profile/joeacarstairs.bsky.social" rel="me">BlueSky</a>,
or <a href="https://github.com/joeacarstairs" rel="me">GitHub</a>.
</small>
</p>
</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,28 @@
{% extends 'base.html.twig' %}
{% block main %}
{% if note %}
<article class="h-entry">
<header>
<h1 class="p-name">Note {{ note.slug }}</h1>
<p hidden><a class="p-author h-card" href="{{ url('index') }}">Joe Carstairs</a></p>
<p hidden <a class="u-url" href="{{ url('note', { slug: note.slug }) }}">Permalink</a></p>
<p>Added: <time class="dt-published">{{ note.publishedDate.format('c') }}</time></p>
</header>
<section class="e-content">
{{ note.content|markdown_to_html }}
</section>
</article>
{% else %}
<main>
<header>
<h1>Note not found</h1>
</header>
<section>
<p>I don't have a note '{{ slug }}'.</p>
<p>Go back to <a href="/notes">Notes</a>.</p>
</section>
</main>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,71 @@
{% extends 'base.html.twig' %}
{% block content %}
<section class="h-feed">
<h1 class="p-name">Joe Carstairs' notes</h1>
<p hidden>
These links are collected by
<a class="p-author h-card" href="{{ url('index') }}">
Joe Carstairs
</a>.
</p>
<p hidden>
<a class="u-url" href="{{ url('notes') }}">Permalink</a>
</p>
{% if years %}
<nav class="skip-to">
Skip to:
<ul>
{% for year in years %}
<li><a href="#{{ year }}">{{ year }}</a></li>
{% endfor %}
</ul>
</nav>
{% endif %}
{% for year in years %}
<h2 id="{{ year }}">{{ year }}</h2>
<nav class="skip-to">
Skip to:
<ul>
{% for month in months[year] %}
<li><a href="#{{ year }}-{{ month }}">
{{ monthNames[month] }} <span class="visually-hidden">{{ year }}</span>
</a></li>
{% endfor %}
</ul>
</nav>
{% for month in months[year] %}
<h3 id="{{ year }}-{{ month }}">{{ monthNames[month] }}</h3>
{% for note in notes[year][month] %}
<section class="h-entry">
<a class="u-url" href="{{ url('note', { slug: note.slug }) }}">
<h4 class="p-name">
Note {{ note.slug }}
</h4>
<p>
Added:
<time class="dt-published" datetime="{{ note.publishedDate.format('c') }}">
{{ note.publishedDate.format('j F Y') }}
</time>
</p>
<section class="e-content">
{% apply markdown_to_html %}
{{ note.content|markdown_to_html|striptags('<i><em><b><strong><sup><p>')|html_to_markdown }}
{% endapply %}
</section>
</a>
</section>
{% endfor %}
{% endfor %}
{% else %}
<p>I have no notes.</p>
{% endfor %}
</section>
{% endblock %}

View File

@@ -0,0 +1,47 @@
{% extends 'base.html.twig' %}
{% block content %}
<section>
<h1>Log in</h1>
{% if app.user %}
<p>
You are logged in as {{ app.user.userIdentifier }}.
If you're not happy about that, <a href="{{ path('logout') }}">log out</a>.
</p>
{% else %}
{% if error %}
<div class="alert alert-danger">{{ error.messageKey|trans(error.messageData, 'security') }}</div>
{% endif %}
<form action="{{ path('login') }}" method="POST">
<label for="username">Email</label>
<input
type="email"
value="{{ lastUsername }}"
name="_username"
id="username"
class="form-control"
autocomplete="email"
required
autofocus
>
<label for="password">Password</label>
<input
type="password"
name="_password"
id="password"
class="form-control"
autocomplete="current-password"
required
>
<input type="hidden" name="_target_path" value="{{ path('index') }}">
<input type="hidden" name="_csrf_token" data-controller="csrf-protection" value="{{ csrf_token('authenticate') }}">
<button type="submit">Log in</button>
</form>
{% endif %}
</section>
{% endblock %}

View File

@@ -0,0 +1,8 @@
{% extends 'base.html.twig' %}
{% block content %}
<section>
<h1>Write a note</h1>
{{ form(form) }}
</section>
{% endblock %}

View File

@@ -0,0 +1,9 @@
<?php
use Symfony\Component\Dotenv\Dotenv;
require dirname(__DIR__).'/vendor/autoload.php';
if (method_exists(Dotenv::class, 'bootEnv')) {
(new Dotenv())->bootEnv(dirname(__DIR__).'/.env');
}

0
symfony/translations/.gitignore vendored Normal file
View File