# frozen_string_literal: true # Esta clase representa un post en un sitio jekyll e incluye métodos # para modificarlos y crear nuevos. # # * Los metadatos se tienen que cargar dinámicamente, solo usamos los # que necesitamos # # class Post # 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 created_at].freeze ATTR_SUFFIXES = %w[? =].freeze 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 # # TODO: Reemplazar cuando leamos el contenido del Document # a demanda? def find_layout(path) File.foreach(path).lazy.grep(/^layout: /).take(1).first&.split(' ')&.last&.tr('\'', '')&.tr('"', '')&.to_sym rescue Errno::ENOENT => e ExceptionNotifier.notify_exception(e, data: { path: path }) :post end # Genera un Post nuevo # # @todo Mergear en Post#initialize # @params :path [String] # @params :site [Site] # @params :locale [String, Symbol] # @params :document [Jekyll::Document] # @params :layout [String,Symbol] # @return [Post] def build(**args) args[:path] ||= '' args[:document] ||= begin site = args[:site] collection = site.collections[args[:locale].to_s] Jekyll::Document.new(args[:path], site: site.jekyll, collection: collection).tap do |doc| doc.data['date'] ||= Date.today.to_time end end Post.new(**args) end end # @return [IndexedPost,nil] def indexed_post site.indexed_posts.find_by(locale: lang.value, path: id) 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) # Genera un método con todos los atributos disponibles @layout = args[:layout] @site = args[:site] @document = args[:document] @attributes = layout.attributes + PUBLIC_ATTRIBUTES @errors = {} @metadata = {} # Inicializar valores attributes.each do |attr| public_send(attr)&.value = args[attr] if args.key?(attr) end document.read! unless new? 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 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 = { 'site' => { 'data' => site.data, 'i18n' => site.data[l], 'lang' => l, 'locale' => l }, 'page' => document.to_liquid } # No tener errores de Liquid site.jekyll.config['liquid']['strict_filters'] = false site.jekyll.config['liquid']['strict_variables'] = false # Renderizar lo estrictamente necesario y convertir a HTML para # poder reemplazar valores. html = Nokogiri::HTML document.renderer.render_document # Los archivos se cargan directamente desde el repositorio, porque # no son públicas hasta que se publica el artículo. html.css('img,audio,video,iframe').each do |element| src = element.attributes['src'] next unless src&.value&.start_with? 'public/' src.value = Rails.application.routes.url_helpers.site_static_file_url(site, file: 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 rescue Liquid::Error => e ExceptionNotifier.notify(e, data: { site: site.name, post: post.id }) '' 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 || modified_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 return if new? 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 def [](attr) public_send attr end # Define metadatos a demanda def method_missing(name, *_args) # Limpiar el nombre del atributo, para que todos los ayudantes # reciban el método en limpio unless attribute? name raise NoMethodError, I18n.t('exceptions.post.no_method', method: name) end define_singleton_method(name) do template = layout.metadata[name.to_s] @metadata[name] ||= MetadataFactory.build(document: document, post: self, site: site, name: name, layout: layout, type: template['type'], label: template['label'], help: template['help'], required: template['required']) end public_send name end # TODO: Mover a method_missing def slug @metadata[:slug] ||= MetadataSlug.new(document: document, site: site, layout: layout, name: :slug, type: :slug, post: self, required: true) end # TODO: Mover a method_missing def date @metadata[:date] ||= MetadataDocumentDate.new(document: document, site: site, layout: layout, name: :date, type: :document_date, post: self, required: true) end # TODO: Mover a method_missing def path @metadata[:path] ||= MetadataPath.new(document: document, site: site, layout: layout, name: :path, type: :path, post: self, required: true) end # TODO: Mover a method_missing def lang @metadata[:lang] ||= MetadataLang.new(document: document, site: site, layout: layout, name: :lang, type: :lang, 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, post: self, required: true) end # La fecha de creación inmodificable del post def created_at @metadata[:created_at] ||= MetadataCreatedAt.new(document: document, site: site, layout: layout, name: :created_at, type: :created_at, post: self, required: true) 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. # # XXX: Nos gustaría no tener que instanciar Metadata acá, pero depende # del valor por defecto que a su vez depende de Layout. def params attributes.map do |attr| public_send(attr)&.to_param end.compact end # Genera el post con metadatos en YAML # # TODO: Cachear por un minuto def full_content body = '' yaml = layout.attributes.map do |attr| template = public_send attr unless template.front_matter? body += "\n\n" if body.present? body += template.value next end # 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 # 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['created_at'] = created_at.value 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. # # TODO: Si el callback falla deberíamos recuperar el archivo. # # @return [Post] def destroy run_callbacks :destroy do FileUtils.rm_f path.absolute end 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! # Si el archivo va a estar duplicado, agregar un número al slug if new? && written? original_slug = slug.value count = 1 while written? count += 1 slug.value = "#{original_slug}-#{count}" end end 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? 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? @errors = {} attributes.each do |attr| errors[attr] = self[attr].errors unless self[attr].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 |attr, value| next unless self[attr].writable? self[attr].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 ||= document_usuaries.empty? ? [] : Usuarie.where(id: document_usuaries).to_a 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| raise ArgumentError, I18n.t("exceptions.post.#{attr}_missing") unless args[attr].present? end end def document_usuaries @document_usuaries ||= document.data.fetch('usuaries', []) end # Ejecuta la acción de guardado en cada atributo. def save_attributes! attributes.map do |attr| self[attr].save end.all? end end