howzit 2.1.45 → 2.1.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3b5d2120c8484147d2d3cb8f8844073ca027e7a54655385749ee721f645d05a5
4
- data.tar.gz: 3cef9cda18bf1b43b28d9288e9eda81d30b07fc9ac2505dd932e508240abf86b
3
+ metadata.gz: daae85713f05d780640619a8ada2291aaa69c0eb3f2d941f6fb9e2964c9247b4
4
+ data.tar.gz: 5773440b0a9718af5dd0b3e66929bfa74023061b99a85c61d65558b76ac57d4d
5
5
  SHA512:
6
- metadata.gz: 4dc4af7fce8a375a33a3dfee3ed2e6f9d914ea3069601aa3426ae552f4c5488d5aafa332522c4eef0a6b4318bd63afef20eda2d638e9c6fd09ce9784c5e6a434
7
- data.tar.gz: fe60529771944221e4f7acd4bd9883669782f413c5a367ae3c03c8c7d8ce0a0e0413fcf4e086e37584868c58bbea10bb4e24ab49c28583521d901166404146be
6
+ metadata.gz: 1f688d2e403997a3cf6f3b91c0ebd3746ed51b1d0bc4dfea2be5fd1297a22715d2a7b5672482b2f243dc26b38b56eee2880c9e57df98c55c16178b84eddeb8da
7
+ data.tar.gz: 934e5ef9e7b71bc0323475ae4c54596d7a6813f552d63fd08dd358f51805c6950780d4435b9cf830aed7712392a5a38d52077dfbcceb69293adf02f9b0970794
data/CHANGELOG.md CHANGED
@@ -1,3 +1,28 @@
1
+ ### 2.1.46
2
+
3
+ 2026-09-27 12:52
4
+
5
+ #### CHANGED
6
+
7
+ - **Shell run blocks** (sh, bash, zsh, dash, ksh, or no hashbang) now receive Howzit variables as environment variables instead of having ${VAR} replaced in the script text, so scripts can use their own variables, parameter expansions, and ENV without Howzit clobbering them.
8
+ - **Command-line arguments** after -- are passed to shell run blocks as script arguments, so $1, $#, and "$@" work like any shell script (including inside shell functions).
9
+
10
+ #### NEW
11
+
12
+ - **:shell_variables: config option** (env or substitute) to switch shell run blocks back to text substitution; defaults to env.
13
+ - **$${VAR} escape** passes ${VAR} through to the shell untouched in @run, @copy, and text-substituted blocks.
14
+
15
+ #### IMPROVED
16
+
17
+ - **Howzit-style defaults** (${name:default}) in shell run blocks are converted to the shell's ${name:-default} so they keep working with environment variables.
18
+
19
+ #### FIXED
20
+
21
+ - **Shell parameter expansions** like ${VAR:-default}, ${VAR:=x}, ${VAR:+x}, ${VAR:?x}, and ${VAR:0:3} are no longer mangled into Howzit defaults when VAR isn't a Howzit variable.
22
+ - **Positional placeholders** ($1, ${2}) without a matching argument are left intact instead of being replaced with an empty string, which broke shell functions and awk '{print $1}'.
23
+ - **$@ and $*** are only replaced when arguments were passed after --, instead of always being replaced with an empty string.
24
+ - **Default values containing colons** (e.g. ${url:http://localhost:3000}) are no longer cut off at the second colon.
25
+
1
26
  ### 2.1.45
2
27
 
3
28
  2026-09-27 12:16
data/lib/howzit/config.rb CHANGED
@@ -21,6 +21,7 @@ module Howzit
21
21
  output_title: false,
22
22
  pager: 'auto',
23
23
  paginate: true,
24
+ shell_variables: 'env', # env: pass variables to shell run blocks via ENV, substitute: replace ${VAR} in text
24
25
  show_all_code: false,
25
26
  show_all_on_error: false,
26
27
  wrap: 0
@@ -153,6 +153,35 @@ module Howzit
153
153
  end
154
154
  end
155
155
 
156
+ ##
157
+ ## Whether a run block is a POSIX-style shell script (sh, bash,
158
+ ## zsh, dash, ksh). Blocks without a hashbang run with sh.
159
+ ##
160
+ ## @param script_content [String] The script content
161
+ ##
162
+ ## @return [Boolean]
163
+ ##
164
+ def shell_script?(script_content)
165
+ first_line = script_content.to_s.lines.first&.strip
166
+ return true unless first_line&.start_with?('#!')
167
+
168
+ first_line.match?(%r{\A#!\s*(?:/usr/bin/env\s+)?(?:\S*/)?(?:sh|bash|zsh|dash|ksh)(?:\s|\z)})
169
+ end
170
+
171
+ ##
172
+ ## Howzit variables as environment variables for shell run blocks
173
+ ##
174
+ ## @return [Hash] name => string value
175
+ ##
176
+ def variables_env
177
+ (Howzit.named_arguments || {}).each_with_object({}) do |(key, value), env|
178
+ name = key.to_s
179
+ next if value.nil? || !name.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
180
+
181
+ env[name] = value.to_s
182
+ end
183
+ end
184
+
156
185
  ##
157
186
  ## Get the injection line for a given interpreter
158
187
  ##
@@ -383,29 +383,64 @@ module Howzit
383
383
  str = dup
384
384
  str.render_named_placeholders
385
385
  str.render_numeric_placeholders
386
- Howzit.arguments.nil? ? str : str.gsub(/\$[@*]/, Shellwords.join(Howzit.arguments))
386
+ args = Howzit.arguments
387
+ return str if args.nil? || args.empty?
388
+
389
+ str.gsub(/(?<!\$)\$[@*]/, Shellwords.join(args))
387
390
  end
388
391
 
392
+ ##
393
+ ## Replace ${NAME} and ${NAME:default} with Howzit variable
394
+ ## values. Names Howzit doesn't know, shell parameter
395
+ ## expansions (${VAR:-x}, ${VAR:=x}, ${VAR:0:3}, etc.), and
396
+ ## escaped $${NAME} placeholders are left for the shell.
397
+ ##
389
398
  def render_named_placeholders
390
- gsub!(/\$\{(?<name>[A-Z0-9_]+(?::.*?)?)\}/i) do
399
+ gsub!(/(?<!\$)\$\{(?<name>[A-Z0-9_]+)(?<rest>:[^}]*)?\}/i) do
391
400
  m = Regexp.last_match
392
- arg, default = m['name'].split(/:/).map(&:strip)
393
- if Howzit.named_arguments&.key?(arg) && !Howzit.named_arguments[arg].nil?
394
- Howzit.named_arguments[arg]
395
- elsif default
396
- default
401
+ original = m[0]
402
+ rest = m['rest'].to_s
403
+ value = placeholder_value(m['name'])
404
+
405
+ if rest.empty?
406
+ value.nil? ? original : value
407
+ elsif rest.start_with?(':-')
408
+ value.nil? || value.empty? || rest.include?('$') ? original : value
409
+ elsif rest.match?(/\A:[=+?\s\d]/)
410
+ original
397
411
  else
398
- # Preserve the original ${VAR} syntax if variable is not defined and no default provided
399
- m[0]
412
+ value.nil? ? rest[1..].strip : value
400
413
  end
401
414
  end
402
415
  end
403
416
 
404
417
  def render_numeric_placeholders
405
- gsub!(/\$\{?(\d+)\}?/) do
406
- arg, default = Regexp.last_match(1).split(/:/)
407
- idx = arg.to_i - 1
408
- Howzit.arguments.length > idx ? Howzit.arguments[idx] : default || Regexp.last_match(0)
418
+ gsub!(/(?<!\$)\$(?:\{(\d+)\}|(\d+))/) do
419
+ m = Regexp.last_match
420
+ placeholder_value(m[1] || m[2]) || m[0]
421
+ end
422
+ end
423
+
424
+ ##
425
+ ## Remove the escape from $${NAME} placeholders, leaving
426
+ ## ${NAME} for the shell. Applied right before execution.
427
+ ##
428
+ ## @return [String] unescaped string
429
+ ##
430
+ def unescape_placeholders
431
+ gsub(/\$\$(?=\{)/, '$')
432
+ end
433
+
434
+ ##
435
+ ## Convert Howzit-style ${NAME:default} to the shell's
436
+ ## ${NAME:-default} so defaults work when variables are
437
+ ## passed to a shell script through the environment
438
+ ##
439
+ ## @return [String] converted string
440
+ ##
441
+ def shellify_defaults
442
+ gsub(/(?<!\$)\$\{([A-Za-z_][A-Za-z0-9_]*|\d+):(?![-=+?\s\d$(])([^}]*)\}/) do
443
+ "${#{Regexp.last_match(1)}:-#{Regexp.last_match(2)}}"
409
444
  end
410
445
  end
411
446
 
@@ -560,6 +595,24 @@ module Howzit
560
595
  Color.template("\n\n#{title}#{tail}{x}\n\n")
561
596
  end
562
597
  end
598
+
599
+ private
600
+
601
+ ##
602
+ ## Look up a placeholder name: digits are positional
603
+ ## arguments, anything else is a named variable
604
+ ##
605
+ ## @return [String, nil] value, or nil if not defined
606
+ ##
607
+ def placeholder_value(name)
608
+ if name.match?(/\A\d+\z/)
609
+ idx = name.to_i - 1
610
+ args = Howzit.arguments || []
611
+ return idx >= 0 && idx < args.length ? args[idx].to_s : nil
612
+ end
613
+
614
+ Howzit.named_arguments&.[](name)&.to_s
615
+ end
563
616
  end
564
617
  end
565
618
 
data/lib/howzit/task.rb CHANGED
@@ -32,7 +32,8 @@ module Howzit
32
32
  @title = attributes[:title]&.to_s
33
33
  @parent = attributes[:parent] || nil
34
34
 
35
- @action = attributes[:action].render_arguments || nil
35
+ action = attributes[:action]
36
+ @action = shell_env_block?(action) ? action : action.render_arguments
36
37
  @log_level = attributes[:log_level]
37
38
  # Get source_file from parent topic if available, or from attributes
38
39
  parent_obj = attributes[:parent]
@@ -61,15 +62,37 @@ module Howzit
61
62
  @title
62
63
  end
63
64
 
65
+ ##
66
+ ## Whether this is a shell run block that receives Howzit
67
+ ## variables through the environment instead of text substitution
68
+ ##
69
+ ## @param content [String] The block content
70
+ ##
71
+ def shell_env_block?(content = @action)
72
+ @type == :block &&
73
+ Howzit.options[:shell_variables].to_s != 'substitute' &&
74
+ ScriptSupport.shell_script?(content)
75
+ end
76
+
64
77
  ##
65
78
  ## Execute a block type
66
79
  ##
67
80
  def run_block
68
81
  Howzit.console.info "#{@prefix}{bg}Running block {bw}#{@title}{x}".c if Howzit.options[:log_level] < 2
69
82
  block = @action
83
+ env_mode = shell_env_block?(block)
70
84
  # Apply variable substitution to block content at execution time
71
85
  # (variables from previous run blocks are now available)
72
- block = block.render_arguments if block && !block.empty?
86
+ if env_mode
87
+ block = block.shellify_defaults
88
+ env = ScriptSupport.variables_env
89
+ script_args = Howzit.arguments || []
90
+ else
91
+ block = block.render_arguments if block && !block.empty?
92
+ env = {}
93
+ script_args = []
94
+ end
95
+ block = block.unescape_placeholders
73
96
  script = Tempfile.new('howzit_script')
74
97
  comm_file = ScriptComm.setup
75
98
  old_log_level = apply_log_level
@@ -117,9 +140,9 @@ module Howzit
117
140
  cmd = ScriptSupport.execution_command_for(script.path, interpreter)
118
141
  # If interpreter is nil, execute directly (will respect hashbang)
119
142
  res = if interpreter.nil?
120
- system(script.path)
143
+ system(env, script.path, *script_args)
121
144
  else
122
- system(cmd)
145
+ system(env, [cmd, *script_args.map { |arg| Shellwords.escape(arg) }].join(' '))
123
146
  end
124
147
  ensure
125
148
  # Restore original directory
@@ -241,7 +264,7 @@ module Howzit
241
264
  Dir.chdir(expanded_exec_dir) if expanded_exec_dir != expanded_original
242
265
  end
243
266
 
244
- res = system(@action)
267
+ res = system(@action.unescape_placeholders)
245
268
  ensure
246
269
  # Restore original directory
247
270
  if exec_dir && Dir.exist?(exec_dir)
@@ -274,7 +297,7 @@ module Howzit
274
297
  @title && !@title.empty? ? @title : @action
275
298
  end
276
299
  Howzit.console.info("#{@prefix}{bg}Copied {bw}#{display_title}{bg} to clipboard{x}".c)
277
- Util.os_copy(@action)
300
+ Util.os_copy(@action.unescape_placeholders)
278
301
  @last_status = 0
279
302
  true
280
303
  end
@@ -3,5 +3,5 @@
3
3
  # Primary module for this gem.
4
4
  module Howzit
5
5
  # Current Howzit version.
6
- VERSION = '2.1.45'
6
+ VERSION = '2.1.46'
7
7
  end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'fileutils'
5
+ require 'tmpdir'
6
+
7
+ describe 'shell variable handling' do
8
+ before do
9
+ Howzit.arguments = []
10
+ Howzit.named_arguments = { 'file' => 'README.md', 'version' => '1.2.3' }
11
+ end
12
+
13
+ after do
14
+ Howzit.arguments = []
15
+ Howzit.named_arguments = {}
16
+ end
17
+
18
+ describe 'String#render_arguments' do
19
+ it 'substitutes Howzit variables' do
20
+ expect('v${version}'.render_arguments).to eq('v1.2.3')
21
+ end
22
+
23
+ it 'leaves unknown variables for the shell' do
24
+ expect('echo "${HOME}"'.render_arguments).to eq('echo "${HOME}"')
25
+ end
26
+
27
+ it 'uses Howzit-style defaults for undefined variables' do
28
+ expect('${missing:fallback}'.render_arguments).to eq('fallback')
29
+ expect('${url:http://localhost:3000}'.render_arguments).to eq('http://localhost:3000')
30
+ end
31
+
32
+ it 'leaves shell parameter expansions alone' do
33
+ %w[${OUT:-/tmp} ${OUT:=/tmp} ${OUT:+set} ${OUT:?missing} ${NAME:0:3} ${#NAME} ${NAME/a/b}].each do |expansion|
34
+ expect(expansion.render_arguments).to eq(expansion)
35
+ end
36
+ end
37
+
38
+ it 'substitutes a defined Howzit variable in ${VAR:-default}' do
39
+ expect('${version:-0.0.0}'.render_arguments).to eq('1.2.3')
40
+ end
41
+
42
+ it 'leaves positional placeholders without a matching argument' do
43
+ expect('f() { echo "$1 ${2}"; }'.render_arguments).to eq('f() { echo "$1 ${2}"; }')
44
+ expect("awk '{print $1}'".render_arguments).to eq("awk '{print $1}'")
45
+ end
46
+
47
+ it 'leaves $@ and $* alone when no arguments were passed' do
48
+ expect('run() { "$@"; }'.render_arguments).to eq('run() { "$@"; }')
49
+ end
50
+
51
+ it 'substitutes positional arguments when passed' do
52
+ Howzit.arguments = ['one', 'two words']
53
+ expect('$1 ${2} ${3:three}'.render_arguments).to eq('one two words three')
54
+ expect('cmd $@'.render_arguments).to eq("cmd one two\\ words")
55
+ end
56
+
57
+ it 'leaves escaped $${VAR} placeholders until execution' do
58
+ rendered = 'echo $${file}'.render_arguments
59
+ expect(rendered).to eq('echo $${file}')
60
+ expect(rendered.unescape_placeholders).to eq('echo ${file}')
61
+ end
62
+ end
63
+
64
+ describe 'String#shellify_defaults' do
65
+ it 'converts Howzit defaults to shell defaults' do
66
+ expect('${name:world} ${1:first}'.shellify_defaults).to eq('${name:-world} ${1:-first}')
67
+ end
68
+
69
+ it 'leaves shell expansions alone' do
70
+ %w[${name} ${name:-x} ${name:0:3} ${name:$offset} ${name: -2}].each do |expansion|
71
+ expect(expansion.shellify_defaults).to eq(expansion)
72
+ end
73
+ end
74
+ end
75
+
76
+ describe 'ScriptSupport.shell_script?' do
77
+ it 'detects shell scripts' do
78
+ expect(Howzit::ScriptSupport.shell_script?("echo hi\n")).to be true
79
+ expect(Howzit::ScriptSupport.shell_script?("#!/bin/bash\necho hi\n")).to be true
80
+ expect(Howzit::ScriptSupport.shell_script?("#!/usr/bin/env zsh\n")).to be true
81
+ expect(Howzit::ScriptSupport.shell_script?("#!/bin/sh -e\n")).to be true
82
+ end
83
+
84
+ it 'excludes other interpreters' do
85
+ expect(Howzit::ScriptSupport.shell_script?("#!/usr/bin/env ruby\n")).to be false
86
+ expect(Howzit::ScriptSupport.shell_script?("#!/usr/bin/env fish\n")).to be false
87
+ expect(Howzit::ScriptSupport.shell_script?("#!/usr/bin/env python3\n")).to be false
88
+ end
89
+ end
90
+
91
+ describe 'running blocks' do
92
+ let(:temp_dir) { File.expand_path(Dir.mktmpdir('howzit_shell_vars_test')) }
93
+ let(:out_file) { File.join(temp_dir, 'out.txt') }
94
+
95
+ before do
96
+ File.write(
97
+ File.join(temp_dir, 'buildnotes.md'),
98
+ <<~NOTE
99
+ # Shell Variables
100
+
101
+ ## Shell Block (target:prod)
102
+
103
+ ```run
104
+ #!/bin/bash
105
+ greet() { echo "arg=$1"; }
106
+ for file in a.rb; do echo "loop=${file}"; done > "#{out_file}"
107
+ echo "howzit=${target}" >> "#{out_file}"
108
+ echo "default=${undefined_var:fallback}" >> "#{out_file}"
109
+ echo "shell_default=${UNSET_THING:-shellval}" >> "#{out_file}"
110
+ echo "env=${HOWZIT_SPEC_ENV}" >> "#{out_file}"
111
+ greet hello >> "#{out_file}"
112
+ echo "script_arg=${1:-none}" >> "#{out_file}"
113
+ ```
114
+
115
+ ## Escaped Run
116
+
117
+ @run(echo "$${HOWZIT_SPEC_ENV}" > "#{out_file}") Escaped
118
+ NOTE
119
+ )
120
+
121
+ ENV['HOWZIT_SPEC_ENV'] = 'from_env'
122
+ Dir.chdir(temp_dir)
123
+ Howzit.instance_variable_set(:@buildnote, nil)
124
+ Howzit.options[:stack] = false
125
+ Howzit.options[:include_upstream] = false
126
+ Howzit.options[:log_level] = 3
127
+ Howzit.named_arguments = { 'file' => 'README.md' }
128
+ end
129
+
130
+ after do
131
+ ENV.delete('HOWZIT_SPEC_ENV')
132
+ Howzit.options[:shell_variables] = 'env'
133
+ Howzit.options[:log_level] = 1
134
+ Dir.chdir(Dir.tmpdir)
135
+ FileUtils.rm_rf(temp_dir) if Dir.exist?(temp_dir)
136
+ Howzit.instance_variable_set(:@buildnote, nil)
137
+ end
138
+
139
+ def output_lines
140
+ File.read(out_file).lines.map(&:strip)
141
+ end
142
+
143
+ it 'passes Howzit variables to shell blocks through the environment' do
144
+ Howzit::BuildNote.new.find_topic('Shell Block')[0].run
145
+
146
+ expect(output_lines).to eq([
147
+ 'loop=a.rb',
148
+ 'howzit=prod',
149
+ 'default=fallback',
150
+ 'shell_default=shellval',
151
+ 'env=from_env',
152
+ 'arg=hello',
153
+ 'script_arg=none'
154
+ ])
155
+ end
156
+
157
+ it 'passes positional arguments to the script' do
158
+ Howzit.arguments = ['cli_arg']
159
+ Howzit::BuildNote.new.find_topic('Shell Block')[0].run
160
+
161
+ expect(output_lines).to include('arg=hello', 'script_arg=cli_arg')
162
+ end
163
+
164
+ it 'uses text substitution when shell_variables is substitute' do
165
+ Howzit.options[:shell_variables] = 'substitute'
166
+ Howzit::BuildNote.new.find_topic('Shell Block')[0].run
167
+
168
+ expect(output_lines).to include('loop=README.md', 'howzit=prod')
169
+ end
170
+
171
+ it 'passes escaped placeholders to the shell in @run commands' do
172
+ Howzit::BuildNote.new.find_topic('Escaped Run')[0].run
173
+
174
+ expect(output_lines).to eq(['from_env'])
175
+ end
176
+ end
177
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: howzit
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.45
4
+ version: 2.1.46
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brett Terpstra
@@ -314,7 +314,6 @@ files:
314
314
  - lib/howzit/topic.rb
315
315
  - lib/howzit/util.rb
316
316
  - lib/howzit/version.rb
317
- - nested-topics-for-howzit.md
318
317
  - scripts/runtests.sh
319
318
  - spec/buildnote_spec.rb
320
319
  - spec/cli_spec.rb
@@ -330,6 +329,7 @@ files:
330
329
  - spec/script_comm_spec.rb
331
330
  - spec/sequential_conditional_spec.rb
332
331
  - spec/set_var_spec.rb
332
+ - spec/shell_variables_spec.rb
333
333
  - spec/spec_helper.rb
334
334
  - spec/stack_mode_spec.rb
335
335
  - spec/stringutils_spec.rb
@@ -378,6 +378,7 @@ test_files:
378
378
  - spec/script_comm_spec.rb
379
379
  - spec/sequential_conditional_spec.rb
380
380
  - spec/set_var_spec.rb
381
+ - spec/shell_variables_spec.rb
381
382
  - spec/spec_helper.rb
382
383
  - spec/stack_mode_spec.rb
383
384
  - spec/stringutils_spec.rb
@@ -1,100 +0,0 @@
1
- ---
2
- title: Nested topics for Howzit
3
- layout: post
4
- tags: [howzit, ruby, markdown, cli, automation]
5
- categories: [Blog,Code]
6
- post_class: 'code'
7
- comments: true
8
- ---
9
- [Howzit](https://github.com/ttscoff/howzit) has always split build notes into topics using Markdown headers. What it didn't do was care *which* header level you used. An `##` and a `###` were both just "a topic," and they all got flattened into one long list. As of version 2.1.44, that's changed: deeper headers now nest under the header above them, and that nesting affects both how notes display and what happens when you run them.
10
-
11
- ### The short version
12
-
13
- A `###` following a `##` is now a subtopic of that `##`. A `####` under the `###` is a subtopic of the `###`, and so on down. The next header at the same level (or shallower) closes out the parent.
14
-
15
- ```markdown
16
- ## Deploy
17
-
18
- @run(./scripts/preflight.sh) Preflight checks
19
-
20
- ### Build assets
21
-
22
- @run(gulp) Build
23
-
24
- ### Upload
25
-
26
- @run(gulp sync) Sync to server
27
-
28
- ## Package management
29
- ```
30
-
31
- Here "Build assets" and "Upload" belong to "Deploy." "Package management" is back at the top level.
32
-
33
- ### Viewing a parent topic
34
-
35
- When you pull up a parent topic, you now get the whole thing: the parent, followed by all of its subtopics.
36
-
37
- ```console
38
- $ howzit deploy
39
- ```
40
-
41
- Subtopic headers get a slightly lighter treatment (a dotted rule and a cyan title) so you can see where the nesting happens without it getting noisy. You can still ask for a subtopic directly:
42
-
43
- ```console
44
- $ howzit upload
45
- ```
46
-
47
- ...and you'll get just that section.
48
-
49
- ### Running a parent topic
50
-
51
- This is the part I actually wanted. Running a parent now runs everything under it:
52
-
53
- ```console
54
- $ howzit -r deploy
55
- ```
56
-
57
- That runs Deploy's own tasks first, then each subtopic's tasks in order, depth-first. So the example above runs preflight, then the build, then the sync. You get one combined summary at the end instead of a "Ran 1 task" line for every section.
58
-
59
- A few details worth knowing:
60
-
61
- - **Errors stop the run.** If a task fails, the remaining subtopics are skipped. Add `--force` if you want it to push through anyway.
62
- - **`@before` and `@after` still work per topic.** Each topic shows its prerequisites before its own tasks run. A parent's `@after` block shows up once the parent *and* all of its subtopics are done, which is usually where you want that "don't forget to..." reminder.
63
- - **Parents don't need tasks of their own.** A parent that's just an umbrella for a few subtopics runs fine, and it won't complain about "No @directive found."
64
- - **Subtopics still run on their own.** `howzit -r upload` runs just the upload step.
65
-
66
- ### Includes get the whole tree too
67
-
68
- If another topic does `@include(Deploy)`, it now runs Deploy and all of its subtopics, same as running it directly. Task counts in include notes and menus reflect the full tree, so you're not surprised by how much is about to happen.
69
-
70
- ### Listing and "show everything"
71
-
72
- Running `howzit` with no arguments shows every topic. It now only prints top-level topics, since subtopics come along with their parents. No more seeing the same section twice.
73
-
74
- `howzit -l` indents subtopics under their parents, so the list reflects the structure of your note:
75
-
76
- ```console
77
- $ howzit -l
78
- Topics:
79
-
80
- - Deploy
81
- - Build assets
82
- - Upload
83
- - Package management
84
- ```
85
-
86
- Shell completions stay flat, so you can still tab-complete straight to a subtopic.
87
-
88
- ### One thing to check in your notes
89
-
90
- If you've been using `###` headers under `##` purely as visual formatting, those are now real subtopics. Viewing the parent will include them (probably what you wanted anyway), and running the parent will run their tasks too. If you had a `###` section with tasks you *don't* want run as part of its parent, bump it up to `##`.
91
-
92
- ### Grab it
93
-
94
- It's in 2.1.44:
95
-
96
- ```console
97
- $ gem install howzit
98
- ```
99
-
100
- The details are in the [build notes anatomy page](https://github.com/ttscoff/howzit/wiki/Anatomy-of-a-build-notes-file#subtopics) on the wiki. I've already started restructuring a few of my own notes around this. Having a single "Release" topic that fans out into build, test, and publish steps is a lot nicer than keeping them all at the same level and chaining them together with `@include`.