# frozen_string_literal: true require 'jekyll/utils' # Esta clase representa un post en un sitio jekyll e incluye métodos # para modificarlos y crear nuevos. # # 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 # 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) # 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 # 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 super(mid, *args) end # rubocop:enable Style/MethodMissingSuper # 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 (DEFAULT_ATTRIBUTES + ATTRIBUTES).include? attribute_name(mid) end end # 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 # 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. # # Recién cuando vamos a guardar creamos el Post, porque ya tenemos # todos los datos para escribir el archivo, que es la condición # necesaria para poder crearlo :P def save cleanup! return false unless valid? return unless write return unless detect_file_rename! # Vuelve a leer el post para tomar los cambios document.read # add_post_to_site! true end alias save! save # Devuelve la ruta del post, si se cambió alguno de los datos, # generamos una ruta nueva para tener siempre la ruta actualizada. def path document.path end # Detecta si el artículo es válido para guardar def valid? validate errors.blank? end # 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 end def slug_changed? new? || @post.data.dig('slug') != slug end # Detecta si un valor es un archivo def url?(name) path = get_front_matter(name) return false unless path.is_a?(String) || path.is_a?(Array) # El primer valor es '' porque la URL empieza con / [path].flatten.map do |p| p.split('/').second == 'public' end.all? end # devuelve las plantillas como strong params, primero los valores # simples, luego los arrays y al final los hashes def template_params @template_params ||= template_fields.map(&:to_param).sort_by do |s| s.is_a?(Symbol) ? 0 : 1 end end private # Completa el front_matter a partir de las variables de otro post que # le sirve de plantilla def front_matter_from_template # XXX: Llamamos a @template en lugar de template porque sino # entramos en una race condition return {} unless @template ft = template_fields.map(&:to_front_matter).reduce({}, :merge) # Convertimos el slug en layout ft['layout'] = template.slug ft 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! # 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 # engañamos eliminando la instancia de @post y recargando otra. def detect_file_rename! return true unless basename_changed? # No eliminamos el archivo a menos que ya exista el reemplazo! return false unless File.exist? path Rails.logger.info I18n.t('posts.logger.rm', path: path) FileUtils.rm @post.path # replace_post! end # Reemplaza el post en el sitio por uno nuevo def replace_post! @old_post = @site.jekyll.collections[@lang].docs.delete @post new_post end # Obtiene el nombre del archivo a partir de los datos que le # pasemos def basename_from_front_matter ext = get_front_matter('ext') || '.markdown' "#{date_as_string}-#{slug}#{ext}" end # Toma los datos del front matter local y los mueve a los datos # que van a ir al post. Si hay símbolos se convierten a cadenas, # porque Jekyll trabaja con cadenas. Se excluyen otros datos que no # van en el frontmatter def merge_with_front_matter!(params) @front_matter.merge! Hash[params.to_hash.map do |k, v| [k, v] unless REJECT_FROM_DATA.include? k end.compact] end # Carga una copia de los datos del post original excluyendo datos # que no nos interesan def load_front_matter! @front_matter = @post.data.reject do |key, _| REJECT_FROM_DATA.include? key end end def cleanup! default_date_is_today! clean_content! slugify_title! update_translations! put_in_order! create_glossary! end # Busca las traducciones y actualiza el frontmatter si es necesario def update_translations! return unless translated? return unless slug_changed? find_translations.each do |post| post.update_attributes(lang: get_front_matter('lang')) post.save end end # Aplica limpiezas básicas del contenido def clean_content! content.try(:delete!, "\r") end # Guarda los cambios en el archivo destino def write r = File.open(path, File::RDWR | File::CREAT, 0o640) do |f| # Bloquear el archivo para que no sea accedido por otro # proceso u otra editora f.flock(File::LOCK_EX) # Empezar por el principio f.rewind # Escribir f.write(full_content) # Eliminar el resto f.flush f.truncate(f.pos) end return true if r.zero? add_error file: I18n.t('posts.errors.file') false end def add_error(hash) hash.each_pair do |k, i| @errors[k] = if @errors.key?(k) [@errors[k], i] else i end end @errors end def default_date_is_today! date ||= Time.now end def slugify_title! self.slug = Jekyll::Utils.slugify(title) if slug.blank? end # Agregar al final de la cola si no especificamos un orden # # TODO si el artículo tiene una fecha que lo coloca en medio de # la colección en lugar de al final, deberíamos reordenar? def put_in_order! return unless order.nil? @front_matter['order'] = @site.posts_for(@collection).count end # Crea el artículo de glosario para cada categoría o tag def create_glossary! return unless site.glossary? %i[tags categories].each do |i| send(i).each do |c| # TODO: no hardcodear, hacer _configurable next if c == 'Glossary' next if site.posts.find do |p| p.title == c end glossary = Post.new(site: site, lang: lang) glossary.update_attributes( title: c, layout: 'glossary', categories: 'Glossary' ) glossary.save! 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