# frozen_string_literal: true # Esta clase representa un post en un sitio jekyll e incluye métodos # para modificarlos y crear nuevos. # # rubocop:disable Metrics/ClassLength # rubocop:disable Style/MissingRespondToMissing class Post < OpenStruct # Atributos por defecto DEFAULT_ATTRIBUTES = %i[site document layout].freeze # Otros atributos que no vienen en los metadatos PRIVATE_ATTRIBUTES = %i[path slug attributes errors].freeze PUBLIC_ATTRIBUTES = %i[lang date uuid].freeze ATTR_SUFFIXES = %w[? =].freeze class << self # Obtiene el layout sin leer el Document # # TODO: Reemplazar cuando leamos el contenido del Document # a demanda? def find_layout(path) IO.foreach(path).lazy.grep(/^layout: /).take(1).first&.split(' ')&.last&.tr('\'', '')&.tr('"', '')&.to_sym end end # 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 # def initialize(**args) default_attributes_missing(**args) super(**args) # Genera un método con todos los atributos disponibles self.attributes = layout.metadata.keys.map(&:to_sym) + PUBLIC_ATTRIBUTES self.errors = {} # 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. # # XXX: En el primer intento de hacerlo más óptimo, movimos esta # lógica a instanciación bajo demanda, pero no solo no logramos # optimizar sino que aumentamos el tiempo de carga :/ layout.metadata.each_pair do |name, template| send "#{name}=".to_sym, MetadataFactory.build(document: document, post: self, site: site, name: name.to_sym, value: args[name.to_sym], layout: layout, type: template['type'], label: template['label'], help: template['help'], required: template['required']) end # TODO: Llamar dinámicamente load_lang! load_slug! load_date! load_path! load_uuid! # XXX: No usamos Post#read porque a esta altura todavía no sabemos # nada del Document document.read! if File.exist? document.path end def inspect "#" end # Renderiza el artículo para poder previsualizarlo. Leemos solo la # información básica, con lo que no van a funcionar artículos # relacionados y otras cuestiones. # # @see app/lib/jekyll/tags/base.rb def render Dir.chdir site.path do # Compatibilidad con jekyll-locales, necesario para el filtro # date_local # # TODO: Cambiar el locale en otro lado site.jekyll.config['lang'] = lang.value site.jekyll.config['locale'] = lang.value # Payload básico con traducciones. document.renderer.payload = { 'site' => { 'data' => site.data, 'i18n' => site.data[lang.value], 'lang' => lang.value, 'locale' => lang.value }, 'page' => document.to_liquid } # Renderizar lo estrictamente necesario y convertir a HTML para # poder reemplazar valores. html = Nokogiri::HTML document.renderer.render_document # Las imágenes se cargan directamente desde el repositorio, porque # no son públicas hasta que se publica el artículo. html.css('img').each do |img| next if %r{\Ahttps?://} =~ img.attributes['src'] img.attributes['src'].value = Rails.application.routes.url_helpers.site_static_file_url(site, file: img.attributes['src'].value) end # Notificar a les usuaries que están viendo una previsualización # XXX: Asume que estamos usando Bootstrap :B html.at_css('body')&.first_element_child&.before("
#{I18n.t('posts.preview.message')}
") # Cacofonía html.to_html.html_safe end end # Devuelve una llave para poder guardar el post en una cache def cache_key 'posts/' + uuid.value end def cache_version updated_at.utc.to_s(:usec) end # Agregar el timestamp para saber si cambió, siguiendo el módulo # ActiveRecord::Integration def cache_key_with_version cache_key + '-' + cache_version end # TODO: Convertir a UUID? def id path.basename end alias to_param id # Fecha de última modificación del archivo def updated_at File.mtime(path.absolute) end # Obtiene la fecha actual de modificación y la guarda hasta la próxima # vez. def modified_at @modified_at ||= Time.now end # 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... def method_missing(mid, *args) # Limpiar el nombre del atributo, para que todos los ayudantes # reciban el método en limpio name = attribute_name mid unless attribute? name raise NoMethodError, I18n.t('exceptions.post.no_method', method: mid) end # OpenStruct super(mid, *args) # Devolver lo mismo que devuelve el método después de definirlo send(mid, *args) end # Detecta si es un atributo válido o no, a partir de la tabla de la # plantilla def attribute?(mid) included = DEFAULT_ATTRIBUTES.include?(mid) || PRIVATE_ATTRIBUTES.include?(mid) || PUBLIC_ATTRIBUTES.include?(mid) included = attributes.include? mid if !included && singleton_class.method_defined?(:attributes) included end # Devuelve los strong params para el layout def params attributes.map do |attr| send(attr).to_param end end # Genera el post con metadatos en YAML # # TODO: Cachear por un minuto def full_content body = '' yaml = layout.metadata.keys.map(&:to_sym).map do |metadata| template = send(metadata) unless template.front_matter? body += "\n\n" body += template.value next end next if template.empty? [metadata.to_s, template.value] end.compact.to_h # TODO: Convertir a Metadata? # Asegurarse que haya un layout yaml['layout'] = layout.name.to_s yaml['uuid'] = uuid.value # Y que no se procese liquid yaml['liquid'] = false yaml['usuaries'] = usuaries.map(&:id).uniq yaml['last_modified_at'] = modified_at "#{yaml.to_yaml}---\n\n#{body}" end # Eliminar el artículo del repositorio y de la lista de artículos del # sitio def destroy FileUtils.rm_f path.absolute site.delete_post self end alias destroy! destroy # Guarda los cambios # TODO: Agregar una forma de congelar todos los valores y solo guardar # uno, para no incorporar modificaciones # rubocop:disable Metrics/CyclomaticComplexity def save(validate: true) return false if validate && !valid? # Salir si tenemos que cambiar el nombre del archivo y no pudimos return false if !new? && path.changed? && !update_path! return false unless save_attributes! return false unless write # Vuelve a leer el post para tomar los cambios read written? end # rubocop:enable Metrics/CyclomaticComplexity alias save! save # Actualiza la ruta del documento y lo lee def read return unless written? document.path = path.absolute document.read! end def new? document.path.blank? end def written? File.exist? path.absolute end # Actualizar la ubicación del archivo si cambió de lugar y si no # existe el destino def update_path! !File.exist?(path.absolute) && FileUtils.mv(path.value_was, path.absolute) && document.path = path.absolute end # Detecta si el artículo es válido para guardar def valid? self.errors = {} layout.metadata.keys.map(&:to_sym).each do |metadata| template = send(metadata) errors[metadata] = template.errors unless template.valid? end errors.blank? end # Guarda los cambios en el archivo destino def write return true if persisted? Site::Writer.new(site: site, file: path.absolute, content: full_content).save end # Verifica si hace falta escribir cambios # # TODO: Cachear el resultado o usar otro método, por ejemplo guardando # la fecha de modificación al leer y compararla al hacer cambios sin # escribirlos. def persisted? File.exist?(path.absolute) && full_content == File.read(path.absolute) end def destroyed? !File.exist?(path.absolute) end def update_attributes(hashable) hashable.to_hash.each do |name, value| self[name].value = value end save end alias update update_attributes # El Document guarda un Array de los ids de Usuarie. Si está vacío, # no hacemos una consulta vacía. Si no, traemos todes les Usuaries # por su id y convertimos a Array para poder agregar o quitar luego # sin pasar por ActiveRecord. def usuaries @usuaries ||= if (d = document_usuaries).empty? [] else Usuarie.where(id: d).to_a end end private # 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 def document_usuaries document.data.fetch('usuaries', []) end # Obtiene el nombre del atributo a partir del nombre del método def attribute_name(attr) # XXX: Los simbolos van al final @attribute_name_cache ||= {} @attribute_name_cache[attr] ||= ATTR_SUFFIXES.reduce(attr.to_s) do |a, suffix| a.chomp suffix end.to_sym end def load_slug! self.slug = MetadataSlug.new(document: document, site: site, layout: layout, name: :slug, type: :slug, post: self, required: true) end def load_date! self.date = MetadataDocumentDate.new(document: document, site: site, layout: layout, name: :date, type: :document_date, post: self, required: true) end def load_path! self.path = MetadataPath.new(document: document, site: site, layout: layout, name: :path, type: :path, post: self, required: true) end def load_lang! self.lang = MetadataLang.new(document: document, site: site, layout: layout, name: :lang, type: :lang, post: self, required: true) end def load_uuid! self.uuid = MetadataUuid.new(document: document, site: site, layout: layout, name: :uuid, type: :uuid, post: self, required: true) end # Ejecuta la acción de guardado en cada atributo def save_attributes! attributes.map do |attr| send(attr).save end.all? end end # rubocop:enable Metrics/ClassLength # rubocop:enable Style/MissingRespondToMissing