Class: Abqari::ArtifactWriter

Inherits:
Object
  • Object
show all
Defined in:
lib/abqari/artifact_writer.rb

Overview

Filesystem-output collaborator. Owns the steps that produce files in _site/ outside of the page-render loop:

- Wiping / preparing the output directory at the start of a build.
- Copying static passthrough trees (public/, themes/<n>/public/).
- Copying vendored assets (D3, fonts).
- Emitting deploy artifacts (sitemap, feed, robots, headers,
security.txt, redirects, manifest, llms.txt, OG images,
syntax-highlighter stylesheet).

Each step is idempotent: re-running an incremental build only copies files that changed (mtime comparison). The reset_output guard prevents accidentally wiping anything outside site_root.

Constant Summary collapse

PROTECTED_SITE_DIRS =

Directory names we refuse to let output_dir resolve to, because wiping them destroys the user's source, not build output. Checked against the target itself AND against the site root's key children.

%w[content config data public .git].freeze

Instance Method Summary collapse

Constructor Details

#initialize(site) ⇒ ArtifactWriter

Returns a new instance of ArtifactWriter.



20
21
22
# File 'lib/abqari/artifact_writer.rb', line 20

def initialize(site)
  @site = site
end

Instance Method Details

#copy_d3Object

Copy vendored D3 to _site/assets/d3/. Only runs when visualizations are enabled — keeps the ~280 KB out of builds that don't need it.



141
142
143
144
145
146
147
148
149
150
151
152
153
# File 'lib/abqari/artifact_writer.rb', line 141

def copy_d3
  return unless @site.visualizations.enabled?

  src = File.join(@site.engine_root, 'vendor', 'd3', 'd3.v7.min.js')
  return unless File.exist?(src)

  dest_dir = File.join(@site.output_dir, 'assets', 'd3')
  FileUtils.mkdir_p(dest_dir)
  dest = File.join(dest_dir, 'd3.v7.min.js')
  return if File.exist?(dest) && File.mtime(dest) >= File.mtime(src)

  FileUtils.cp(src, dest)
end

#copy_fontsObject

Mirror the FontPipeline-populated vendor/fonts/ into _site/assets/fonts/. No-op when the cache is missing (FontPipeline is opt-in via bin/fetch-fonts).



123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
# File 'lib/abqari/artifact_writer.rb', line 123

def copy_fonts
  src = File.join(@site.site_root, 'vendor', 'fonts')
  return unless Dir.exist?(src)

  dest_dir = File.join(@site.output_dir, 'assets', 'fonts')
  FileUtils.mkdir_p(dest_dir)
  Dir.glob(File.join(src, '*')).sort.each do |entry|
    next if File.directory?(entry) || File.basename(entry).start_with?('.')

    dest = File.join(dest_dir, File.basename(entry))
    next if File.exist?(dest) && File.mtime(dest) >= File.mtime(entry)

    FileUtils.cp(entry, dest)
  end
end

#copy_publicObject

Copy static passthrough files into the build output. Three sources, later wins on conflict:

1. Top-level `public/` — site-wide files (favicons, manifest).
2. The ENGINE theme's `themes/<name>/public/` — theme-scoped
 vendor files (e.g. the Bootstrap dist for `theme: bootstrap`).
3. The SITE's `themes/<name>/public/` — per-file overrides.

Engine and site theme dirs MERGE (same semantics as the asset pipeline): a site adding one file to themes/bootstrap/public/ must not stop the engine's vendored dist from shipping. The old find_in_paths picked exactly one dir, so a single site-local file silently 404'd /css/bootstrap.min.css. Precedence is resolved in the map — NOT via copy order + the mtime skip, which would let whichever source file happened to be newer win.



77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/abqari/artifact_writer.rb', line 77

def copy_public
  theme = @site.config['theme'].to_s
  sources = [
    File.join(@site.site_root, 'public'),
    File.join(@site.engine_root, 'themes', theme, 'public'),
    File.join(@site.site_root, 'themes', theme, 'public')
  ]

  merged = {}
  sources.each do |public_dir|
    next unless Dir.exist?(public_dir)

    Dir.glob(File.join(public_dir, '**', '*'), File::FNM_DOTMATCH).sort.each do |entry|
      next if File.directory?(entry)
      # Symlink containment — the one tree-walker that used to miss
      # this. `FileUtils.cp` follows symlinks, so without the guard a
      # `public/notes.txt -> ~/.ssh/id_ed25519` copies the target's
      # CONTENTS into _site/ and ships them. Every other walker
      # (asset pipeline, bundle assets, image pipeline, loader)
      # enforces the same invariant.
      next if @site.external_symlink?(entry)

      rel = entry.sub("#{public_dir}/", '')
      # Dotfile filter checks EVERY path component, not just the
      # basename. FNM_DOTMATCH descends into hidden directories, so a
      # basename-only check would still copy `.git/config`,
      # `.svn/entries`, editor state dirs, etc. `.well-known/` is the
      # one hidden dir a static site legitimately publishes.
      next if hidden_path?(rel)

      merged[rel] = entry
    end
  end

  merged.each do |rel, entry|
    dest = File.join(@site.output_dir, rel)
    next if File.exist?(dest) && File.mtime(dest) >= File.mtime(entry)

    FileUtils.mkdir_p(File.dirname(dest))
    FileUtils.cp(entry, dest)
  end
end

#reset_outputObject

Wipe output_dir and recreate it empty. Refuses to delete:

- The filesystem root (`/`, `C:\`)
- The user's home directory
- The site root itself (`ABQARI_OUTPUT_DIR=.` — the guard below
for "under site_root" passes on equality, which would wipe the
whole project).
- A source directory (`content/`, `config/`, `data/`, `.git`, …).
- Anything not under `site_root` (unless explicitly opted-in
via ABQARI_ALLOW_EXTERNAL_OUTPUT=true).

Raises:



38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# File 'lib/abqari/artifact_writer.rb', line 38

def reset_output
  target    = File.expand_path(@site.output_dir)
  site_root = File.expand_path(@site.site_root)

  raise PathSafe::Error, "refusing to wipe filesystem root: #{target}" if filesystem_root?(target)
  raise PathSafe::Error, "refusing to wipe home dir: #{target}" if target == File.expand_path('~')
  raise PathSafe::Error, "refusing to wipe site root: #{target}" if target == site_root

  protected_dir = PROTECTED_SITE_DIRS.find do |name|
    dir = File.expand_path(File.join(site_root, name))
    target == dir || dir.start_with?(target + File::SEPARATOR)
  end
  if protected_dir
    raise PathSafe::Error,
          "refusing to wipe #{target}: it is (or contains) the site's #{protected_dir}/ directory"
  end

  unless ENV['ABQARI_ALLOW_EXTERNAL_OUTPUT'] == 'true'
    PathSafe.assert_under(@site.site_root, target)
  end

  FileUtils.rm_rf(target)
  FileUtils.mkdir_p(target)
end

#write_deploy_artifactsObject

Emit every deploy artifact that ships alongside the rendered HTML. Order matters only insofar as some artifacts (e.g. Manifest) read config values that should already be validated; all of those run after Site#build has done its setup.



159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
# File 'lib/abqari/artifact_writer.rb', line 159

def write_deploy_artifacts
  # Crawler-facing inventories (sitemap, llms.txt) are suppressed when
  # the site opts out of indexing — otherwise a staging/preview deploy
  # with `indexable: false` still publishes a full list of its URLs,
  # defeating the point. `robots.txt` (which emits `Disallow: /` in
  # that mode) is always written so crawlers get the signal.
  indexable = @site.config['indexable'] != false

  Sitemap.new(@site).write if indexable
  Feed.write_all(@site)
  Robots.new(@site).write
  HeadersFile.new(@site).write
  SecurityTxt.new(@site).write
  Redirects.new(@site).write
  Manifest.new(@site).write
  LlmsTxt.new(@site).write if indexable
  OgImageGenerator.new(@site).write
  SyntaxHighlighter::Stylesheet.new(@site).write
end