WIP: refactorización de Post

El objetivo es aprovechar metaprogramación para tener un modelo similar
a ActiveRecord pero con otras propiedades, como atributos dinámicos a
partir de plantillas.

Estamos eliminando un montón de código, con lo que no todo puede
funcionar.
This commit is contained in:
f 2019-08-06 20:17:29 -03:00
parent 3b46600ed5
commit efe401d740
No known key found for this signature in database
GPG key ID: 2AE5A13E321F953D
11 changed files with 449 additions and 546 deletions

View file

@ -1,8 +1,10 @@
# frozen_string_literal: true
# Representa la plantilla de un campo en los metadatos del artículo
#
# TODO: Validar el tipo de valor pasado a value= según el :type
MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
:value, :help, :required,
:value, :help, :required, :errors,
keyword_init: true) do
# El valor por defecto
def default_value
@ -12,11 +14,36 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
# Valores posibles, busca todos los valores actuales en otros
# artículos del mismo sitio
def values
site.everything_of(name.to_s)
site.everything_of(name)
end
# Valor actual o por defecto
def value
super || document.data.dig(name.to_s, default_value)
self[:value] || document.data.fetch(name.to_s, default_value)
end
# Detecta si el valor está vacío
def empty?
value.blank?
end
# Comprueba si el metadato es válido
def valid?
validate
end
def validate
self.errors = []
errors << I18n.t("metadata.#{type}.cant_be_empty") unless can_be_empty?
errors.empty?
end
private
# Si es obligatorio no puede estar vacío
def can_be_empty?
true unless required && empty?
end
end

View file

@ -3,125 +3,120 @@
require 'jekyll/utils'
# Esta clase representa un post en un sitio jekyll e incluye métodos
# para modificarlos y crear nuevos
# para modificarlos y crear nuevos.
#
# Cuando estamos editando un post, instanciamos este modelo y le
# asociamos el Jekyll::Document correspondiente.
#
# Cuando estamos creando un post, no creamos su Jekyll::Document
# hasta que se guardan los datos, porque para poder guardarlo
# necesitamos su front_matter completo.
#
# El front matter está duplicado. El Post mantiene una copia de los
# datos y los sincroniza al momento de leer y de escribir el Document.
class Post
attr_accessor :content, :front_matter
attr_reader :post, :site, :errors, :old_post, :lang, :template,
:template_fields, :collection
# rubocop:disable Metrics/ClassLength
class Post < OpenStruct
# Atributos por defecto
# XXX: Volver document opcional cuando estemos creando
DEFAULT_ATTRIBUTES = %i[site document layout].freeze
# Otros atributos que no vienen en los metadatos
ATTRIBUTES = %i[content lang date slug attributes errors].freeze
REJECT_FROM_DATA = %w[excerpt].freeze
# datos que no tienen que terminar en el front matter
REJECT_FROM_FRONT_MATTER = %w[date slug ext].freeze
# datos que no traemos del template
REJECT_FROM_TEMPLATE = %w[draft categories layout ext tags date slug post pre].freeze
DEFAULT_PARAMS = [:title, :date, :content, :slug, :cover,
:layout, :permalink, :dir,
{ lang: {} }, { tags: [] }, { categories: [] }].freeze
# Redefinir el inicializador de OpenStruct
#
# @param site: [Site] el sitio en Sutty
# @param document: [Jekyll::Document] el documento leído por Jekyll
# @param layout: [Layout] la plantilla
#
# rubocop:disable Metrics/AbcSize
# rubocop:disable Metrics/MethodLength
def initialize(**args)
default_attributes_missing(args)
super(args)
def inspect
"#<Post @id=#{id} @site=#{site.name}>"
# Genera un método con todos los atributos disponibles
self.attributes = DEFAULT_ATTRIBUTES +
ATTRIBUTES +
layout.metadata.keys.map(&:to_sym)
# El contenido
self.content = document.content
self.date = document.date
self.slug = document.data['slug']
# Genera un atributo por cada uno de los campos de la plantilla,
# MetadataFactory devuelve un tipo de campo por cada campo. A
# partir de ahí se pueden obtener los valores actuales y una lista
# de valores por defecto.
layout.metadata.each_pair do |name, template|
send "#{name}=".to_sym,
MetadataFactory.build(document: document,
site: site,
name: name,
type: template['type'],
label: template['label'],
help: template['help'],
required: template['required'])
end
end
# rubocop:enable Metrics/AbcSize
# rubocop:enable Metrics/MethodLength
# Levanta un error si al construir el artículo no pasamos un atributo.
def default_attributes_missing(**args)
DEFAULT_ATTRIBUTES.each do |attr|
i18n = I18n.t("exceptions.post.#{attr}_missing")
raise ArgumentError, i18n unless args[attr].present?
end
end
# Trabajar con posts. Si estamos creando uno nuevo, el **site** y
# el **front_matter** son necesarios, sino, **site** y **post**.
# XXX chequear que se den las condiciones
def initialize(site:, post: nil, front_matter: {}, lang: nil, template: nil)
unless site.is_a?(Site)
raise ArgumentError,
I18n.t('errors.argument_error', argument: :site, class: Site)
# Solo ejecuta la magia de OpenStruct si el campo existe en la
# plantilla
#
# XXX: Reemplazarlo por nuestro propio método, mantener todo lo demás
# compatible con OpenStruct
#
# XXX: rubocop dice que tenemos que usar super cuando ya lo estamos
# usando...
#
# rubocop:disable Style/MethodMissingSuper
def method_missing(mid, *args)
unless attribute? mid
raise NoMethodError, I18n.t('exceptions.post.no_method',
method: mid)
end
unless post.nil? || post.is_a?(Jekyll::Document)
raise ArgumentError,
I18n.t('errors.argument_error', argument: :post,
class: Jekyll::Document)
end
super(mid, *args)
end
# rubocop:enable Style/MethodMissingSuper
@site = site
@post = post
@template = template
# los errores tienen que ser un hash para que
# ActiveModel pueda traer los errores normalmente
@errors = {}
# Si el sitio está traducido, trabajamos con la colección del
# idioma, sino, con posts
@collection = if @site.i18n?
@lang = lang || I18n.locale.to_s
else
'posts'
end
# sincronizar los datos del document
if new?
@front_matter = front_matter_from_template
update_attributes front_matter
# Detecta si es un atributo válido o no, a partir de la tabla de la
# plantilla
def attribute?(mid)
if singleton_class.method_defined? :attributes
attributes.include? attribute_name(mid)
else
load_front_matter!
merge_with_front_matter! front_matter.stringify_keys
(DEFAULT_ATTRIBUTES + ATTRIBUTES).include? attribute_name(mid)
end
end
# Limpiar los errores
def reset_errors!
@errors = {}
# Genera el post con metadatos en YAML
def full_content
yaml = layout.metadata.keys.map(&:to_sym).map do |metadata|
template = send(metadata)
{ metadata.to_s => template.value } unless template.empty?
end.compact.inject(:merge)
"#{yaml.to_yaml}---\n\n#{content}"
end
# El post es nuevo si no hay un documento asociado
def new?
@post.nil? || !File.exist?(@post.try(:path))
end
def draft?
fetch_front_matter('draft', false)
end
def incomplete?
fetch_front_matter('incomplete', false)
end
# El número de orden del artículo, si no tiene uno, se le asigna la
# posición en la colección de artículos
def order
get_front_matter 'order'
end
def ordered?
!order.nil?
end
# Determina si fue traducido, buscando los slugs de su front_matter
# lang en otras colecciones
def translated?
@site.i18n? && get_front_matter('lang').present?
end
def translations
@translations ||= find_translations
end
def find_translations
slugs = get_front_matter('lang')
return [] unless slugs.present?
slugs.map do |lang, id|
next if lang == @lang
@site.posts_for(lang).find do |p|
p.id == id
end
end.compact
# Eliminar el artículo del repositorio y de la lista de artículos del
# sitio
#
# XXX Commit
def destroy
FileUtils.rm_f path
site.posts(lang: lang).delete_if do |post|
post.path == path
end
!File.exist?(path) && !site.posts(lang: lang).include?(self)
end
alias destroy! destroy
# Guarda los cambios.
#
@ -133,60 +128,49 @@ class Post
return false unless valid?
new_post if new?
return unless write
return unless detect_file_rename!
# Vuelve a leer el post para tomar los cambios
@post.read
add_post_to_site!
document.read
# add_post_to_site!
true
end
alias save! save
def title
get_front_matter 'title'
end
def author
get_front_matter 'author'
end
def date
get_front_matter 'date'
end
def date_as_string
date.strftime('%F')
end
def tags
get_front_matter('tags') || []
end
def categories
get_front_matter('categories') || []
end
alias category categories
# Devuelve la ruta del post, si se cambió alguno de los datos,
# generamos una ruta nueva para tener siempre la ruta actualizada.
def path
if basename_changed?
File.join(@site.path, "_#{@collection}", basename_from_front_matter)
else
@post.try(:path)
end
document.path
end
# TODO: los slugs se pueden repetir, el identificador real sería
# fecha+slug, pero se ve feo en las urls?
def id
get_front_matter 'slug'
# Detecta si el artículo es válido para guardar
def valid?
validate
errors.blank?
end
alias slug id
alias to_s id
# Requisitos para que el post sea válido
def validate
self.errors = {}
layout.metadata.keys.map(&:to_sym).each do |metadata|
template = send(metadata)
errors[metadata] = template.errors unless template.valid?
end
end
alias validate! validate
# Permite ordenar los posts
def <=>(other)
@post <=> other.post
end
# ****************
# A PARTIR DE ACA ESTAMOS CONSIDERANDO CUALES METODOS QUEDAN Y CUALES
# NO
# ****************
def basename_changed?
@post.try(:basename) != basename_from_front_matter
@ -196,108 +180,6 @@ class Post
new? || @post.data.dig('slug') != slug
end
# Trae el contenido del post, si no lo seteamos ya. O sea que si solo
# enviamos actualizaciones al front matter, debería traer el contenido
# del post sin cambios
def content
@content ||= @post.try(:content) || template.try(:content)
end
# Determina si el post lleva contenido o es solo front_matter
def content?
has_field? :content
end
def has_field?(field)
if template
template.fetch_front_matter("has_#{field}", true)
else
true
end
end
# imita Model.update_attributes de ActiveRecord
# TODO Todo esto es malísimo, necesitamos una forma genérica de
# convertir params a objetos ruby (o que lo haga YAML directamente,
# ya que estamos). Tal vez separar en varios pasos, uno que arregle
# los hashes con indices numericos y los convierta a arrays, otro que
# convierta los params en hashes, otro que convierta los archivos
# temporales en una subida de archivos, etc.
def update_attributes(attrs)
# convertir los hashes en arrays si los campos son anidados
# usamos to_hash por todos lados porque sino son
# HashWithIndifferentAccess
_attrs = attrs.to_hash.map do |k, v|
t = template_fields.find { |t| t.key == k }
if t
# Subir la imagen!
# TODO pasar a su propio método
if t.image?
begin
i = Post::ImageUploader.new(site)
if t.multiple?
v = v.map do |tmp|
i.store! tmp.tempfile
i.url
end
else
i.store! v.tempfile
v = i.url
end
rescue CarrierWave::ProcessingError, CarrierWave::IntegrityError => e
v = e.message
end
end
if t.nested?
v = t.array? ? v.map(&:to_hash) : v.to_hash
end
end
if v.is_a? ActionController::Parameters
{ k => v.to_hash }
else
{ k => v }
end
end.reduce({}, :merge).stringify_keys
# el cuerpo se maneja por separado
@content = _attrs.delete('content') if _attrs.key? 'content'
merge_with_front_matter! _attrs
end
# Requisitos para que el post sea válido
# TODO verificar que el id sea único
# TODO validar los parametros de la plantilla
def validate
add_error validate: I18n.t('posts.errors.date') unless date.is_a? Time
add_error validate: I18n.t('posts.errors.title') if title.blank?
add_error validate: I18n.t('posts.errors.slug_with_path') if slug.try(:include?, '/')
# XXX este es un principio de validación de plantillas, aunque no es
# recursivo
return if fetch_front_matter('incomplete', false)
template_fields.each do |tf|
errors = [get_front_matter(tf.key)].flatten.compact
if tf.image? && errors.map { |i| File.exist?(File.join(site.path, i)) }.none?
add_error Hash[tf.key.to_sym, errors]
end
end
end
def valid?
reset_errors!
validate
@errors.empty?
end
# Permite ordenar los posts
def <=>(other)
@post <=> other.post
end
# Detecta si un valor es un archivo
def url?(name)
path = get_front_matter(name)
@ -309,87 +191,14 @@ class Post
end.all?
end
def image?(name)
return false unless url? name
# TODO: no chequear por la extensión
%(gif jpg jpeg png).include? get_front_matter(name).gsub(/.*\./, '')
end
# Obtiene metadatos de forma recursiva
# TODO devolver un valor por defecto en base al template?
def get_front_matter(name)
name = if name.is_a? Array
# Convertir los indices numericos a integers
name.map { |i| /[0-9]+/.match?(i) ? i.to_i : i }
else
# XXX retrocompatibilidad
name.to_s
end
@front_matter.dig(*name)
end
# Como get_front_matter pero con un valor por defecto
def fetch_front_matter(name, default)
r = get_front_matter(name)
# Solo cuando es nulo, sino devolvemos el default si el valor es
# false
r.nil? ? default : r
end
# Trae el template a partir del layout
def template_from_layout
@site.templates.find do |t|
t.get_front_matter('slug') == get_front_matter('layout')
end
end
# TODO: convertir a hash para que sea más fácil buscar uno
def template_fields
return [] unless template
@template_fields ||= template.front_matter.map do |key, contents|
next if REJECT_FROM_TEMPLATE.include? key
next if key.start_with? 'has_'
# XXX: Esto está acá hasta que convirtamos todo en plantillas
if key == 'title' && content.is_a?(String)
contents = {
'value' => 'text',
'label' => I18n.t('posts.title'),
'required' => true
}
end
Post::TemplateField.new(self, key, contents)
end.compact
end
# devuelve las plantillas como strong params, primero los valores
# simples, luego los arrays y al final los hashes
def template_params
@template_params ||= (DEFAULT_PARAMS + template_fields.map(&:to_param)).sort_by do |s|
@template_params ||= template_fields.map(&:to_param).sort_by do |s|
s.is_a?(Symbol) ? 0 : 1
end
end
def template
@template ||= template_from_layout
end
# Eliminar el artículo del repositorio y de la lista de artículos del
# sitio
def destroy
FileUtils.rm_f path
site.posts_for(collection).delete_if do |post|
post.path == path
end
true
end
private
# Completa el front_matter a partir de las variables de otro post que
@ -406,30 +215,18 @@ class Post
ft
end
# Genera un post nuevo y lo agrega a la colección del sitio.
def new_post
opts = { site: @site.jekyll, collection: @site.jekyll.collections[@collection] }
@post = Jekyll::Document.new(path, opts)
end
# Solo agregar el post al sitio una vez que lo guardamos
#
# TODO no sería la forma correcta de hacerlo en Rails
def add_post_to_site!
@site.jekyll.collections[@collection].docs << @post
@site.jekyll.collections[@collection].docs.sort!
# def add_post_to_site!
# @site.jekyll.collections[@collection].docs << @post
# @site.jekyll.collections[@collection].docs.sort!
unless @site.collections[@collection].include? self
@site.collections[@collection] << self
@site.collections[@collection].sort!
end
end
# Los define, asegurandose que las llaves siempre son strings, para no
# tener incompatibilidades con jekyll
def set_front_matter(name, value)
@front_matter[name.to_s] = value
end
# unless @site.collections[@collection].include? self
# @site.collections[@collection] << self
# @site.collections[@collection].sort!
# end
# end
# Cambiar el nombre del archivo si cambió el título o la fecha.
# Como Jekyll no tiene métodos para modificar un Document, lo
@ -441,7 +238,7 @@ class Post
Rails.logger.info I18n.t('posts.logger.rm', path: path)
FileUtils.rm @post.path
replace_post!
# replace_post!
end
# Reemplaza el post en el sitio por uno nuevo
@ -478,25 +275,14 @@ class Post
end
def cleanup!
things_to_arrays!
default_date_is_today!
date_to_time!
clean_content!
slugify_title!
remove_empty_front_matter!
update_lang_front_matter!
update_translations!
put_in_order!
create_glossary!
end
# Setea el propio idioma en el front_matter de slugs
def update_lang_front_matter!
return unless translated?
@front_matter['lang'][@lang] = slug
end
# Busca las traducciones y actualiza el frontmatter si es necesario
def update_translations!
return unless translated?
@ -508,15 +294,9 @@ class Post
end
end
def remove_empty_front_matter!
@front_matter.delete_if do |_k, v|
v.blank?
end
end
# Aplica limpiezas básicas del contenido
def clean_content!
@content.try(:delete!, "\r")
content.try(:delete!, "\r")
end
# Guarda los cambios en el archivo destino
@ -543,20 +323,6 @@ class Post
false
end
# Genera el post con front matter, menos los campos que no necesitamos
# que estén en el front matter.
#
# El contenido se toma de `content` en lugar de `@content`, para poder
# obtener el contenido por defecto si es que no lo enviamos
# modificaciones, como en `update_translations!`
def full_content
yaml = @front_matter.reject do |k, _|
REJECT_FROM_FRONT_MATTER.include? k
end
"#{yaml.to_yaml}---\n\n#{content}"
end
def add_error(hash)
hash.each_pair do |k, i|
@errors[k] = if @errors.key?(k)
@ -570,28 +336,11 @@ class Post
end
def default_date_is_today!
set_front_matter('date', Time.now) unless date
end
def date_to_time!
unless @front_matter.dig(:date).is_a? Time
@front_matter['date'] = @front_matter.dig('date').try(:to_time) || Time.now
end
end
# XXX es necesario ahora que tenemos select2?
def things_to_arrays!
%i[tags categories].each do |c|
thing = @front_matter.dig(c.to_s)
next if thing.blank?
next if thing.is_a? Array
@front_matter[c.to_s] = thing.split(',').map(&:strip)
end
date ||= Time.now
end
def slugify_title!
@front_matter['slug'] = Jekyll::Utils.slugify(title) if slug.blank?
self.slug = Jekyll::Utils.slugify(title) if slug.blank?
end
# Agregar al final de la cola si no especificamos un orden
@ -627,4 +376,12 @@ class Post
end
end
end
private
# Obtiene el nombre del atributo sin
def attribute_name(attr)
attr.to_s.split('=').first.to_sym
end
end
# rubocop:enable Metrics/ClassLength

View file

@ -0,0 +1,25 @@
# frozen_string_literal: true
# La relación de un sitio con sus artículos, esto nos permite generar
# artículos como si estuviésemos usando ActiveRecord.
class PostRelation < Array
# No necesitamos cambiar el sitio
attr_reader :site
def initialize(site:)
@site = site
# Proseguimos la inicialización sin valores por defecto
super()
end
# Genera un artículo nuevo con los parámetros que le pasemos y lo suma
# al array
def build(**args)
self << Post.new(site: site, **args)
end
# Intenta guardar todos y devuelve true si pudo
def save_all
map(&:save).all?
end
end

View file

@ -31,17 +31,18 @@ class Site < ApplicationRecord
before_destroy :remove_directories!
# Carga el sitio Jekyll una vez que se inicializa el modelo o después
# de crearlo
after_initialize :load_jekyll!
after_create :load_jekyll!
after_initialize :load_jekyll
after_create :load_jekyll
# Cambiar el nombre del directorio
before_update :update_name!
# Guardar la configuración si hubo cambios
after_save :sync_attributes_with_config!
attr_accessor :jekyll, :collections
accepts_nested_attributes_for :deploys, allow_destroy: true
# El sitio en Jekyll
attr_accessor :jekyll
# No permitir HTML en estos atributos
def title=(title)
super(title.strip_tags)
@ -56,11 +57,6 @@ class Site < ApplicationRecord
@repository ||= Site::Repository.new path
end
# Trae los cambios del skel y verifica que haya cambios
def needs_pull?
!repository.commits.empty?
end
# TODO: Mover esta consulta a la base de datos para no traer un montón
# de cosas a la memoria
def invitade?(usuarie)
@ -71,179 +67,138 @@ class Site < ApplicationRecord
usuaries.pluck(:id).include? usuarie.id
end
# Este sitio acepta invitades?
def invitades?
config.fetch('invitades', false)
end
# Traer la ruta del sitio
#
# Equivale a _sites + nombre
def path
File.join(Site.site_path, name)
end
def old_path
# La ruta anterior
def path_was
File.join(Site.site_path, name_was)
end
# Este sitio acepta invitadxs?
def invitadxs?
config.fetch('invitadxs', false)
end
def cover
"/covers/#{name}.png"
end
# Determina si el sitio está en varios idiomas
def i18n?
!translations.empty?
end
# Define si el sitio tiene un glosario
def glossary?
config.fetch('glossary', false)
end
# Obtiene la lista de traducciones actuales
def translations
config.fetch('i18n', [])
end
# Devuelve el idioma por defecto del sitio
#
# TODO: volver elegante
def default_lang
# Si está traducido, intentamos saber si podemos trabajar en el
# idioma actual de la plataforma.
if i18n?
i18n = I18n.locale.to_s
if translations.include? i18n
# Podemos trabajar en el idioma actual
i18n
else
# Sino, trabajamos con el primer idioma
translations.first
end
else
# Si el sitio no está traducido, estamos trabajando con posts en
# cualquier idioma
#
# XXX: no será un dirty hack?
'posts'
end
# Siempre tiene que tener algo porque las traducciones están
# incorporadas a los sitios de Sutty, aunque les usuaries no traduzcan
# sus sitios.
def translations
config.fetch('translations', I18n.available_locales.map(&:to_s))
end
def layouts
@layouts ||= @jekyll.layouts.keys.sort
end
def name_with_i18n(lang)
[name, lang].join('/')
# Devuelve el idioma por defecto del sitio, el primero de la lista.
def default_language
translations.first
end
alias default_lang default_language
# Lee el sitio y todos los artículos
def read
@jekyll.read
end
# Fuerza relectura del sitio, eliminando el sitio actual en favor de
# un sitio nuevo, leído desde cero
def read!
@jekyll = Site.load_jekyll(@jekyll.path)
@jekyll.read
end
# Trae los datos del directorio _data dentro del sitio
#
# XXX: Leer directamente sin pasar por Jekyll
def data
if @jekyll.data.empty?
read
Rails.logger.info 'Leyendo data'
end
read if @jekyll.data.empty?
@jekyll.data
end
# Traer las colecciones. Todos los artículos van a estar dentro de
# colecciones.
def collections
read if @jekyll.collections.empty?
@jekyll.collections
end
# Traer la configuración de forma modificable
def config
@config ||= Site::Config.new(self)
end
# TODO: Cambiar a Site::Config apenas empecemos a testear esto
def collections_names
@jekyll.config['collections'].keys
end
# Los posts en el idioma actual o en uno en particular
#
# @param lang: [String|Symbol] traer los artículos de este idioma
def posts(lang: nil)
@posts ||= {}
lang ||= I18n.locale
# Los posts de este sitio, si el sitio está traducido, trae los posts
# del idioma actual, porque _posts va a estar vacío
def posts
@posts ||= posts_for('posts')
return @posts[lang] if @posts[lang].present?
@posts[lang] = PostRelation.new site: self
# No fallar si no existe colección para este idioma
# XXX: queremos fallar silenciosamente?
(collections[lang.to_s].try(:docs) || []).each do |doc|
layout = layouts[doc.data['layout'].to_sym]
@posts[lang].build(document: doc, layout: layout, lang: lang)
end
@posts[lang]
end
# Obtiene todas las plantillas de artículos
def templates
@templates ||= posts_for('templates') || []
end
# Obtiene todos los posts de una colección determinada
#
# Devuelve nil si la colección no existe
def posts_for(collection)
return unless collections_names.include? collection
# Si pedimos 'posts' pero estamos en un sitio traducido, traemos el
# idioma actual
collection = default_lang if collection == 'posts' && i18n?
@collections ||= {}
c = @collections[collection]
return c if c
Rails.logger.info "Procesando #{collection}"
col = @jekyll.collections[collection].docs
if col.empty?
@jekyll.read
# Queremos saber cuantas veces releemos los articulos
Rails.logger.info 'Leyendo articulos'
end
# Los convertimos a una clase intermedia capaz de acceder a sus
# datos y modificarlos
@collections[collection] = col.map do |post|
Post.new(site: self, post: post, lang: collection)
end
end
def categories(lang: nil)
everything_of :categories, lang: lang
end
def tags(lang: nil)
everything_of :tags, lang: lang
# @return { post: Layout }
def layouts
@layouts ||= data.fetch('layouts', {}).map do |name, metadata|
{ name.to_sym => Layout.new(site: self,
name: name.to_sym,
metadata: metadata) }
end.inject(:merge)
end
# Trae todos los valores disponibles para un campo
#
# TODO: Traer recursivamente, si el campo contiene Hash
#
# @return Array
def everything_of(attr, lang: nil)
collection = lang || 'posts'
return [] unless collections_names.include? collection
posts_for(collection).map do |p|
p.get_front_matter attr
posts(lang: lang).map do |p|
# XXX: Tener cuidado con los métodos que no existan
p.send(attr).try :value
end.flatten.uniq.compact
end
# Poner en la cola de compilación
def enqueue!
!enqueued? && update_attribute(:status, 'enqueued')
end
# Está en la cola de compilación?
def enqueued?
status == 'enqueued'
end
# Verifica si los posts están ordenados
def ordered?(collection = 'posts')
posts_for(collection).map(&:order).all?
def ordered?(lang: nil)
posts(lang: lang).map(&:order).all?
end
# Reordena la colección usando la posición informada
#
# new_order es un hash cuya key es la posición actual del post y el
# valor la posición nueva
#
# TODO: Refactorizar y testear
def reorder_collection(collection, new_order)
# Tenemos que pasar el mismo orden
return if new_order.values.map(&:to_i).sort != new_order.keys.map(&:to_i).sort
@ -275,50 +230,65 @@ class Site < ApplicationRecord
alias reorder_posts! reorder_collection!
# Obtener una ruta disponible para Sutty
#
# TODO: Refactorizar y testear
def get_url_for_sutty(path)
# Remover los puntos para que no nos envíen a ../../
File.join('/', 'sites', id, path.gsub('..', ''))
end
# El directorio donde se almacenan los sitios
def self.site_path
File.join(Rails.root, '_sites')
end
# Cargar el sitio Jekyll
#
# TODO: En lugar de leer todo junto de una vez, extraer la carga de
# documentos de Jekyll hacia Sutty para que podamos leer los datos que
# necesitamos.
def self.load_jekyll(path)
def load_jekyll
return unless name.present? && File.directory?(path)
Dir.chdir(path) do
@jekyll = Jekyll::Site.new(jekyll_config)
end
end
def jekyll_config
# Pasamos destination porque configuration() toma el directorio
# actual y se mezclan :/
# actual
#
# Especificamos `safe` para no cargar los _plugins, que interfieren
# entre sitios incluso
config = ::Jekyll.configuration('source' => path,
'destination' => File.join(path, '_site'),
'safe' => true,
'watch' => false,
'quiet' => true)
# entre sitios incluso.
#
# excerpt_separator está vacío para no incorporar el Excerpt en los
# metadatos de Document
configuration =
::Jekyll.configuration('source' => path,
'destination' => File.join(path, '_site'),
'safe' => true, 'watch' => false,
'quiet' => true, 'excerpt_separator' => '')
# No necesitamos cargar plugins en este momento
%w[plugins gems theme].each do |unneeded|
config[unneeded] = [] if config.key? unneeded
configuration[unneeded] = [] if configuration.key? unneeded
end
# Si estamos usando nuestro propio plugin de i18n, los posts están
# en "colecciones"
i18n = config.dig('i18n')
i18n&.each do |i|
config['collections'][i] = {}
translations.each do |i|
configuration['collections'][i] = {}
end
Jekyll::Site.new(config)
configuration
end
# Devuelve el dominio actual
def self.domain
ENV.fetch('SUTTY', 'sutty.nl')
end
# El directorio donde se almacenan los sitios
def self.site_path
File.join(Rails.root, '_sites')
end
private
# Clona el esqueleto de Sutty para crear el sitio nuevo, no pasa nada
@ -329,15 +299,6 @@ class Site < ApplicationRecord
Rugged::Repository.clone_at ENV['SKEL_SUTTY'], path
end
# Carga el sitio Jekyll
def load_jekyll!
return unless name.present? && File.directory?(path)
Dir.chdir(path) do
@jekyll ||= Site.load_jekyll(Dir.pwd)
end
end
# Elimina el directorio del sitio
def remove_directories!
FileUtils.rm_rf path
@ -346,11 +307,14 @@ class Site < ApplicationRecord
def update_name!
return unless name_changed?
FileUtils.mv old_path, path
FileUtils.mv path_was, path
end
# Sincroniza algunos atributos del sitio con su configuración y
# guarda los cambios
#
# TODO: Guardar la configuración también, quizás aprovechando algún
# método de ActiveRecord para que lance un salvado recursivo.
def sync_attributes_with_config!
config.theme = design.gem unless design_id_changed?
config.description = description unless description_changed?

View file

@ -79,6 +79,11 @@ class Site
walker.each.to_a
end
# Hay commits sin aplicar?
def needs_pull?
commits.empty?
end
# Guarda los cambios en git, de a un archivo por vez
# rubocop:disable Metrics/AbcSize
def commit(file:, usuarie:, message:)

View file

@ -3,10 +3,10 @@
# Política de aceptación de colaboradorxs
CollaborationPolicy = Struct.new(:usuarie, :collaboration) do
def collaborate?
collaboration.site.invitadxs?
collaboration.site.invitades?
end
def accept_collaboration?
collaboration.site.invitadxs?
collaboration.site.invitades?
end
end

View file

@ -77,7 +77,7 @@
= fa_icon 'building'
= t('sites.enqueue')
- if policy(site).pull? && site.needs_pull?
- if policy(site).pull? && site.repository.needs_pull?
= render 'layouts/btn_with_tooltip',
tooltip: t('help.sites.pull'),
text: t('.pull'),

View file

@ -1,4 +1,17 @@
en:
metadata:
array:
cant_be_empty: 'This field cannot be empty'
string:
cant_be_empty: 'This field cannot be empty'
image:
cant_be_empty: 'This field cannot be empty'
exceptions:
post:
site_missing: 'Needs an instance of Site'
layout_missing: 'Needs an instance of Layout'
document_missing: 'Needs an instance of Jekyll::Document'
no_method: '%{method} not allowed'
es: Castillian Spanish
en: English
seconds: '%{seconds} seconds'

View file

@ -1,4 +1,17 @@
es:
metadata:
array:
cant_be_empty: 'El campo no puede estar vacío'
string:
cant_be_empty: 'El campo no puede estar vacío'
image:
cant_be_empty: 'El campo no puede estar vacío'
exceptions:
post:
site_missing: 'Necesita una instancia de Site'
layout_missing: 'Necesita una instancia de Layout'
document_missing: 'Necesita una instancia de Jekyll::Document'
no_method: '%{method} no está permitido'
es: Castellano
en: Inglés
seconds: '%{seconds} segundos'

89
test/models/post_test.rb Normal file
View file

@ -0,0 +1,89 @@
# frozen_string_literal: true
class PostTest < ActiveSupport::TestCase
setup do
# Trabajamos con el sitio de sutty porque tiene artículos
#
# TODO: Cambiar a skel cuando publiquemos los códigos y privacidad
@site = create :site, name: 'sutty.nl'
@site.read
@post = @site.posts.sample
end
teardown do
# @site.destroy
end
test 'se puede acceder a los valores' do
assert @site.posts.size.positive?
assert @post.categories.values.size.positive?
assert @post.tags.values.size.positive?
assert @post.title.size.positive?
assert @post.content.size.positive?
end
test 'no se puede setear cualquier atributo' do
assert_raise NoMethodError do
@post.verdura = 'verdura'
end
end
test 'se pueden eliminar' do
# TODO: cuando esté con git, solo aplicar git reset
tmp = File.join(Rails.root, 'tmp', 'eliminar.md')
FileUtils.cp @post.path, tmp
assert @post.destroy
assert_not File.exist?(@post.path)
assert_not @site.posts.include?(@post)
FileUtils.mv tmp, @post.path
end
test 'se puede ver el contenido completo' do
tmp = Tempfile.new
begin
tmp.write(@post.full_content)
tmp.close
collection = Jekyll::Collection.new(@site.jekyll, I18n.locale.to_s)
document = Jekyll::Document.new(tmp.path, site: @site.jekyll,
collection: collection)
document.read
document.data['categories'].try(:delete_if) do |x|
x == 'tmp'
end
# Queremos saber si todos los atributos del post terminaron en el
# archivo
@post.attributes.each do |attr|
template = @post.send(attr)
# ignorar atributos que no son datos
next unless template.is_a? MetadataTemplate
if template.empty?
assert_not document.data[attr.to_s].present?
else
assert_equal template.value, document.data[attr.to_s]
end
end
ensure
tmp.unlink
end
end
test 'se pueden validar' do
assert @post.valid?
# XXX: si usamos nil va a traer el valor original del documento, no
# queremos tener esa información sincronizada...
@post.title.value = ''
assert_not @post.valid?
end
test 'se pueden guardar los cambios' do
end
end

View file

@ -61,6 +61,7 @@ class SiteTest < ActiveSupport::TestCase
test 'se puede leer un sitio' do
site = create :site, name: 'sutty.nl'
site.read
assert site.valid?
assert !site.posts.empty?
@ -85,4 +86,13 @@ class SiteTest < ActiveSupport::TestCase
assert_equal 'hola', site.description
assert_equal 'hola', site.title
end
test 'el sitio tiene artículos en distintos idiomas' do
site = create :site, name: 'sutty.nl'
site.read
I18n.available_locales.each do |locale|
assert site.posts(lang: locale).size.positive?
end
end
end