# frozen_string_literal: true # Un sitio es un directorio dentro del directorio de sitios de cada # Usuaria class Site < ApplicationRecord include FriendlyId include Site::Forms include Site::FindAndReplace # TODO: Hacer que los diferentes tipos de deploy se auto registren # @see app/services/site_service.rb DEPLOYS = %i[local www zip].freeze validates :name, uniqueness: true, hostname: { allow_root_label: true } validates :design_id, presence: true validate :deploy_local_presence validates_inclusion_of :status, in: %w[waiting enqueued building] validates_presence_of :title validates :description, length: { in: 50..160 } friendly_id :name, use: %i[finders] belongs_to :design belongs_to :licencia has_many :deploys has_many :build_stats, through: :deploys has_many :roles has_many :usuaries, -> { where('roles.rol = ?', 'usuarie') }, through: :roles has_many :invitades, -> { where('roles.rol = ?', 'invitade') }, through: :roles, source: :usuarie # Mantenemos el nombre que les da Jekyll has_many_attached :static_files # Clonar el directorio de esqueleto antes de crear el sitio before_create :clone_skel! # Elimina el directorio al destruir un sitio 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, :static_file_migration! # Cambiar el nombre del directorio before_update :update_name! # Guardar la configuración si hubo cambios after_save :sync_attributes_with_config! accepts_nested_attributes_for :deploys, allow_destroy: true # El sitio en Jekyll attr_reader :jekyll # No permitir HTML en estos atributos def title=(title) super(title.strip_tags) end def description=(description) super(description.strip_tags) end # El repositorio git para este sitio def repository @repository ||= Site::Repository.new path end def hostname sub = name || I18n.t('deploys.deploy_local.ejemplo') if sub.ends_with? '.' sub.gsub(/\.\Z/, '') else "#{sub}.#{Site.domain}" end end # Devuelve la URL siempre actualizada a través del hostname # # @param slash Boolean Agregar / al final o no # @return String La URL con o sin / al final def url(slash: true) 'https://' + hostname + (slash ? '/' : '') end # Obtiene los dominios alternativos # # @return Array def alternative_hostnames deploys.where(type: 'DeployAlternativeDomain').map(&:hostname).map do |h| h.end_with?('.') ? h[0..-2] : h + '.' + Site.domain end end # Obtiene todas las URLs alternativas para este sitio # # @return Array def alternative_urls(slash: true) alternative_hostnames.map do |h| 'https://' + h + (slash ? '/' : '') end end # Todas las URLs posibles para este sitio # # @return Array def urls(slash: true) alternative_urls(slash: slash) << url(slash: slash) end def invitade?(usuarie) !invitades.where(id: usuarie).empty? end def usuarie?(usuarie) !usuaries.where(id: usuarie).empty? end # Este sitio acepta invitades? def invitades? config.fetch('invitades', false) end # Traer la ruta del sitio def path File.join(Site.site_path, name) end # La ruta anterior def path_was File.join(Site.site_path, name_was) end # Obtiene la lista de traducciones actuales # # Siempre tiene que tener algo porque las traducciones están # incorporadas a los sitios de Sutty, aunque les usuaries no traduzcan # sus sitios. def locales config.fetch('locales', I18n.available_locales.map(&:to_s)) end # Similar a site.i18n en jekyll-locales def i18n data[I18n.locale.to_s] end # Devuelve el idioma por defecto del sitio, el primero de la lista. def default_locale locales.first 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 @jekyll.data end # Traer las colecciones. Todos los artículos van a estar dentro de # colecciones. def collections read @jekyll.collections end # Traer la configuración de forma modificable def config @config ||= Site::Config.new(self) 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) read # Traemos los posts del idioma actual por defecto lang ||= I18n.locale # Crea un Struct dinámico con los valores de los locales, si # llegamos a pasar un idioma que no existe vamos a tener una # excepción NoMethodError @posts ||= Struct.new(*locales.map(&:to_sym)).new return @posts[lang] unless @posts[lang].blank? @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[Post.find_layout(doc)] @posts[lang].build(document: doc, layout: layout, lang: lang) end @posts[lang] end # Todos los Post del sitio para poder buscar en todos. # # @return PostRelation def docs @docs ||= PostRelation.new(site: self).push(locales.flat_map do |locale| posts(lang: locale) end).flatten! end # Elimina un artículo de la colección def delete_post(post) lang = post.lang.value collections[lang.to_s].docs.delete(post.document) && posts(lang: lang).delete(post) post end # Obtiene todas las plantillas de artículos # # @return { post: Layout } def layouts # Crea un Struct dinámico cuyas llaves son los nombres de todos los # layouts. Si pasamos un layout que no existe, obtenemos un # NoMethodError @layouts_struct ||= Struct.new(*data.fetch('layouts', {}).keys.map(&:to_sym), keyword_init: true) @layouts ||= @layouts_struct.new(**data.fetch('layouts', {}).map do |name, metadata| { name.to_sym => Layout.new(site: self, name: name.to_sym, metadata: metadata.with_indifferent_access) } end.inject(:merge)) end # Trae todos los valores disponibles para un campo # # TODO: Traer recursivamente, si el campo contiene Hash # # TODO: Mover a PostRelation#pluck # # @param attr [Symbol|String] El atributo a buscar # @return Array def everything_of(attr, lang: nil) attr = attr.to_sym posts(lang: lang).flat_map do |p| p.send(attr).try(:value) if p.attribute? attr end.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 # 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 # 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 load_jekyll return unless name.present? && File.directory?(path) reload_jekyll! end def reload_jekyll! reset Dir.chdir(path) do @jekyll = Jekyll::Site.new(jekyll_config) end end def reload super reload_jekyll! end def jekyll_config # Pasamos destination porque configuration() toma el directorio # actual # # Especificamos `safe` para no cargar los _plugins, que interfieren # 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].each do |unneeded| configuration[unneeded] = [] if configuration.key? unneeded end # Eliminar el theme si no es una gema válida configuration.delete 'theme' unless theme_available? # Si estamos usando nuestro propio plugin de i18n, los posts están # en "colecciones" locales.each do |i| configuration['collections'][i] = {} end configuration end # Lista los nombres de las plantillas disponibles como gemas, # tomándolas dinámicamente de las que agreguemos en el grupo :themes # del Gemfile. def available_themes @available_themes ||= Bundler.load.current_dependencies.select do |gem| gem.groups.include? :themes end.map(&:name) end # Detecta si el tema actual es una gema def theme_available? available_themes.include? design.gem 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 def reset @read = false @layouts = nil @layouts_struct = nil end private # Clona el esqueleto de Sutty para crear el sitio nuevo, no pasa nada # si el sitio ya existe def clone_skel! return if File.directory? path Rugged::Repository.clone_at ENV['SKEL_SUTTY'], path end # Elimina el directorio del sitio def remove_directories! FileUtils.rm_rf path end def update_name! return unless name_changed? FileUtils.mv path_was, path reload_jekyll! 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 config.description = description config.title = title config.url = url config.hostname = hostname end # Migra los archivos a Sutty def static_file_migration! Site::StaticFileMigration.new(site: self).migrate! end # Valida si el sitio tiene al menos una forma de alojamiento asociada # y es la local # # TODO: Volver opcional el alojamiento local, pero ahora mismo está # atado a la generación del sitio así que no puede faltar def deploy_local_presence # Usamos size porque queremos saber la cantidad de deploys sin # guardar también return if deploys.size.positive? && deploys.map(&:type).include?('DeployLocal') errors.add(:deploys, I18n.t('activerecord.errors.models.site.attributes.deploys.deploy_local_presence')) end end