5
0
Fork 0
mirror of https://0xacab.org/sutty/sutty synced 2024-11-16 10:41:41 +00:00

Merge branch 'rails' into recuperar-partials

This commit is contained in:
f 2022-03-07 12:06:27 -03:00
commit 55bd712291
147 changed files with 4422 additions and 2071 deletions

View file

@ -1,6 +1,11 @@
RAILS_ENV=
RAILS_GROUPS=assets
DELEGATE=athshe.sutty.nl
HAINISH=../haini.sh/haini.sh
DATABASE=
RAILS_ENV=development
IMAP_SERVER=
DEFAULT_FROM=
EXCEPTION_TO=
SKEL_SUTTY=https://0xacab.org/sutty/skel.sutty.nl
# XXX: Si cambiás esta variable, tenés que editar config/webpacker.yml también :(
SUTTY=sutty.local
@ -27,3 +32,6 @@ TIENDA=tienda.sutty.local
PANEL_URL=https://panel.sutty.nl
AIRBRAKE_SITE_ID=1
AIRBRAKE_API_KEY=
GITLAB_URI=https://0xacab.org
GITLAB_PROJECT=
GITLAB_TOKEN=

View file

@ -1 +0,0 @@
2.7.1

View file

@ -2,20 +2,22 @@
# el mismo repositorio de trabajo. Cuando tengamos CI/CD algunas cosas
# como el tarball van a tener que cambiar porque ya vamos a haber hecho
# un clone/pull limpio.
FROM alpine:3.13.4 AS build
FROM alpine:3.13.6 AS build
MAINTAINER "f <f@sutty.nl>"
ARG RAILS_MASTER_KEY
ARG BRANCH
# Un entorno base
ENV BRANCH=$BRANCH
ENV SECRET_KEY_BASE solo_es_necesaria_para_correr_rake
ENV RAILS_ENV production
ENV RAILS_MASTER_KEY=$RAILS_MASTER_KEY
RUN apk add --no-cache libxslt libxml2 tzdata ruby ruby-bundler ruby-json ruby-bigdecimal ruby-rake
RUN apk add --no-cache libxslt libxml2 tzdata ruby ruby-json ruby-bigdecimal ruby-rake
RUN apk add --no-cache postgresql-libs git yarn brotli libssh2 python3
RUN test "2.7.3" = `ruby -e 'puts RUBY_VERSION'`
RUN test "2.7.4" = `ruby -e 'puts RUBY_VERSION'`
# https://github.com/rubygems/rubygems/issues/2918
# https://gitlab.alpinelinux.org/alpine/aports/issues/10808
@ -27,7 +29,7 @@ RUN cd /usr/lib/ruby/2.7.0 && patch -Np 0 -i /tmp/rubygems-platform-musl.patch
RUN addgroup -g 82 -S www-data
RUN adduser -s /bin/sh -G www-data -h /home/app -D app
RUN install -dm750 -o app -g www-data /home/app/sutty
RUN gem install --no-document bundler
RUN gem install --no-document bundler:2.1.4
# Empezamos con la usuaria app
USER app
@ -37,7 +39,8 @@ WORKDIR /home/app/sutty
# Copiamos solo el Gemfile para poder instalar las gemas necesarias
COPY --chown=app:www-data ./Gemfile .
COPY --chown=app:www-data ./Gemfile.lock .
RUN bundle config set no-cache 'true'
RUN bundle config set no-cache true
RUN bundle config set specific_platform true
RUN bundle install --path=./vendor --without='test development'
# Vaciar la caché
RUN rm vendor/ruby/2.7.0/cache/*.gem
@ -47,22 +50,17 @@ COPY --chown=app:www-data ./.git/ ./.git/
# Hacer un clon limpio del repositorio en lugar de copiar todos los
# archivos
RUN cd .. && git clone sutty checkout
RUN cd ../checkout && git checkout $BRANCH
WORKDIR /home/app/checkout
# Traer las gemas:
RUN rm -rf ./vendor
RUN mv ../sutty/vendor ./vendor
RUN mv ../sutty/.bundle ./.bundle
# Instalar secretos
COPY --chown=app:root ./config/credentials.yml.enc ./config/
# Traer los assets pre-compilados
COPY --chown=app:www-data ./public/assets ./public/assets
COPY --chown=app:www-data ./public/packs ./public/packs
# Eliminar la necesidad de un runtime JS en producción, porque los
# assets ya están pre-compilados.
RUN sed -re "/(sassc|uglifier|bootstrap|coffee-rails)/d" -i Gemfile
RUN bundle clean
RUN rm -rf ./node_modules ./tmp/cache ./.git ./test ./doc
# Eliminar archivos innecesarios
USER root
@ -70,7 +68,7 @@ RUN apk add --no-cache findutils
RUN find /home/app/checkout/vendor/ruby/2.7.0 -maxdepth 3 -type d -name test -o -name spec -o -name rubocop | xargs -r rm -rf
# Contenedor final
FROM sutty/monit:latest
FROM registry.nulo.in/sutty/monit:3.13.6
ENV RAILS_ENV production
# Pandoc
@ -78,13 +76,13 @@ RUN echo 'http://dl-cdn.alpinelinux.org/alpine/edge/testing' >> /etc/apk/reposit
# Instalar las dependencias, separamos la librería de base de datos para
# poder reutilizar este primer paso desde otros contenedores
RUN apk add --no-cache libxslt libxml2 tzdata ruby ruby-bundler ruby-json ruby-bigdecimal ruby-rake ruby-irb
RUN apk add --no-cache libxslt libxml2 tzdata ruby ruby-json ruby-bigdecimal ruby-rake ruby-irb ruby-io-console ruby-etc
RUN apk add --no-cache postgresql-libs libssh2 file rsync git jpegoptim vips
RUN apk add --no-cache ffmpeg imagemagick pandoc tectonic oxipng jemalloc
RUN apk add --no-cache git-lfs
RUN apk add --no-cache git-lfs openssh-client patch
# Chequear que la versión de ruby sea la correcta
RUN test "2.7.3" = `ruby -e 'puts RUBY_VERSION'`
RUN test "2.7.4" = `ruby -e 'puts RUBY_VERSION'`
# https://github.com/rubygems/rubygems/issues/2918
# https://gitlab.alpinelinux.org/alpine/aports/issues/10808
@ -96,7 +94,7 @@ RUN apk add --no-cache patch && cd /usr/lib/ruby/2.7.0 && patch -Np 0 -i /tmp/ru
# principal
RUN apk add --no-cache yarn
# Instalar foreman para poder correr los servicios
RUN gem install --no-document --no-user-install bundler foreman
RUN gem install --no-document --no-user-install bundler:2.1.4 foreman
# Agregar el grupo del servidor web y la usuaria
RUN addgroup -g 82 -S www-data
@ -110,13 +108,10 @@ RUN rm -rf /srv/http/_sites /srv/http/_deploy
RUN ln -s data/_storage /srv/http/_storage
RUN ln -s data/_sites /srv/http/_sites
RUN ln -s data/_deploy /srv/http/_deploy
RUN ln -s data/_public /srv/http/_public
RUN ln -s data/_private /srv/http/_private
# Volver a root para cerrar la compilación
USER root
# Sincronizar los assets a un directorio compartido
RUN install -m 755 /srv/http/sync_assets.sh /usr/local/bin/sync_assets
# Instalar la configuración de monit
RUN install -m 640 -o root -g root /srv/http/monit.conf /etc/monit.d/sutty.conf
RUN apk add --no-cache daemonize ruby-webrick

47
Gemfile
View file

@ -1,18 +1,7 @@
# frozen_string_literal: true
# TODO: Podríamos usar solo gems.sutty.nl pero por alguna razón bundler
# prefiere x86_64-linux-musl antes que x86_64-linux y ya perdimos mucho
# tiempo buscando soporte para musl
if ENV['RAILS_ENV'] == 'production'
source 'https://gems.sutty.nl'
else
source 'https://rubygems.org'
end
git_source(:github) do |repo_name|
repo_name = "#{repo_name}/#{repo_name}" unless repo_name.include?('/')
"https://github.com/#{repo_name}.git"
end
puts 'Usa haini.sh para generar un entorno de trabajo reproducible'
source 'https://gems.sutty.nl'
ruby '~> 2.7'
@ -22,13 +11,18 @@ gem 'dotenv-rails', require: 'dotenv/rails-now'
gem 'rails', '~> 6'
# Use Puma as the app server
gem 'puma'
# See https://github.com/rails/execjs#readme for more supported runtimes
# gem 'therubyracer', platforms: :ruby
# Use SCSS for stylesheets
gem 'sassc-rails'
# Use Uglifier as compressor for JavaScript assets
gem 'uglifier', '>= 1.3.0'
gem 'bootstrap', '~> 4'
# Solo incluir las gemas cuando estemos en desarrollo o compilando los
# assets. No es necesario instalarlas en producción.
#
# XXX: Supuestamente Rails ya soporta RAILS_GROUPS, pero Bundler no.
if ENV['RAILS_GROUPS']&.split(',')&.include? 'assets'
gem 'sassc-rails'
gem 'uglifier', '>= 1.3.0'
gem 'bootstrap', '~> 4'
end
gem 'nokogiri'
# Turbolinks makes navigating your web application faster. Read more:
# https://github.com/turbolinks/turbolinks
@ -39,6 +33,7 @@ gem 'jbuilder', '~> 2.5'
# Use ActiveModel has_secure_password
gem 'bcrypt', '~> 3.1.7'
gem 'blazer'
gem 'chartkick'
gem 'commonmarker'
gem 'devise'
gem 'devise-i18n'
@ -52,22 +47,24 @@ gem 'hiredis'
gem 'image_processing'
gem 'icalendar'
gem 'inline_svg'
gem 'httparty'
gem 'safe_yaml', source: 'https://gems.sutty.nl'
gem 'jekyll', '~> 4.2'
gem 'jekyll-data', source: 'https://gems.sutty.nl'
gem 'jekyll-commonmark'
gem 'jekyll-images'
gem 'jekyll-include-cache'
gem 'sutty-liquid'
gem 'sutty-liquid', '>= 0.7.3'
gem 'loaf'
gem 'lockbox'
gem 'mini_magick'
gem 'mobility'
gem 'pg'
gem 'pundit'
gem 'rails-i18n'
gem 'rails_warden'
gem 'redis', require: %w[redis redis/connection/hiredis]
gem 'redis-rails'
gem 'rollups', git: 'https://github.com/ankane/rollup.git', branch: 'master'
gem 'rubyzip'
gem 'rugged'
gem 'concurrent-ruby-ext'
@ -77,6 +74,12 @@ gem 'terminal-table'
gem 'validates_hostname'
gem 'webpacker'
gem 'yaml_db', git: 'https://0xacab.org/sutty/yaml_db.git'
gem 'kaminari'
# database
gem 'hairtrigger'
gem 'pg'
gem 'pg_search'
# performance
gem 'flamegraph'

View file

@ -6,6 +6,15 @@ GIT
rails (>= 3.0)
rake (>= 0.8.7)
GIT
remote: https://github.com/ankane/rollup.git
revision: 0ab6c603450175eb1004f7793e86486943cb9f72
branch: master
specs:
rollups (0.1.3)
activesupport (>= 5.1)
groupdate (>= 5.2)
GIT
remote: https://github.com/fauno/email_address
revision: 536b51f7071b68a55140c0c1726b4cd401d1c04d
@ -18,66 +27,66 @@ GIT
GEM
remote: https://gems.sutty.nl/
specs:
actioncable (6.1.3.1)
actionpack (= 6.1.3.1)
activesupport (= 6.1.3.1)
actioncable (6.1.4.1)
actionpack (= 6.1.4.1)
activesupport (= 6.1.4.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (6.1.3.1)
actionpack (= 6.1.3.1)
activejob (= 6.1.3.1)
activerecord (= 6.1.3.1)
activestorage (= 6.1.3.1)
activesupport (= 6.1.3.1)
actionmailbox (6.1.4.1)
actionpack (= 6.1.4.1)
activejob (= 6.1.4.1)
activerecord (= 6.1.4.1)
activestorage (= 6.1.4.1)
activesupport (= 6.1.4.1)
mail (>= 2.7.1)
actionmailer (6.1.3.1)
actionpack (= 6.1.3.1)
actionview (= 6.1.3.1)
activejob (= 6.1.3.1)
activesupport (= 6.1.3.1)
actionmailer (6.1.4.1)
actionpack (= 6.1.4.1)
actionview (= 6.1.4.1)
activejob (= 6.1.4.1)
activesupport (= 6.1.4.1)
mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0)
actionpack (6.1.3.1)
actionview (= 6.1.3.1)
activesupport (= 6.1.3.1)
actionpack (6.1.4.1)
actionview (= 6.1.4.1)
activesupport (= 6.1.4.1)
rack (~> 2.0, >= 2.0.9)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (6.1.3.1)
actionpack (= 6.1.3.1)
activerecord (= 6.1.3.1)
activestorage (= 6.1.3.1)
activesupport (= 6.1.3.1)
actiontext (6.1.4.1)
actionpack (= 6.1.4.1)
activerecord (= 6.1.4.1)
activestorage (= 6.1.4.1)
activesupport (= 6.1.4.1)
nokogiri (>= 1.8.5)
actionview (6.1.3.1)
activesupport (= 6.1.3.1)
actionview (6.1.4.1)
activesupport (= 6.1.4.1)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
activejob (6.1.3.1)
activesupport (= 6.1.3.1)
activejob (6.1.4.1)
activesupport (= 6.1.4.1)
globalid (>= 0.3.6)
activemodel (6.1.3.1)
activesupport (= 6.1.3.1)
activerecord (6.1.3.1)
activemodel (= 6.1.3.1)
activesupport (= 6.1.3.1)
activestorage (6.1.3.1)
actionpack (= 6.1.3.1)
activejob (= 6.1.3.1)
activerecord (= 6.1.3.1)
activesupport (= 6.1.3.1)
activemodel (6.1.4.1)
activesupport (= 6.1.4.1)
activerecord (6.1.4.1)
activemodel (= 6.1.4.1)
activesupport (= 6.1.4.1)
activestorage (6.1.4.1)
actionpack (= 6.1.4.1)
activejob (= 6.1.4.1)
activerecord (= 6.1.4.1)
activesupport (= 6.1.4.1)
marcel (~> 1.0.0)
mini_mime (~> 1.0.2)
activesupport (6.1.3.1)
mini_mime (>= 1.1.0)
activesupport (6.1.4.1)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
tzinfo (~> 2.0)
zeitwerk (~> 2.3)
addressable (2.7.0)
addressable (2.8.0)
public_suffix (>= 2.0.2, < 5.0)
adhesiones-jekyll-theme (0.2.1)
jekyll (~> 4.0)
@ -89,13 +98,13 @@ GEM
jekyll-relative-urls (~> 0.0)
jekyll-seo-tag (~> 2.1)
ast (2.4.2)
autoprefixer-rails (10.2.4.0)
execjs
bcrypt (3.1.16)
bcrypt_pbkdf (1.1.0)
benchmark-ips (2.8.4)
bindex (0.8.1)
blazer (2.4.2)
autoprefixer-rails (10.3.3.0)
execjs (~> 2)
bcrypt (3.1.16-x86_64-linux-musl)
bcrypt_pbkdf (1.1.0-x86_64-linux-musl)
benchmark-ips (2.9.2)
bindex (0.8.1-x86_64-linux-musl)
blazer (2.4.7)
activerecord (>= 5)
chartkick (>= 3.2)
railties (>= 5)
@ -104,7 +113,7 @@ GEM
autoprefixer-rails (>= 9.1.0)
popper_js (>= 1.14.3, < 2)
sassc-rails (>= 2.0.0)
brakeman (5.0.0)
brakeman (5.1.2)
builder (3.2.4)
capybara (2.18.0)
addressable
@ -113,24 +122,24 @@ GEM
rack (>= 1.0.0)
rack-test (>= 0.5.4)
xpath (>= 2.0, < 4.0)
chartkick (4.0.3)
childprocess (3.0.0)
chartkick (4.1.2)
childprocess (4.1.0)
coderay (1.1.3)
colorator (1.1.0)
commonmarker (0.21.2)
commonmarker (0.21.2-x86_64-linux-musl)
ruby-enum (~> 0.5)
concurrent-ruby (1.1.8)
concurrent-ruby-ext (1.1.8)
concurrent-ruby (= 1.1.8)
concurrent-ruby (1.1.9)
concurrent-ruby-ext (1.1.9-x86_64-linux-musl)
concurrent-ruby (= 1.1.9)
crass (1.0.6)
database_cleaner (2.0.1)
database_cleaner-active_record (~> 2.0.0)
database_cleaner-active_record (2.0.0)
database_cleaner-active_record (2.0.1)
activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1)
dead_end (1.1.6)
derailed_benchmarks (2.0.1)
dead_end (3.1.0)
derailed_benchmarks (2.1.1)
benchmark-ips (~> 2)
dead_end
get_process_mem (~> 0)
@ -142,25 +151,25 @@ GEM
rake (> 10, < 14)
ruby-statistics (>= 2.1)
thor (>= 0.19, < 2)
devise (4.7.3)
devise (4.8.0)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
devise-i18n (1.9.3)
devise (>= 4.7.1)
devise_invitable (2.0.4)
devise-i18n (1.10.1)
devise (>= 4.8.0)
devise_invitable (2.0.5)
actionmailer (>= 5.0)
devise (>= 4.6)
dotenv (2.7.6)
dotenv-rails (2.7.6)
dotenv (= 2.7.6)
railties (>= 3.2)
down (5.2.0)
addressable (~> 2.5)
ed25519 (1.2.4)
editorial-autogestiva-jekyll-theme (0.3.0)
down (5.2.4)
addressable (~> 2.8)
ed25519 (1.2.4-x86_64-linux-musl)
editorial-autogestiva-jekyll-theme (0.3.4)
jekyll (~> 4)
jekyll-commonmark (~> 1.3)
jekyll-data (~> 1.1)
@ -179,44 +188,50 @@ GEM
jekyll-unique-urls (~> 0)
jekyll-write-and-commit-changes (~> 0)
sutty-liquid (~> 0)
em-websocket (0.5.2)
em-websocket (0.5.3)
eventmachine (>= 0.12.9)
http_parser.rb (~> 0.6.0)
http_parser.rb (~> 0)
errbase (0.2.1)
erubi (1.10.0)
eventmachine (1.2.7)
eventmachine (1.2.7-x86_64-linux-musl)
exception_notification (4.4.3)
actionmailer (>= 4.0, < 7)
activesupport (>= 4.0, < 7)
execjs (2.7.0)
factory_bot (6.1.0)
execjs (2.8.1)
factory_bot (6.2.0)
activesupport (>= 5.0.0)
factory_bot_rails (6.1.0)
factory_bot (~> 6.1.0)
factory_bot_rails (6.2.0)
factory_bot (~> 6.2.0)
railties (>= 5.0.0)
fast_blank (1.0.0)
fast_jsonparser (0.5.0)
ffi (1.15.0)
fast_blank (1.0.1-x86_64-linux-musl)
fast_jsonparser (0.5.0-x86_64-linux-musl)
ffi (1.15.4-x86_64-linux-musl)
flamegraph (0.9.5)
forwardable-extended (2.6.0)
friendly_id (5.4.2)
activerecord (>= 4.0.0)
get_process_mem (0.2.7)
ffi (~> 1.0)
globalid (0.4.2)
activesupport (>= 4.2.0)
haml (5.2.1)
globalid (0.6.0)
activesupport (>= 5.0)
groupdate (5.2.2)
activesupport (>= 5)
hairtrigger (0.2.24)
activerecord (>= 5.0, < 7)
ruby2ruby (~> 2.4)
ruby_parser (~> 3.10)
haml (5.2.2)
temple (>= 0.8.0)
tilt
haml-lint (0.999.999)
haml_lint
haml_lint (0.37.0)
haml_lint (0.37.1)
haml (>= 4.0, < 5.3)
parallel (~> 1.10)
rainbow
rubocop (>= 0.50.0)
sysexits (~> 1.1)
hamlit (2.15.0)
hamlit (2.15.1-x86_64-linux-musl)
temple (>= 0.8.2)
thor
tilt
@ -227,25 +242,25 @@ GEM
railties (>= 4.0.1)
heapy (0.2.0)
thor
hiredis (0.6.3)
http_parser.rb (0.6.0)
hiredis (0.6.3-x86_64-linux-musl)
http_parser.rb (0.8.0-x86_64-linux-musl)
httparty (0.18.1)
mime-types (~> 3.0)
multi_xml (>= 0.5.2)
i18n (1.8.10)
i18n (1.8.11)
concurrent-ruby (~> 1.0)
icalendar (2.7.1)
ice_cube (~> 0.16)
ice_cube (0.16.3)
ice_cube (0.16.4)
image_processing (1.12.1)
mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3)
inline_svg (1.7.2)
activesupport (>= 3.0)
nokogiri (>= 1.6)
jbuilder (2.11.2)
jbuilder (2.11.3)
activesupport (>= 5.0.0)
jekyll (4.2.0)
jekyll (4.2.1)
addressable (~> 2.4)
colorator (~> 1.0)
em-websocket (~> 0.5)
@ -260,8 +275,8 @@ GEM
rouge (~> 3.0)
safe_yaml (~> 1.0)
terminal-table (~> 2.0)
jekyll-commonmark (1.3.1)
commonmarker (~> 0.14)
jekyll-commonmark (1.3.2)
commonmarker (~> 0.14, < 0.22)
jekyll (>= 3.7, < 5.0)
jekyll-data (1.1.2)
jekyll (>= 3.3, < 5.0.0)
@ -272,21 +287,19 @@ GEM
jekyll (>= 3.7, < 5.0)
jekyll-hardlinks (0.1.2)
jekyll (~> 4)
jekyll-ignore-layouts (0.1.0)
jekyll-ignore-layouts (0.1.2)
jekyll (~> 4)
jekyll-images (0.2.7)
jekyll-images (0.3.0)
jekyll (~> 4)
ruby-filemagic (~> 0.7)
ruby-vips (~> 2)
jekyll-include-cache (0.2.1)
jekyll (>= 3.7, < 5.0)
jekyll-linked-posts (0.2.0)
jekyll-linked-posts (0.4.2)
jekyll (~> 4)
jekyll-locales (0.1.12)
jekyll-lunr (0.2.0)
jekyll-locales (0.1.13)
jekyll-lunr (0.3.0)
loofah (~> 2.4)
jekyll-node-modules (0.1.0)
jekyll (~> 4)
jekyll-order (0.1.4)
jekyll-relative-urls (0.0.6)
jekyll (~> 4)
@ -294,9 +307,9 @@ GEM
sassc (> 2.0.1, < 3.0)
jekyll-seo-tag (2.7.1)
jekyll (>= 3.8, < 5.0)
jekyll-spree-client (0.1.12)
jekyll-spree-client (0.1.19)
fast_blank (~> 1)
spree-api-client (~> 0.2)
spree-api-client (>= 0.2.4)
jekyll-turbolinks (0.0.5)
jekyll (~> 4)
turbolinks-source (~> 5)
@ -304,9 +317,21 @@ GEM
jekyll (~> 4)
jekyll-watch (2.2.1)
listen (~> 3.0)
jekyll-write-and-commit-changes (0.1.2)
jekyll-write-and-commit-changes (0.2.1)
jekyll (~> 4)
rugged (~> 1)
kaminari (1.2.1)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.1)
kaminari-activerecord (= 1.2.1)
kaminari-core (= 1.2.1)
kaminari-actionview (1.2.1)
actionview
kaminari-core (= 1.2.1)
kaminari-activerecord (1.2.1)
activerecord
kaminari-core (= 1.2.1)
kaminari-core (1.2.1)
kramdown (2.3.1)
rexml
kramdown-parser-gfm (1.1.0)
@ -320,72 +345,77 @@ GEM
rb-fsevent (~> 0.9, >= 0.9.4)
rb-inotify (~> 0.9, >= 0.9.7)
ruby_dep (~> 1.2)
lockbox (0.6.4)
loaf (0.10.0)
railties (>= 3.2)
lockbox (0.6.6)
lograge (0.11.2)
actionpack (>= 4)
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.9.1)
loofah (2.12.0)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
mail (2.7.1)
mini_mime (>= 0.1.1)
marcel (1.0.1)
marcel (1.0.2)
memory_profiler (1.0.0)
mercenary (0.4.0)
method_source (1.0.0)
mime-types (3.3.1)
mime-types (3.4.1)
mime-types-data (~> 3.2015)
mime-types-data (3.2021.0225)
mime-types-data (3.2021.1115)
mini_histogram (0.3.1)
mini_magick (4.11.0)
mini_mime (1.0.3)
mini_portile2 (2.5.0)
mini_mime (1.1.2)
mini_portile2 (2.6.1)
minima (2.5.1)
jekyll (>= 3.5, < 5.0)
jekyll-feed (~> 0.9)
jekyll-seo-tag (~> 2.1)
minitest (5.14.4)
mobility (1.1.1)
mobility (1.2.4)
i18n (>= 0.6.10, < 2)
request_store (~> 1.0)
multi_xml (0.6.0)
net-ssh (6.1.0)
netaddr (2.0.4)
nio4r (2.5.7)
nokogiri (1.11.3)
mini_portile2 (~> 2.5.0)
netaddr (2.0.5)
nio4r (2.5.8-x86_64-linux-musl)
nokogiri (1.12.5-x86_64-linux-musl)
mini_portile2 (~> 2.6.1)
racc (~> 1.4)
orm_adapter (0.5.0)
parallel (1.20.1)
parser (3.0.1.0)
parallel (1.21.0)
parser (3.0.2.0)
ast (~> 2.4.1)
pathutil (0.16.2)
forwardable-extended (~> 2.6)
pg (1.2.3)
pg (1.2.3-x86_64-linux-musl)
pg_search (2.3.5)
activerecord (>= 5.2)
activesupport (>= 5.2)
popper_js (1.16.0)
prometheus_exporter (0.7.0)
prometheus_exporter (1.0.0)
webrick
pry (0.14.1)
coderay (~> 1.1)
method_source (~> 1.0)
public_suffix (4.0.6)
puma (5.2.2)
puma (5.5.2-x86_64-linux-musl)
nio4r (~> 2.0)
pundit (2.1.0)
pundit (2.1.1)
activesupport (>= 3.0.0)
racc (1.5.2)
racc (1.6.0-x86_64-linux-musl)
rack (2.2.3)
rack-cors (1.1.1)
rack (>= 2.0.0)
rack-mini-profiler (2.3.1)
rack-mini-profiler (2.3.3)
rack (>= 1.2.0)
rack-proxy (0.6.5)
rack-proxy (0.7.0)
rack
rack-test (1.1.0)
rack (>= 1.0, < 3)
radios-comunitarias-jekyll-theme (0.1.4)
radios-comunitarias-jekyll-theme (0.1.5)
jekyll (~> 4.0)
jekyll-data (~> 1.1)
jekyll-feed (~> 0.9)
@ -396,65 +426,66 @@ GEM
jekyll-relative-urls (~> 0.0)
jekyll-seo-tag (~> 2.1)
jekyll-turbolinks (~> 0)
rails (6.1.3.1)
actioncable (= 6.1.3.1)
actionmailbox (= 6.1.3.1)
actionmailer (= 6.1.3.1)
actionpack (= 6.1.3.1)
actiontext (= 6.1.3.1)
actionview (= 6.1.3.1)
activejob (= 6.1.3.1)
activemodel (= 6.1.3.1)
activerecord (= 6.1.3.1)
activestorage (= 6.1.3.1)
activesupport (= 6.1.3.1)
rails (6.1.4.1)
actioncable (= 6.1.4.1)
actionmailbox (= 6.1.4.1)
actionmailer (= 6.1.4.1)
actionpack (= 6.1.4.1)
actiontext (= 6.1.4.1)
actionview (= 6.1.4.1)
activejob (= 6.1.4.1)
activemodel (= 6.1.4.1)
activerecord (= 6.1.4.1)
activestorage (= 6.1.4.1)
activesupport (= 6.1.4.1)
bundler (>= 1.15.0)
railties (= 6.1.3.1)
railties (= 6.1.4.1)
sprockets-rails (>= 2.0.0)
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
nokogiri (>= 1.6)
rails-html-sanitizer (1.3.0)
rails-html-sanitizer (1.4.2)
loofah (~> 2.3)
rails-i18n (6.0.0)
i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 7)
rails_warden (0.6.0)
warden (>= 1.2.0)
railties (6.1.3.1)
actionpack (= 6.1.3.1)
activesupport (= 6.1.3.1)
railties (6.1.4.1)
actionpack (= 6.1.4.1)
activesupport (= 6.1.4.1)
method_source
rake (>= 0.8.7)
rake (>= 0.13)
thor (~> 1.0)
rainbow (3.0.0)
rake (13.0.3)
rb-fsevent (0.10.4)
rake (13.0.6)
rb-fsevent (0.11.0)
rb-inotify (0.10.1)
ffi (~> 1.0)
recursero-jekyll-theme (0.1.2)
jekyll (~> 4.0)
recursero-jekyll-theme (0.2.0)
jekyll (~> 4)
jekyll-commonmark (~> 1.3)
jekyll-data (~> 1.1)
jekyll-feed (~> 0.9)
jekyll-dotenv (>= 0.2)
jekyll-feed (~> 0.15)
jekyll-ignore-layouts (~> 0)
jekyll-images (~> 0.2)
jekyll-include-cache (~> 0)
jekyll-linked-posts (~> 0.2)
jekyll-linked-posts (~> 0)
jekyll-locales (~> 0.1)
jekyll-lunr (~> 0.1)
jekyll-node-modules (~> 0.1)
jekyll-order (~> 0.1)
jekyll-relative-urls (~> 0.0)
jekyll-seo-tag (~> 2.1)
jekyll-turbolinks (~> 0)
jekyll-order (~> 0)
jekyll-relative-urls (~> 0)
jekyll-seo-tag (~> 2)
jekyll-unique-urls (~> 0.1)
sutty-archives (~> 2.2)
sutty-liquid (~> 0.1)
redis (4.2.5)
sutty-liquid (~> 0)
redis (4.5.1)
redis-actionpack (5.2.0)
actionpack (>= 5, < 7)
redis-rack (>= 2.1.0, < 3)
redis-store (>= 1.1.0, < 2)
redis-activesupport (5.2.0)
redis-activesupport (5.2.1)
activesupport (>= 3, < 7)
redis-store (>= 1.3, < 2)
redis-rack (2.1.3)
@ -473,36 +504,41 @@ GEM
actionpack (>= 5.0)
railties (>= 5.0)
rexml (3.2.5)
rouge (3.26.0)
rubocop (1.12.1)
rouge (3.26.1)
rubocop (1.23.0)
parallel (~> 1.10)
parser (>= 3.0.0.0)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml
rubocop-ast (>= 1.2.0, < 2.0)
rubocop-ast (>= 1.12.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 3.0)
rubocop-ast (1.4.1)
parser (>= 2.7.1.5)
rubocop-rails (2.9.1)
rubocop-ast (1.13.0)
parser (>= 3.0.1.1)
rubocop-rails (2.12.4)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 0.90.0, < 2.0)
rubocop (>= 1.7.0, < 2.0)
ruby-enum (0.9.0)
i18n
ruby-filemagic (0.7.2)
ruby-filemagic (0.7.2-x86_64-linux-musl)
ruby-progressbar (1.11.0)
ruby-statistics (2.1.3)
ruby-vips (2.1.0)
ruby-statistics (3.0.0)
ruby-vips (2.1.4)
ffi (~> 1.12)
ruby2ruby (2.4.4)
ruby_parser (~> 3.1)
sexp_processor (~> 4.6)
ruby_dep (1.5.0)
rubyzip (2.3.0)
rugged (1.1.0)
ruby_parser (3.18.1)
sexp_processor (~> 4.16)
rubyzip (2.3.2)
rugged (1.2.0-x86_64-linux-musl)
safe_yaml (1.0.6)
safely_block (0.3.0)
errbase (>= 0.1.1)
sassc (2.4.0)
sassc (2.4.0-x86_64-linux-musl)
ffi (~> 1.9)
sassc-rails (2.1.2)
railties (>= 4.0.0)
@ -510,10 +546,12 @@ GEM
sprockets (> 3.0)
sprockets-rails
tilt
selenium-webdriver (3.142.7)
childprocess (>= 0.5, < 4.0)
selenium-webdriver (4.1.0)
childprocess (>= 0.5, < 5.0)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2)
semantic_range (3.0.0)
sexp_processor (4.16.0)
share-to-fediverse-jekyll-theme (0.1.4)
jekyll (~> 4.0)
jekyll-data (~> 1.1)
@ -525,7 +563,7 @@ GEM
simpleidn (0.2.1)
unf (~> 0.1.4)
sourcemap (0.1.1)
spree-api-client (0.2.1)
spree-api-client (0.2.4)
fast_blank (~> 1)
httparty (~> 0.18.0)
spring (2.1.1)
@ -535,12 +573,12 @@ GEM
sprockets (4.0.2)
concurrent-ruby (~> 1.0)
rack (> 1, < 3)
sprockets-rails (3.2.2)
actionpack (>= 4.0)
activesupport (>= 4.0)
sprockets-rails (3.4.1)
actionpack (>= 5.2)
activesupport (>= 5.2)
sprockets (>= 3.0.0)
sqlite3 (1.4.2)
stackprof (0.2.16)
sqlite3 (1.4.2-x86_64-linux-musl)
stackprof (0.2.17-x86_64-linux-musl)
sucker_punch (3.0.1)
concurrent-ruby (~> 1.0)
sutty-archives (2.5.4)
@ -562,14 +600,14 @@ GEM
jekyll-include-cache (~> 0)
jekyll-relative-urls (~> 0.0)
jekyll-seo-tag (~> 2.1)
sutty-liquid (0.7.2)
sutty-liquid (0.7.4)
fast_blank (~> 1.0)
jekyll (~> 4)
sutty-minima (2.5.0)
jekyll (>= 3.5, < 5.0)
jekyll-feed (~> 0.9)
jekyll-seo-tag (~> 2.1)
symbol-fstring (1.0.0)
symbol-fstring (1.0.2-x86_64-linux-musl)
sysexits (1.2.0)
temple (0.8.2)
terminal-table (2.0.0)
@ -586,33 +624,34 @@ GEM
execjs (>= 0.3.0, < 3)
unf (0.1.4)
unf_ext
unf_ext (0.0.7.7)
unicode-display_width (1.7.0)
unf_ext (0.0.8-x86_64-linux-musl)
unicode-display_width (1.8.0)
validates_hostname (1.0.11)
activerecord (>= 3.0)
activesupport (>= 3.0)
warden (1.2.9)
rack (>= 2.0.9)
web-console (4.1.0)
web-console (4.2.0)
actionview (>= 6.0.0)
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
webpacker (5.2.1)
webpacker (5.4.3)
activesupport (>= 5.2)
rack-proxy (>= 0.6.1)
railties (>= 5.2)
semantic_range (>= 2.3.0)
webrick (1.7.0)
websocket-driver (0.7.3)
websocket-driver (0.7.5-x86_64-linux-musl)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.4.2)
zeitwerk (2.5.1)
PLATFORMS
ruby
x86_64-linux-musl
DEPENDENCIES
adhesiones-jekyll-theme
@ -622,6 +661,7 @@ DEPENDENCIES
bootstrap (~> 4)
brakeman
capybara (~> 2.13)
chartkick
commonmarker
concurrent-ruby-ext
database_cleaner
@ -640,9 +680,11 @@ DEPENDENCIES
fast_jsonparser
flamegraph
friendly_id
hairtrigger
haml-lint
hamlit-rails
hiredis
httparty
icalendar
image_processing
inline_svg
@ -652,8 +694,10 @@ DEPENDENCIES
jekyll-data!
jekyll-images
jekyll-include-cache
kaminari
letter_opener
listen (>= 3.0.5, < 3.2)
loaf
lockbox
lograge
memory_profiler
@ -661,7 +705,9 @@ DEPENDENCIES
minima
mobility
net-ssh
nokogiri
pg
pg_search
prometheus_exporter
pry
puma
@ -675,6 +721,7 @@ DEPENDENCIES
recursero-jekyll-theme
redis
redis-rails
rollups!
rubocop-rails
rubyzip
rugged
@ -690,7 +737,7 @@ DEPENDENCIES
sucker_punch
sutty-donaciones-jekyll-theme
sutty-jekyll-theme
sutty-liquid
sutty-liquid (>= 0.7.3)
sutty-minima
symbol-fstring
terminal-table
@ -706,4 +753,4 @@ RUBY VERSION
ruby 2.7.1p83
BUNDLED WITH
2.1.4
2.2.2

211
Makefile
View file

@ -1,99 +1,156 @@
# Incluir las variables de entorno
mkfile_path := $(abspath $(lastword $(MAKEFILE_LIST)))
root_dir := $(patsubst %/,%,$(dir $(mkfile_path)))
include $(root_dir)/.env
SHELL := /bin/bash
.DEFAULT_GOAL := help
delegate := athshe
# Copiar el archivo de configuración y avisar cuando hay que
# actualizarlo.
.env: .env.example
@test -f $@ || cp -v $< $@
@test -f $@ && echo "Revisa $@ para actualizarlo con respecto a $<"
@test -f $@ && diff -auN --color $@ $<
assets := package.json yarn.lock $(shell find app/assets/ app/javascript/ -type f)
include .env
alpine_version := 3.12
export
public/packs/manifest.json.br: $(assets)
PANEL_URL=https://panel.sutty.nl RAILS_ENV=production NODE_ENV=production bundle exec rake assets:precompile assets:clean
# XXX: El espacio antes del comentario cuenta como espacio
args ?=## Argumentos para Hain
commit ?= origin/rails## Commit desde el que actualizar
env ?= staging## Entorno del nodo delegado
sutty ?= $(SUTTY)## Dirección local
delegate ?= $(DELEGATE)## Cambia el nodo delegado
hain ?= ENV_FILE=.env $(HAINISH)## Ubicación de Hainish
assets: public/packs/manifest.json.br
serve: /etc/hosts
bundle exec rails s -b "ssl://0.0.0.0:3000?key=../sutty.local/domain/$(SUTTY).key&cert=../sutty.local/domain/$(SUTTY).crt"
# Servir JS con el dev server.
# Esto acelera la compilación del javascript, tiene que correrse por separado
# de serve.
serve-js: /etc/hosts
bundle exec ./bin/webpack-dev-server
# Limpiar los archivos de testeo
clean:
rm -rf _sites/test-* _deploy/test-*
# Generar la imagen Docker
build: assets
time docker build --build-arg="RAILS_MASTER_KEY=`cat config/master.key`" -t sutty/sutty .
docker tag sutty/sutty:latest sutty:keep
save:
time docker save sutty/sutty:latest | ssh root@$(delegate).sutty.nl docker load
date +%F | xargs git tag -f
@echo -e "\a"
load:
ssh root@sutty.nl sh -c "gunzip -c sutty.latest.gz | docker load"
# Crear el directorio donde se almacenan las gemas binarias
../gems/:
mkdir -p $@
gem_dir := $(shell readlink -f ../gems)
gem_cache_dir := $(gem_dir)/cache
gem_binary_dir := $(gem_dir)/$(alpine_version)
ifeq ($(MAKECMDGOALS),build-gems)
gems := $(shell bundle show --paths | xargs -I {} sh -c 'find {}/ext/ -name extconf.rb &>/dev/null && basename {}')
gems := $(patsubst %-x86_64-linux,%,$(gems))
gems := $(patsubst %,$(gem_cache_dir)/%.gem,$(gems))
gems_musl := $(patsubst $(gem_cache_dir)/%.gem,$(gem_binary_dir)/%-x86_64-linux-musl.gem,$(gems))
# El nodo delegado tiene dos entornos, production y staging.
# Dependiendo del entorno que elijamos, se van a generar los assets y el
# contenedor y subirse a un servidor u otro. No utilizamos CI/CD (aún).
#
# Production es el entorno de panel.sutty.nl
ifeq ($(env),production)
container ?= sutty
## TODO: Cambiar a otra cosa
branch ?= rails
public ?= public
endif
$(gem_binary_dir)/%-x86_64-linux-musl.gem:
@docker run \
-v $(gem_dir):/srv/gems \
-v `readlink -f ~/.ccache`:/home/builder/.ccache \
-e HTTP_BASIC_USER=$(HTTP_BASIC_USER) \
-e HTTP_BASIC_PASSWORD=$(HTTP_BASIC_PASSWORD) \
-e GEM=`echo $(notdir $*) | sed -re "s/-[^-]+$$//"` \
-e VERSION=`echo $(notdir $*) | sed -re "s/.*-([^-]+)$$/\1/"` \
-e JOBS=2 \
--rm -it \
sutty/gem-compiler:latest || echo "No se pudo compilar $*"
# Staging es el entorno de panel.staging.sutty.nl
ifeq ($(env),staging)
container := staging
branch := staging
public := staging
endif
# Compilar todas las gemas binarias y subirlas a gems.sutty.nl para que
# al crear el contenedor no tengamos que compilarlas cada vez
build-gems: $(gems_musl)
help: always ## Ayuda
@echo -e "Sutty\n" | sed -re "s/^.*/\x1B[38;5;197m&\x1B[0m/"
@echo -e "Servidor: https://panel.$(SUTTY_WITH_PORT)/\n"
@echo -e "Uso: make TAREA args=\"ARGUMENTOS\"\n"
@echo -e "Tareas:\n"
@grep -E "^[a-z\-]+:.*##" Makefile | sed -re "s/(.*):.*##(.*)/\1;\2/" | column -s ";" -t | sed -re "s/^([^ ]+) /\x1B[38;5;197m\1\x1B[0m/"
@echo -e "\nArgumentos:\n"
@grep -E "^[a-z\-]+ \?=.*##" Makefile | sed -re "s/(.*) \?=.*##(.*)/\1;\2/" | column -s ";" -t | sed -re "s/^([^ ]+) /\x1B[38;5;197m\1\x1B[0m/"
cached_gems = $(wildcard $(gem_dir)/cache/*.gem)
rebuild_gems = $(patsubst $(gem_dir)/cache/%.gem,$(gem_dir)/$(alpine_version)/%-x86_64-linux-musl.gem,$(cached_gems))
rebuild-gems: $(rebuild_gems)
assets: public/packs/manifest.json.br ## Compilar los assets
dirs := $(patsubst %,root/%,data sites deploy public)
test: always ## Ejecutar los tests
$(MAKE) rake args="test RAILS_ENV=test $(args)"
$(dirs):
mkdir -p $@
postgresql: /etc/hosts ## Iniciar la base de datos
pgrep postgres >/dev/null || $(hain) postgresql
app/assets/fonts/forkawesome-webfont.woff2: fa.txt
which glyphhanger || npm i -g glyphhanger
grep -v "^#" fa.txt | sed "s/^/U+/" | cut -d " " -f 1 | tr "\n" "," | xargs -rI {} glyphhanger --subset=node_modules/fork-awesome/fonts/forkawesome-webfont.ttf --formats=woff2 --whitelist="{}"
mv node_modules/fork-awesome/fonts/forkawesome-webfont-subset.woff2 $@
serve-js: /etc/hosts node_modules ## Iniciar el servidor de desarrollo de Javascript
$(hain) 'bundle exec ./bin/webpack-dev-server'
fa: app/assets/fonts/forkawesome-webfont.woff2 ## Fork Awesome
serve: /etc/hosts postgresql Gemfile.lock ## Iniciar el servidor de desarrollo de Rails
$(MAKE) rails args=server
ota: assets
sudo chgrp -R 82 public/
rsync -av public/ athshe:/srv/sutty/srv/http/data/_public/
rails: ## Corre rails dentro del entorno de desarrollo (pasar argumentos con args=).
$(MAKE) bundle args="exec rails $(args)"
rake: ## Corre rake dentro del entorno de desarrollo (pasar argumentos con args=).
$(MAKE) bundle args="exec rake $(args)"
bundle: ## Corre bundle dentro del entorno de desarrollo (pasar argumentos con args=).
$(hain) 'bundle $(args)'
psql := psql -h $(PG_HOST) -U $(PG_USER) -p $(PG_PORT) -d sutty
copy-table:
test -n "$(table)"
echo "truncate $(table) $(cascade);" | $(psql)
ssh $(delegate) docker exec postgresql pg_dump -U sutty -d sutty -t $(table) | $(psql)
psql:
$(psql)
rubocop: ## Yutea el código que está por ser commiteado
git status --porcelain \
| grep -E "^(A|M)" \
| sed "s/^...//" \
| grep ".rb$$" \
| ../haini.sh/haini.sh "xargs -r ./bin/rubocop --auto-correct"
audit: ## Encuentra dependencias con vulnerabilidades
$(hain) 'gem install bundler-audit'
$(hain) 'bundle audit --update'
brakeman: ## Busca posibles vulnerabilidades en Sutty
$(MAKE) bundle args='exec brakeman'
yarn: ## Tareas de yarn
$(hain) 'yarn $(args)'
clean: ## Limpieza
rm -rf _sites/test-* _deploy/test-* log/*.log tmp/cache tmp/letter_opener tmp/miniprofiler tmp/storage
build: Gemfile.lock ## Generar la imagen Docker
time docker build --build-arg="BRANCH=$(branch)" --build-arg="RAILS_MASTER_KEY=`cat config/master.key`" -t sutty/$(container) .
docker tag sutty/$(container):latest sutty:keep
@echo -e "\a"
save: ## Subir la imagen Docker al nodo delegado
time docker save sutty/$(container):latest | ssh root@$(delegate) docker load
date +%F | xargs -I {} git tag -f $(container)-{}
@echo -e "\a"
ota-js: assets ## Actualizar Javascript en el nodo delegado
rsync -avi --delete-after --chown 1000:82 public/ root@$(delegate):/srv/sutty/srv/http/data/_$(public)/
ssh root@$(delegate) docker exec $(container) sh -c "cat /srv/http/tmp/puma.pid | xargs -r kill -USR2"
ota: ## Actualizar Rails en el nodo delegado
umask 022; git format-patch $(commit)
ssh $(delegate) mkdir -p /tmp/patches-$(commit)/
scp ./0*.patch $(delegate):/tmp/patches-$(commit)/
scp ./ota.sh $(delegate):/tmp/
ssh $(delegate) docker cp /tmp/patches-$(shell echo $(commit) | cut -d / -f 1) $(container):/tmp/
ssh $(delegate) docker cp /tmp/ota.sh $(container):/usr/local/bin/ota
ssh $(delegate) docker exec $(container) apk add --no-cache patch
ssh $(delegate) docker exec $(container) ota $(commit)
rm ./0*.patch
# Todos los archivos de assets. Si alguno cambia, se van a recompilar
# los assets que luego se suben al nodo delegado.
assets := package.json yarn.lock $(shell find app/assets/ app/javascript/ -type f)
public/packs/manifest.json.br: $(assets)
$(hain) 'PANEL_URL=https://panel.sutty.nl RAILS_ENV=production NODE_ENV=production bundle exec rake assets:precompile assets:clean'
# Correr un test en particular por ejemplo
# `make test/models/usuarie_test.rb`
tests := $(shell find test/ -name "*_test.rb")
$(tests): always
$(MAKE) test args="TEST=$@"
# Agrega las direcciones locales al sistema
/etc/hosts: always
@echo "Chequeando si es necesario agregar el dominio local $(SUTTY)"
@grep -q " $(SUTTY)$$" $@ || echo -e "127.0.0.1 $(SUTTY)\n::1 $(SUTTY)" | sudo tee -a $@
@grep -q " api.$(SUTTY)$$" $@ || echo -e "127.0.0.1 api.$(SUTTY)\n::1 api.$(SUTTY)" | sudo tee -a $@
@grep -q " panel.$(SUTTY)$$" $@ || echo -e "127.0.0.1 panel.$(SUTTY)\n::1 panel.$(SUTTY)" | sudo tee -a $@
@grep -q " postgresql.$(SUTTY)$$" $@ || echo -e "127.0.0.1 postgresql.$(SUTTY)\n::1 postgresql.$(SUTTY)" | sudo tee -a $@
# Instala las dependencias de Javascript
node_modules: package.json
$(MAKE) yarn
# Instala las dependencias de Rails
Gemfile.lock: Gemfile
$(MAKE) bundle args=install
.PHONY: always

View file

@ -15,6 +15,17 @@ Este repositorio es la plataforma _Ruby on Rails_ para alojar el
Para más información visita el [sitio de Sutty](https://sutty.nl/).
### Desarrollar
Todas las tareas se gestionan con `make`, por favor instala GNU Make
antes de comenzar.
```bash
make help
```
[Leer la documentación](https://docs.sutty.nl/)
## English
Sutty is a platform for hosting safer, faster and more resilient
@ -25,3 +36,13 @@ This repository is the Ruby on Rails platform that hosts the
self-managed [panel](https://panel.sutty.nl/).
For more information, visit [Sutty's website](https://sutty.nl/en/).
### Development
Every task is run via `make`, please install GNU Make before developing.
```bash
make help
```
[Read the documentation](https://docs.sutty.nl/en/)

View file

@ -21,6 +21,10 @@ $form-feedback-invalid-color: $magenta;
$form-feedback-icon-valid-color: $black;
$component-active-bg: $magenta;
$spacers: (
2-plus: 0.75rem
);
@import "bootstrap";
@import "editor";
@ -210,6 +214,10 @@ svg {
}
}
.btn-sm {
@extend .badge
}
.black-bg {
color: $white;
background-color: $black;
@ -355,6 +363,13 @@ $bezier: cubic-bezier(0.75, 0, 0.25, 1);
.text-column-#{$size} {
column-count: $size;
}
.line-clamp-#{$size} {
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: $size;
-webkit-box-orient: vertical;
}
}
/*

View file

@ -2,6 +2,13 @@
box-sizing: border-box;
*, *::before, *::after { box-sizing: inherit; }
// Arreglo temporal para que las cosas sean legibles en modo oscuro
--foreground: black;
--background: white;
--color: #f206f9;
background: var(--background);
color: var(--foreground);
h1, h2, h3, h4, h5, h6, p, li {
min-height: 1.5rem;
}
@ -64,6 +71,10 @@
strong, em, del, u, sub, sup, small { background: #0002; }
a { background: #13fefe50; }
[data-editor-selected] { outline: #f206f9 solid thick; }
p[data-multimedia-inner] {
// Ignorar clicks en el párrafo placeholder
pointer-events: none;
}
}
*[data-editor-loading] {

View file

@ -23,8 +23,6 @@ class ApplicationController < ActionController::Base
redirect_to sites_path
end
def markdown; end
private
def uuid?(string)
@ -42,11 +40,21 @@ class ApplicationController < ActionController::Base
site
end
# Devuelve el idioma actual y si no lo encuentra obtiene uno por
# defecto.
#
# Esto se refiere al idioma de la interfaz, no de los artículos.
def current_locale(include_params: true, site: nil)
return params[:locale] if include_params && params[:locale].present?
current_usuarie&.lang || I18n.locale
end
# El idioma es el preferido por le usuarie, pero no necesariamente se
# corresponde con el idioma de los artículos, porque puede querer
# traducirlos.
def set_locale(&action)
I18n.with_locale(current_usuarie&.lang || I18n.default_locale, &action)
I18n.with_locale(current_locale(include_params: false), &action)
end
# Muestra una página 404

View file

@ -7,80 +7,67 @@ class PostsController < ApplicationController
before_action :authenticate_usuarie!
# TODO: Traer los comunes desde ApplicationController
breadcrumb -> { current_usuarie.email }, :edit_usuarie_registration_path
breadcrumb 'sites.index', :sites_path, match: :exact
breadcrumb -> { site.title }, -> { site_posts_path(site, locale: locale) }, match: :exact
# Las URLs siempre llevan el idioma actual o el de le usuarie
def default_url_options
{ locale: params[:locale] || current_usuarie&.lang || I18n.locale }
{ locale: current_locale }
end
def index
authorize Post
@site = find_site
@category = params.dig(:category)
@layout = params.dig(:layout)
@locale = locale
# XXX: Cada vez que cambiamos un Post tocamos el sitio con lo que es
# más simple saber si hubo cambios.
if @category || @layout || stale?(@site)
@posts = @site.posts(lang: locale)
@posts = @posts.where(categories: @category) if @category
@posts = @posts.where(layout: @layout) if @layout
@posts = PostPolicy::Scope.new(current_usuarie, @posts).resolve
return unless stale?([current_usuarie, site, filter_params])
@category_name = if uuid?(@category)
@site.posts(lang: locale).find(@category, uuid: true)&.title&.value
else
@category
end
# Todos los artículos de este sitio para el idioma actual
@posts = site.indexed_posts.where(locale: locale)
# De este tipo
@posts = @posts.where(layout: filter_params[:layout]) if filter_params[:layout]
# Que estén dentro de la categoría
@posts = @posts.in_category(filter_params[:category]) if filter_params[:category]
# Aplicar los parámetros de búsqueda
@posts = @posts.search(locale, filter_params[:q]) if filter_params[:q].present?
# A los que este usuarie tiene acceso
@posts = PostPolicy::Scope.new(current_usuarie, @posts).resolve
# Filtrar los posts que les invitades no pueden ver
@usuarie = @site.usuarie? current_usuarie
# Orden descendiente por número y luego por fecha
@posts.sort_by!(:order, :date).reverse!
end
# Filtrar los posts que les invitades no pueden ver
@usuarie = site.usuarie? current_usuarie
end
def show
@site = find_site
@post = @site.posts(lang: locale).find params[:id]
authorize @post
@locale = locale
fresh_when @post
authorize post
breadcrumb post.title.value, ''
fresh_when post
end
# Genera una previsualización del artículo.
#
# TODO: No todos los artículos tienen previsualización!
def preview
@site = find_site
@post = @site.posts(lang: locale).find params[:post_id]
authorize post
authorize @post
render html: @post.render
render html: post.render
end
def new
authorize Post
@site = find_site
@post = @site.posts.build(lang: locale, layout: params[:layout])
@locale = locale
@post = site.posts(lang: locale).build(layout: params[:layout])
breadcrumb I18n.t('loaf.breadcrumbs.posts.new', layout: @post.layout.humanized_name.downcase), ''
end
def create
authorize Post
@site = find_site
service = PostService.new(site: @site,
service = PostService.new(site: site,
usuarie: current_usuarie,
params: params)
@post = service.create
if @post.persisted?
@site.touch
site.touch
forget_content
redirect_to site_post_path(@site, @post)
@ -90,30 +77,24 @@ class PostsController < ApplicationController
end
def edit
@site = find_site
@post = @site.posts(lang: locale).find params[:id]
authorize @post
@locale = locale
authorize post
breadcrumb post.title.value, site_post_path(site, post, locale: locale), match: :exact
breadcrumb 'posts.edit', ''
end
def update
@site = find_site
@post = @site.posts(lang: locale).find params[:id]
authorize post
authorize @post
service = PostService.new(site: @site,
post: @post,
service = PostService.new(site: site,
post: post,
usuarie: current_usuarie,
params: params)
if service.update.persisted?
@site.touch
site.touch
forget_content
redirect_to site_post_path(@site, @post)
redirect_to site_post_path(site, post)
else
render 'posts/edit'
end
@ -121,34 +102,30 @@ class PostsController < ApplicationController
# Eliminar artículos
def destroy
@site = find_site
@post = @site.posts(lang: locale).find params[:id]
authorize post
authorize @post
service = PostService.new(site: @site,
post: @post,
service = PostService.new(site: site,
post: post,
usuarie: current_usuarie,
params: params)
# TODO: Notificar si se pudo o no
service.destroy
@site.touch
redirect_to site_posts_path(@site)
site.touch
redirect_to site_posts_path(site, locale: post.lang.value)
end
# Reordenar los artículos
def reorder
@site = find_site
authorize @site
authorize site
service = PostService.new(site: @site,
service = PostService.new(site: site,
usuarie: current_usuarie,
params: params)
service.reorder
@site.touch
redirect_to site_posts_path(@site)
site.touch
redirect_to site_posts_path(site, locale: site.default_locale)
end
# Devuelve el idioma solicitado a través de un parámetro, validando
@ -159,7 +136,7 @@ class PostsController < ApplicationController
# solicite a le usuarie crear el nuevo idioma y que esto lo agregue al
# _config.yml del sitio en lugar de mezclar idiomas.
def locale
@site&.locales&.find(-> { I18n.locale }) do |l|
@locale ||= site&.locales&.find(-> { site&.default_locale }) do |l|
l.to_s == params[:locale]
end
end
@ -169,4 +146,24 @@ class PostsController < ApplicationController
def forget_content
flash[:js] = { target: 'editor', action: 'forget-content', keys: (params[:storage_keys] || []).to_json }
end
private
# Los parámetros de filtros que vamos a mantener en todas las URLs,
# solo los que no estén vacíos.
#
# @return [Hash]
def filter_params
@filter_params ||= params.permit(:q, :category, :layout).to_hash.select do |_, v|
v.present?
end.transform_keys(&:to_sym)
end
def site
@site ||= find_site
end
def post
@post ||= site.posts(lang: locale).find(params[:post_id] || params[:id])
end
end

View file

@ -7,6 +7,9 @@ class SitesController < ApplicationController
before_action :authenticate_usuarie!
breadcrumb -> { current_usuarie.email }, :edit_usuarie_registration_path
breadcrumb 'sites.index', :sites_path, match: :exact
# Ver un listado de sitios
def index
authorize Site
@ -20,10 +23,12 @@ class SitesController < ApplicationController
def show
authorize site
redirect_to site_posts_path(site)
redirect_to site_posts_path(site, locale: site.default_locale)
end
def new
breadcrumb 'sites.new', :new_site_path
@site = Site.new
authorize @site
@ -35,7 +40,7 @@ class SitesController < ApplicationController
params: site_params)
if (@site = service.create).persisted?
redirect_to site_posts_path(@site)
redirect_to site_posts_path(@site, locale: @site.default_locale)
else
render 'new'
end
@ -43,6 +48,10 @@ class SitesController < ApplicationController
def edit
authorize site
breadcrumb site.title, site_posts_path(site, locale: site.default_locale), match: :exact
breadcrumb 'sites.edit', site_path(site)
SiteService.new(site: site).build_deploys
end
@ -53,7 +62,7 @@ class SitesController < ApplicationController
usuarie: current_usuarie)
if service.update.valid?
redirect_to site_posts_path(site)
redirect_to site_posts_path(site, locale: site.default_locale)
else
render 'edit'
end
@ -63,9 +72,10 @@ class SitesController < ApplicationController
authorize site
# XXX: Convertir en una máquina de estados?
DeployJob.perform_async site.id if site.enqueue!
site.enqueue!
DeployJob.perform_async site.id
redirect_to site_posts_path(site)
redirect_to site_posts_path(site, locale: site.default_locale)
end
def reorder_posts
@ -85,7 +95,7 @@ class SitesController < ApplicationController
flash[:danger] = I18n.t('errors.posts.reorder')
end
redirect_to site_posts_path(site)
redirect_to site_posts_path(site, locale: site.default_locale)
end
def fetch
@ -97,7 +107,7 @@ class SitesController < ApplicationController
def merge
authorize site
if site.repository.merge(current_usuarie)
if SiteService.new(site: site, usuarie: current_usuarie).merge
flash[:success] = I18n.t('sites.fetch.merge.success')
else
flash[:error] = I18n.t('sites.fetch.merge.error')

View file

@ -3,16 +3,166 @@
# Estadísticas del sitio
class StatsController < ApplicationController
include Pundit
include ActionView::Helpers::DateHelper
before_action :authenticate_usuarie!
before_action :authorize_stats
EXTRA_OPTIONS = {
builds: {},
space_used: { bytes: true },
build_time: {}
}.freeze
# XXX: Permitir a Chart.js inyectar su propio CSS
content_security_policy only: :index do |policy|
policy.style_src :self, :unsafe_inline
policy.script_src :self, :unsafe_inline
end
def index
@chart_params = { interval: interval }
hostnames
last_stat
chart_options
normalized_urls
end
# Genera un gráfico de visitas por dominio asociado a este sitio
def host
return unless stale? [last_stat, hostnames, interval]
stats = Rollup.where_dimensions(host: hostnames).multi_series('host', interval: interval).tap do |series|
series.each do |serie|
serie[:name] = serie.dig(:dimensions, 'host')
serie[:data].transform_values! do |value|
value * nodes
end
end
end
render json: stats
end
def resources
return unless stale? [last_stat, interval, resource]
options = {
interval: interval,
dimensions: {
deploy_id: @site.deploys.where(type: 'DeployLocal').pluck(:id).first
}
}
render json: Rollup.series(resource, **options)
end
def uris
return unless stale? [last_stat, hostnames, interval, normalized_urls]
options = { host: hostnames, uri: normalized_paths }
stats = Rollup.where_dimensions(**options).multi_series('host|uri', interval: interval).tap do |series|
series.each do |serie|
serie[:name] = serie[:dimensions].slice('host', 'uri').values.join.sub('/index.html', '/')
serie[:data].transform_values! do |value|
value * nodes
end
end
end
render json: stats
end
private
def last_stat
@last_stat ||= Stat.last
end
def authorize_stats
@site = find_site
authorize SiteStat.new(@site)
end
# Solo queremos el promedio de tiempo de compilación, no de
# instalación de dependencias.
stats = @site.build_stats.jekyll
@build_avg = stats.average(:seconds).to_f.round(2)
@build_max = stats.maximum(:seconds).to_f.round(2)
# TODO: Eliminar cuando mergeemos referer-origin
def hostnames
@hostnames ||= [@site.hostname, @site.alternative_hostnames].flatten
end
# Normalizar las URLs
#
# @return [Array]
def normalized_urls
@normalized_urls ||= params.permit(:urls).try(:[],
:urls)&.split("\n")&.map(&:strip)&.select(&:present?)&.select do |uri|
uri.start_with? 'https://'
end&.map do |u|
# XXX: Eliminar
# @see {https://0xacab.org/sutty/containers/nginx/-/merge_requests/1}
next u unless u.end_with? '/'
"#{u}index.html"
end&.uniq || [@site.url, @site.urls].flatten.uniq
end
def normalized_paths
@normalized_paths ||= normalized_urls.map do |u|
"/#{u.split('/', 4).last}"
end.map do |u|
URI.decode_www_form_component u
end
end
# Opciones por defecto para los gráficos.
#
# La invitación a volver dentro de X tiempo es para dar un estimado de
# cuándo habrá información disponible, porque Rollup genera intervalos
# completos (¿aunque dice que no?)
#
# La diferencia se calcula sumando el intervalo a la hora de última
# toma de estadísticas y restando el tiempo que pasó desde ese
# momento.
def chart_options
time = (last_stat&.created_at || Time.now) + 1.try(interval)
please_return_at = { please_return_at: distance_of_time_in_words(Time.now, time) }
@chart_options ||= {
locale: I18n.locale,
empty: I18n.t('stats.index.empty', **please_return_at),
loading: I18n.t('stats.index.loading'),
html: %(<div id="%{id}" class="d-flex align-items-center justify-content-center" style="height: %{height}; width: %{width};">%{loading}</div>)
}
end
# Obtiene y valida los intervalos
#
# @return [Symbol]
def interval
@interval ||= begin
i = params[:interval]&.to_sym
Stat::INTERVALS.include?(i) ? i : :day
end
end
def resource
@resource ||= begin
r = params[:resource].to_sym
Stat::RESOURCES.include?(r) ? r : :builds
end
end
# Obtiene la cantidad de nodos de Sutty, para poder calcular la
# cantidad de visitas.
#
# Como repartimos las visitas por nodo rotando las IPs en el
# nameserver y los resolvedores de DNS eligen un nameserver
# aleatoriamente, la cantidad de visitas se reparte
# equitativamente.
#
# XXX: Remover cuando podamos centralizar los AccessLog
#
# @return [Integer]
def nodes
@nodes ||= ENV.fetch('NODES', 1).to_i
end
end

View file

@ -7,12 +7,18 @@ class UsuariesController < ApplicationController
include Pundit
before_action :authenticate_usuarie!
# TODO: Traer los comunes desde ApplicationController
breadcrumb -> { current_usuarie.email }, :edit_usuarie_registration_path
breadcrumb 'sites.index', :sites_path, match: :exact
breadcrumb -> { site.title }, -> { site_posts_path(site, locale: locale) }, match: :exact
# Mostrar todes les usuaries e invitades de un sitio
def index
@site = find_site
site_usuarie = SiteUsuarie.new(@site, current_usuarie)
site_usuarie = SiteUsuarie.new(site, current_usuarie)
authorize site_usuarie
breadcrumb 'usuaries.index', ''
@policy = policy(site_usuarie)
end
@ -156,4 +162,8 @@ class UsuariesController < ApplicationController
'invitade'
end
end
def site
@site ||= find_site
end
end

View file

@ -1,18 +1,23 @@
import { storeContent, restoreContent, forgetContent } from 'editor/storage'
import { storeContent, restoreContent, forgetContent } from "editor/storage";
import {
isDirectChild, moveChildren, safeGetSelection, safeGetRangeAt,
setAuxiliaryToolbar, parentBlockNames, clearSelected,
} from 'editor/utils'
import { types, getValidChildren, getType } from 'editor/types'
import { setupButtons as setupMarksButtons } from 'editor/types/marks'
import { setupButtons as setupBlocksButtons } from 'editor/types/blocks'
import { setupButtons as setupParentBlocksButtons } from 'editor/types/parentBlocks'
import { setupAuxiliaryToolbar as setupLinkAuxiliaryToolbar } from 'editor/types/link'
isDirectChild,
moveChildren,
safeGetSelection,
safeGetRangeAt,
setAuxiliaryToolbar,
parentBlockNames,
clearSelected,
} from "editor/utils";
import { types, getValidChildren, getType } from "editor/types";
import { setupButtons as setupMarksButtons } from "editor/types/marks";
import { setupButtons as setupBlocksButtons } from "editor/types/blocks";
import { setupButtons as setupParentBlocksButtons } from "editor/types/parentBlocks";
import { setupAuxiliaryToolbar as setupLinkAuxiliaryToolbar } from "editor/types/link";
import {
setupAuxiliaryToolbar as setupMultimediaAuxiliaryToolbar,
setupButtons as setupMultimediaButtons,
} from 'editor/types/multimedia'
import { setupAuxiliaryToolbar as setupMarkAuxiliaryToolbar } from 'editor/types/mark'
setupAuxiliaryToolbar as setupMultimediaAuxiliaryToolbar,
setupButtons as setupMultimediaButtons,
} from "editor/types/multimedia";
import { setupAuxiliaryToolbar as setupMarkAuxiliaryToolbar } from "editor/types/mark";
// Esta funcion corrije errores que pueden haber como:
// * que un nodo que no tiene 'text' permitido no tenga children (se les
@ -22,79 +27,76 @@ import { setupAuxiliaryToolbar as setupMarkAuxiliaryToolbar } from 'editor/types
// * convierte <i> y <b> en <em> y <strong>
// Lo hace para que siga la estructura del documento y que no se borren por
// cleanContent luego.
function fixContent (editor: Editor, node: Element = editor.contentEl): void {
if (node.tagName === 'SCRIPT' || node.tagName === 'STYLE') {
node.parentElement?.removeChild(node)
return
}
function fixContent(editor: Editor, node: Element = editor.contentEl): void {
if (node.tagName === "SCRIPT" || node.tagName === "STYLE") {
node.parentElement?.removeChild(node);
return;
}
if (node.tagName === 'I') {
const el = document.createElement('em')
moveChildren(node, el, null)
node.parentElement?.replaceChild(el, node)
node = el
}
if (node.tagName === 'B') {
const el = document.createElement('strong')
moveChildren(node, el, null)
node.parentElement?.replaceChild(el, node)
node = el
}
if (node.tagName === "I") {
const el = document.createElement("em");
moveChildren(node, el, null);
node.parentElement?.replaceChild(el, node);
node = el;
}
if (node.tagName === "B") {
const el = document.createElement("strong");
moveChildren(node, el, null);
node.parentElement?.replaceChild(el, node);
node = el;
}
if (node instanceof HTMLImageElement) {
node.dataset.multimediaInner = ''
const figureEl = types.multimedia.create(editor)
if (node instanceof HTMLImageElement) {
node.dataset.multimediaInner = "";
const figureEl = types.multimedia.create(editor);
let targetEl = node.parentElement
if (!targetEl) throw new Error('No encontré lx objetivo')
while (true) {
const type = getType(targetEl)
if (!type) throw new Error('lx objetivo tiene tipo')
if (type.type.allowedChildren.includes('multimedia')) break
if (!targetEl.parentElement) throw new Error('No encontré lx objetivo')
targetEl = targetEl.parentElement
}
let targetEl = node.parentElement;
if (!targetEl) throw new Error("No encontré lx objetivo");
while (true) {
const type = getType(targetEl);
if (!type) throw new Error("lx objetivo tiene tipo");
if (type.type.allowedChildren.includes("multimedia")) break;
if (!targetEl.parentElement) throw new Error("No encontré lx objetivo");
targetEl = targetEl.parentElement;
}
let parentEl = [...targetEl.childNodes].find(
el => el.contains(node)
)
if (!parentEl) throw new Error('no encontré lx pariente')
targetEl.insertBefore(figureEl, parentEl)
let parentEl = [...targetEl.childNodes].find((el) => el.contains(node));
if (!parentEl) throw new Error("no encontré lx pariente");
targetEl.insertBefore(figureEl, parentEl);
const innerEl = figureEl.querySelector('[data-multimedia-inner]')
if (!innerEl) throw new Error('Raro.')
figureEl.replaceChild(node, innerEl)
const innerEl = figureEl.querySelector("[data-multimedia-inner]");
if (!innerEl) throw new Error("Raro.");
figureEl.replaceChild(node, innerEl);
node = figureEl
}
node = figureEl;
}
const _type = getType(node)
if (!_type) return
const _type = getType(node);
if (!_type) return;
const { typeName, type } = _type
const { typeName, type } = _type;
if (type.allowedChildren !== 'ignore-children') {
const sel = safeGetSelection(editor)
const range = sel && safeGetRangeAt(sel)
if (type.allowedChildren !== "ignore-children") {
const sel = safeGetSelection(editor);
const range = sel && safeGetRangeAt(sel);
if (getValidChildren(node, type).length == 0) {
if (typeof type.handleEmpty !== 'string') {
const el = type.handleEmpty.create(editor)
// mover cosas que pueden haber
// por ejemplo: cuando convertís a un <ul>, queda texto fuera del li que
// creamos acá
moveChildren(node, el, null)
node.appendChild(el)
if (range?.intersectsNode(node))
sel?.collapse(el)
}
}
for (const child of node.childNodes) {
if (!(child instanceof Element)) continue
fixContent(editor, child)
}
}
if (getValidChildren(node, type).length == 0) {
if (typeof type.handleEmpty !== "string") {
const el = type.handleEmpty.create(editor);
// mover cosas que pueden haber
// por ejemplo: cuando convertís a un <ul>, queda texto fuera del li que
// creamos acá
moveChildren(node, el, null);
node.appendChild(el);
if (range?.intersectsNode(node)) sel?.collapse(el);
}
}
for (const child of node.childNodes) {
if (!(child instanceof Element)) continue;
fixContent(editor, child);
}
}
}
// Esta funcion hace que los elementos del editor sigan la estructura.
@ -102,205 +104,236 @@ function fixContent (editor: Editor, node: Element = editor.contentEl): void {
// Edge cases:
// * no borramos los <br> por que se requieren para que los navegadores
// funcionen bien al escribir. no se deberían mostrar de todas maneras
function cleanContent (editor: Editor, node: Element = editor.contentEl): void {
const _type = getType(node)
if (!_type) {
node.parentElement?.removeChild(node)
return
}
function cleanContent(editor: Editor, node: Element = editor.contentEl): void {
const _type = getType(node);
if (!_type) {
node.parentElement?.removeChild(node);
return;
}
const { type } = _type
const { type } = _type;
if (type.allowedChildren !== 'ignore-children') {
for (const child of node.childNodes) {
if (child.nodeType === Node.TEXT_NODE
&& !type.allowedChildren.includes('text')
) {
node.removeChild(child)
continue
}
if (type.allowedChildren !== "ignore-children") {
for (const child of node.childNodes) {
if (
child.nodeType === Node.TEXT_NODE &&
!type.allowedChildren.includes("text")
) {
node.removeChild(child);
continue;
}
if (!(child instanceof Element)) continue
if (!(child instanceof Element)) continue;
const childType = getType(child)
if (childType?.typeName === 'br') continue
if (!childType || !type.allowedChildren.includes(childType.typeName)) {
// XXX: esto extrae las cosas de adentro para que no sea destructivo
moveChildren(child, node, child)
node.removeChild(child)
return
}
const childType = getType(child);
if (childType?.typeName === "br") continue;
if (!childType || !type.allowedChildren.includes(childType.typeName)) {
// XXX: esto extrae las cosas de adentro para que no sea destructivo
moveChildren(child, node, child);
node.removeChild(child);
return;
}
cleanContent(editor, child)
}
cleanContent(editor, child);
}
// solo contar children válido para ese nodo
const validChildrenLength = getValidChildren(node, type).length
// solo contar children válido para ese nodo
const validChildrenLength = getValidChildren(node, type).length;
const sel = safeGetSelection(editor)
const range = sel && safeGetRangeAt(sel)
if (type.handleEmpty === 'remove'
&& validChildrenLength == 0
//&& (!range || !range.intersectsNode(node))
) {
node.parentNode?.removeChild(node)
return
}
}
const sel = safeGetSelection(editor);
const range = sel && safeGetRangeAt(sel);
if (
type.handleEmpty === "remove" &&
validChildrenLength == 0
//&& (!range || !range.intersectsNode(node))
) {
node.parentNode?.removeChild(node);
return;
}
}
}
function routine (editor: Editor): void {
try {
fixContent(editor)
cleanContent(editor)
storeContent(editor)
function routine(editor: Editor): void {
try {
fixContent(editor);
cleanContent(editor);
storeContent(editor);
editor.htmlEl.value = editor.contentEl.innerHTML
} catch (error) {
console.error('Hubo un problema corriendo la rutina', editor, error)
}
editor.htmlEl.value = editor.contentEl.innerHTML;
} catch (error) {
console.error("Hubo un problema corriendo la rutina", editor, error);
}
}
export interface Editor {
editorEl: HTMLElement,
toolbarEl: HTMLElement,
toolbar: {
auxiliary: {
mark: {
parentEl: HTMLElement,
colorEl: HTMLInputElement,
},
multimedia: {
parentEl: HTMLElement,
fileEl: HTMLInputElement,
uploadEl: HTMLButtonElement,
altEl: HTMLInputElement,
removeEl: HTMLButtonElement,
},
link: {
parentEl: HTMLElement,
urlEl: HTMLInputElement,
},
},
},
contentEl: HTMLElement,
wordAlertEl: HTMLElement,
htmlEl: HTMLTextAreaElement,
editorEl: HTMLElement;
toolbarEl: HTMLElement;
toolbar: {
auxiliary: {
mark: {
parentEl: HTMLElement;
colorEl: HTMLInputElement;
textColorEl: HTMLInputElement;
};
multimedia: {
parentEl: HTMLElement;
fileEl: HTMLInputElement;
uploadEl: HTMLButtonElement;
altEl: HTMLInputElement;
removeEl: HTMLButtonElement;
};
link: {
parentEl: HTMLElement;
urlEl: HTMLInputElement;
};
};
};
contentEl: HTMLElement;
wordAlertEl: HTMLElement;
htmlEl: HTMLTextAreaElement;
}
function getSel<T extends Element>(parentEl: HTMLElement, selector: string): T {
const el = parentEl.querySelector<T>(selector)
if (!el) throw new Error(`No pude encontrar un componente \`${selector}\``)
return el
const el = parentEl.querySelector<T>(selector);
if (!el) throw new Error(`No pude encontrar un componente \`${selector}\``);
return el;
}
function setupEditor (editorEl: HTMLElement): void {
// XXX: ¡Esto afecta a todo el documento! ¿Quizás usar un iframe para el editor?
document.execCommand('defaultParagraphSeparator', false, 'p')
function setupEditor(editorEl: HTMLElement): void {
// XXX: ¡Esto afecta a todo el documento! ¿Quizás usar un iframe para el editor?
document.execCommand("defaultParagraphSeparator", false, "p");
const editor: Editor = {
editorEl,
toolbarEl: getSel(editorEl, '.editor-toolbar'),
toolbar: {
auxiliary: {
mark: {
parentEl: getSel(editorEl, '[data-editor-auxiliary=mark]'),
colorEl: getSel(editorEl, '[data-editor-auxiliary=mark] [name=mark-color]'),
},
multimedia: {
parentEl: getSel(editorEl, '[data-editor-auxiliary=multimedia]'),
fileEl: getSel(editorEl, '[data-editor-auxiliary=multimedia] [name=multimedia-file]'),
uploadEl: getSel(editorEl, '[data-editor-auxiliary=multimedia] [name=multimedia-file-upload]'),
altEl: getSel(editorEl, '[data-editor-auxiliary=multimedia] [name=multimedia-alt]'),
removeEl: getSel(editorEl, '[data-editor-auxiliary=multimedia] [name=multimedia-remove]'),
},
link: {
parentEl: getSel(editorEl, '[data-editor-auxiliary=link]'),
urlEl: getSel(editorEl, '[data-editor-auxiliary=link] [name=link-url]'),
},
},
},
contentEl: getSel(editorEl, '.editor-content'),
wordAlertEl: getSel(editorEl, '.editor-aviso-word'),
htmlEl: getSel(editorEl, 'textarea'),
}
console.debug('iniciando editor', editor)
const editor: Editor = {
editorEl,
toolbarEl: getSel(editorEl, ".editor-toolbar"),
toolbar: {
auxiliary: {
mark: {
parentEl: getSel(editorEl, "[data-editor-auxiliary=mark]"),
colorEl: getSel(
editorEl,
"[data-editor-auxiliary=mark] [name=mark-color]"
),
textColorEl: getSel(
editorEl,
"[data-editor-auxiliary=mark] [name=mark-text-color]"
),
},
multimedia: {
parentEl: getSel(editorEl, "[data-editor-auxiliary=multimedia]"),
fileEl: getSel(
editorEl,
"[data-editor-auxiliary=multimedia] [name=multimedia-file]"
),
uploadEl: getSel(
editorEl,
"[data-editor-auxiliary=multimedia] [name=multimedia-file-upload]"
),
altEl: getSel(
editorEl,
"[data-editor-auxiliary=multimedia] [name=multimedia-alt]"
),
removeEl: getSel(
editorEl,
"[data-editor-auxiliary=multimedia] [name=multimedia-remove]"
),
},
link: {
parentEl: getSel(editorEl, "[data-editor-auxiliary=link]"),
urlEl: getSel(
editorEl,
"[data-editor-auxiliary=link] [name=link-url]"
),
},
},
},
contentEl: getSel(editorEl, ".editor-content"),
wordAlertEl: getSel(editorEl, ".editor-aviso-word"),
htmlEl: getSel(editorEl, "textarea"),
};
console.debug("iniciando editor", editor);
// Recuperar el contenido si hay algo guardado, si tuviéramos un campo
// de última edición podríamos saber si el artículo fue editado
// después o la versión local es la última.
//
// TODO: Preguntar si se lo quiere recuperar.
restoreContent(editor)
// Recuperar el contenido si hay algo guardado, si tuviéramos un campo
// de última edición podríamos saber si el artículo fue editado
// después o la versión local es la última.
//
// TODO: Preguntar si se lo quiere recuperar.
restoreContent(editor);
// Word alert
editor.contentEl.addEventListener('paste', () => {
editor.wordAlertEl.style.display = 'block'
})
// Word alert
editor.contentEl.addEventListener("paste", () => {
editor.wordAlertEl.style.display = "block";
});
// Setup routine listeners
const observer = new MutationObserver(() => routine(editor))
observer.observe(editor.contentEl, {
childList: true,
attributes: true,
subtree: true,
characterData: true,
})
// Setup routine listeners
const observer = new MutationObserver(() => routine(editor));
observer.observe(editor.contentEl, {
childList: true,
attributes: true,
subtree: true,
characterData: true,
});
document.addEventListener("selectionchange", () => routine(editor))
document.addEventListener("selectionchange", () => routine(editor));
// Capture onClick
editor.contentEl.addEventListener('click', event => {
const target = event.target! as Element
const type = getType(target)
if (!type || !type.type.onClick) {
setAuxiliaryToolbar(editor, null)
clearSelected(editor)
return true
}
type.type.onClick(editor, target)
return false
}, true)
// Capture onClick
editor.contentEl.addEventListener(
"click",
(event) => {
const target = event.target! as Element;
const type = getType(target);
if (!type || !type.type.onClick) {
setAuxiliaryToolbar(editor, null);
clearSelected(editor);
return true;
}
type.type.onClick(editor, target);
return false;
},
true
);
// Clean seleted
const selectedEl = editor.contentEl.querySelector('[data-editor-selected]')
if (selectedEl) delete (selectedEl as HTMLElement).dataset.editorSelected
// Clean seleted
const selectedEl = editor.contentEl.querySelector("[data-editor-selected]");
if (selectedEl) delete (selectedEl as HTMLElement).dataset.editorSelected;
// Setup botones
setupMarksButtons(editor)
setupBlocksButtons(editor)
setupParentBlocksButtons(editor)
setupMultimediaButtons(editor)
// Setup botones
setupMarksButtons(editor);
setupBlocksButtons(editor);
setupParentBlocksButtons(editor);
setupMultimediaButtons(editor);
setupLinkAuxiliaryToolbar(editor)
setupMultimediaAuxiliaryToolbar(editor)
setupMarkAuxiliaryToolbar(editor)
setupLinkAuxiliaryToolbar(editor);
setupMultimediaAuxiliaryToolbar(editor);
setupMarkAuxiliaryToolbar(editor);
// Finally...
routine(editor)
// Finally...
routine(editor);
}
document.addEventListener("turbolinks:load", () => {
const flash = document.querySelector<HTMLElement>('.js-flash')
const flash = document.querySelector<HTMLElement>(".js-flash");
if (flash) {
const keys = JSON.parse(flash.dataset.keys || '[]')
if (flash) {
const keys = JSON.parse(flash.dataset.keys || "[]");
switch (flash.dataset.target) {
case 'editor':
switch (flash.dataset.action) {
case 'forget-content':
keys.forEach(forgetContent)
}
}
}
switch (flash.dataset.target) {
case "editor":
switch (flash.dataset.action) {
case "forget-content":
keys.forEach(forgetContent);
}
}
}
for (const editorEl of document.querySelectorAll<HTMLElement>('.editor[data-editor]')) {
try {
setupEditor(editorEl)
} catch (error) {
// TODO: mostrar error
console.error('no se pudo iniciar el editor, error completo', error)
}
}
})
for (const editorEl of document.querySelectorAll<HTMLElement>(
".editor[data-editor]"
)) {
try {
setupEditor(editorEl);
} catch (error) {
// TODO: mostrar error
console.error("no se pudo iniciar el editor, error completo", error);
}
}
});

View file

@ -1,4 +1,4 @@
import { Editor } from 'editor/editor'
import { Editor } from "editor/editor";
/*
* Guarda una copia local de los cambios para poder recuperarlos
@ -6,27 +6,33 @@ import { Editor } from 'editor/editor'
*
* Usamos la URL completa sin anchors.
*/
function getStorageKey (editor: Editor): string {
const keyEl = editor.editorEl.querySelector<HTMLInputElement>('[data-target="storage-key"]')
if (!keyEl) throw new Error('No encuentro la llave para guardar los artículos')
return keyEl.value
function getStorageKey(editor: Editor): string {
const keyEl = editor.editorEl.querySelector<HTMLInputElement>(
'[data-target="storage-key"]'
);
if (!keyEl)
throw new Error("No encuentro la llave para guardar los artículos");
return keyEl.value;
}
export function forgetContent (storedKey: string): void {
window.localStorage.removeItem(storedKey)
export function forgetContent(storedKey: string): void {
window.localStorage.removeItem(storedKey);
}
export function storeContent (editor: Editor): void {
if (editor.contentEl.innerText.trim().length === 0) return
export function storeContent(editor: Editor): void {
if (editor.contentEl.innerText.trim().length === 0) return;
window.localStorage.setItem(getStorageKey(editor), editor.contentEl.innerHTML)
window.localStorage.setItem(
getStorageKey(editor),
editor.contentEl.innerHTML
);
}
export function restoreContent (editor: Editor): void {
const content = window.localStorage.getItem(getStorageKey(editor))
export function restoreContent(editor: Editor): void {
const content = window.localStorage.getItem(getStorageKey(editor));
if (!content) return
if (content.trim().length === 0) return
if (!content) return;
if (content.trim().length === 0) return;
editor.contentEl.innerHTML = content
editor.contentEl.innerHTML = content;
}

View file

@ -1,126 +1,140 @@
import { Editor } from 'editor/editor'
import { marks } from 'editor/types/marks'
import { blocks, li, EditorBlock } from 'editor/types/blocks'
import { parentBlocks } from 'editor/types/parentBlocks'
import { multimedia } from 'editor/types/multimedia'
import { blockNames, parentBlockNames, safeGetRangeAt, safeGetSelection } from 'editor/utils'
import { Editor } from "editor/editor";
import { marks } from "editor/types/marks";
import { blocks, li, EditorBlock } from "editor/types/blocks";
import { parentBlocks } from "editor/types/parentBlocks";
import { multimedia } from "editor/types/multimedia";
import {
blockNames,
parentBlockNames,
safeGetRangeAt,
safeGetSelection,
} from "editor/utils";
export interface EditorNode {
selector: string,
// la string es el nombre en la gran lista de types O 'text'
// XXX: esto es un hack para no poner EditorNode dentro de EditorNode,
// quizás podemos hacer que esto sea una función que retorna bool
allowedChildren: string[] | 'ignore-children',
selector: string;
// la string es el nombre en la gran lista de types O 'text'
// XXX: esto es un hack para no poner EditorNode dentro de EditorNode,
// quizás podemos hacer que esto sea una función que retorna bool
allowedChildren: string[] | "ignore-children";
// * si es 'do-nothing', no hace nada si está vacío (esto es para cuando
// permitís 'text' entonces se puede tipear adentro, ej: párrafo vacío)
// * si es 'remove', sacamos el coso si está vacío.
// ej: strong: { handleNothing: 'remove' }
// * si es un block, insertamos el bloque y movemos la selección ahí
// ej: ul: { handleNothing: li }
handleEmpty: 'do-nothing' | 'remove' | EditorBlock,
// * si es 'do-nothing', no hace nada si está vacío (esto es para cuando
// permitís 'text' entonces se puede tipear adentro, ej: párrafo vacío)
// * si es 'remove', sacamos el coso si está vacío.
// ej: strong: { handleNothing: 'remove' }
// * si es un block, insertamos el bloque y movemos la selección ahí
// ej: ul: { handleNothing: li }
handleEmpty: "do-nothing" | "remove" | EditorBlock;
// esta función puede ser llamada para cosas que no necesariamente sea la
// creación del nodo con el botón; por ejemplo, al intentar recuperar
// el formato. esto es importante por que, por ejemplo, no deberíamos
// cambiar la selección acá.
create: (editor: Editor) => HTMLElement,
// esta función puede ser llamada para cosas que no necesariamente sea la
// creación del nodo con el botón; por ejemplo, al intentar recuperar
// el formato. esto es importante por que, por ejemplo, no deberíamos
// cambiar la selección acá.
create: (editor: Editor) => HTMLElement;
onClick?: (editor: Editor, target: Element) => void,
onClick?: (editor: Editor, target: Element) => void;
}
export const types: { [propName: string]: EditorNode } = {
...marks,
...blocks,
li,
...parentBlocks,
contentEl: {
selector: '.editor-content',
allowedChildren: [...blockNames, ...parentBlockNames, 'multimedia'],
handleEmpty: blocks.paragraph,
create: () => { throw new Error('se intentó crear contentEl') }
},
br: {
selector: 'br',
allowedChildren: [],
handleEmpty: 'do-nothing',
create: () => { throw new Error('se intentó crear br') }
},
multimedia,
}
...marks,
...blocks,
li,
...parentBlocks,
contentEl: {
selector: ".editor-content",
allowedChildren: [...blockNames, ...parentBlockNames, "multimedia"],
handleEmpty: blocks.paragraph,
create: () => {
throw new Error("se intentó crear contentEl");
},
},
br: {
selector: "br",
allowedChildren: [],
handleEmpty: "do-nothing",
create: () => {
throw new Error("se intentó crear br");
},
},
multimedia,
};
export function getType (node: Element): { typeName: string, type: EditorNode } | null {
for (let [typeName, type] of Object.entries(types)) {
if (node.matches(type.selector)) {
return { typeName, type }
}
}
return null
export function getType(
node: Element
): { typeName: string; type: EditorNode } | null {
for (let [typeName, type] of Object.entries(types)) {
if (node.matches(type.selector)) {
return { typeName, type };
}
}
return null;
}
// encuentra el primer pariente que pueda tener al type, y retorna un array
// donde
// array[0] = elemento que matchea el type
// array[array.len - 1] = primer elemento seleccionado
export function getValidParentInSelection (args: {
editor: Editor,
type: string,
export function getValidParentInSelection(args: {
editor: Editor;
type: string;
}): Element[] {
const sel = safeGetSelection(args.editor)
if (!sel) throw new Error('No se donde insertar esto')
const range = safeGetRangeAt(sel)
if (!range) throw new Error('No se donde insertar esto')
const sel = safeGetSelection(args.editor);
if (!sel) throw new Error("No se donde insertar esto");
const range = safeGetRangeAt(sel);
if (!range) throw new Error("No se donde insertar esto");
let list: Element[] = []
if (!sel.anchorNode) {
throw new Error('No se donde insertar esto')
} else if (sel.anchorNode instanceof Element) {
list = [sel.anchorNode]
} else if (sel.anchorNode.parentElement) {
list = [sel.anchorNode.parentElement]
} else {
throw new Error('No se donde insertar esto')
}
let list: Element[] = [];
while (true) {
const el = list[0]
if (!args.editor.contentEl.contains(el)
&& el != args.editor.contentEl)
throw new Error('No se donde insertar esto')
const type = getType(el)
if (!sel.anchorNode) {
throw new Error("No se donde insertar esto");
} else if (sel.anchorNode instanceof Element) {
list = [sel.anchorNode];
} else if (sel.anchorNode.parentElement) {
list = [sel.anchorNode.parentElement];
} else {
throw new Error("No se donde insertar esto");
}
if (type) {
//if (type.typeName === 'contentEl') break
//if (parentBlockNames.includes(type.typeName)) break
if ((type.type.allowedChildren instanceof Array)
&& type.type.allowedChildren.includes(args.type)) break
}
if (el.parentElement) {
list = [el.parentElement, ...list]
} else {
throw new Error('No se donde insertar esto')
}
}
return list
while (true) {
const el = list[0];
if (!args.editor.contentEl.contains(el) && el != args.editor.contentEl)
throw new Error("No se donde insertar esto");
const type = getType(el);
if (type) {
//if (type.typeName === 'contentEl') break
//if (parentBlockNames.includes(type.typeName)) break
if (
type.type.allowedChildren instanceof Array &&
type.type.allowedChildren.includes(args.type)
)
break;
}
if (el.parentElement) {
list = [el.parentElement, ...list];
} else {
throw new Error("No se donde insertar esto");
}
}
return list;
}
export function getValidChildren (node: Element, type: EditorNode): Node[] {
if (type.allowedChildren === 'ignore-children')
throw new Error('se llamó a getValidChildren con un type que no lo permite!')
return [...node.childNodes].filter(n => {
// si permite texto y esto es un texto, es válido
if (n.nodeType === Node.TEXT_NODE)
return type.allowedChildren.includes('text') && n.textContent?.length
export function getValidChildren(node: Element, type: EditorNode): Node[] {
if (type.allowedChildren === "ignore-children")
throw new Error(
"se llamó a getValidChildren con un type que no lo permite!"
);
return [...node.childNodes].filter((n) => {
// si permite texto y esto es un texto, es válido
if (n.nodeType === Node.TEXT_NODE)
return type.allowedChildren.includes("text") && n.textContent?.length;
// si no es un elemento, no es válido
if (!(n instanceof Element))
return false
// si no es un elemento, no es válido
if (!(n instanceof Element)) return false;
const t = getType(n)
if (!t) return false
return type.allowedChildren.includes(t.typeName)
})
const t = getType(n);
if (!t) return false;
return type.allowedChildren.includes(t.typeName);
});
}

View file

@ -1,72 +1,76 @@
import { Editor } from 'editor/editor'
import { Editor } from "editor/editor";
import {
safeGetSelection, safeGetRangeAt,
moveChildren,
markNames, blockNames, parentBlockNames,
} from 'editor/utils'
import { EditorNode, getType, getValidParentInSelection } from 'editor/types'
safeGetSelection,
safeGetRangeAt,
moveChildren,
markNames,
blockNames,
parentBlockNames,
} from "editor/utils";
import { EditorNode, getType, getValidParentInSelection } from "editor/types";
export interface EditorBlock extends EditorNode {
export interface EditorBlock extends EditorNode {}
function makeBlock(tag: string): EditorBlock {
return {
selector: tag,
allowedChildren: [...markNames, "text"],
handleEmpty: "do-nothing",
create: () => document.createElement(tag),
};
}
function makeBlock (tag: string): EditorBlock {
return {
selector: tag,
allowedChildren: [...markNames, 'text'],
handleEmpty: 'do-nothing',
create: () => document.createElement(tag),
}
}
export const li: EditorBlock = makeBlock('li')
export const li: EditorBlock = makeBlock("li");
// XXX: si agregás algo acá, agregalo a blockNames
// (y probablemente le quieras hacer un botón en app/views/posts/attributes/_content.haml)
export const blocks: { [propName: string]: EditorBlock } = {
paragraph: makeBlock('p'),
h1: makeBlock('h1'),
h2: makeBlock('h2'),
h3: makeBlock('h3'),
h4: makeBlock('h4'),
h5: makeBlock('h5'),
h6: makeBlock('h6'),
unordered_list: {
...makeBlock('ul'),
allowedChildren: ['li'],
handleEmpty: li,
},
ordered_list: {
...makeBlock('ol'),
allowedChildren: ['li'],
handleEmpty: li,
},
}
export function setupButtons (editor: Editor): void {
for (const [ name, type ] of Object.entries(blocks)) {
const buttonEl = editor.toolbarEl.querySelector(`[data-editor-button="block-${name}"]`)
if (!buttonEl) continue
buttonEl.addEventListener("click", event => {
event.preventDefault()
const list = getValidParentInSelection({ editor, type: name })
// No borrar cosas como multimedia
if (blockNames.indexOf(getType(list[1])!.typeName) === -1) {
return
}
let replacementType = list[1].matches(type.selector)
? blocks.paragraph
: type
const el = replacementType.create(editor)
replacementType.onClick && replacementType.onClick(editor, el)
moveChildren(list[1], el, null)
list[0].replaceChild(el, list[1])
window.getSelection()?.collapse(el)
return false
})
}
paragraph: makeBlock("p"),
h1: makeBlock("h1"),
h2: makeBlock("h2"),
h3: makeBlock("h3"),
h4: makeBlock("h4"),
h5: makeBlock("h5"),
h6: makeBlock("h6"),
unordered_list: {
...makeBlock("ul"),
allowedChildren: ["li"],
handleEmpty: li,
},
ordered_list: {
...makeBlock("ol"),
allowedChildren: ["li"],
handleEmpty: li,
},
};
export function setupButtons(editor: Editor): void {
for (const [name, type] of Object.entries(blocks)) {
const buttonEl = editor.toolbarEl.querySelector(
`[data-editor-button="block-${name}"]`
);
if (!buttonEl) continue;
buttonEl.addEventListener("click", (event) => {
event.preventDefault();
const list = getValidParentInSelection({ editor, type: name });
// No borrar cosas como multimedia
if (blockNames.indexOf(getType(list[1])!.typeName) === -1) {
return;
}
let replacementType = list[1].matches(type.selector)
? blocks.paragraph
: type;
const el = replacementType.create(editor);
replacementType.onClick && replacementType.onClick(editor, el);
moveChildren(list[1], el, null);
list[0].replaceChild(el, list[1]);
window.getSelection()?.collapse(el);
return false;
});
}
}

View file

@ -1,37 +1,37 @@
import { Editor } from 'editor/editor'
import { EditorNode } from 'editor/types'
import { markNames, setAuxiliaryToolbar, clearSelected } from 'editor/utils'
import { Editor } from "editor/editor";
import { EditorNode } from "editor/types";
import { markNames, setAuxiliaryToolbar, clearSelected } from "editor/utils";
function select (editor: Editor, el: HTMLAnchorElement): void {
clearSelected(editor)
el.dataset.editorSelected = ''
editor.toolbar.auxiliary.link.urlEl.value = el.href
setAuxiliaryToolbar(editor, editor.toolbar.auxiliary.link.parentEl)
function select(editor: Editor, el: HTMLAnchorElement): void {
clearSelected(editor);
el.dataset.editorSelected = "";
editor.toolbar.auxiliary.link.urlEl.value = el.href;
setAuxiliaryToolbar(editor, editor.toolbar.auxiliary.link.parentEl);
}
export const link: EditorNode = {
selector: 'a',
allowedChildren: [...markNames.filter(n => n !== 'link'), 'text'],
handleEmpty: 'remove',
create: () => document.createElement('a'),
onClick (editor, el) {
if (!(el instanceof HTMLAnchorElement))
throw new Error('oh no')
select(editor, el)
},
}
selector: "a",
allowedChildren: [...markNames.filter((n) => n !== "link"), "text"],
handleEmpty: "remove",
create: () => document.createElement("a"),
onClick(editor, el) {
if (!(el instanceof HTMLAnchorElement)) throw new Error("oh no");
select(editor, el);
},
};
export function setupAuxiliaryToolbar (editor: Editor): void {
editor.toolbar.auxiliary.link.urlEl.addEventListener('input', event => {
const url = editor.toolbar.auxiliary.link.urlEl.value
const selectedEl = editor.contentEl
.querySelector<HTMLAnchorElement>('a[data-editor-selected]')
if (!selectedEl)
throw new Error('No pude encontrar el link para setear el enlace')
selectedEl.href = url
})
editor.toolbar.auxiliary.link.urlEl.addEventListener('keydown', event => {
if (event.keyCode == 13) event.preventDefault()
})
export function setupAuxiliaryToolbar(editor: Editor): void {
editor.toolbar.auxiliary.link.urlEl.addEventListener("input", (event) => {
const url = editor.toolbar.auxiliary.link.urlEl.value;
const selectedEl = editor.contentEl.querySelector<HTMLAnchorElement>(
"a[data-editor-selected]"
);
if (!selectedEl)
throw new Error("No pude encontrar el link para setear el enlace");
selectedEl.href = url;
});
editor.toolbar.auxiliary.link.urlEl.addEventListener("keydown", (event) => {
if (event.keyCode == 13) event.preventDefault();
});
}

View file

@ -1,49 +1,66 @@
import { Editor } from 'editor/editor'
import { EditorNode } from 'editor/types'
import { markNames, setAuxiliaryToolbar, clearSelected } from 'editor/utils'
import { Editor } from "editor/editor";
import { EditorNode } from "editor/types";
import { markNames, setAuxiliaryToolbar, clearSelected } from "editor/utils";
const hex = (x: string) => ("0" + parseInt(x).toString(16)).slice(-2)
const hex = (x: string) => ("0" + parseInt(x).toString(16)).slice(-2);
// https://stackoverflow.com/a/3627747
// TODO: cambiar por una solución más copada
function rgbToHex (rgb: string): string {
const matches = rgb.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/)
if (!matches) throw new Error('no pude parsear el rgb()')
return "#" + hex(matches[1]) + hex(matches[2]) + hex(matches[3])
function rgbToHex(rgb: string): string {
const matches = rgb.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/);
if (!matches) throw new Error("no pude parsear el rgb()");
return "#" + hex(matches[1]) + hex(matches[2]) + hex(matches[3]);
}
function select (editor: Editor, el: HTMLElement): void {
clearSelected(editor)
el.dataset.editorSelected = ''
editor.toolbar.auxiliary.mark.colorEl.value
= el.style.backgroundColor
? rgbToHex(el.style.backgroundColor)
: '#f206f9'
setAuxiliaryToolbar(editor, editor.toolbar.auxiliary.mark.parentEl)
function select(editor: Editor, el: HTMLElement): void {
clearSelected(editor);
el.dataset.editorSelected = "";
editor.toolbar.auxiliary.mark.colorEl.value = el.style.backgroundColor
? rgbToHex(el.style.backgroundColor)
: "#f206f9";
editor.toolbar.auxiliary.mark.textColorEl.value = el.style.color
? rgbToHex(el.style.color)
: "#000000";
setAuxiliaryToolbar(editor, editor.toolbar.auxiliary.mark.parentEl);
}
export const mark: EditorNode = {
selector: 'mark',
allowedChildren: [...markNames.filter(n => n !== 'mark'), 'text'],
handleEmpty: 'remove',
create: () => document.createElement('mark'),
onClick (editor, el) {
if (!(el instanceof HTMLElement))
throw new Error('oh no')
select(editor, el)
},
}
selector: "mark",
allowedChildren: [...markNames.filter((n) => n !== "mark"), "text"],
handleEmpty: "remove",
create: () => document.createElement("mark"),
onClick(editor, el) {
if (!(el instanceof HTMLElement)) throw new Error("oh no");
select(editor, el);
},
};
export function setupAuxiliaryToolbar (editor: Editor): void {
editor.toolbar.auxiliary.mark.colorEl.addEventListener('input', event => {
const color = editor.toolbar.auxiliary.mark.colorEl.value
const selectedEl = editor.contentEl
.querySelector<HTMLElement>('mark[data-editor-selected]')
if (!selectedEl)
throw new Error('No pude encontrar el mark para setear el color')
selectedEl.style.backgroundColor = color
})
editor.toolbar.auxiliary.mark.colorEl.addEventListener('keydown', event => {
if (event.keyCode == 13) event.preventDefault()
})
export function setupAuxiliaryToolbar(editor: Editor): void {
editor.toolbar.auxiliary.mark.colorEl.addEventListener("input", (event) => {
const color = editor.toolbar.auxiliary.mark.colorEl.value;
const selectedEl = editor.contentEl.querySelector<HTMLElement>(
"mark[data-editor-selected]"
);
if (!selectedEl)
throw new Error("No pude encontrar el mark para setear el color");
selectedEl.style.backgroundColor = color;
});
editor.toolbar.auxiliary.mark.textColorEl.addEventListener(
"input",
(event) => {
const color = editor.toolbar.auxiliary.mark.textColorEl.value;
const selectedEl = editor.contentEl.querySelector<HTMLElement>(
"mark[data-editor-selected]"
);
if (!selectedEl)
throw new Error(
"No pude encontrar el mark para setear el color del text"
);
selectedEl.style.color = color;
}
);
editor.toolbar.auxiliary.mark.colorEl.addEventListener("keydown", (event) => {
if (event.keyCode == 13) event.preventDefault();
});
}

View file

@ -1,96 +1,102 @@
import { Editor } from 'editor/editor'
import { EditorNode } from 'editor/types'
import { Editor } from "editor/editor";
import { EditorNode } from "editor/types";
import {
safeGetSelection, safeGetRangeAt,
moveChildren,
markNames,
} from 'editor/utils'
import { link } from 'editor/types/link'
import { mark } from 'editor/types/mark'
safeGetSelection,
safeGetRangeAt,
moveChildren,
markNames,
} from "editor/utils";
import { link } from "editor/types/link";
import { mark } from "editor/types/mark";
function makeMark (name: string, tag: string): EditorNode {
return {
selector: tag,
allowedChildren: [...markNames.filter(n => n !== name), 'text'],
handleEmpty: 'remove',
create: () => document.createElement(tag),
}
function makeMark(name: string, tag: string): EditorNode {
return {
selector: tag,
allowedChildren: [...markNames.filter((n) => n !== name), "text"],
handleEmpty: "remove",
create: () => document.createElement(tag),
};
}
// XXX: si agregás algo acá, agregalo a markNames
export const marks: { [propName: string]: EditorNode } = {
bold: makeMark('bold', 'strong'),
italic: makeMark('italic', 'em'),
deleted: makeMark('deleted', 'del'),
underline: makeMark('underline', 'u'),
sub: makeMark('sub', 'sub'),
super: makeMark('super', 'sup'),
mark,
link,
small: makeMark('small', 'small'),
}
bold: makeMark("bold", "strong"),
italic: makeMark("italic", "em"),
deleted: makeMark("deleted", "del"),
underline: makeMark("underline", "u"),
sub: makeMark("sub", "sub"),
super: makeMark("super", "sup"),
mark,
link,
small: makeMark("small", "small"),
};
function recursiveFilterSelection (
node: Element,
selection: Selection,
selector: string,
function recursiveFilterSelection(
node: Element,
selection: Selection,
selector: string
): Element[] {
let output: Element[] = []
for (const child of [...node.children]) {
if (child.matches(selector)
&& selection.containsNode(child)
) output.push(child)
output = [...output, ...recursiveFilterSelection(child, selection, selector)]
}
return output
let output: Element[] = [];
for (const child of [...node.children]) {
if (child.matches(selector) && selection.containsNode(child))
output.push(child);
output = [
...output,
...recursiveFilterSelection(child, selection, selector),
];
}
return output;
}
export function setupButtons (editor: Editor): void {
for (const [ name, type ] of Object.entries(marks)) {
const buttonEl = editor.toolbarEl.querySelector(`[data-editor-button="mark-${name}"]`)
if (!buttonEl) continue
buttonEl.addEventListener("click", event => {
event.preventDefault()
export function setupButtons(editor: Editor): void {
for (const [name, type] of Object.entries(marks)) {
const buttonEl = editor.toolbarEl.querySelector(
`[data-editor-button="mark-${name}"]`
);
if (!buttonEl) continue;
buttonEl.addEventListener("click", (event) => {
event.preventDefault();
const sel = safeGetSelection(editor)
if (!sel) return
const range = safeGetRangeAt(sel)
if (!range) return
const sel = safeGetSelection(editor);
if (!sel) return;
const range = safeGetRangeAt(sel);
if (!range) return;
let parentEl = range.commonAncestorContainer
while (!(parentEl instanceof Element)) {
if (!parentEl.parentElement) return
parentEl = parentEl.parentElement
}
let parentEl = range.commonAncestorContainer;
while (!(parentEl instanceof Element)) {
if (!parentEl.parentElement) return;
parentEl = parentEl.parentElement;
}
const existingMarks = recursiveFilterSelection(
parentEl,
sel,
type.selector,
)
console.debug('marks encontradas:', existingMarks)
const existingMarks = recursiveFilterSelection(
parentEl,
sel,
type.selector
);
console.debug("marks encontradas:", existingMarks);
if (existingMarks.length > 0) {
const mark = existingMarks[0]
if (!mark.parentElement)
throw new Error(':/')
moveChildren(mark, mark.parentElement, mark)
mark.parentElement.removeChild(mark)
} else {
if (range.commonAncestorContainer === editor.contentEl)
// TODO: mostrar error
return console.error("No puedo marcar cosas a través de distintos bloques!")
if (existingMarks.length > 0) {
const mark = existingMarks[0];
if (!mark.parentElement) throw new Error(":/");
moveChildren(mark, mark.parentElement, mark);
mark.parentElement.removeChild(mark);
} else {
if (range.commonAncestorContainer === editor.contentEl)
// TODO: mostrar error
return console.error(
"No puedo marcar cosas a través de distintos bloques!"
);
const tagEl = type.create(editor)
type.onClick && type.onClick(editor, tagEl)
const tagEl = type.create(editor);
type.onClick && type.onClick(editor, tagEl);
tagEl.appendChild(range.extractContents())
tagEl.appendChild(range.extractContents());
range.insertNode(tagEl)
range.selectNode(tagEl)
}
range.insertNode(tagEl);
range.selectNode(tagEl);
}
return false
})
}
return false;
});
}
}

View file

@ -1,206 +1,230 @@
import * as ActiveStorage from '@rails/activestorage'
import { Editor } from 'editor/editor'
import { EditorNode, getValidParentInSelection } from 'editor/types'
import * as ActiveStorage from "@rails/activestorage";
import { Editor } from "editor/editor";
import { EditorNode, getValidParentInSelection } from "editor/types";
import {
safeGetSelection, safeGetRangeAt,
markNames, parentBlockNames,
setAuxiliaryToolbar, clearSelected,
} from 'editor/utils'
safeGetSelection,
safeGetRangeAt,
markNames,
parentBlockNames,
setAuxiliaryToolbar,
clearSelected,
} from "editor/utils";
function uploadFile (file: File): Promise<string> {
return new Promise((resolve, reject) => {
const upload = new ActiveStorage.DirectUpload(
file,
origin + '/rails/active_storage/direct_uploads',
)
function uploadFile(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const upload = new ActiveStorage.DirectUpload(
file,
origin + "/rails/active_storage/direct_uploads"
);
upload.create((error: any, blob: any) => {
if (error) {
reject(error)
} else {
const url = `${origin}/rails/active_storage/blobs/${blob.signed_id}/${blob.filename}`
resolve(url)
}
})
})
upload.create((error: any, blob: any) => {
if (error) {
reject(error);
} else {
const url = `${origin}/rails/active_storage/blobs/${blob.signed_id}/${blob.filename}`;
resolve(url);
}
});
});
}
function getAlt (multimediaInnerEl: HTMLElement): string | null {
switch (multimediaInnerEl.tagName) {
case 'VIDEO':
case 'AUDIO':
return multimediaInnerEl.getAttribute('aria-label')
case 'IMG':
return (multimediaInnerEl as HTMLImageElement).alt
case 'IFRAME':
return multimediaInnerEl.title
default:
throw new Error('no pude conseguir el alt')
}
function getAlt(multimediaInnerEl: HTMLElement): string | null {
switch (multimediaInnerEl.tagName) {
case "VIDEO":
case "AUDIO":
return multimediaInnerEl.getAttribute("aria-label");
case "IMG":
return (multimediaInnerEl as HTMLImageElement).alt;
case "IFRAME":
return multimediaInnerEl.title;
default:
throw new Error("no pude conseguir el alt");
}
}
function setAlt (multimediaInnerEl: HTMLElement, value: string): void {
switch (multimediaInnerEl.tagName) {
case 'VIDEO':
case 'AUDIO':
multimediaInnerEl.setAttribute('aria-label', value)
break
case 'IMG':
(multimediaInnerEl as HTMLImageElement).alt = value
break
case 'IFRAME':
multimediaInnerEl.title = value
break
default:
throw new Error('no pude setear el alt')
}
function setAlt(multimediaInnerEl: HTMLElement, value: string): void {
switch (multimediaInnerEl.tagName) {
case "VIDEO":
case "AUDIO":
multimediaInnerEl.setAttribute("aria-label", value);
break;
case "IMG":
(multimediaInnerEl as HTMLImageElement).alt = value;
break;
case "IFRAME":
multimediaInnerEl.title = value;
break;
default:
throw new Error("no pude setear el alt");
}
}
function select (editor: Editor, el: HTMLElement): void {
clearSelected(editor)
el.dataset.editorSelected = ''
function select(editor: Editor, el: HTMLElement): void {
clearSelected(editor);
el.dataset.editorSelected = "";
const innerEl = el.querySelector<HTMLElement>('[data-multimedia-inner]')
if (!innerEl) throw new Error('No hay multimedia válida')
if (innerEl.tagName === "P") {
editor.toolbar.auxiliary.multimedia.altEl.value = "";
editor.toolbar.auxiliary.multimedia.altEl.disabled = true;
} else {
editor.toolbar.auxiliary.multimedia.altEl.value = getAlt(innerEl) || "";
editor.toolbar.auxiliary.multimedia.altEl.disabled = false;
}
const innerEl = el.querySelector<HTMLElement>("[data-multimedia-inner]");
if (!innerEl) throw new Error("No hay multimedia válida");
if (innerEl.tagName === "P") {
editor.toolbar.auxiliary.multimedia.altEl.value = "";
editor.toolbar.auxiliary.multimedia.altEl.disabled = true;
} else {
editor.toolbar.auxiliary.multimedia.altEl.value = getAlt(innerEl) || "";
editor.toolbar.auxiliary.multimedia.altEl.disabled = false;
}
setAuxiliaryToolbar(editor, editor.toolbar.auxiliary.multimedia.parentEl)
setAuxiliaryToolbar(editor, editor.toolbar.auxiliary.multimedia.parentEl);
}
export const multimedia: EditorNode = {
selector: 'figure[data-multimedia]',
allowedChildren: 'ignore-children',
handleEmpty: 'remove',
create: () => {
const figureEl = document.createElement('figure')
figureEl.dataset.multimedia = ''
figureEl.contentEditable = 'false'
selector: "figure[data-multimedia]",
allowedChildren: "ignore-children",
handleEmpty: "remove",
create: () => {
const figureEl = document.createElement("figure");
figureEl.dataset.multimedia = "";
figureEl.contentEditable = "false";
const placeholderEl = document.createElement('p')
placeholderEl.dataset.multimediaInner = ''
// TODO i18n
placeholderEl.append('¡Clickeame para subir un archivo!')
figureEl.appendChild(placeholderEl)
const placeholderEl = document.createElement("p");
placeholderEl.dataset.multimediaInner = "";
// TODO i18n
placeholderEl.append("¡Clickeame para subir un archivo!");
figureEl.appendChild(placeholderEl);
const descriptionEl = document.createElement('figcaption')
descriptionEl.contentEditable = 'true'
// TODO i18n
descriptionEl.append('Escribí acá la descripción del archivo.')
figureEl.appendChild(descriptionEl)
const descriptionEl = document.createElement("figcaption");
descriptionEl.contentEditable = "true";
// TODO i18n
descriptionEl.append("Escribí acá la descripción del archivo.");
figureEl.appendChild(descriptionEl);
return figureEl
},
onClick (editor, el) {
if (!(el instanceof HTMLElement))
throw new Error('oh no')
select(editor, el)
},
}
function createElementWithFile (url: string, type: string): HTMLElement {
if (type.match(/^image\/.+$/)) {
const el = document.createElement('img')
el.dataset.multimediaInner = ''
el.src = url
return el
} else if (type.match(/^video\/.+$/)) {
const el = document.createElement('video')
el.controls = true
el.dataset.multimediaInner = ''
el.src = url
return el
} else if (type.match(/^audio\/.+$/)) {
const el = document.createElement('audio')
el.controls = true
el.dataset.multimediaInner = ''
el.src = url
return el
} else if (type.match(/^application\/pdf$/)) {
const el = document.createElement('iframe')
el.dataset.multimediaInner = ''
el.src = url
return el
} else {
// TODO: chequear si el archivo es válido antes de subir
throw new Error('Tipo de archivo no reconocido')
}
return figureEl;
},
onClick(editor, el) {
if (!(el instanceof HTMLElement)) throw new Error("oh no");
select(editor, el);
},
};
function createElementWithFile(url: string, type: string): HTMLElement {
if (type.match(/^image\/.+$/)) {
const el = document.createElement("img");
el.dataset.multimediaInner = "";
el.src = url;
return el;
} else if (type.match(/^video\/.+$/)) {
const el = document.createElement("video");
el.controls = true;
el.dataset.multimediaInner = "";
el.src = url;
return el;
} else if (type.match(/^audio\/.+$/)) {
const el = document.createElement("audio");
el.controls = true;
el.dataset.multimediaInner = "";
el.src = url;
return el;
} else if (type.match(/^application\/pdf$/)) {
const el = document.createElement("iframe");
el.dataset.multimediaInner = "";
el.src = url;
return el;
} else {
// TODO: chequear si el archivo es válido antes de subir
throw new Error("Tipo de archivo no reconocido");
}
}
export function setupAuxiliaryToolbar (editor: Editor): void {
editor.toolbar.auxiliary.multimedia.uploadEl.addEventListener('click', event => {
const files = editor.toolbar.auxiliary.multimedia.fileEl.files
if (!files || !files.length) throw new Error('no hay archivos para subir')
const file = files[0]
export function setupAuxiliaryToolbar(editor: Editor): void {
editor.toolbar.auxiliary.multimedia.uploadEl.addEventListener(
"click",
(event) => {
const files = editor.toolbar.auxiliary.multimedia.fileEl.files;
if (!files || !files.length)
throw new Error("no hay archivos para subir");
const file = files[0];
const selectedEl = editor.contentEl
.querySelector<HTMLElement>('figure[data-editor-selected]')
if (!selectedEl)
throw new Error('No pude encontrar el elemento para setear el archivo')
const selectedEl = editor.contentEl.querySelector<HTMLElement>(
"figure[data-editor-selected]"
);
if (!selectedEl)
throw new Error("No pude encontrar el elemento para setear el archivo");
selectedEl.dataset.editorLoading = ''
uploadFile(file)
.then(url => {
const innerEl = selectedEl.querySelector('[data-multimedia-inner]')
if (!innerEl) throw new Error('No hay multimedia a reemplazar')
selectedEl.dataset.editorLoading = "";
uploadFile(file)
.then((url) => {
const innerEl = selectedEl.querySelector("[data-multimedia-inner]");
if (!innerEl) throw new Error("No hay multimedia a reemplazar");
const el = createElementWithFile(url, file.type)
setAlt(el, editor.toolbar.auxiliary.multimedia.altEl.value)
selectedEl.replaceChild(el, innerEl)
const el = createElementWithFile(url, file.type);
setAlt(el, editor.toolbar.auxiliary.multimedia.altEl.value);
selectedEl.replaceChild(el, innerEl);
select(editor, selectedEl)
select(editor, selectedEl);
delete selectedEl.dataset.editorError
})
.catch(err => {
console.error(err)
// TODO: mostrar error
selectedEl.dataset.editorError = ''
})
.finally(() => { delete selectedEl.dataset.editorLoading })
})
delete selectedEl.dataset.editorError;
})
.catch((err) => {
console.error(err);
// TODO: mostrar error
selectedEl.dataset.editorError = "";
})
.finally(() => {
delete selectedEl.dataset.editorLoading;
});
}
);
editor.toolbar.auxiliary.multimedia.removeEl.addEventListener('click', event => {
const selectedEl = editor.contentEl
.querySelector<HTMLElement>('figure[data-editor-selected]')
if (!selectedEl)
throw new Error('No pude encontrar el elemento para borrar')
editor.toolbar.auxiliary.multimedia.removeEl.addEventListener(
"click",
(event) => {
const selectedEl = editor.contentEl.querySelector<HTMLElement>(
"figure[data-editor-selected]"
);
if (!selectedEl)
throw new Error("No pude encontrar el elemento para borrar");
selectedEl.parentElement?.removeChild(selectedEl)
setAuxiliaryToolbar(editor, null)
})
selectedEl.parentElement?.removeChild(selectedEl);
setAuxiliaryToolbar(editor, null);
}
);
editor.toolbar.auxiliary.multimedia.altEl.addEventListener('input', event => {
const selectedEl = editor.contentEl
.querySelector<HTMLAnchorElement>('figure[data-editor-selected]')
if (!selectedEl)
throw new Error('No pude encontrar el multimedia para setear el alt')
editor.toolbar.auxiliary.multimedia.altEl.addEventListener(
"input",
(event) => {
const selectedEl = editor.contentEl.querySelector<HTMLAnchorElement>(
"figure[data-editor-selected]"
);
if (!selectedEl)
throw new Error("No pude encontrar el multimedia para setear el alt");
const innerEl = selectedEl.querySelector<HTMLElement>('[data-multimedia-inner]')
if (!innerEl) throw new Error('No hay multimedia a para setear el alt')
const innerEl = selectedEl.querySelector<HTMLElement>(
"[data-multimedia-inner]"
);
if (!innerEl) throw new Error("No hay multimedia a para setear el alt");
setAlt(innerEl, editor.toolbar.auxiliary.multimedia.altEl.value)
})
editor.toolbar.auxiliary.multimedia.altEl.addEventListener('keydown', event => {
if (event.keyCode == 13) event.preventDefault()
})
setAlt(innerEl, editor.toolbar.auxiliary.multimedia.altEl.value);
}
);
editor.toolbar.auxiliary.multimedia.altEl.addEventListener(
"keydown",
(event) => {
if (event.keyCode == 13) event.preventDefault();
}
);
}
export function setupButtons (editor: Editor): void {
const buttonEl = editor.toolbarEl.querySelector('[data-editor-button="multimedia"]')
if (!buttonEl) throw new Error('No encontre el botón de multimedia')
buttonEl.addEventListener('click', event => {
event.preventDefault()
export function setupButtons(editor: Editor): void {
const buttonEl = editor.toolbarEl.querySelector(
'[data-editor-button="multimedia"]'
);
if (!buttonEl) throw new Error("No encontre el botón de multimedia");
buttonEl.addEventListener("click", (event) => {
event.preventDefault();
const list = getValidParentInSelection({ editor, type: 'multimedia' })
const list = getValidParentInSelection({ editor, type: "multimedia" });
const el = multimedia.create(editor)
list[0].insertBefore(el, list[1].nextElementSibling)
select(editor, el)
const el = multimedia.create(editor);
list[0].insertBefore(el, list[1].nextElementSibling);
select(editor, el);
return false
})
return false;
});
}

View file

@ -1,70 +1,78 @@
import { Editor } from 'editor/editor'
import { Editor } from "editor/editor";
import {
safeGetSelection, safeGetRangeAt,
moveChildren,
blockNames, parentBlockNames,
} from 'editor/utils'
import { EditorNode, getType, getValidParentInSelection } from 'editor/types'
safeGetSelection,
safeGetRangeAt,
moveChildren,
blockNames,
parentBlockNames,
} from "editor/utils";
import { EditorNode, getType, getValidParentInSelection } from "editor/types";
function makeParentBlock (tag: string, create: EditorNode["create"]): EditorNode {
return {
selector: tag,
allowedChildren: [...blockNames, 'multimedia'],
handleEmpty: 'remove',
create,
}
function makeParentBlock(
tag: string,
create: EditorNode["create"]
): EditorNode {
return {
selector: tag,
allowedChildren: [...blockNames, "multimedia"],
handleEmpty: "remove",
create,
};
}
// TODO: añadir blockquote
// XXX: si agregás algo acá, probablemente le quieras hacer un botón
// en app/views/posts/attributes/_content.haml
export const parentBlocks: { [propName: string]: EditorNode } = {
left: makeParentBlock('div[data-align=left]', () => {
const el = document.createElement('div')
el.dataset.align = 'left'
return el
}),
center: makeParentBlock('div[data-align=center]', () => {
const el = document.createElement('div')
el.dataset.align = 'center'
return el
}),
right: makeParentBlock('div[data-align=right]', () => {
const el = document.createElement('div')
el.dataset.align = 'right'
return el
}),
}
export function setupButtons (editor: Editor): void {
for (const [ name, type ] of Object.entries(parentBlocks)) {
const buttonEl = editor.toolbarEl.querySelector(
`[data-editor-button="parentBlock-${name}"]`
)
if (!buttonEl) continue
buttonEl.addEventListener("click", event => {
event.preventDefault()
// TODO: Esto solo mueve el bloque en el que está el final de la selección
// (anchorNode). quizás lo podemos hacer al revés (iterar desde contentEl
// para encontrar los bloques que están seleccionados y moverlos/cambiarles
// el parentBlock)
const list = getValidParentInSelection({ editor, type: name })
const replacementEl = type.create(editor)
if (list[0] == editor.contentEl) {
// no está en un parentBlock
editor.contentEl.insertBefore(replacementEl, list[1])
replacementEl.appendChild(list[1])
} else {
// está en un parentBlock
moveChildren(list[0], replacementEl, null)
editor.contentEl.replaceChild(replacementEl, list[0])
}
window.getSelection()?.collapse(replacementEl)
return false
})
}
left: makeParentBlock("div[data-align=left]", () => {
const el = document.createElement("div");
el.dataset.align = "left";
el.style.textAlign = "left";
return el;
}),
center: makeParentBlock("div[data-align=center]", () => {
const el = document.createElement("div");
el.dataset.align = "center";
el.style.textAlign = "center";
return el;
}),
right: makeParentBlock("div[data-align=right]", () => {
const el = document.createElement("div");
el.dataset.align = "right";
el.style.textAlign = "right";
return el;
}),
};
export function setupButtons(editor: Editor): void {
for (const [name, type] of Object.entries(parentBlocks)) {
const buttonEl = editor.toolbarEl.querySelector(
`[data-editor-button="parentBlock-${name}"]`
);
if (!buttonEl) continue;
buttonEl.addEventListener("click", (event) => {
event.preventDefault();
// TODO: Esto solo mueve el bloque en el que está el final de la selección
// (anchorNode). quizás lo podemos hacer al revés (iterar desde contentEl
// para encontrar los bloques que están seleccionados y moverlos/cambiarles
// el parentBlock)
const list = getValidParentInSelection({ editor, type: name });
const replacementEl = type.create(editor);
if (list[0] == editor.contentEl) {
// no está en un parentBlock
editor.contentEl.insertBefore(replacementEl, list[1]);
replacementEl.appendChild(list[1]);
} else {
// está en un parentBlock
moveChildren(list[0], replacementEl, null);
editor.contentEl.replaceChild(replacementEl, list[0]);
}
window.getSelection()?.collapse(replacementEl);
return false;
});
}
}

View file

@ -1,77 +1,101 @@
import { Editor } from 'editor/editor'
import { Editor } from "editor/editor";
export const blockNames = ['paragraph', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'unordered_list', 'ordered_list']
export const markNames = ['bold', 'italic', 'deleted', 'underline', 'sub', 'super', 'mark', 'link', 'small']
export const parentBlockNames = ['left', 'center', 'right']
export const blockNames = [
"paragraph",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"unordered_list",
"ordered_list",
];
export const markNames = [
"bold",
"italic",
"deleted",
"underline",
"sub",
"super",
"mark",
"link",
"small",
];
export const parentBlockNames = ["left", "center", "right"];
export function moveChildren (from: Element, to: Element, toRef: Node | null) {
while (from.firstChild) to.insertBefore(from.firstChild, toRef)
export function moveChildren(from: Element, to: Element, toRef: Node | null) {
while (from.firstChild) to.insertBefore(from.firstChild, toRef);
}
export function isDirectChild (node: Node, supposedChild: Node): boolean {
for (const child of node.childNodes) {
if (child == supposedChild) return true
}
return false
export function isDirectChild(node: Node, supposedChild: Node): boolean {
for (const child of node.childNodes) {
if (child == supposedChild) return true;
}
return false;
}
export function safeGetSelection (editor: Editor): Selection | null {
const sel = window.getSelection()
if (!sel) return null
// XXX: no damos la selección si esta fuera o _es_ el contentEl, ¿quizás
// deberíamos mostrar un error?
if (
!editor.contentEl.contains(sel.anchorNode)
|| !editor.contentEl.contains(sel.focusNode)
|| sel.anchorNode == editor.contentEl
|| sel.focusNode == editor.contentEl
) return null
return sel
export function safeGetSelection(editor: Editor): Selection | null {
const sel = window.getSelection();
if (!sel) return null;
// XXX: no damos la selección si esta fuera o _es_ el contentEl, ¿quizás
// deberíamos mostrar un error?
if (
!editor.contentEl.contains(sel.anchorNode) ||
!editor.contentEl.contains(sel.focusNode) ||
sel.anchorNode == editor.contentEl ||
sel.focusNode == editor.contentEl
)
return null;
return sel;
}
export function safeGetRangeAt (selection: Selection, num = 0): Range | null {
try {
return selection.getRangeAt(num)
} catch (error) {
return null
}
export function safeGetRangeAt(selection: Selection, num = 0): Range | null {
try {
return selection.getRangeAt(num);
} catch (error) {
return null;
}
}
interface SplitNode {
range: Range,
node: Node,
range: Range;
node: Node;
}
export function splitNode (node: Element, range: Range): [SplitNode, SplitNode] {
const [left, right] = [
{ range: document.createRange(), node: node.cloneNode(false) },
{ range: document.createRange(), node: node.cloneNode(false) },
]
export function splitNode(node: Element, range: Range): [SplitNode, SplitNode] {
const [left, right] = [
{ range: document.createRange(), node: node.cloneNode(false) },
{ range: document.createRange(), node: node.cloneNode(false) },
];
if (node.firstChild) left.range.setStartBefore(node.firstChild)
left.range.setEnd(range.startContainer, range.startOffset)
left.range.surroundContents(left.node)
if (node.firstChild) left.range.setStartBefore(node.firstChild);
left.range.setEnd(range.startContainer, range.startOffset);
left.range.surroundContents(left.node);
right.range.setStart(range.endContainer, range.endOffset)
if (node.lastChild) right.range.setEndAfter(node.lastChild)
right.range.surroundContents(right.node)
right.range.setStart(range.endContainer, range.endOffset);
if (node.lastChild) right.range.setEndAfter(node.lastChild);
right.range.surroundContents(right.node);
if (!node.parentElement)
throw new Error('No pude separar los nodos por que no tiene parentNode')
if (!node.parentElement)
throw new Error("No pude separar los nodos por que no tiene parentNode");
moveChildren(node, node.parentElement, node)
node.parentElement.removeChild(node)
moveChildren(node, node.parentElement, node);
node.parentElement.removeChild(node);
return [left, right]
return [left, right];
}
export function setAuxiliaryToolbar (editor: Editor, bar: HTMLElement | null): void {
for (const { parentEl } of Object.values(editor.toolbar.auxiliary)) {
delete parentEl.dataset.editorAuxiliaryActive
}
if (bar) bar.dataset.editorAuxiliaryActive = 'active'
export function setAuxiliaryToolbar(
editor: Editor,
bar: HTMLElement | null
): void {
for (const { parentEl } of Object.values(editor.toolbar.auxiliary)) {
delete parentEl.dataset.editorAuxiliaryActive;
}
if (bar) bar.dataset.editorAuxiliaryActive = "active";
}
export function clearSelected (editor: Editor): void {
const selectedEl = editor.contentEl.querySelector('[data-editor-selected]')
if (selectedEl) delete (selectedEl as HTMLElement).dataset.editorSelected
export function clearSelected(editor: Editor): void {
const selectedEl = editor.contentEl.querySelector("[data-editor-selected]");
if (selectedEl) delete (selectedEl as HTMLElement).dataset.editorSelected;
}

View file

@ -18,6 +18,7 @@ import 'etc'
import Rails from '@rails/ujs'
import Turbolinks from 'turbolinks'
import * as ActiveStorage from '@rails/activestorage'
import 'chartkick/chart.js'
Rails.start()
Turbolinks.start()

View file

@ -30,15 +30,17 @@ class BacktraceJob < ApplicationJob
# Encuentra el código fuente del error
source = data.dig('sourcesContent', data['sources']&.index(backtrace['file']))&.split("\n")
backtrace['function'] = source[backtrace['line'] - 1] if source.present?
# XXX: Elimina la sangría aunque cambie las columnas porque
# eso lo vamos a ver en el archivo fuente directo.
backtrace['function'] = source[backtrace['line'] - 1].strip if source.present?
end
end
end
begin
raise BacktraceException, "#{origin}: #{params['errors']&.first&.dig('message')}"
raise BacktraceException, "#{origin}: #{message}"
rescue BacktraceException => e
ExceptionNotifier.notify_exception(e, data: { site: site.name, params: params, _backtrace: true })
ExceptionNotifier.notify_exception(e, data: { site: site.name, params: params, javascript_backtrace: true })
end
end
@ -102,4 +104,9 @@ class BacktraceJob < ApplicationJob
rescue URI::Error
params.dig('context', 'url')
end
# @return [String,Nil]
def message
@message ||= params['errors']&.first&.dig('message')
end
end

View file

@ -5,16 +5,33 @@ class DeployJob < ApplicationJob
class DeployException < StandardError; end
# rubocop:disable Metrics/MethodLength
def perform(site, notify = true)
def perform(site, notify = true, time = Time.now)
ActiveRecord::Base.connection_pool.with_connection do
@site = Site.find(site)
@site.update_attribute :status, 'building'
# Si ya hay una tarea corriendo, aplazar esta. Si estuvo
# esperando más de 10 minutos, recuperar el estado anterior.
#
# Como el trabajo actual se aplaza al siguiente, arrastrar la
# hora original para poder ir haciendo timeouts.
if @site.building?
if 10.minutes.ago >= time
@site.update status: 'waiting'
raise DeployException,
"#{@site.name} la tarea estuvo más de 10 minutos esperando, volviendo al estado original"
end
DeployJob.perform_in(60, site, notify, time)
return
end
@site.update status: 'building'
# Asegurarse que DeployLocal sea el primero!
@deployed = { deploy_local: deploy_locally }
# No es opcional
unless @deployed[:deploy_local]
@site.update_attribute :status, 'waiting'
@site.update status: 'waiting'
notify_usuaries if notify
# Hacer fallar la tarea
@ -22,8 +39,11 @@ class DeployJob < ApplicationJob
end
deploy_others
# Volver a la espera
@site.update status: 'waiting'
notify_usuaries if notify
@site.update_attribute :status, 'waiting'
end
end
# rubocop:enable Metrics/MethodLength

View file

@ -0,0 +1,260 @@
# frozen_string_literal: true
# Notifica excepciones a una instancia de Gitlab, como incidencias
# nuevas o como comentarios a las incidencias pre-existentes.
class GitlabNotifierJob < ApplicationJob
include ExceptionNotifier::BacktraceCleaner
# Variables que vamos a acceder luego
attr_reader :exception, :options, :issue_data, :cached
queue_as :low_priority
# @param [Exception] la excepción lanzada
# @param [Hash] opciones de ExceptionNotifier
def perform(exception, **options)
@exception = exception
@options = options
@issue_data = { count: 1 }
# Necesitamos saber si el issue ya existía
@cached = false
# Traemos los datos desde la caché si existen, sino generamos un
# issue nuevo e inicializamos la caché
@issue_data = Rails.cache.fetch(cache_key) do
issue = client.new_issue confidential: true, title: title, description: description, issue_type: 'incident'
@cached = true
{
count: 1,
issue: issue['iid'],
user_agents: [user_agent].compact,
params: [request&.filtered_parameters].compact,
urls: [url].compact
}
end
# No seguimos actualizando si acabamos de generar el issue
return if cached
# Incrementar la cuenta de veces que ocurrió
issue_data[:count] += 1
# Guardar información útil
issue_data[:urls] << url unless issue_data[:urls].include? url
issue_data[:user_agents] << user_agent unless issue_data[:user_agents].include? user_agent
# Editar el título para que incluya la cuenta de eventos
client.edit_issue(iid: issue_data[:issue], title: title, state_event: 'reopen')
# Agregar un comentario con la información posiblemente nueva
client.new_note(iid: issue_data[:issue], body: body)
# Guardar para después
Rails.cache.write(cache_key, issue_data)
# Si este trabajo genera una excepción va a entrar en un loop, así que
# la notificamos por correo
rescue Exception => e
email_notification.call(e)
email_notification.call(exception, options)
end
private
# Notificar por correo
#
# @return [ExceptionNotifier::EmailNotifier]
def email_notification
@email_notification ||= ExceptionNotifier::EmailNotifier.new(email_prefix: '[ERROR] ', sender_address: ENV['DEFAULT_FROM'], exception_recipients: ENV['EXCEPTION_TO'])
end
# La llave en la cache tiene en cuenta la excepción, el mensaje, la
# ruta del backtrace y los errores de JS
#
# @return [String]
def cache_key
@cache_key ||= [
exception.class.name,
Digest::SHA1.hexdigest(exception.message),
Digest::SHA1.hexdigest(backtrace&.first.to_s),
Digest::SHA1.hexdigest(options.dig(:data, :params, 'errors').to_s)
].join('/')
end
# Define si es una excepción de javascript o local
#
# @see BacktraceJob
# @return [Boolean]
def javascript?
@javascript ||= options.dig(:data, :javascript_backtrace).present?
end
# Título
#
# @return [String]
def title
@title ||= ''.dup.tap do |t|
t << "[#{exception.class}] " unless javascript?
t << exception.message
t << " [#{issue_data[:count]}]"
end
end
# Descripción
#
# @return [String]
def description
@description ||= ''.dup.tap do |d|
d << request_section
d << javascript_section
d << javascript_footer
d << backtrace_section
d << data_section
end
end
# Comentario
#
# @return [String]
def body
@body ||= ''.dup.tap do |b|
b << request_section
b << javascript_footer
b << data_section
end
end
# Cadena de archivos donde se produjo el error
#
# @return [Array,Nil]
def backtrace
@backtrace ||= exception.backtrace ? clean_backtrace(exception) : nil
end
# Entorno del error
#
# @return [Hash]
def env
options[:env]
end
# Genera una petición a partir del entorno
#
# @return [ActionDispatch::Request]
def request
@request ||= ActionDispatch::Request.new(env) if env.present?
end
# Cliente de la API de Gitlab
#
# @return [GitlabApiClient]
def client
@client ||= GitlabApiClient.new
end
# Muestra información de la petición
#
# @return [String]
def request_section
return '' unless request
<<~REQUEST
# Request
```
#{request.request_method} #{url}
#{pp request.filtered_parameters}
```
REQUEST
end
# Muestra información de JavaScript
#
# @return [String]
def javascript_section
return '' unless javascript?
options.dig(:data, :params, 'errors')&.map do |error|
# Algunos errores no son excepciones (?)
error['type'] = 'undefined' if error['type'].blank?
<<~JAVASCRIPT
## #{error['type']}: #{error['message']}
```
#{Terminal::Table.new headings: error['backtrace'].first.keys, rows: error['backtrace'].map(&:values)}
```
JAVASCRIPT
end&.join
end
# Muestra información de la visita que generó el error en JS
#
# @return [String]
def javascript_footer
return '' unless javascript?
<<~JAVASCRIPT
#{user_agent}
<#{url}>
JAVASCRIPT
end
# Muestra el historial del error en Ruby
#
# @return [String]
def backtrace_section
return '' if javascript?
return '' unless backtrace
<<~BACKTRACE
## Backtrace
```
#{backtrace.join("\n")}
```
BACKTRACE
end
# Muestra datos extra de la visita
#
# @return [String]
def data_section
return '' unless options[:data]
<<~DATA
## Data
```
#{pp options[:data]}
```
DATA
end
# Obtiene el UA de este error
#
# @return [String]
def user_agent
@user_agent ||= options.dig(:data, :params, 'context', 'userAgent') if javascript?
@user_agent ||= request.headers['user-agent'] if request
@user_agent
end
# Obtiene la URL actual
#
# @return [String]
def url
@url ||= request&.url || options.dig(:data, :params, 'context', 'url')
end
end

55
app/jobs/periodic_job.rb Normal file
View file

@ -0,0 +1,55 @@
# frozen_string_literal: true
# Una tarea que se corre periódicamente
class PeriodicJob < ApplicationJob
class RunAgainException < StandardError; end
STARTING_INTERVAL = Stat::INTERVALS.first
# Tener el sitio a mano
attr_reader :site
# Descartar y notificar si pasó algo más.
#
# XXX: En realidad deberíamos seguir reintentando?
discard_on(StandardError) do |_, error|
ExceptionNotifier.notify_exception(error)
end
# Correr indefinidamente una vez por hora.
#
# XXX: El orden importa, si el descarte viene después, nunca se va a
# reintentar.
retry_on(PeriodicJob::RunAgainException, wait: 1.try(STARTING_INTERVAL), attempts: Float::INFINITY, jitter: 0)
private
# Las clases que implementen esta tienen que usar este método al
# terminar.
def run_again!
raise PeriodicJob::RunAgainException, 'Reintentando'
end
# El intervalo de inicio
#
# @return [Symbol]
def starting_interval
STARTING_INTERVAL
end
# La última recolección de estadísticas o empezar desde el principio
# de los tiempos.
#
# @return [Stat]
def last_stat
@last_stat ||= site.stats.where(name: stat_name).last ||
site.stats.build(created_at: Time.new(1970, 1, 1))
end
# Devuelve el comienzo del intervalo
#
# @return [Time]
def beginning_of_interval
@beginning_of_interval ||= last_stat.created_at.try(:"beginning_of_#{starting_interval}")
end
end

View file

@ -0,0 +1,67 @@
# frozen_string_literal: true
# Genera resúmenes de información para poder mostrar estadísticas y se
# corre regularmente a sí misma.
class StatCollectionJob < ApplicationJob
STAT_NAME = 'stat_collection_job'
def perform(site_id:, once: true)
@site = Site.find site_id
scope.rollup('builds', **options)
scope.rollup('space_used', **options) do |rollup|
rollup.average(:bytes)
end
scope.rollup('build_time', **options) do |rollup|
rollup.average(:seconds)
end
# XXX: Es correcto promediar promedios?
Stat::INTERVALS.reduce do |previous, current|
rollup(name: 'builds', interval_previous: previous, interval: current)
rollup(name: 'space_used', interval_previous: previous, interval: current, operation: :average)
rollup(name: 'build_time', interval_previous: previous, interval: current, operation: :average)
current
end
# Registrar que se hicieron todas las recolecciones
site.stats.create! name: STAT_NAME
run_again! unless once
end
private
# Genera un rollup recursivo en base al período anterior y aplica una
# operación.
#
# @return [NilClass]
def rollup(name:, interval_previous:, interval:, operation: :sum)
Rollup.where(name: name, interval: interval_previous)
.where_dimensions(site_id: site.id)
.group("dimensions->'site_id'")
.rollup(name, interval: interval, update: true) do |rollup|
rollup.try(:operation, :value)
end
end
# Los registros a procesar
#
# @return [ActiveRecord::Relation]
def scope
@scope ||= site.build_stats
.jekyll
.where('created_at => ?', beginning_of_interval)
.group(:site_id)
end
# Las opciones por defecto
#
# @return [Hash]
def options
@options ||= { interval: starting_interval, update: true }
end
end

View file

@ -0,0 +1,106 @@
# frozen_string_literal: true
# Procesar una lista de URIs para una lista de dominios. Esto nos
# permite procesar estadísticas a demanda.
#
# Hay varias cosas acá que van a convertirse en métodos propios, como la
# detección de URIs de un sitio (aunque la versión actual detecta todas
# las páginas y no solo las de posts como tenemos planeado, hay que
# resolver eso).
#
# Los hostnames de un sitio van a poder obtenerse a partir de
# Site#hostnames con la garantía de que son únicos.
class UriCollectionJob < PeriodicJob
# Ignoramos imágenes porque suelen ser demasiadas y no aportan a las
# estadísticas.
IMAGES = %w[.png .jpg .jpeg .gif .webp].freeze
STAT_NAME = 'uri_collection_job'
def perform(site_id:, once: true)
@site = Site.find site_id
hostnames.each do |hostname|
uris.each do |uri|
return if File.exist? Rails.root.join('tmp', 'uri_collection_job_stop')
AccessLog.where(host: hostname, uri: uri)
.where('created_at >= ?', beginning_of_interval)
.completed_requests
.non_robots
.group(:host, :uri)
.rollup('host|uri', interval: starting_interval, update: true)
# Reducir las estadísticas calculadas aplicando un rollup sobre el
# intervalo más amplio.
Stat::INTERVALS.reduce do |previous, current|
Rollup.where(name: 'host|uri', interval: previous)
.where_dimensions(host: hostname, uri: uri)
.group("dimensions->'host'", "dimensions->'uri'")
.rollup('host|uri', interval: current, update: true) do |rollup|
rollup.sum(:value)
end
# Devolver el intervalo actual
current
end
end
end
# Recordar la última vez que se corrió la tarea
site.stats.create! name: STAT_NAME
run_again! unless once
end
private
def stat_name
STAT_NAME
end
# @return [String]
#
# TODO: Cambiar al mergear origin-referer
def destination
@destination ||= site.deploys.find_by(type: 'DeployLocal').destination
end
# TODO: Cambiar al mergear origin-referer
#
# @return [Array]
def hostnames
@hostnames ||= site.deploys.map do |deploy|
case deploy
when DeployLocal
site.hostname
when DeployWww
deploy.fqdn
when DeployAlternativeDomain
deploy.hostname.dup.tap do |h|
h.replace(h.end_with?('.') ? h[0..-2] : "#{h}.#{Site.domain}")
end
when DeployHiddenService
deploy.onion
end
end.compact
end
# Recolecta todas las URIs menos imágenes
#
# @return [Array]
def uris
@uris ||= Dir.chdir destination do
(Dir.glob('**/*.html') + Dir.glob('public/**/*').reject do |p|
File.directory? p
end.reject do |p|
p = p.downcase
IMAGES.any? do |i|
p.end_with? i
end
end).map do |uri|
"/#{uri}"
end
end
end
end

View file

@ -0,0 +1,17 @@
# frozen_string_literal: true
module ExceptionNotifier
# Notifica las excepciones como incidencias en Gitlab
class GitlabNotifier
def initialize(_); end
# Recibe la excepción y empieza la tarea de notificación en segundo
# plano.
#
# @param [Exception]
# @param [Hash]
def call(exception, **options)
GitlabNotifierJob.perform_async(exception, **options)
end
end
end

View file

@ -0,0 +1,59 @@
# frozen_string_literal: true
require 'httparty'
class GitlabApiClient
include HTTParty
# TODO: Hacer configurable por sitio
base_uri ENV.fetch('GITLAB_URI', 'https://0xacab.org')
# No seguir redirecciones. Si nos olvidamos https:// en la dirección,
# las redirecciones nos pueden llevar a cualquier lado y obtener
# resultados diferentes.
no_follow true
# Trae todos los proyectos. Como estamos usando un Project Token,
# siempre va a traer uno solo.
#
# @return [HTTParty::Response]
def projects
self.class.get('/api/v4/projects', { query: { membership: true }, headers: headers })
end
# Obtiene el identificador del proyecto
#
# @return [Integer]
def project_id
@project_id ||= ENV['GITLAB_PROJECT'] || projects&.first&.dig('id')
end
# Crea un issue
#
# @see https://docs.gitlab.com/ee/api/issues.html#new-issue
# @return [HTTParty::Response]
def new_issue(**args)
self.class.post("/api/v4/projects/#{project_id}/issues", { body: args, headers: headers })
end
# Modifica un issue
#
# @see https://docs.gitlab.com/ee/api/issues.html#edit-issue
# @return [HTTParty::Response]
def edit_issue(iid:, **args)
self.class.put("/api/v4/projects/#{project_id}/issues/#{iid}", { body: args, headers: headers })
end
# Crea un comentario
#
# @see https://docs.gitlab.com/ee/api/notes.html#create-new-issue-note
# @return [HTTParty::Response]
def new_note(iid:, **args)
self.class.post("/api/v4/projects/#{project_id}/issues/#{iid}/notes", { body: args, headers: headers })
end
private
def headers(extra = {})
{ 'Authorization' => "Bearer #{ENV['GITLAB_TOKEN']}" }.merge(extra)
end
end

View file

@ -1,4 +1,12 @@
# frozen_string_literal: true
class AccessLog < ApplicationRecord
# Las peticiones completas son las que terminaron bien y se
# respondieron con 200 OK o 304 Not Modified
#
# @see {https://en.wikipedia.org/wiki/List_of_HTTP_status_codes}
scope :completed_requests, -> { where(request_method: 'GET', request_completion: 'OK', status: [200, 304]) }
scope :non_robots, -> { where(crawler: false) }
scope :robots, -> { where(crawler: true) }
scope :pages, -> { where(sent_http_content_type: ['text/html', 'text/html; charset=utf-8', 'text/html; charset=UTF-8']) }
end

View file

@ -62,10 +62,15 @@ class DeployLocal < Deploy
'AIRBRAKE_PROJECT_ID' => site.id.to_s,
'AIRBRAKE_PROJECT_KEY' => site.airbrake_api_key,
'JEKYLL_ENV' => Rails.env,
'LANG' => ENV['LANG']
'LANG' => ENV['LANG'],
'YARN_CACHE_FOLDER' => yarn_cache_dir
}
end
def yarn_cache_dir
Rails.root.join('_yarn_cache').to_s
end
def yarn_lock
File.join(site.path, 'yarn.lock')
end
@ -80,9 +85,9 @@ class DeployLocal < Deploy
# Corre yarn dentro del repositorio
def yarn
return unless yarn_lock?
return true unless yarn_lock?
run 'yarn'
run 'yarn install --production'
end
def bundle

View file

@ -0,0 +1,46 @@
# frozen_string_literal: true
# La representación indexable de un artículo
class IndexedPost < ApplicationRecord
include PgSearch::Model
# La traducción del locale según Sutty al locale según PostgreSQL
DICTIONARIES = {
es: 'spanish',
en: 'english'
}.freeze
# TODO: Los indexed posts tienen que estar scopeados al idioma actual,
# no buscar sobre todos
pg_search_scope :search,
lambda { |locale, query|
{
against: :content,
query: query,
using: {
tsearch: {
dictionary: IndexedPost.to_dictionary(locale: locale),
tsvector_column: 'indexed_content'
},
trigram: {
word_similarity: true
}
}
}
}
# Trae los IndexedPost en el orden en que van a terminar en el sitio.
default_scope -> { order(order: :desc, created_at: :desc) }
scope :in_category, ->(category) { where("front_matter->'categories' ? :category", category: category.to_s) }
scope :by_usuarie, ->(usuarie) { where("front_matter->'usuaries' @> :usuarie::jsonb", usuarie: usuarie.to_s) }
belongs_to :site
# Convertir locale a direccionario de PG
#
# @param [String,Symbol]
# @return [String]
def self.to_dictionary(locale:)
DICTIONARIES[locale.to_sym] || 'simple'
end
end

View file

@ -13,6 +13,26 @@ class MetadataArray < MetadataTemplate
false
end
# Solo los datos públicos se indexan, aunque MetadataArray no se cifra
# aun, dejamos esto preparado para la posteridad.
def indexable?
true && !private?
end
def to_s
value.join(', ')
end
# Obtiene el valor desde el documento, convirtiéndolo a Array si no lo
# era ya, por retrocompabilidad.
#
# @return [Array]
def document_value
[super].flatten(1).compact
end
alias indexable_values value
private
# TODO: Sanitizar otros valores

View file

@ -3,13 +3,6 @@
# Almacena el UUID de otro Post y actualiza el valor en el Post
# relacionado.
class MetadataBelongsTo < MetadataRelatedPosts
def value_was=(new_value)
@belongs_to = nil
@belonged_to = nil
super(new_value)
end
# TODO: Convertir algunos tipos de valores en módulos para poder
# implementar varios tipos de campo sin repetir código
#
@ -20,6 +13,13 @@ class MetadataBelongsTo < MetadataRelatedPosts
''
end
# Obtiene el valor desde el documento.
#
# @return [String]
def document_value
document.data[name.to_s]
end
def validate
super
@ -39,10 +39,14 @@ class MetadataBelongsTo < MetadataRelatedPosts
# Si estamos cambiando la relación, tenemos que eliminar la relación
# anterior
belonged_to[inverse].value.delete post.uuid.value if changed? && belonged_to.present?
if belonged_to.present?
belonged_to[inverse].value = belonged_to[inverse].value.reject do |rej|
rej == post.uuid.value
end
end
# No duplicar las relaciones
belongs_to[inverse].value << post.uuid.value unless belongs_to.blank? || included?
belongs_to[inverse].value = (belongs_to[inverse].value.dup << post.uuid.value) unless belongs_to.blank? || included?
true
end
@ -63,20 +67,13 @@ class MetadataBelongsTo < MetadataRelatedPosts
end
# El Post relacionado con este artículo
#
# XXX: Memoizamos usando el valor para tener el valor siempre
# actualizado.
def belongs_to
return if value.blank?
@belongs_to ||= posts.find(value, uuid: true)
posts.find(value, uuid: true) if value.present?
end
# El artículo relacionado anterior
def belonged_to
return if value_was.blank?
@belonged_to ||= posts.find(value_was, uuid: true)
posts.find(value_was, uuid: true) if value_was.present?
end
def related_posts?
@ -87,6 +84,10 @@ class MetadataBelongsTo < MetadataRelatedPosts
@related_methods ||= %i[belongs_to belonged_to].freeze
end
def indexable_values
belongs_to&.title&.value
end
private
def post_exists?

View file

@ -25,10 +25,19 @@ class MetadataBoolean < MetadataTemplate
# * false
# * true
def value
return document.data.fetch(name.to_s, default_value) if self[:value].nil?
return self[:value] unless self[:value].is_a? String
case self[:value]
when NilClass
document.data.fetch(name.to_s, default_value)
when String
true_values.include? self[:value]
else
self[:value]
end
end
self[:value] = true_values.include? self[:value]
# Siempre guardar el valor de este campo a menos que sea nulo
def empty?
value.nil?
end
private

View file

@ -15,14 +15,26 @@ class MetadataContent < MetadataTemplate
false
end
def document_value
document.content
end
def indexable?
true && !private?
end
def to_s
sanitizer.sanitize value, tags: [], attributes: []
end
private
# Detectar si el contenido estaba en Markdown y pasarlo a HTML
def legacy_content
return unless document.content
return document.content if /^\s*</ =~ document.content
return unless document_value
return document_value if /^\s*</ =~ document_value
CommonMarker.render_doc(document.content, %i[FOOTNOTES UNSAFE], %i[table strikethrough autolink]).to_html
CommonMarker.render_doc(document_value, %i[FOOTNOTES UNSAFE], %i[table strikethrough autolink]).to_html
end
# Limpiar el HTML que recibimos
@ -44,7 +56,7 @@ class MetadataContent < MetadataTemplate
uri = URI element['src']
# No permitimos recursos externos
element.remove unless uri.hostname.end_with? Site.domain
element.remove unless uri.scheme == 'https' && uri.hostname.end_with?(Site.domain)
rescue URI::Error
element.remove
end

View file

@ -7,17 +7,58 @@ class MetadataDocumentDate < MetadataTemplate
Date.today.to_time
end
def value_from_document
# @return [Time]
def document_value
return nil if post.new?
document.date
end
def indexable?
true && !private?
end
# Siempre es obligatorio
def required
true
end
def validate
super
errors << I18n.t('metadata.date.invalid_format') unless valid_format?
errors.empty?
end
# El valor puede ser un Date, Time o una String en el formato
# "yyyy-mm-dd"
#
# XXX: Date.iso8601 acepta fechas en el futuro lejano, como 20000,
# pero Jekyll las limita a cuatro cifras, así que vamos a mantener
# eso.
#
# @see {https://github.com/jekyll/jekyll/blob/master/lib/jekyll/document.rb#L15}
def value
return (self[:value] = value_from_document || default_value) if self[:value].nil?
self[:value] =
case self[:value]
when String
begin
Date.iso8601(self[:value]).to_time
rescue Date::Error
document_value || default_value
end
else
self[:value] || document_value || default_value
end
end
self[:value] = Date.iso8601(self[:value]).to_time if self[:value].is_a? String
private
self[:value]
def valid_format?
return true if self[:value].is_a?(Time)
@valid_format_re ||= /\A\d{2,4}-\d{1,2}-\d{1,2}\z/
@valid_format_re =~ self[:value].to_s
end
end

View file

@ -16,6 +16,7 @@ class MetadataFile < MetadataTemplate
def validate
super
errors << I18n.t("metadata.#{type}.site_invalid") if site.invalid?
errors << I18n.t("metadata.#{type}.path_required") if path_missing?
errors << I18n.t("metadata.#{type}.no_file_for_description") if no_file_for_description?

View file

@ -0,0 +1,31 @@
# frozen_string_literal: true
# Un campo numérico de punto flotante
class MetadataFloat < MetadataTemplate
# Nada
def default_value
super || nil
end
def save
return true unless changed?
self[:value] = value.to_f
self[:value] = encrypt(value) if private?
true
end
# Indicarle al navegador que acepte números decimales
#
# @return [Float]
def step
0.05
end
private
def decrypt(value)
super(value).to_f
end
end

View file

@ -18,6 +18,7 @@ class MetadataHasAndBelongsToMany < MetadataHasMany
#
# Buscamos en belongs_to la relación local, si se eliminó hay que
# quitarla de la relación remota, sino hay que agregarla.
#
def save
# XXX: No usamos super
self[:value] = sanitize value
@ -25,27 +26,21 @@ class MetadataHasAndBelongsToMany < MetadataHasMany
return true unless changed?
return true unless inverse?
# XXX: Usamos asignación para aprovechar value= que setea el valor
# anterior en @value_was
(had_many - has_many).each do |remove|
remove[inverse]&.value&.delete post.uuid.value
remove[inverse].value = remove[inverse].value.reject do |rej|
rej == post.uuid.value
end
end
(has_many - had_many).each do |add|
next unless add[inverse]
next if add[inverse].value.include? post.uuid.value
add[inverse].value << post.uuid.value
add[inverse].value = (add[inverse].value.dup << post.uuid.value)
end
true
end
private
# Igual que en MetadataRelatedPosts
# TODO: Mover a un módulo
def sanitize(uuid)
super(uuid.map do |u|
u.to_s.gsub(/[^a-f0-9\-]/i, '')
end)
end
end

View file

@ -6,32 +6,18 @@
# Localmente tenemos un Array de UUIDs. Remotamente tenemos una String
# apuntando a un Post, que se mantiene actualizado como el actual.
class MetadataHasMany < MetadataRelatedPosts
# Invalidar la relación anterior
def value_was=(new_value)
@had_many = nil
@has_many = nil
super(new_value)
end
def validate
super
errors << I18n.t('metadata.has_many.missing_posts') unless posts_exist?
errors.empty?
end
# Todos los Post relacionados
def has_many
@has_many ||= posts.where(uuid: value)
return default_value if value.blank?
posts.where(uuid: value)
end
# La relación anterior
def had_many
return [] if value_was.blank?
return default_value if value_was.blank?
@had_many ||= posts.where(uuid: value_was)
posts.where(uuid: value_was)
end
def inverse?
@ -71,8 +57,4 @@ class MetadataHasMany < MetadataRelatedPosts
def related_methods
@related_methods ||= %i[has_many had_many].freeze
end
def posts_exist?
has_many.size == sanitize(value).size
end
end

View file

@ -0,0 +1,12 @@
# frozen_string_literal: true
# Campos en HTML
class MetadataHtml < MetadataContent
def front_matter?
true
end
def document_value
document.data[name.to_s]
end
end

View file

@ -6,12 +6,13 @@ class MetadataLang < MetadataTemplate
super || I18n.locale
end
def value_from_document
# @return [Symbol]
def document_value
document.collection.label.to_sym
end
def value
self[:value] ||= value_from_document || default_value
self[:value] ||= document_value || default_value
end
def values

View file

@ -9,14 +9,15 @@ class MetadataMarkdownContent < MetadataText
end
def value
self[:value] || value_from_document || default_value
self[:value] || document_value || default_value
end
def front_matter?
false
end
def value_from_document
# @return [String]
def document_value
document.content
end

View file

@ -3,12 +3,16 @@
# Este campo representa el archivo donde se almacenan los datos
class MetadataPath < MetadataTemplate
# :label en este caso es el idioma/colección
#
# @return [String]
def default_value
File.join(site.path, "_#{lang}", "#{date}-#{slug}#{ext}")
end
# El valor no vuelve desde el documento
def value_from_document
# La ruta del archivo según Jekyll
#
# @return [String]
def document_value
document.path
end

View file

@ -2,6 +2,12 @@
# Este metadato permite generar rutas manuales.
class MetadataPermalink < MetadataString
# El valor por defecto una vez creado es la URL que le asigne Jekyll,
# de forma que nunca cambia aunque se cambie el título.
def default_value
document.url.sub(%r{\A/}, '') unless post.new?
end
# Los permalinks nunca pueden ser privados
def private?
false

View file

@ -0,0 +1,24 @@
# frozen_string_literal: true
# Un campo de texto seleccionado de una lista de valores posibles
class MetadataPredefinedValue < MetadataString
# Obtiene todos los valores desde el layout, en un formato compatible
# con options_for_select.
#
# @return [Hash]
def values
@values ||= layout.dig(:metadata, name, 'values', I18n.locale.to_s)&.invert || {}
end
private
# Solo permite almacenar los valores predefinidos.
#
# @return [String]
def sanitize(string)
v = super string
return '' unless values.values.include? v
v
end
end

View file

@ -18,11 +18,19 @@ class MetadataRelatedPosts < MetadataArray
false
end
def indexable?
false
end
def indexable_values
posts.where(uuid: value).map(&:title).map(&:value)
end
private
# Obtiene todos los posts y opcionalmente los filtra
def posts
@posts ||= site.posts(lang: lang).where(**filter)
site.posts(lang: lang).where(**filter)
end
def title(post)

View file

@ -7,6 +7,10 @@ class MetadataString < MetadataTemplate
super || ''
end
def indexable?
true && !private?
end
private
# No se permite HTML en las strings

View file

@ -7,6 +7,11 @@
MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
:value, :help, :required, :errors, :post,
:layout, keyword_init: true) do
# Determina si el campo es indexable
def indexable?
false
end
def inspect
"#<#{self.class} site=#{site.name.inspect} post=#{post.id.inspect} value=#{value.inspect}>"
end
@ -14,17 +19,23 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
# Queremos que los artículos nuevos siempre cacheen, si usamos el UUID
# siempre vamos a obtener un item nuevo.
def cache_key
return layout.value + '/' + name.to_s if post.new?
return "#{layout.value}/#{name}" if post.new?
@cache_key ||= 'post/' + post.uuid.value + '/' + name.to_s
@cache_key ||= "post/#{post.uuid.value}/#{name}"
end
# Genera una versión de caché en base a la fecha de modificación del
# Post, el valor actual y los valores posibles, de forma que cualquier
# cambio permita renovar la caché.
#
# @return [String]
def cache_version
value.hash.to_s + values.hash.to_s
post.cache_version + value.hash.to_s + values.hash.to_s
end
# @return [String]
def cache_key_with_version
cache_key + '-' + cache_version
"#{cache_key}-#{cache_version}"
end
# XXX: Deberíamos sanitizar durante la asignación?
@ -38,11 +49,7 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
def value_was
return @value_was if instance_variable_defined? '@value_was'
@value_was = value_from_document
end
def value_from_document
@value_from_document ||= document.data[name.to_s]
@value_was = document_value
end
def changed?
@ -74,7 +81,7 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
# Valor actual o por defecto. Al memoizarlo podemos modificarlo
# usando otros métodos que el de asignación.
def value
self[:value] ||= if (data = value_from_document).present?
self[:value] ||= if (data = document_value).present?
private? ? decrypt(data) : data
else
default_value
@ -205,9 +212,13 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
box.decrypt_str value.to_s
rescue Lockbox::DecryptionError => e
ExceptionNotifier.notify_exception(e, data: { site: site.name, post: post.path.absolute, name: name })
if value.to_s.include? ' '
value
else
ExceptionNotifier.notify_exception(e, data: { site: site.name, post: post.path.absolute, name: name })
I18n.t('lockbox.help.decryption_error')
I18n.t('lockbox.help.decryption_error')
end
end
# Cifra el valor.

View file

@ -17,6 +17,12 @@ class Post
attr_reader :attributes, :errors, :layout, :site, :document
# TODO: Modificar el historial de Git con callbacks en lugar de
# services. De esta forma podríamos agregar soporte para distintos
# backends.
include ActiveRecord::Callbacks
include Post::Indexable
class << self
# Obtiene el layout sin leer el Document
#
@ -53,9 +59,7 @@ class Post
public_send(attr)&.value = args[attr] if args.key?(attr)
end
# XXX: No usamos Post#read porque a esta altura todavía no sabemos
# nada del Document
document.read! if File.exist? document.path
document.read! unless new?
end
def inspect
@ -75,6 +79,9 @@ class Post
# TODO: Cambiar el locale en otro lado
l = lang.value.to_s
site.jekyll.config['locale'] = site.jekyll.config['lang'] = l
# XXX: Es necesario leer los layouts para poder renderizar el
# sitio
site.theme_layouts
# Payload básico con traducciones.
document.renderer.payload = {
@ -114,7 +121,7 @@ class Post
end
def cache_version
updated_at.utc.to_s(:usec)
(updated_at || modified_at).utc.to_s(:usec)
end
# Agregar el timestamp para saber si cambió, siguiendo el módulo
@ -131,6 +138,8 @@ class Post
# Fecha de última modificación del archivo
def updated_at
return if new?
File.mtime(path.absolute)
end
@ -195,6 +204,8 @@ class Post
post: self, required: true)
end
alias locale lang
# TODO: Mover a method_missing
def uuid
@metadata[:uuid] ||= MetadataUuid.new(document: document, site: site, layout: layout, name: :uuid, type: :uuid,
@ -232,12 +243,14 @@ class Post
template = public_send attr
unless template.front_matter?
body += "\n\n"
body += "\n\n" if body.present?
body += template.value
next
end
next if template.empty?
# Queremos mantener los Array en el resultado final para que
# siempre respondan a {% for %} en Liquid.
next if template.empty? && !template.value.is_a?(Array)
[attr.to_s, template.value]
end.compact.to_h
@ -255,11 +268,17 @@ class Post
end
# Eliminar el artículo del repositorio y de la lista de artículos del
# sitio
# sitio.
#
# TODO: Si el callback falla deberíamos recuperar el archivo.
#
# @return [Post]
def destroy
FileUtils.rm_f path.absolute
run_callbacks :destroy do
FileUtils.rm_f path.absolute
site.delete_post self
site.delete_post self
end
end
alias destroy! destroy
@ -283,10 +302,13 @@ class Post
end
end
return false unless save_attributes!
return false unless write
run_callbacks :save do
return false unless save_attributes!
return false unless write
end
# Vuelve a leer el post para tomar los cambios
document.reset
read
written?

View file

@ -0,0 +1,76 @@
# frozen_string_literal: true
class Post
# Vuelve indexables a los Posts
module Indexable
extend ActiveSupport::Concern
included do
# Indexa o reindexa el Post
after_save :index!
after_destroy :remove_from_index!
# Devuelve una versión indexable del Post
#
# @return [IndexedPost]
def to_index
IndexedPost.find_or_create_by(id: uuid.value).tap do |indexed_post|
indexed_post.layout = layout.name
indexed_post.site_id = site.id
indexed_post.path = path.basename
indexed_post.locale = locale.value
indexed_post.dictionary = IndexedPost.to_dictionary(locale: locale.value)
indexed_post.title = title.value
indexed_post.front_matter = indexable_front_matter
indexed_post.content = indexable_content
indexed_post.created_at = date.value
indexed_post.order = attribute?(:order) ? order.value : 0
end
end
private
# Indexa o reindexa el Post
#
# @return [Boolean]
def index!
to_index.save
end
def remove_from_index!
to_index.destroy.destroyed?
end
# Los metadatos que se almacenan como objetos JSON. Empezamos con
# las categorías porque se usan para filtrar en el listado de
# artículos.
#
# @return [Hash]
def indexable_front_matter
{}.tap do |ifm|
ifm[:usuaries] = usuaries.map(&:id)
ifm[:draft] = attribute?(:draft) ? draft.value : false
ifm[:categories] = categories.indexable_values if attribute? :categories
end
end
# Devuelve un documento indexable en texto plano
#
# XXX: No memoizamos para permitir actualizaciones, aunque
# probablemente se indexe una sola vez.
#
# @return [String]
def indexable_content
indexable_attributes.map do |attr|
self[attr].to_s.tr("\n", ' ')
end.join("\n").squeeze("\n")
end
def indexable_attributes
@indexable_attributes ||= attributes.select do |attr|
self[attr].indexable?
end
end
end
end
end

View file

@ -93,8 +93,7 @@ class PostRelation < Array
def where(**args)
return self if args.empty?
@where ||= {}
@where[args.hash.to_s] ||= begin
begin
PostRelation.new(site: site, lang: lang).concat(select do |post|
result = args.map do |attr, value|
next unless post.attribute?(attr)

View file

@ -26,7 +26,7 @@ class Site < ApplicationRecord
validates :design_id, presence: true
validates_inclusion_of :status, in: %w[waiting enqueued building]
validates_presence_of :title
validates :description, length: { in: 50..160 }
validates :description, length: { in: 10..160 }
validate :deploy_local_presence
validate :compatible_layouts, on: :update
@ -37,6 +37,7 @@ class Site < ApplicationRecord
belongs_to :design
belongs_to :licencia
has_many :stats
has_many :log_entries, dependent: :destroy
has_many :deploys, dependent: :destroy
has_many :build_stats, through: :deploys
@ -65,8 +66,8 @@ class Site < ApplicationRecord
accepts_nested_attributes_for :deploys, allow_destroy: true
# El sitio en Jekyll
attr_reader :jekyll
# XXX: Es importante incluir luego de los callbacks de :load_jekyll
include Site::Index
# No permitir HTML en estos atributos
def title=(title)
@ -97,7 +98,7 @@ class Site < ApplicationRecord
# @param slash Boolean Agregar / al final o no
# @return String La URL con o sin / al final
def url(slash: true)
'https://' + hostname + (slash ? '/' : '')
"https://#{hostname}#{slash ? '/' : ''}"
end
# Obtiene los dominios alternativos
@ -105,7 +106,7 @@ class Site < ApplicationRecord
# @return Array
def alternative_hostnames
deploys.where(type: 'DeployAlternativeDomain').map(&:hostname).map do |h|
h.end_with?('.') ? h[0..-2] : h + '.' + Site.domain
h.end_with?('.') ? h[0..-2] : "#{h}.#{Site.domain}"
end
end
@ -114,7 +115,7 @@ class Site < ApplicationRecord
# @return Array
def alternative_urls(slash: true)
alternative_hostnames.map do |h|
'https://' + h + (slash ? '/' : '')
"https://#{h}#{slash ? '/' : ''}"
end
end
@ -175,41 +176,30 @@ class Site < ApplicationRecord
end
alias default_lang default_locale
def read?
@read ||= false
end
# Lee el sitio y todos los artículos
def read
# No hacer nada si ya se leyó antes
return if read?
@jekyll.read
@read = true
end
# Trae los datos del directorio _data dentro del sitio
#
# XXX: Leer directamente sin pasar por Jekyll
def data
read
# Define los valores por defecto según la llave buscada
@jekyll.data.default_proc = proc do |data, key|
data[key] = case key
when 'layout' then {}
end
unless jekyll.data.present?
run_in_path do
jekyll.reader.read_data
jekyll.data['layouts'] ||= {}
end
end
@jekyll.data
jekyll.data
end
# Traer las colecciones. Todos los artículos van a estar dentro de
# colecciones.
def collections
read
unless @read
run_in_path do
jekyll.reader.read_collections
end
@jekyll.collections
@read = true
end
jekyll.collections
end
# Traer la configuración de forma modificable
@ -221,10 +211,8 @@ class Site < ApplicationRecord
#
# @param lang: [String|Symbol] traer los artículos de este idioma
def posts(lang: nil)
read
# Traemos los posts del idioma actual por defecto
lang ||= I18n.locale
# Traemos los posts del idioma actual por defecto o el que haya
lang ||= locales.include?(I18n.locale) ? I18n.locale : default_locale
lang = lang.to_sym
# Crea un Struct dinámico con los valores de los locales, si
@ -275,7 +263,9 @@ class Site < ApplicationRecord
# NoMethodError
@layouts_struct ||= Struct.new(*layout_keys, keyword_init: true)
@layouts ||= @layouts_struct.new(**data['layouts'].map do |name, metadata|
[name.to_sym, Layout.new(site: self, name: name.to_sym, meta: metadata.delete('meta')&.with_indifferent_access, metadata: metadata.with_indifferent_access)]
[name.to_sym,
Layout.new(site: self, name: name.to_sym, meta: metadata.delete('meta')&.with_indifferent_access,
metadata: metadata.with_indifferent_access)]
end.to_h)
end
@ -293,6 +283,15 @@ class Site < ApplicationRecord
layout_keys.include? layout.to_sym
end
# Lee los layouts en HTML desde el sitio
#
# @return [Hash]
def theme_layouts
run_in_path do
jekyll.reader.read_layouts
end
end
# Trae todos los valores disponibles para un campo
#
# TODO: Traer recursivamente, si el campo contiene Hash
@ -313,14 +312,31 @@ class Site < ApplicationRecord
# Poner en la cola de compilación
def enqueue!
!enqueued? && update_attribute(:status, 'enqueued')
update(status: 'enqueued') if waiting?
end
# Está en la cola de compilación?
#
# TODO: definir todos estos métodos dinámicamente, aunque todavía no
# tenemos una máquina de estados propiamente dicha.
def enqueued?
status == 'enqueued'
end
def waiting?
status == 'waiting'
end
def building?
status == 'building'
end
def jekyll
run_in_path do
@jekyll ||= Jekyll::Site.new(configuration)
end
end
# Cargar el sitio Jekyll
#
# TODO: En lugar de leer todo junto de una vez, extraer la carga de
@ -334,10 +350,7 @@ class Site < ApplicationRecord
def reload_jekyll!
reset
Dir.chdir(path) do
@jekyll = Jekyll::Site.new(configuration)
end
jekyll
end
def reload
@ -390,7 +403,7 @@ class Site < ApplicationRecord
# Detecta si el tema actual es una gema
def theme_available?
available_themes.include? design.gem
available_themes.include? design&.gem
end
# Devuelve el dominio actual
@ -404,7 +417,7 @@ class Site < ApplicationRecord
end
def self.default
find_by(name: Site.domain + '.')
find_by(name: "#{Site.domain}.")
end
def reset
@ -515,4 +528,8 @@ class Site < ApplicationRecord
errors.add(:design_id,
I18n.t('activerecord.errors.models.site.attributes.design_id.layout_incompatible.error'))
end
def run_in_path(&block)
Dir.chdir path, &block
end
end

24
app/models/site/index.rb Normal file
View file

@ -0,0 +1,24 @@
# frozen_string_literal: true
# Indexa todos los artículos de un sitio
#
# TODO: Hacer opcional
class Site
module Index
extend ActiveSupport::Concern
included do
# TODO: Debería ser un Job?
after_create :index_posts!
has_many :indexed_posts, dependent: :destroy
def index_posts!
Site.transaction do
docs.each do |post|
post.to_index.save
end
end
end
end
end
end

View file

@ -44,8 +44,8 @@ class Site
#
# @return [Integer]
def fetch
if origin.check_connection :fetch
rugged.fetch(origin)[:received_objects]
if origin.check_connection(:fetch, credentials: credentials)
rugged.fetch(origin, credentials: credentials)[:received_objects]
else
0
end
@ -149,6 +149,26 @@ class Site
private
# Si Sutty tiene una llave privada de tipo ED25519, devuelve las
# credenciales necesarias para trabajar con repositorios remotos.
#
# @return [Nil, Rugged::Credentials::SshKey]
def credentials
return unless File.exist? private_key
@credentials ||= Rugged::Credentials::SshKey.new username: 'git', publickey: public_key, privatekey: private_key
end
# @return [String]
def public_key
@public_key ||= Rails.root.join('.ssh', 'id_ed25519.pub').to_s
end
# @return [String]
def private_key
@private_key ||= Rails.root.join('.ssh', 'id_ed25519').to_s
end
def relativize(file)
Pathname.new(file).relative_path_from(Pathname.new(path)).to_s
end

10
app/models/stat.rb Normal file
View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
# Registran cuándo fue la última recolección de datos.
class Stat < ApplicationRecord
# XXX: Los intervalos van en orden de mayor especificidad a menor
INTERVALS = %i[day month year].freeze
RESOURCES = %i[builds space_used build_time].freeze
belongs_to :site
end

View file

@ -59,9 +59,7 @@ class PostPolicy
def resolve
return scope if scope&.first&.site&.usuarie? usuarie
scope.select do |post|
post.usuaries.include? usuarie
end
scope.by_usuarie(usuarie.id)
end
end
end

View file

@ -12,4 +12,16 @@ class SiteStatPolicy
def index?
site_stat.site.usuarie? usuarie
end
def host?
index?
end
def resources?
index?
end
def uris?
index?
end
end

View file

@ -61,6 +61,18 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
commit_config(action: :tor)
end
# Trae cambios desde la rama remota y reindexa los artículos.
#
# @return [Boolean]
def merge
result = site.repository.merge(usuarie)
# TODO: Implementar callbacks
site.try(:index_posts!) if result
result.present?
end
private
# Guarda los cambios de la configuración en el repositorio git
@ -110,6 +122,8 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
# la búsqueda.
def change_licencias
site.locales.each do |locale|
next unless I18n.available_locales.include? locale
Mobility.with_locale(locale) do
permalink = "#{I18n.t('activerecord.models.licencia').downcase}/"
post = site.posts(lang: locale).find_by(permalink: permalink)

View file

@ -1,5 +1,3 @@
= render 'layouts/breadcrumb', crumbs: nil
= content_for :body do
- 'black-bg'

View file

@ -1,5 +1,3 @@
= render 'layouts/breadcrumb', crumbs: nil
= content_for :body do
- 'black-bg'

View file

@ -1,5 +1,3 @@
= render 'layouts/breadcrumb', crumbs: nil
= content_for :body do
- 'black-bg'

View file

@ -1,5 +1,3 @@
= render 'layouts/breadcrumb', crumbs: nil
= content_for :body do
- 'black-bg'

View file

@ -1,5 +1,3 @@
= render 'layouts/breadcrumb', crumbs: nil
= content_for :body do
- 'black-bg'

View file

@ -1,5 +1,4 @@
= render 'layouts/breadcrumb',
crumbs: [link_to(t('.index'), sites_path), t('.title')]
- breadcrumb 'sites.index', sites_path
= content_for :body do
- 'black-bg'

View file

@ -1,5 +1,3 @@
= render 'layouts/breadcrumb', crumbs: nil
= content_for :body do
- 'black-bg'

View file

@ -1,5 +1,3 @@
= render 'layouts/breadcrumb', crumbs: nil
= content_for :body do
- 'black-bg'

View file

@ -1,5 +1,3 @@
= render 'layouts/breadcrumb', crumbs: nil
= content_for :body do
- 'black-bg'

View file

@ -1,7 +1,7 @@
= cache @site do
:plain
window.env = {
AIRBRAKE_SITE_ID: #{@site&.id || 1},
AIRBRAKE_API_KEY: "#{@site&.airbrake_api_key}",
AIRBRAKE_SITE_ID: #{@site.id},
AIRBRAKE_API_KEY: "#{@site.airbrake_api_key}",
PANEL_URL: "#{ENV['PANEL_URL']}"
}

View file

@ -1,4 +1,4 @@
<% unless @data[:_backtrace] %>
<% unless @data[:javascript_backtrace] %>
```
<%= raw @backtrace.join("\n") %>
```

View file

@ -1,9 +1,9 @@
<% if @data[:_backtrace] %>
<% if @data[:javascript_backtrace] %>
<% @data.dig(:params, 'errors')&.each do |error| %>
# <%= error['type'] %>: <%= error['message'] %>
```
<%= Terminal::Table.new headings: error['backtrace'].first.keys, rows: error['backtrace'].map(&:values).map(&:strip) %>
<%= Terminal::Table.new headings: error['backtrace'].first.keys, rows: error['backtrace'].map(&:values) %>
```
<% end %>

View file

@ -1,7 +0,0 @@
= render 'layouts/breadcrumb',
crumbs: [link_to(t('sites.index.title'), sites_path),
link_to(@site.name, site_path(@site)),
t('i18n.index'),
t('i18n.edit')]
= render 'i18n/form'

View file

@ -3,21 +3,14 @@
= inline_svg_tag 'sutty.svg', class: 'black', aria: true,
title: t('svg.sutty.title'), desc: t('svg.sutty.desc')
- if crumbs
%nav{ aria: { label: t('.title') }, role: 'navigation' }
%ol.breadcrumb
%li.breadcrumb-item
= link_to edit_usuarie_registration_path,
data: { toggle: 'tooltip' },
title: t('help.usuarie.edit') do
= current_usuarie.email
- crumbs.compact.each do |crumb|
- if crumb == crumbs.last
%li.breadcrumb-item.active{ aria: { current: 'page' } }
= crumb
%nav{ aria: { label: t('.title') } }
%ol.breadcrumb.m-0.flex-wrap
- breadcrumb_trail do |crumb|
%li.breadcrumb-item{ class: crumb.current? ? 'active' : '' }
- if crumb.current?
%span.line-clamp-1{ aria: { current: 'page' } }= crumb.name
- else
%li.breadcrumb-item= crumb
%span.line-clamp-1= link_to crumb.name, crumb.url
- if current_usuarie
%ul.navbar-nav

View file

@ -20,6 +20,7 @@
%body{ class: yield(:body) }
.container-fluid#sutty
= render 'layouts/breadcrumb'
= yield
- if flash[:js]
.js-flash.d-none{ data: flash[:js] }

View file

@ -43,7 +43,7 @@
- metadata = post[attribute]
- type = metadata.type
- cache metadata do
- cache [metadata, I18n.locale] do
= render("posts/attributes/#{type}",
base: 'post', post: post, attribute: attribute,
metadata: metadata, site: site,

View file

@ -0,0 +1,3 @@
%tr{ id: attribute }
%th= post_label_t(attribute, post: post)
%td{ dir: dir, lang: locale }= metadata.value

View file

@ -0,0 +1,3 @@
%tr{ id: attribute }
%th= post_label_t(attribute, post: post)
%td{ lang: locale, dir: dir }= metadata.value.html_safe

View file

@ -0,0 +1,3 @@
%tr{ id: attribute }
%th= post_label_t(attribute, post: post)
%td{ dir: dir, lang: locale }= metadata.value

View file

@ -101,6 +101,8 @@
.form-group{ data: { editor_auxiliary: 'mark' } }
%label{ for: 'mark-color' }= t('editor.color')
%input.form-control{ type: 'color', name: 'mark-color' }/
%label{ for: 'mark-text-color' }= t('editor.text-color')
%input.form-control{ type: 'color', name: 'mark-text-color' }/
%div{ data: { editor_auxiliary: 'multimedia' } }
.form-group

View file

@ -0,0 +1,6 @@
.form-group
= label_tag "#{base}_#{attribute}", post_label_t(attribute, post: post)
= number_field base, attribute, value: metadata.value, step: metadata.step,
**field_options(attribute, metadata)
= render 'posts/attribute_feedback',
post: post, attribute: attribute, metadata: metadata

View file

@ -0,0 +1,6 @@
-# Editor de contenido
= render 'posts/attributes/content',
base: 'post', post: post, attribute: attribute,
metadata: metadata, site: site,
dir: dir, locale: locale,
autofocus: (post.attributes.first == attribute)

View file

@ -1,8 +1,8 @@
.form-group.markdown-content
= label_tag "#{base}_#{attribute}", post_label_t(attribute, post: post)
= render 'posts/attribute_feedback',
post: post, attribute: attribute, metadata: metadata
= text_area_tag "#{base}[#{attribute}]", metadata.value,
dir: dir, lang: locale,
**field_options(attribute, metadata, class: 'content')
.editor.mt-1
= render 'posts/attribute_feedback',
post: post, attribute: attribute, metadata: metadata
.markdown-editor.mt-1

View file

@ -0,0 +1,7 @@
.form-group
= label_tag "#{base}_#{attribute}", post_label_t(attribute, post: post)
= select_tag(plain_field_name_for(base, attribute),
options_for_select(metadata.values, metadata.value),
**field_options(attribute, metadata), include_blank: t('.empty'))
= render 'posts/attribute_feedback',
post: post, attribute: attribute, metadata: metadata

View file

@ -1,10 +1,3 @@
= render 'layouts/breadcrumb',
crumbs: [link_to(t('sites.index.title'), sites_path),
link_to(@site.name, site_posts_path(@site)),
link_to(t('posts.index'), site_posts_path(@site)),
link_to(@post.title.value, site_post_path(@site, @post.id)),
t('posts.edit')]
.row.justify-content-center
.col-md-8
= render 'posts/form', site: @site, post: @post

View file

@ -1,10 +1,3 @@
= render 'layouts/breadcrumb',
crumbs: [link_to(t('sites.index.title'), sites_path),
@site.name,
link_to(t('posts.index'),
site_posts_path(@site)),
@category_name]
%main.row
%aside.menu.col-md-3
%h1= link_to @site.title, @site.url
@ -14,15 +7,13 @@
%table.mb-3
- @site.layouts.each do |layout|
- next if layout.hidden?
- filter = params[:layout] == layout.value
%tr
%th= layout.humanized_name
%td.pl-3= link_to t('posts.add'),
new_site_post_path(@site, layout: layout.name),
class: 'badge badge-secondary'
%td= link_to t(filter ? 'posts.remove_filter' : 'posts.filter'),
site_posts_path(@site, layout: (filter ? nil : layout.value)),
class: 'badge badge-' + (filter ? 'primary' : 'secondary')
%td.pl-3= link_to t('posts.add'), new_site_post_path(@site, layout: layout.value), class: 'btn btn-secondary btn-sm'
- if @filter_params[:layout] == layout.name.to_s
%td= link_to t('posts.remove_filter'), site_posts_path(@site, **@filter_params.merge(layout: nil)), class: 'btn btn-primary btn-sm'
- else
%td= link_to t('posts.filter'), site_posts_path(@site, **@filter_params.merge(layout: layout.value)), class: 'btn btn-secondary btn-sm'
- if policy(@site).edit?
= link_to t('sites.edit.btn', site: @site.title), edit_site_path(@site), class: 'btn'
@ -48,87 +39,102 @@
%section.col
= render 'layouts/flash'
.d-flex.justify-content-between.align-items-center.pl-2-plus.pr-2-plus.mb-2
%form{ action: site_posts_path }
- @filter_params.each do |param, value|
- next if param == 'q'
%input{ type: 'hidden', name: param, value: value }
.form-group.flex-grow-0.m-0
%input.form-control.border.border-magenta{ type: 'search', placeholder: 'Buscar', name: 'q', value: @filter_params[:q] }
%input.sr-only{ type: 'submit' }
- if @site.locales.size > 1
%nav#locales
- @site.locales.each do |locale|
= link_to @site.data.dig(locale.to_s, 'locale') || locale, site_posts_path(@site, **@filter_params.merge(locale: locale)),
class: "mr-2 mt-2 mb-2 #{locale == @locale ? 'active font-weight-bold' : ''}"
.pl-2-plus
- @filter_params.each do |param, value|
- if param == 'layout'
- value = @site.layouts[value.to_sym].humanized_name
= link_to site_posts_path(@site, **@filter_params.reject { |k, _| k == param }),
class: 'btn btn-secondary btn-sm',
title: t('posts.remove_filter_help', filter: value),
aria: { labelledby: "help-filter-#{param}" } do
= value
&times;
- if @posts.empty?
%h2= t('posts.none')
%h2= t('posts.empty')
- else
= form_tag site_posts_reorder_path, method: :post do
.d-flex.justify-content-between.align-items-center
-#
TODO: Pensar una interfaz mejor para cuando haya más de tres
idiomas
- unless @site.locales.length == 1
.locales
- @site.locales.each do |locale|
= link_to t("locales.#{locale}.name"), site_posts_path(@site, locale: locale),
class: "mr-2 mt-2 mb-2#{locale == @locale ? 'active font-weight-bold' : ''}"
%input{ type: 'hidden', name: 'post[lang]', value: @locale }
%table.table{ data: { controller: 'reorder' } }
%caption.sr-only= t('posts.caption')
%thead
%tr
%th.border-0.background-white.position-sticky{ style: 'top: 0; z-index: 2', colspan: '4' }
= submit_tag t('posts.reorder.submit'), class: 'btn'
%button.btn{ data: { action: 'reorder#unselect' } }
= t('posts.reorder.unselect')
%span.badge{ data: { target: 'reorder.counter' } } 0
%button.btn{ data: { action: 'reorder#up' } }= t('posts.reorder.up')
%button.btn{ data: { action: 'reorder#down' } }= t('posts.reorder.down')
%button.btn{ data: { action: 'reorder#top' } }= t('posts.reorder.top')
%button.btn{ data: { action: 'reorder#bottom' } }= t('posts.reorder.bottom')
.d-flex.flex-row.justify-content-between
%div
= submit_tag t('posts.reorder.submit'), class: 'btn'
%button.btn{ data: { action: 'reorder#unselect' } }
= t('posts.reorder.unselect')
%span.badge{ data: { target: 'reorder.counter' } } 0
%button.btn{ data: { action: 'reorder#up' } }= t('posts.reorder.up')
%button.btn{ data: { action: 'reorder#down' } }= t('posts.reorder.down')
%button.btn{ data: { action: 'reorder#top' } }= t('posts.reorder.top')
%button.btn{ data: { action: 'reorder#bottom' } }= t('posts.reorder.bottom')
%div
%tbody
- dir = t("locales.#{@locale}.dir")
- size = @posts.size
- @posts.each_with_index do |post, i|
-#
TODO: Solo les usuaries cachean porque tenemos que separar
les botones por permisos.
TODO: Verificar qué pasa cuando se gestiona el sitio en
distintos idiomas a la vez
- begin
- cache_if @usuarie, post do
- checkbox_id = "checkbox-#{post.uuid.value}"
%tr{ id: post.uuid.value, data: { target: 'reorder.row' } }
- cache_if @usuarie, [post, I18n.locale] do
- checkbox_id = "checkbox-#{post.id}"
%tr{ id: post.id, data: { target: 'reorder.row' } }
%td
.custom-control.custom-checkbox
%input.custom-control-input{ id: checkbox_id, type: 'checkbox', autocomplete: 'off', data: { action: 'reorder#select' } }
%label.custom-control-label{ for: checkbox_id }
%span.sr-only= t('posts.reorder.select')
-# Orden más alto es mayor prioridad
= hidden_field 'post[reorder]', post.uuid.value,
value: @posts.length - i,
= hidden_field 'post[reorder]', post.id,
value: size - i,
data: { reorder: true }
%td.w-100{ class: dir }
= link_to site_post_path(@site, post.id) do
%span{ lang: post.lang.value, dir: dir }= post.title.value
- if post.attributes.include? :draft
- if post.draft.value
%span.badge.badge-primary
= post_label_t(:draft, post: post)
- if post.attributes.include? :categories
- unless post.categories.value.empty?
%br
%small
- (post.categories.respond_to?(:belongs_to) ? post.categories.belongs_to : post.categories.value).each do |c|
= link_to site_posts_path(@site, category: (c.respond_to?(:uuid) ? c.uuid.value : c)) do
%span{ lang: post.lang.value, dir: dir }= (c.respond_to?(:title) ? c.title.value : c)
= link_to site_post_path(@site, post.path) do
%span{ lang: post.locale, dir: dir }= post.title
- if post.front_matter['draft'].present?
%span.badge.badge-primary= I18n.t('posts.attributes.draft.label')
%br
%small
= link_to @site.layouts[post.layout].humanized_name, site_posts_path(@site, **@filter_params.merge(layout: post.layout))
- post.front_matter['categories']&.each do |category|
= link_to site_posts_path(@site, **@filter_params.merge(category: category)) do
%span{ lang: post.locale, dir: dir }= category
= '/' unless post.front_matter['categories'].last == category
%td
= post.date.value.strftime('%F')
%td.text-nowrap
= post.created_at.strftime('%F')
%br/
- if post.attribute? :order
= post.order.value
%td
= post.order
%td.text-nowrap
- if @usuarie || policy(post).edit?
= link_to t('posts.edit'),
edit_site_post_path(@site, post.id),
class: 'btn btn-block'
= link_to t('posts.edit'), edit_site_post_path(@site, post.path), class: 'btn btn-block'
- if @usuarie || policy(post).destroy?
= link_to t('posts.destroy'),
site_post_path(@site, post.id),
class: 'btn btn-block',
method: :delete,
data: { confirm: t('posts.confirm_destroy') }
= link_to t('posts.destroy'), site_post_path(@site, post.path), class: 'btn btn-block', method: :delete, data: { confirm: t('posts.confirm_destroy') }
-#
Rescatar cualquier error en un post, notificarlo e
ignorar su renderización.
- rescue ActionView::Template::Error => e
- ExceptionNotifier.notify_exception(e.cause, data: { site: @site.name, post: @post.path.absolute, usuarie: current_usuarie.id })
#footnotes{ hidden: true }
- @filter_params.each do |param, value|
- if param == 'layout'
- value = @site.layouts[value.to_sym].humanized_name
%label{ id: "help-filter-#{param}" }= t('posts.remove_filter_help', filter: value)

View file

@ -1,9 +1,3 @@
= render 'layouts/breadcrumb',
crumbs: [link_to(t('sites.index.title'), sites_path),
@site.name,
link_to(t('posts.index'),
site_posts_path(@site)), t('posts.new')]
.row.justify-content-center
.col-md-8
= render 'posts/form', site: @site, post: @post

View file

@ -1,9 +1,3 @@
= render 'layouts/breadcrumb',
crumbs: [link_to(t('sites.index.title'), sites_path),
@site.name,
link_to(t('posts.index'), site_posts_path(@site)),
@post.title.value]
- dir = t("locales.#{@locale}.dir")
.row.justify-content-center
.col-md-8
@ -28,7 +22,7 @@
- metadata = @post[attr]
- next unless metadata.front_matter?
- cache metadata do
- cache [metadata, I18n.locale] do
= render("posts/attribute_ro/#{metadata.type}",
post: @post, attribute: attr,
metadata: metadata,
@ -42,6 +36,6 @@
- metadata = @post[attr]
- next if metadata.front_matter?
- cache metadata do
- cache [metadata, I18n.locale] do
%section.editor{ id: attr, dir: dir }
= @post.public_send(attr).to_s.html_safe
= @post.public_send(attr).value.html_safe

View file

@ -1,15 +1,9 @@
- if policy(site).build?
- if site.enqueued?
= render 'layouts/btn_with_tooltip',
tooltip: t('help.sites.enqueued'),
text: t('sites.enqueued'),
type: 'secondary',
link: nil
- else
= form_tag site_enqueue_path(site),
method: :post, class: 'form-inline inline' do
= button_tag type: 'submit',
class: 'btn no-border-radius',
title: t('help.sites.enqueue'),
data: { toggle: 'tooltip' } do
= t('sites.enqueue')
= form_tag site_enqueue_path(site),
method: :post,
class: 'form-inline inline' do
= submit_tag site.enqueued? ? t('sites.enqueued') : t('sites.enqueue'),
class: 'btn no-border-radius',
title: site.enqueued? ? t('help.sites.enqueued') : t('help.sites.enqueue'),
data: { disable_with: t('sites.enqueued') },
disabled: site.enqueued?

View file

@ -39,7 +39,7 @@
%h2= f.label :description
%p.lead= t('.help.description')
= f.text_area :description, class: form_control(site, :description),
maxlength: 160, minlength: 50, required: true
maxlength: 160, minlength: 10, required: true
- if invalid? site, :description
.invalid-feedback= site.errors.messages[:description].join(', ')
%hr/

View file

@ -1,6 +1,3 @@
= render 'layouts/breadcrumb',
crumbs: [link_to(t('sites.index.title'), sites_path),
t('.title', site: @site.name)]
.row.justify-content-center
.col-md-8
%h1= t('.title', site: @site.name)

View file

@ -1,6 +1,3 @@
= render 'layouts/breadcrumb',
crumbs: [link_to(t('sites.index.title'), sites_path), t('.title')]
.row.justify-content-center
.col-md-8#pull
%h1= t('.title')

View file

@ -1,5 +1,3 @@
= render 'layouts/breadcrumb', crumbs: [t('sites.index.title')]
%main.row
%aside.col-md-3
%h1= t('.title')
@ -16,16 +14,17 @@
%table.table.table-condensed
%tbody
- @sites.each do |site|
- next unless site.jekyll
- rol = current_usuarie.rol_for_site(site)
-#
TODO: Solo les usuaries cachean porque tenemos que separar
les botones por permisos.
- cache_if (rol.usuarie? && !rol.temporal), site do
- cache_if (rol.usuarie? && !rol.temporal), [site, I18n.locale] do
%tr
%td
%h2
- if policy(site).show?
= link_to site.title, site_path(site)
= link_to site.title, site_posts_path(site, locale: site.default_locale)
- else
= site.title
%p.lead= site.description

View file

@ -1,6 +1,3 @@
= render 'layouts/breadcrumb',
crumbs: [link_to(t('sites.index.title'), sites_path), t('.title')]
.row.justify-content-center
.col-md-8
%h1= t('.title')

View file

@ -1,17 +1,43 @@
= render 'layouts/breadcrumb',
crumbs: [link_to(t('sites.index.title'), sites_path),
link_to(@site.name, site_path(@site)), t('.title')]
.row
.col
%h1= t('.title')
%p.lead= t('.help')
- if @last_stat
%p
%small
= t('.last_update')
%time{ datetime: @last_stat.created_at }
#{time_ago_in_words @last_stat.created_at}.
%table.table.table-condensed
%tbody
%tr
%td= t('.build.average')
%td= distance_of_time_in_words_if_more_than_a_minute @build_avg
%tr
%td= t('.build.maximum')
%td= distance_of_time_in_words_if_more_than_a_minute @build_max
.mb-5
- Stat::INTERVALS.each do |interval|
= link_to t(".#{interval}"), site_stats_path(interval: interval, urls: params[:urls]), class: "btn #{'btn-primary active' if @interval == interval}"
.mb-5
%h2= t('.host.title', count: @hostnames.size)
%p.lead= t('.host.description')
= line_chart site_stats_host_path(@chart_params), **@chart_options
.mb-5
%h2= t('.urls.title')
%p.lead= t('.urls.description')
%form
%input{ type: 'hidden', name: 'interval', value: @interval }
.form-group
%label{ for: 'urls' }= t('.urls.label')
%textarea#urls.form-control{ name: 'urls', autocomplete: 'on', required: true, rows: @normalized_urls.size, aria_describedby: 'help-urls' }= @normalized_urls.join("\n")
%small#help-urls.feedback.form-text.text-muted= t('.urls.help')
.form-group
%button.btn{ type: 'submit' }= t('.urls.submit')
- if @normalized_urls.present?
= line_chart site_stats_uris_path(urls: params[:urls], **@chart_params), **@chart_options
.mb-5
%h2= t('.resources.title')
%p.lead= t('.resources.description')
- Stat::RESOURCES.each do |resource|
.mb-5
%h3= t(".resources.#{resource}.title")
%p.lead= t(".resources.#{resource}.description")
= line_chart site_stats_resources_path(resource: resource, **@chart_params), **@chart_options.merge(StatsController::EXTRA_OPTIONS[resource])

View file

@ -1,32 +1,24 @@
= render 'layouts/breadcrumb',
crumbs: [link_to(t('sites.index.title'), sites_path),
link_to(@site.name, @site),
t('.title')]
.row
.col
.row.justify-content-center
.col.col-md-8
%h1= t('.title')
.row
.col
-# Una tabla de usuaries y otra de invitades, con acciones
- %i[usuaries invitades].each do |u|
%h2
= t(".#{u}")
.btn-group{ role: 'group', 'aria-label': t('.actions') }
- if @policy.invite?
= link_to t('.invite'),
site_usuaries_invite_path(@site, invite_as: u.to_s),
class: 'btn',
data: { toggle: 'tooltip' },
title: t('.help.invite', invite_as: u.to_s)
- if policy(Collaboration.new(@site)).collaborate?
= link_to t('.public_invite'),
site_collaborate_path(@site),
class: 'btn',
data: { toggle: 'tooltip' },
title: t('.help.public_invite')
%p= t(".help.#{u}")
%h2.mt-5= t(".#{u}")
.btn-group{ role: 'group', 'aria-label': t('.actions') }
- if @policy.invite?
= link_to t('.invite'),
site_usuaries_invite_path(@site, invite_as: u.to_s),
class: 'btn',
data: { toggle: 'tooltip' },
title: t('.help.invite', invite_as: u.to_s)
- if policy(Collaboration.new(@site)).collaborate?
= link_to t('.public_invite'),
site_collaborate_path(@site),
class: 'btn',
data: { toggle: 'tooltip' },
title: t('.help.public_invite')
%p.lead= t(".help.#{u}")
%table.table.table-condensed
%tbody
- @site.send(u).each do |cuenta|

Some files were not shown because too many files have changed in this diff Show more