# frozen_string_literal: true module Vite # We use two manifest files to list the assets loaded in our backend: # - manifest.json: This is the main file that lists all the javascript and stylesheets our frontend app uses # - manifest-assets.json: This only lists media assets linked directly from our backend, mainly images # # Not all the assets listed in the manifest.json file are linked or used by the app view, so we don't need to # keep track of those. The idea behind this class is to keep track only of the main entrypoints and all their # subsequent imports. Imports in this case include other javascript modules and stylesheets generated by Vite. # # This class focuses on reducing the amount of data we keep per entrypoint and making fast lookups. To do so # it keeps two main datastructures: # - entries: A Hash with references to each main entrypoint (including media assets) # - pool: An Array with all the referenced imports and stylesheets # # Each entrypoint has a list of IDs for each import and stylesheet that it needs to load, this ID is # an index to the main pool. With this the referenced asset is stored only once and referenced across # entries with a unique ID. In the original manifest files references are also nested, if an entrypoint # imports a javascript module, this first module can import other modules and stylesheet and so on. To keep # fetching entrypoints fast, the final list of modules and stylesheets to load per entrypoint # is expanded when loading the manifest data and not when rendering views. Each entrypoint has the final # list of IDs of imports and stylesheets it will need to link in the backend, without further recursive # checks. class Manifest class MissingManifestError < StandardError; end class MissingEntryError < StandardError def initialize(entry) super("Failed to find entry '#{entry}' in manifest files") end end Entrypoint = Struct.new(:file, :integrity, :import_ids, :stylesheet_ids) Asset = Struct.new(:file, :integrity) attr_reader :config, :entries, :pool, :logger def initialize(config:, logger:) @config = config @virtual = {} @entries = {} @pool = [] @loaded = false @logger = logger end def reload @loaded = false load end def load return if @loaded retries = 0 begin build if retries.positive? load_manifest @loaded = true rescue Errno::ENOENT => e if retries.zero? && config.auto_build? retries = 1 retry else raise MissingManifestError, e.message end end end def fetch(name, type: nil) load key = type == :virtual ? @virtual[name] : name entries[key] end def fetch!(name, type: nil) fetch(name, type:) || raise(MissingEntryError, name) end def imports_for(entry) find_entries(entry[:import_ids] || []) end def stylesheets_for(entry) find_entries(entry[:stylesheet_ids] || []) end private def load_manifest @virtual = {} @entries = {} @pool = [] # intermediate Hash for fast lookups while parsing the manifest @lookup = {} logger.debug { "loading manifest file: #{config.manifest_path}" } json = JSON.load_file(config.manifest_path) json.each do |key, raw| # Only select main entrypoints and locales next unless raw['isEntry'] || key.start_with?('mastodon/locales/') import_ids, stylesheet_ids = resolve_relations(json, raw) @virtual[raw['name']] = key @entries[key] = Entrypoint.new( raw['file'], raw['integrity'], import_ids, stylesheet_ids ) end logger.debug { "loading manifest file: #{config.manifest_assets_path}" } assets = JSON.load_file(config.manifest_assets_path) assets.each do |key, raw| @entries[key] = Asset.new( raw['file'], raw['integrity'] ) end ensure @lookup = {} end def build logger.debug { 'AutoBuild enabled' } builder.build end def builder @builder ||= Vite::Builder.new(config:, logger:) end def resolve_relations(manifest, raw) # NOTE: By using Set we make sure we add each asset only once imports = Set.new stylesheets = Set.new raw['imports']&.each do |import| raw_import = manifest[import] next unless raw_import id, deps = lookup(file: raw_import['file'], integrity: raw_import['integrity']) do resolve_relations(manifest, raw_import) end imports.merge(deps[0]) stylesheets.merge(deps[1]) imports.add id end raw['css']&.each do |stylesheet| # the css field includes the file names of the stylesheets and not # the src names which are the ones used as keys in the manifest raw_style = manifest.values.find { |value| value['file'] == stylesheet } # some listed CSS files are not included as a entry in the manifest, # but they are among the generated assets. We can still link to them # but without the integrity field raw_style = { 'file' => stylesheet } if raw_style.nil? id, = lookup(file: raw_style['file'], integrity: raw_style['integrity']) stylesheets.add id end [imports, stylesheets] end # Lookup in the internal cache if an entry has been processed # If not, yield and save the result of the block as the entry deps def lookup(file:, integrity:) id, deps = @lookup[file] || [] if id.nil? @pool << Asset.new(file, integrity) id = @pool.size - 1 # last ID deps = yield if block_given? @lookup[file] = [id, deps] end [id, deps] end def find_entries(ids) ids.filter_map { |id| pool[id] } end end end