diff --git a/.agent/team/frontend-dev-styles.adoc b/.agent/team/frontend-dev-styles.adoc new file mode 100644 index 0000000..d1ffb76 --- /dev/null +++ b/.agent/team/frontend-dev-styles.adoc @@ -0,0 +1,408 @@ += ReleaseHx Frontend Development Styles & Conventions +:toc: macro +:toclevels: 2 + +Project-specific guidance for frontend work on ReleaseHx 0.2.0 template overhaul (Issue #38). + +toc::[] + +== Context & Starting Point + +See `.agent/team/template-overhaul-plan.adoc` for full implementation strategy and phased approach. + +All work is tracked in `.agent/team/template-overhaul-plan.imyml.yml` with specific task details. + +== Quick Start for Frontend Developers + +**For Phase 1 (HTML Templates):** + +. Read `.agent/team/template-overhaul-plan.adoc` sections on "Tier 1: Semantic HTML Foundation" and "Phase 1: HTML Templates Core" +. Review current templates in `lib/releasehx/rhyml/templates/` to understand patterns +. Check `artifacts/` in releasehx-demo repo to see current output +. Look at specific task in IMYML file for detailed requirements +. Review "HTML Component Patterns" section below for ReleaseHx conventions +. Start implementing with Bootstrap 5 and semantic HTML + +## Technologies & Frameworks + +=== Templating + +* **Liquid**: Jekyll variant with custom filters +* Available filters in ReleaseHx: `md_to_asciidoc`, `render`, `indent`, `pasterize`, `demarkupify`, `inspect_yaml` +* Template location: `lib/releasehx/rhyml/templates/` + +=== CSS Framework + +* **Bootstrap 5**: Loaded via CDN in current templates +* Bootstrap CDN: `https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css` +* Font Awesome 4.7: `https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css` +* No JavaScript required (pure HTML/CSS solution) + +=== Output Targets + +* **HTML**: Standalone (with embedded CSS), SSG partial (with external CSS), or minimal +* **AsciiDoc**: Using role attributes `[.classname]` for styling hooks +* **Markdown**: Graceful text-based fallback with HTML class syntax where supported +* **PDF**: Generated from HTML via Asciidoctor or other converters + +== HTML Component Patterns + +=== Release History Structure + +[source,html] +---- +
+

Release History

+ +
+---- + +=== Release Section + +[source,html] +---- +
+

Version 1.0.0

+ +
+---- + +=== Changelog Section + +[source,html] +---- +
+

Changelog

+
+ +
+
+---- + +=== Changelog Entry + +[source,html] +---- +
+
+
+ Change summary text + +
+ +
+
+---- + +=== Release Notes Section + +[source,html] +---- +
+

Release Notes

+ +
+---- + +=== Release Note Card + +[source,html] +---- +
+
+
Note title
+
+ +
+ +
+
+---- + +=== Metadata Display + +[source,html] +---- +
+ + Bug Fix + + breaking + + + PROJ-123 + + + + a1b2c3d + +
+---- + +== CSS Styling Conventions + +=== Class Naming + +Use semantic, BEM-inspired naming: + +[source,css] +---- +.release-history { } /* Main component */ +.release-section { } /* Block element */ +.release-section__header { } /* Child element */ + +.release-note { } /* Component */ +.release-note--breaking { } /* Modifier variant */ + +.is-active { } /* State class */ +.has-note { } /* State class */ +---- + +=== Styling Modes + +**Minimal** (default, backwards compatible): +* Emit semantic classes only +* Minimal inline styles +* Rely on Bootstrap utilities +* External CSS for any custom styling + +**Embedded** (optional): +* Include ` + + + + {{ config.history.head | default: "Release History" }} + + + - + +
{%- endif -%} {%- if config.modes.html_frontmatter -%} {%- assign frontmatter = config.history.html_frontmatter | render: vars -%} {%- if frontmatter and frontmatter != "" -%} - + {%- endif -%} {%- endif -%} -
-

{{ config.history.head | render: vars | default: "Release History" }}

+
+
+

{{ config.history.head | render: vars | default: "Release History" }}

+

Complete history of releases, changes, and improvements

+
- {%- for release in var.releases %} - {%- assign config = var.config %} - {%- assign release = var.release %} - {%- assign changes = var.changes %} - {%- assign sorted = var.sorted -%} - {%- include release.html.liquid - release=release - config=config - level=2 %} - {%- endfor %} -
+
+{%- for release in var.releases %} +{%- assign config = var.config %} +{%- assign release = var.release %} +{%- assign changes = var.changes %} +{%- assign sorted = var.sorted -%} +{%- include release.html.liquid + release=release + config=config + level=2 %} +{%- endfor %} +
+ -{%- if config.modes.wrapped != false -%} +
+ +{%- if config.modes.html_wrap != false -%} diff --git a/lib/releasehx/rhyml/templates/metadata-entry.adoc.liquid b/lib/releasehx/rhyml/templates/metadata-entry.adoc.liquid index d6b2fe6..dce7bbb 100644 --- a/lib/releasehx/rhyml/templates/metadata-entry.adoc.liquid +++ b/lib/releasehx/rhyml/templates/metadata-entry.adoc.liquid @@ -1,45 +1,90 @@ {%- assign show_labels = entry_metadata_labels %} {%- assign show_icons = entry_metadata_icons %} {%- assign metadata_kinds = 'type,part' | split: ',' %} -{%- assign format = "asciidoc" %} +{%- assign format = 'asciidoc' %} +{%- assign metadata_items = '' | split: ',' %} {%- embed tags-listing.liquid %} {%- embed parts-listing.liquid %} -{%- capture change_metadata -%} -{%- if parts_listing != "" and entry_show_parts_label -%} -{{ parts_listing | trim }} {% endif -%} -{%- if change.parts and change.parts.size > 0 and entry_show_parts_label == false -%} -{%- for part in change.parts -%} -{%- unless part == "changelog" or part == "release_note_needed" -%} -{{ config.parts[part]['text'] | default: part }} {% endunless -%} -{%- endfor -%} -{%- endif -%} -{%- if change.type -%} -{%- assign type_config = config.types[change.type] -%} -{%- if entry_show_type_label -%} -**{{ labeling_type_label }}:** {% endif -%} -{%- if type_config.icon and show_icons and show_icons == "before" -%} -icon:{{ type_config.icon | default: 'tag' }}[role=meta-icon]{% endif -%} -{{ type_config.text | default: change.type }} {% if type_config.icon and show_icons and show_icons == "after" -%} -icon:{{ type_config.icon | default: 'tag' }}[role=meta-icon]{% endif -%} -{%- endif -%} -{%- if tags_listing != "" and entry_show_tags_label -%} -{{ tags_listing | trim }} {% endif -%} -{%- if change.tags and change.tags.size > 0 and entry_show_tags_label == false -%} -{%- for tag in change.tags -%} -{%- unless tag == "changelog" or tag == "release_note_needed" -%} -{{ config.tags[tag]['text'] | default: tag }} {% endunless -%} -{%- endfor -%} -{%- endif -%} -{%- if entry_show_lead and change.lead -%} -{%- if entry_show_lead_label -%} -**{{ labeling_lead_label }}:** {% endif -%} -{%- if entry_user_link_template -%} -link:{{ entry_user_link_template | render: change }}[icon:user[]{{ change.lead }}] {% else -%} -icon:user[]{{ change.lead }} {% endif -%} -{%- endif -%} -{%- if config.links.web.href and config.links.web.href != "" -%} -link:{{ config.links.web.href | render: change }}[icon:{{ config.links.web.icon }}[]{{ config.links.web.text | render: change }}] {% endif -%} -{%- if entry_show_git_links and config.links.git.href and config.links.git.href != "" -%} -link:{{ config.links.git.href | render: change }}[icon:{{ config.links.git.icon | default: 'code-fork' }}[]{{ change.hash | slice: 0, 7 }}] {% endif -%} + +{%- comment %}Build inline metadata array for compact formatting{%- endcomment %} + +{%- comment %}Add parts/components{%- endcomment %} +{%- if parts_listing != '' and entry_show_parts_label %} + {%- assign metadata_items = metadata_items | push: parts_listing %} +{%- elsif change.parts and change.parts.size > 0 and entry_show_parts_label == false %} + {%- for part in change.parts %} + {%- unless part == 'changelog' or part == 'release_note_needed' %} + {%- assign part_text = config.parts[part].text | default: part %} + {%- assign part_badge = '[.badge.part-badge.part-' + | append: part + | append: ']#' + | append: part_text + | append: '#' + %} + {%- assign metadata_items = metadata_items | push: part_badge %} + {%- endunless %} + {%- endfor %} +{%- endif %} + +{%- comment %}Add type{%- endcomment %} +{%- if change.type %} + {%- assign type_config = config.types[change.type] %} + {%- capture type_item %} +{%- if entry_show_type_label %}[.meta-label.type-label]*{{ labeling_type_label }}:* {% endif -%} +{%- if type_config.icon and show_icons and show_icons == "before" %}icon:{{ type_config.icon | default: 'tag' }}[role=meta-icon,role=type-icon] {% endif -%} +[.badge.type-badge.type-{{ change.type }}]#{{ type_config.text | default: change.type }}# +{%- if type_config.icon and show_icons and show_icons == "after" %} icon:{{ type_config.icon | default: 'tag' }}[role=meta-icon,role=type-icon]{% endif -%} {%- endcapture %} + {%- assign metadata_items = metadata_items | push: type_item %} +{%- endif %} + +{%- comment %}Add tags{%- endcomment %} +{%- if tags_listing != '' and entry_show_tags_label %} + {%- assign metadata_items = metadata_items | push: tags_listing %} +{%- elsif change.tags and change.tags.size > 0 and entry_show_tags_label == false %} + {%- for tag in change.tags %} + {%- unless tag == 'changelog' or tag == 'release_note_needed' %} + {%- assign tag_text = config.tags[tag].text | default: tag %} + {%- assign tag_badge = '[.badge.tag-badge.tag-' | append: tag | append: ']#' | append: tag_text | append: '#' %} + {%- assign metadata_items = metadata_items | push: tag_badge %} + {%- endunless %} + {%- endfor %} +{%- endif %} + +{%- comment %}Add lead/assignee{%- endcomment %} +{%- if entry_show_lead and change.lead %} + {%- capture lead_item %} +{%- if entry_show_lead_label %}[.meta-label.lead-label]*{{ labeling_lead_label }}:* {% endif -%} +{%- if entry_user_link_template -%} +link:{{ entry_user_link_template | render: change }}[icon:user[role=user-icon]{{ change.lead }},role=user-link] +{%- else -%} +icon:user[role=user-icon]{{ change.lead }} +{%- endif -%} +{%- endcapture %} + {%- assign metadata_items = metadata_items | push: lead_item %} +{%- endif %} + +{%- comment %}Add ticket/issue link{%- endcomment %} +{%- if config.links.web.href and config.links.web.href != '' %} + {%- capture ticket_link %}link:{{ config.links.web.href | render: change }}[icon:{{ config.links.web.icon }}[role=ticket-icon]{{ config.links.web.text | render: change }},role=ticket-link]{% endcapture %} + {%- assign metadata_items = metadata_items | push: ticket_link %} +{%- endif %} + +{%- comment %}Add git commit link{%- endcomment %} +{%- if entry_show_git_links and config.links.git.href and config.links.git.href != '' %} + {%- capture git_link %}link:{{ config.links.git.href | render: change }}[icon:{{ config.links.git.icon | default: 'code-fork' }}[role=git-icon]{{ change.hash | slice: 0, 7 }},role=git-link]{% endcapture %} + {%- assign metadata_items = metadata_items | push: git_link %} +{%- endif %} + +{%- comment %}Add docs link{%- endcomment %} +{%- if config.links.docs and config.links.docs.href and config.links.docs.href != '' %} + {%- capture docs_link %}link:{{ config.links.docs.href | render: change }}[icon:{{ config.links.docs.icon | default: 'book' }}[role=docs-icon]{{ config.links.docs.text | render: change | default: 'Docs' }},role=docs-link]{% endcapture %} + {%- assign metadata_items = metadata_items | push: docs_link %} +{%- endif %} + +{%- comment %}Output as inline metadata with semantic classes{%- endcomment %} +{%- assign change_metadata = metadata_items | join: ' ' | strip %} +{%- if change_metadata != '' %} + {%- assign change_metadata = '[.change-metadata]' | append: newline | append: change_metadata %} +{%- endif %} {%- assign catch_metadata_error = change_metadata %} diff --git a/lib/releasehx/rhyml/templates/metadata-entry.html.liquid b/lib/releasehx/rhyml/templates/metadata-entry.html.liquid index 6afbb60..fca714a 100644 --- a/lib/releasehx/rhyml/templates/metadata-entry.html.liquid +++ b/lib/releasehx/rhyml/templates/metadata-entry.html.liquid @@ -1,2 +1,61 @@ -{%- embed metadata-note.html.liquid -%} +{%- assign show_labels = content_config.items.metadata_labels %} +{%- assign show_icons = content_config.items.metadata_icons %} +{%- assign metadata_kinds = 'type,part' | split: ',' %} +{%- embed tags-listing.liquid %} + +{%- capture change_metadata %} +{%- if change.type %} +{%- assign type_config = config.types[change.type] %} +{%- if type_config.icon and show_icons and show_icons == "before" %} + +{%- endif %} +{%- if show_labels %} +{%- assign type_text = type_config.text | default: change.type %} +{{ type_text }} +{%- endif %} +{%- if type_config.icon and show_icons and show_icons == "after" %} + +{%- endif %} +{%- endif %} + +{%- if change.part and change.part != "changelog" and change.part != "release_note_needed" %} +{%- assign part_config = config.parts[change.part] %} +{%- if part_config %} +{%- if part_config.icon and show_icons and show_icons == "before" %} + +{%- endif %} +{%- if show_labels %} +{%- assign part_text = part_config.text | default: change.part %} +{{ part_text }} +{%- endif %} +{%- if part_config.icon and show_icons and show_icons == "after" %} + +{%- endif %} +{%- endif %} +{%- endif %} + +{%- if tags_listing != "" %} +{{ tags_listing | trim }} +{%- endif %} + +{%- if config.links.web and config.links.web.href %} + + {{ config.links.web.text | render: change | default: change.tick }} + +{%- endif %} + +{%- if config.links.git and config.links.git.href %} +{%- assign git_text = config.links.git.text | render: change | default: change.hash %} + + {{ git_text | slice: 0, 7 }} + +{%- endif %} + +{%- if change.note %} + + NOTE + +{%- endif %} +{%- endcapture %} +{%- assign catch_metadata_error = change_metadata %} diff --git a/lib/releasehx/rhyml/templates/metadata-entry.md.liquid b/lib/releasehx/rhyml/templates/metadata-entry.md.liquid index babcbbc..6204163 100644 --- a/lib/releasehx/rhyml/templates/metadata-entry.md.liquid +++ b/lib/releasehx/rhyml/templates/metadata-entry.md.liquid @@ -1,130 +1,82 @@ -{%- assign metadata_parts = "" | split: "," -%} +{%- comment -%} Build inline metadata array with minimal HTML badges {%- endcomment -%} +{%- assign metadata_items = "" | split: "," -%} -{%- comment -%}Collect ticket link{%- endcomment -%} -{%- if entry_show_issue_links and change.tick and config.links.web.href and config.links.web.href != "" -%} -{%- assign ticket_link_text = config.links.web.text | render: change -%} -{%- if config.links.web.href and config.links.web.href != "" -%} -{%- assign ticket_link = "[" | append: ticket_link_text | append: "](" | append: config.links.web.href | render: change | append: ")" -%} +{%- comment -%} Type badge {%- endcomment -%} +{%- if metadata_kinds contains "type" and change.type -%} +{%- assign type_config = config.types[change.type] -%} +{%- if type_config -%} +{%- assign type_text = type_config.text | default: change.type -%} +{%- assign type_slug = change.type | sluggerize: slug_type -%} +{%- capture type_badge -%}{{ type_text }}{%- endcapture -%} +{%- assign metadata_items = metadata_items | push: type_badge -%} {%- endif -%} -{%- assign ticket_part = ticket_link | split: "," -%} -{%- assign metadata_parts = metadata_parts | concat: ticket_part -%} {%- endif -%} -{%- if metadata_kinds contains "part" %} -{%- comment -%}Collect parts/components{%- endcomment -%} -{%- assign effective_parts = "" | split: "," -%} -{%- if change.parts and change.parts.size > 0 -%} -{%- assign effective_parts = change.parts -%} -{%- elsif change.part -%} -{%- assign effective_parts = change.part | split: "," -%} -{%- endif -%} -{%- if effective_parts.size > 0 -%} -{%- assign parts_formatted = "" | split: "," -%} -{%- assign parts_count = 0 -%} -{%- for part in effective_parts -%} -{%- assign skip_part = false -%} -{%- if part == "changelog" or part == "release_note_needed" -%} -{%- assign skip_part = true -%} -{%- elsif part == group1_slug or part == group2_slug -%} -{%- assign skip_part = true -%} -{%- elsif config.parts[part] == nil -%} -{%- assign skip_part = true -%} -{%- endif -%} -{%- unless skip_part -%} -{%- assign part_config = config.parts[part] -%} -{%- assign part_text = part_config.text | default: part -%} -{%- assign bold_part = "**" | append: part_text | append: "**" -%} -{%- assign bold_part_array = bold_part | split: "," -%} -{%- assign parts_formatted = parts_formatted | concat: bold_part_array -%} -{%- assign parts_count = parts_count | plus: 1 -%} -{%- endunless -%} -{%- endfor -%} -{%- if parts_count > 0 -%} -{%- assign parts_joined = parts_formatted | join: labeling_join_string -%} -{%- if entry_show_parts_label -%} -{%- if labeling_singularize_labels and parts_count == 1 -%} -{%- assign parts_text = labeling_part_label | append: ": " | append: parts_joined -%} -{%- else -%} -{%- assign parts_text = labeling_parts_label | append: ": " | append: parts_joined -%} +{%- comment -%} Parts/components {%- endcomment -%} +{%- if metadata_kinds contains "part" -%} +{%- assign effective_parts = "" | split: "," -%} +{%- if change.parts and change.parts.size > 0 -%} +{%- assign effective_parts = change.parts -%} +{%- elsif change.part -%} +{%- assign effective_parts = change.part | split: "," -%} +{%- endif -%} +{%- if effective_parts.size > 0 -%} +{%- for part in effective_parts -%} +{%- assign skip_part = false -%} +{%- if part == "changelog" or part == "release_note_needed" -%} +{%- assign skip_part = true -%} +{%- elsif part == group1_slug or part == group2_slug -%} +{%- assign skip_part = true -%} +{%- elsif config.parts[part] == nil -%} +{%- assign skip_part = true -%} {%- endif -%} -{%- else -%} -{%- assign parts_text = parts_joined -%} -{%- endif -%} -{%- assign parts_part = parts_text | split: "," -%} -{%- assign metadata_parts = metadata_parts | concat: parts_part -%} +{%- unless skip_part -%} +{%- assign part_config = config.parts[part] -%} +{%- assign part_text = part_config.text | default: part -%} +{%- assign part_slug = part | sluggerize: slug_type -%} +{%- capture part_badge -%}{{ part_text }}{%- endcapture -%} +{%- assign metadata_items = metadata_items | push: part_badge -%} +{%- endunless -%} +{%- endfor -%} {%- endif -%} {%- endif -%} -{%- endif -%} -{%- if metadata_kinds contains "type" %} -{%- comment -%}Collect type (only if not already being grouped by type){%- endcomment -%} -{%- assign skip_type = false -%} -{%- if change.type == group1_slug or change.type == group2_slug -%} -{%- assign skip_type = true -%} +{%- comment -%} Tags {%- endcomment -%} +{%- if metadata_kinds contains "tag" and change.tags and change.tags.size > 0 -%} +{%- for tag in change.tags -%} +{%- assign tag_slug = tag | sluggerize: slug_type -%} +{%- capture tag_badge -%}{{ tag }}{%- endcapture -%} +{%- assign metadata_items = metadata_items | push: tag_badge -%} +{%- endfor -%} {%- endif -%} -{%- if change.type and skip_type == false -%} -{%- assign type_config = config.types[change.type] -%} -{%- if entry_show_type_label -%} -{%- assign type_text = labeling_type_label | append: ": " | append: type_config.text | default: change.type | capitalize -%} + +{%- comment -%} Lead/user {%- endcomment -%} +{%- if metadata_kinds contains "lead" and change.lead -%} +{%- if entry_show_lead_links and config.links.user.href -%} +{%- capture lead_link -%}[@{{ change.lead }}]({{ config.links.user.href | replace: '{{user}}', change.lead }}){%- endcapture -%} +{%- assign metadata_items = metadata_items | push: lead_link -%} {%- else -%} -{%- assign type_text = type_config.text | default: change.type | capitalize -%} +{%- capture lead_text -%}@{{ change.lead }}{%- endcapture -%} +{%- assign metadata_items = metadata_items | push: lead_text -%} {%- endif -%} -{%- assign type_part = type_text | split: "," -%} -{%- assign metadata_parts = metadata_parts | concat: type_part -%} -{%- endif -%} {%- endif -%} -{%- if metadata_kinds contains "tag" %} -{%- comment -%}Collect tags{%- endcomment -%} -{%- if change.tags and change.tags.size > 0 -%} -{%- assign tags_formatted = "" | split: "," -%} -{%- assign tags_count = 0 -%} -{%- for tag in change.tags -%} -{%- assign skip_tag = false -%} -{%- if tag == "changelog" or tag == "release_note_needed" -%} -{%- assign skip_tag = true -%} -{%- elsif tag == group1_slug or tag == group2_slug -%} -{%- assign skip_tag = true -%} -{%- elsif config.tags[tag] == nil -%} -{%- assign skip_tag = true -%} -{%- endif -%} -{%- unless skip_tag -%} -{%- assign tag_text_array = tag | split: "," -%} -{%- assign tags_formatted = tags_formatted | concat: tag_text_array -%} -{%- assign tags_count = tags_count | plus: 1 -%} -{%- endunless -%} -{%- endfor -%} -{%- if tags_count > 0 -%} -{%- if entry_show_tags_label -%} -{%- if labeling_singularize_labels and tags_count == 1 -%} -{%- assign tags_text = labeling_tag_label | append: ": " | append: tags_formatted | join: labeling_join_string -%} -{%- else -%} -{%- assign tags_text = labeling_tags_label | append: ": " | append: tags_formatted | join: labeling_join_string -%} -{%- endif -%} -{%- else -%} -{%- assign tags_text = tags_formatted | join: labeling_join_string -%} -{%- endif -%} -{%- assign tags_part = tags_text | split: "," -%} -{%- assign metadata_parts = metadata_parts | concat: tags_part -%} -{%- endif -%} -{%- endif -%} +{%- comment -%} Issue/ticket link {%- endcomment -%} +{%- if entry_show_issue_links and change.tick and config.links.web.href and config.links.web.href != "" -%} +{%- assign ticket_link_text = config.links.web.text | render: change -%} +{%- capture ticket_link -%}[{{ ticket_link_text }}]({{ config.links.web.href | render: change }}){%- endcapture -%} +{%- assign metadata_items = metadata_items | push: ticket_link -%} {%- endif -%} -{%- comment -%}Collect lead contributor{%- endcomment -%} -{%- if entry_show_lead and change.lead -%} -{%- if entry_show_lead_label -%} -{%- assign lead_prefix = labeling_lead_label | append: ": " -%} -{%- else -%} -{%- assign lead_prefix = "" -%} -{%- endif -%} -{%- if entry_user_link_template -%} -{%- assign lead_text = lead_prefix | append: "[" | append: change.lead | append: "](" | append: entry_user_link_template | render: change | append: ")" -%} -{%- else -%} -{%- assign lead_text = lead_prefix | append: change.lead -%} -{%- endif -%} -{%- assign lead_part = lead_text | split: "," -%} -{%- assign metadata_parts = metadata_parts | concat: lead_part -%} +{%- comment -%} Git commit link {%- endcomment -%} +{%- if metadata_kinds contains "git" and change.ghsh and config.links.git.href -%} +{%- capture git_link -%}[`{{ change.ghsh | slice: 0, 7 }}`]({{ config.links.git.href | render: change }}){%- endcapture -%} +{%- assign metadata_items = metadata_items | push: git_link -%} {%- endif -%} -{%- comment -%}Output the final metadata string{%- endcomment -%} -{%- assign change_metadata = metadata_parts | join: " | " -%} +{%- comment -%} Join metadata inline {%- endcomment -%} +{%- if metadata_items.size > 0 -%} +{%- assign change_metadata = metadata_items | join: " " -%} +{%- else -%} +{%- assign change_metadata = "" -%} +{%- endif -%} diff --git a/lib/releasehx/rhyml/templates/metadata-note.adoc.liquid b/lib/releasehx/rhyml/templates/metadata-note.adoc.liquid index b9b43cc..9116468 100644 --- a/lib/releasehx/rhyml/templates/metadata-note.adoc.liquid +++ b/lib/releasehx/rhyml/templates/metadata-note.adoc.liquid @@ -1,45 +1,90 @@ {%- assign show_labels = note_metadata_labels %} {%- assign show_icons = note_metadata_icons %} {%- assign metadata_kinds = 'type,part' | split: ',' %} -{%- assign format = "asciidoc" %} +{%- assign format = 'asciidoc' %} +{%- assign metadata_items = '' | split: ',' %} {%- embed tags-listing.liquid %} {%- embed parts-listing.liquid %} -{%- capture change_metadata -%} -{%- if parts_listing != "" and note_show_parts_label -%} -{{ parts_listing | trim }} {% endif -%} -{%- if change.parts and change.parts.size > 0 and note_show_parts_label == false -%} -{%- for part in change.parts -%} -{%- unless part == "changelog" or part == "release_note_needed" -%} -{{ config.parts[part]['text'] | default: part }} {% endunless -%} -{%- endfor -%} -{%- endif -%} -{%- if change.type -%} -{%- assign type_config = config.types[change.type] -%} -{%- if note_show_type_label -%} -**{{ labeling_type_label }}:** {% endif -%} -{%- if type_config.icon and show_icons and show_icons == "before" -%} -icon:{{ type_config.icon | default: 'tag' }}[role=meta-icon]{% endif -%} -{{ type_config.text | default: change.type }} {% if type_config.icon and show_icons and show_icons == "after" -%} -icon:{{ type_config.icon | default: 'tag' }}[role=meta-icon]{% endif -%} -{%- endif -%} -{%- if tags_listing != "" and note_show_tags_label -%} -{{ tags_listing | trim }} {% endif -%} -{%- if change.tags and change.tags.size > 0 and note_show_tags_label == false -%} -{%- for tag in change.tags -%} -{%- unless tag == "changelog" or tag == "release_note_needed" -%} -{{ config.tags[tag]['text'] | default: tag }} {% endunless -%} -{%- endfor -%} -{%- endif -%} -{%- if note_show_lead and change.lead -%} -{%- if note_show_lead_label -%} -**{{ labeling_lead_label }}:** {% endif -%} -{%- if note_user_link_template -%} -link:{{ note_user_link_template | render: change }}[icon:user[]{{ change.lead }}] {% else -%} -icon:user[]{{ change.lead }} {% endif -%} -{%- endif -%} -{%- if config.links.web.href and config.links.web.href != "" -%} -link:{{ config.links.web.href | render: change }}[icon:{{ config.links.web.icon }}[]{{ config.links.web.text | render: change }}] {% endif -%} -{%- if note_show_git_links and config.links.git.href and config.links.git.href != "" -%} -link:{{ config.links.git.href | render: change }}[icon:{{ config.links.git.icon | default: 'code-fork' }}[]{{ change.hash | slice: 0, 7 }}] {% endif -%} + +{%- comment %}Build inline metadata array for compact formatting{%- endcomment %} + +{%- comment %}Add parts/components{%- endcomment %} +{%- if parts_listing != '' and note_show_parts_label %} + {%- assign metadata_items = metadata_items | push: parts_listing %} +{%- elsif change.parts and change.parts.size > 0 and note_show_parts_label == false %} + {%- for part in change.parts %} + {%- unless part == 'changelog' or part == 'release_note_needed' %} + {%- assign part_text = config.parts[part].text | default: part %} + {%- assign part_badge = '[.badge.part-badge.part-' + | append: part + | append: ']#' + | append: part_text + | append: '#' + %} + {%- assign metadata_items = metadata_items | push: part_badge %} + {%- endunless %} + {%- endfor %} +{%- endif %} + +{%- comment %}Add type{%- endcomment %} +{%- if change.type %} + {%- assign type_config = config.types[change.type] %} + {%- capture type_item %} +{%- if note_show_type_label %}[.meta-label.type-label]*{{ labeling_type_label }}:* {% endif -%} +{%- if type_config.icon and show_icons and show_icons == "before" %}icon:{{ type_config.icon | default: 'tag' }}[role=meta-icon,role=type-icon] {% endif -%} +[.badge.type-badge.type-{{ change.type }}]#{{ type_config.text | default: change.type }}# +{%- if type_config.icon and show_icons and show_icons == "after" %} icon:{{ type_config.icon | default: 'tag' }}[role=meta-icon,role=type-icon]{% endif -%} {%- endcapture %} + {%- assign metadata_items = metadata_items | push: type_item %} +{%- endif %} + +{%- comment %}Add tags{%- endcomment %} +{%- if tags_listing != '' and note_show_tags_label %} + {%- assign metadata_items = metadata_items | push: tags_listing %} +{%- elsif change.tags and change.tags.size > 0 and note_show_tags_label == false %} + {%- for tag in change.tags %} + {%- unless tag == 'changelog' or tag == 'release_note_needed' %} + {%- assign tag_text = config.tags[tag].text | default: tag %} + {%- assign tag_badge = '[.badge.tag-badge.tag-' | append: tag | append: ']#' | append: tag_text | append: '#' %} + {%- assign metadata_items = metadata_items | push: tag_badge %} + {%- endunless %} + {%- endfor %} +{%- endif %} + +{%- comment %}Add lead/assignee{%- endcomment %} +{%- if note_show_lead and change.lead %} + {%- capture lead_item %} +{%- if note_show_lead_label %}[.meta-label.lead-label]*{{ labeling_lead_label }}:* {% endif -%} +{%- if note_user_link_template -%} +link:{{ note_user_link_template | render: change }}[icon:user[role=user-icon]{{ change.lead }},role=user-link] +{%- else -%} +icon:user[role=user-icon]{{ change.lead }} +{%- endif -%} +{%- endcapture %} + {%- assign metadata_items = metadata_items | push: lead_item %} +{%- endif %} + +{%- comment %}Add ticket/issue link{%- endcomment %} +{%- if config.links.web.href and config.links.web.href != '' %} + {%- capture ticket_link %}link:{{ config.links.web.href | render: change }}[icon:{{ config.links.web.icon }}[role=ticket-icon]{{ config.links.web.text | render: change }},role=ticket-link]{% endcapture %} + {%- assign metadata_items = metadata_items | push: ticket_link %} +{%- endif %} + +{%- comment %}Add git commit link{%- endcomment %} +{%- if note_show_git_links and config.links.git.href and config.links.git.href != '' %} + {%- capture git_link %}link:{{ config.links.git.href | render: change }}[icon:{{ config.links.git.icon | default: 'code-fork' }}[role=git-icon]{{ change.hash | slice: 0, 7 }},role=git-link]{% endcapture %} + {%- assign metadata_items = metadata_items | push: git_link %} +{%- endif %} + +{%- comment %}Add docs link{%- endcomment %} +{%- if config.links.docs and config.links.docs.href and config.links.docs.href != '' %} + {%- capture docs_link %}link:{{ config.links.docs.href | render: change }}[icon:{{ config.links.docs.icon | default: 'book' }}[role=docs-icon]{{ config.links.docs.text | render: change | default: 'Docs' }},role=docs-link]{% endcapture %} + {%- assign metadata_items = metadata_items | push: docs_link %} +{%- endif %} + +{%- comment %}Output as inline metadata with semantic classes{%- endcomment %} +{%- assign change_metadata = metadata_items | join: ' ' | strip %} +{%- if change_metadata != '' %} + {%- assign change_metadata = '[.note-metadata]' | append: newline | append: change_metadata %} +{%- endif %} {%- assign catch_metadata_error = change_metadata %} diff --git a/lib/releasehx/rhyml/templates/metadata-note.html.liquid b/lib/releasehx/rhyml/templates/metadata-note.html.liquid index 7a644b7..d3ac0c0 100644 --- a/lib/releasehx/rhyml/templates/metadata-note.html.liquid +++ b/lib/releasehx/rhyml/templates/metadata-note.html.liquid @@ -1,36 +1,73 @@ {%- assign show_labels = content_config.items.metadata_labels %} {%- assign show_icons = content_config.items.metadata_icons %} +{%- assign metadata_kinds = 'type,part' | split: ',' %} {%- embed tags-listing.liquid %} {%- capture change_metadata %} -{%- for kind in metadata_kinds %} -{%- assign change_meta_kind = change[kind] %} -{%- assign kind_plural = kind | append: "s" %} -{%- if kind == "tag" %} -{%- continue %} -{%- endif %} -{%- if config[kind_plural][change_meta_kind].icon and show_icons and show_icons == "before" %} - +{%- if change.type %} +{%- assign type_config = config.types[change.type] %} +{%- if type_config.icon and show_icons and show_icons == "before" %} + +{%- endif %} +{%- if show_labels %} +{%- assign type_text = type_config.text | default: change.type %} +{{ type_text }} +{%- endif %} +{%- if type_config.icon and show_icons and show_icons == "after" %} + +{%- endif %} +{%- endif %} + +{%- if change.part and change.part != "changelog" and change.part != "release_note_needed" %} +{%- assign part_config = config.parts[change.part] %} +{%- if part_config %} +{%- if part_config.icon and show_icons and show_icons == "before" %} + {%- endif %} {%- if show_labels %} -{{ config[kind_plural][change_meta_kind]['text'] | default: change_meta_kind }} +{%- assign part_text = part_config.text | default: change.part %} +{{ part_text }} {%- endif %} -{%- if config[kind_plural][change_meta_kind].icon and show_icons and show_icons == "after" %} - +{%- if part_config.icon and show_icons and show_icons == "after" %} + {%- endif %} -{%- endfor %} -{%- if tags_listing != "" %} -{{ tags_listing | trim }} {%- endif %} -{%- if config.links.web and config.links.web.href %} - - {{ config.links.web.text | render: change | default: change.tick }} +{%- endif %} + +{%- if tags_listing != "" %} +{{ tags_listing | trim }} +{%- endif %} + +{%- if change.lead and config.links.user and config.links.user.href %} +{%- assign user_link = config.links.user.href | render: change %} + + {{ change.lead }} -{%- endif -%} -{%- if config.links.git and config.links.git.href %} - - {% assign git_text = config.links.git.text | render: change | default: change.hash %}{{ git_text | slice: 0, 7 }} +{%- elsif change.lead %} + + {{ change.lead }} + +{%- endif %} + +{%- if config.links.web and config.links.web.href %} + + {{ config.links.web.text | render: change | default: change.tick }} -{%- endif %} +{%- endif %} + +{%- if config.links.git and config.links.git.href %} +{%- assign git_text = config.links.git.text | render: change | default: change.hash %} + + {{ git_text | slice: 0, 7 }} + +{%- endif %} + +{%- if config.links.docs and config.links.docs.href %} + + {{ config.links.docs.text | render: change | default: 'Docs' }} + +{%- endif %} {%- endcapture %} {%- assign catch_metadata_error = change_metadata %} + + diff --git a/lib/releasehx/rhyml/templates/metadata-note.md.liquid b/lib/releasehx/rhyml/templates/metadata-note.md.liquid index 5341394..9dbfaf6 100644 --- a/lib/releasehx/rhyml/templates/metadata-note.md.liquid +++ b/lib/releasehx/rhyml/templates/metadata-note.md.liquid @@ -1,30 +1,75 @@ -{%- capture change_metadata -%} -{%- assign metadata_parts = "" | split: "," -%} +{%- comment -%} Build inline metadata with badges for release notes {%- endcomment -%} +{%- assign metadata_kinds = 'type,part' | split: ',' -%} +{%- assign metadata_items = "" | split: "," -%} + +{%- comment -%} Type badge {%- endcomment -%} +{%- if metadata_kinds contains "type" and change.type -%} +{%- assign type_config = config.types[change.type] -%} +{%- if type_config -%} +{%- assign type_text = type_config.text | default: change.type -%} +{%- assign type_slug = change.type | sluggerize: slug_type -%} +{%- capture type_badge -%}{{ type_text }}{%- endcapture -%} +{%- assign metadata_items = metadata_items | push: type_badge -%} +{%- endif -%} +{%- endif -%} + +{%- comment -%} Parts {%- endcomment -%} +{%- if metadata_kinds contains "part" -%} +{%- assign effective_parts = "" | split: "," -%} +{%- if change.parts and change.parts.size > 0 -%} +{%- assign effective_parts = change.parts -%} +{%- elsif change.part -%} +{%- assign effective_parts = change.part | split: "," -%} +{%- endif -%} +{%- if effective_parts.size > 0 -%} +{%- for part in effective_parts -%} +{%- assign part_config = config.parts[part] -%} +{%- if part_config -%} +{%- assign part_text = part_config.text | default: part -%} +{%- assign part_slug = part | sluggerize: slug_type -%} +{%- capture part_badge -%}{{ part_text }}{%- endcapture -%} +{%- assign metadata_items = metadata_items | push: part_badge -%} +{%- endif -%} +{%- endfor -%} +{%- endif -%} +{%- endif -%} + +{%- comment -%} Tags {%- endcomment -%} +{%- if metadata_kinds contains "tag" and change.tags and change.tags.size > 0 -%} +{%- for tag in change.tags -%} +{%- assign tag_slug = tag | sluggerize: slug_type -%} +{%- capture tag_badge -%}{{ tag }}{%- endcapture -%} +{%- assign metadata_items = metadata_items | push: tag_badge -%} +{%- endfor -%} +{%- endif -%} + +{%- comment -%} Lead/user {%- endcomment -%} {%- if note_show_lead and change.lead -%} - {%- capture lead_text -%} - {%- if note_show_lead_links and config.links.user.href -%} - [{{ change.lead }}]({{ config.links.user.href | replace: '{{user}}', change.lead }}) - {%- else -%} - {{ change.lead }} - {%- endif -%} - {%- endcapture -%} - {%- assign metadata_parts = metadata_parts | push: lead_text -%} +{%- if note_show_lead_links and config.links.user.href -%} +{%- capture lead_link -%}[@{{ change.lead }}]({{ config.links.user.href | replace: '{{user}}', change.lead }}){%- endcapture -%} +{%- assign metadata_items = metadata_items | push: lead_link -%} +{%- else -%} +{%- capture lead_text -%}@{{ change.lead }}{%- endcapture -%} +{%- assign metadata_items = metadata_items | push: lead_text -%} +{%- endif -%} {%- endif -%} + +{%- comment -%} Issue link {%- endcomment -%} {%- if note_show_issue_links and change.tick and config.links.web.href -%} - {%- capture issue_link -%}[{{ config.links.web.text | render: change }}]({{ config.links.web.href | render: change }}){%- endcapture -%} - {%- assign metadata_parts = metadata_parts | push: issue_link -%} +{%- capture issue_link -%}[{{ config.links.web.text | render: change }}]({{ config.links.web.href | render: change }}){%- endcapture -%} +{%- assign metadata_items = metadata_items | push: issue_link -%} {%- elsif change.tick -%} - {%- assign metadata_parts = metadata_parts | push: change.tick -%} -{%- endif -%} -{%- embed parts-listing.liquid -%} -{%- if parts_listing and parts_listing != "" -%} - {%- assign metadata_parts = metadata_parts | push: parts_listing -%} +{%- assign metadata_items = metadata_items | push: change.tick -%} {%- endif -%} -{%- embed tags-listing.liquid -%} -{%- if tags_listing and tags_listing != "" -%} - {%- assign metadata_parts = metadata_parts | push: tags_listing -%} + +{%- comment -%} Git commit {%- endcomment -%} +{%- if metadata_kinds contains "git" and change.ghsh and config.links.git.href -%} +{%- capture git_link -%}[`{{ change.ghsh | slice: 0, 7 }}`]({{ config.links.git.href | render: change }}){%- endcapture -%} +{%- assign metadata_items = metadata_items | push: git_link -%} {%- endif -%} -{%- if metadata_parts.size > 0 -%} -({{ metadata_parts | join: ", " }}) + +{%- if metadata_items.size > 0 -%} +{%- assign change_metadata = metadata_items | join: " " -%} +{%- else -%} +{%- assign change_metadata = "" -%} {%- endif -%} -{%- endcapture -%} diff --git a/lib/releasehx/rhyml/templates/note.html.liquid b/lib/releasehx/rhyml/templates/note.html.liquid index 6ce7b55..cff004a 100644 --- a/lib/releasehx/rhyml/templates/note.html.liquid +++ b/lib/releasehx/rhyml/templates/note.html.liquid @@ -1,25 +1,31 @@ +{%- assign raw_note = change.note | default: "NO RELEASE NOTE PROVIDED" %} +{%- assign change_note = raw_note | replace: "## Release Note", "" | trim %} {%- if config.conversions.markup == "markdown" %} -{%- assign change_note = change.note | default: "NO RELEASE NOTE PROVIDED" | md_to_html | trim %} -{%- else %} -{%- assign change_note = change.note | default: "NO RELEASE NOTE PROVIDED" | trim %} +{%- assign change_note = change_note | markdownify | trim %} +{%- elsif config.conversions.markup == "asciidoc" %} +{%- assign change_note = change_note | asciidocify | trim %} {%- endif %} {%- include metadata-note.html.liquid %} -
-
- {%- if change.head %} -
{{ change.head }}
- {%- endif %} - -
- {{ change_note }} -
- - {%- if change_metadata != "" %} - - {%- endif %} +
+
+ {%- if change.head %} +
{{ change.head }}
+ {%- else %} +
Release Note
+ {%- endif %} +
+
+
+ {{ change_note }} +
+ {%- if change_metadata != "" %} + -
+ {%- endif %} +
+ diff --git a/lib/releasehx/rhyml/templates/note.md.liquid b/lib/releasehx/rhyml/templates/note.md.liquid index a71e5d0..fad37fd 100644 --- a/lib/releasehx/rhyml/templates/note.md.liquid +++ b/lib/releasehx/rhyml/templates/note.md.liquid @@ -1,33 +1,51 @@ -{% if config.conversions.markup == "markdown" -%} -{% assign change_note = change.note | default: "NO RELEASE NOTE PROVIDED" | trim -%} + +{% if config.conversions.markup == 'markdown' -%} + {% assign change_note = change.note | default: 'NO RELEASE NOTE PROVIDED' | trim -%} {% else -%} -{% assign change_note = change.note | default: "NO RELEASE NOTE PROVIDED" | adoc_to_md | trim -%} + {% assign change_note = change.note | default: 'NO RELEASE NOTE PROVIDED' | adoc_to_md | trim -%} {% endif -%} +{%- comment -%} Remove "## Release Note" heading if it appears in the note content {%- endcomment -%} +{%- assign change_note = change_note | replace: '## Release Note', '' | trim -%} +{% embed metadata-note.md.liquid -%} -{%- if config.notes.items.frame == "table" %} -| Details | -|---------| -| {% if change.head %}**{{ change.head }}**
{% endif %}{{ change_note }} | -{%- elsif config.notes.items.frame == "desc-list" %} -**{{ change.head | default: change.summ }}** -{{ change_note }} -{% elsif config.notes.items.frame == "admonition" %} -{% if change.type == "removal" or change.type == "deprecation" or change.type == "breaking" %} -{% assign admonition_type = "Warning" %} -{% elsif change.type == "security" %} -{% assign admonition_type = "Important" %} -{% elsif change.type == "experimental" %} -{% assign admonition_type = "Tip" %} -{% else %} -{% assign admonition_type = "Note" %} -{% endif %} -> **{{ admonition_type }}**: {% if change.head %}**{{ change.head }}** - {% endif %}{{ change_note }} +{%- if config.notes.items.frame == 'table' %} + | Change | Details | |--------|---------| | {{ change_metadata }} | + {% if change.head %}**{{ change.head }}**
{% endif -%} + {{- change_note }} | +{%- elsif config.notes.items.frame == 'table-cols-1' %} + | Note | |------| | {{ change_metadata -}} +
+ {% if change.head %}**{{ change.head }}**
{% endif -%} + {{- change_note }} | +{%- elsif config.notes.items.frame == 'table-cols-2' %} + | Note | Metadata | |------|----------| | {% if change.head %}**{{ change.head }}**
{% endif -%} + {{- change_note }} | {{ change_metadata }} | +{%- elsif config.notes.items.frame == 'desc-list' %} + {% if change.head -%} + **{{ change.head }}** + {% endif -%} + {{ change_note }} + {{ change_metadata }} +{% elsif config.notes.items.frame == 'admonition' %} + {% if change.type == 'removal' or change.type == 'deprecation' or change.type == 'breaking' %} + {% assign admonition_type = '⚠️ Warning' %} + {% elsif change.type == 'security' %} + {% assign admonition_type = '🔒 Security' %} + {% elsif change.type == 'experimental' %} + {% assign admonition_type = '💡 Tip' %} + {% else %} + {% assign admonition_type = '📝 Note' %} + {% endif %} + + > **{{ admonition_type }}**: {% if change.head %}**{{ change.head }}** — {% endif -%} + {{- change_note }} > {{ change_metadata }} {% else -%} -{% if change.head -%} -### {{ change.head }} -{% endif -%} -{{ change_note }} -{% endif %} + {% if change.head -%} + ### {{ change.head }} + {% endif -%} + {{ change_note }} + {{ change_metadata }} +{% endif %} diff --git a/lib/releasehx/rhyml/templates/release-notes.adoc.liquid b/lib/releasehx/rhyml/templates/release-notes.adoc.liquid index 6669311..788538c 100644 --- a/lib/releasehx/rhyml/templates/release-notes.adoc.liquid +++ b/lib/releasehx/rhyml/templates/release-notes.adoc.liquid @@ -11,4 +11,6 @@ {%- assign item_count = 0 %} {%- embed section-text.liquid %} +[.notes-section] + {%- embed groupings.liquid %} \ No newline at end of file diff --git a/lib/releasehx/rhyml/templates/release-notes.html.liquid b/lib/releasehx/rhyml/templates/release-notes.html.liquid index b301125..a5c0dea 100644 --- a/lib/releasehx/rhyml/templates/release-notes.html.liquid +++ b/lib/releasehx/rhyml/templates/release-notes.html.liquid @@ -4,11 +4,13 @@ {%- assign dflt_next_header_level = level | plus: 1 -%} {%- assign item_count = 0 %} -
- {%- include header.liquid format=format level=level text=content_config.head %} +
+ {%- include header.liquid format=format level=level text=content_config.head %} - {%- embed section-text.liquid %} + {%- embed section-text.liquid %} +
{%- embed groupings.liquid %} -
+
+ diff --git a/lib/releasehx/rhyml/templates/release-notes.md.liquid b/lib/releasehx/rhyml/templates/release-notes.md.liquid index 151e57d..1f7dae7 100644 --- a/lib/releasehx/rhyml/templates/release-notes.md.liquid +++ b/lib/releasehx/rhyml/templates/release-notes.md.liquid @@ -1,3 +1,4 @@ + {%- assign content_config = config.notes %} {%- assign section_type = "notes" %} {%- embed config-cascade.liquid %} diff --git a/lib/releasehx/rhyml/templates/release.adoc.liquid b/lib/releasehx/rhyml/templates/release.adoc.liquid index ce80e5b..8c297fb 100644 --- a/lib/releasehx/rhyml/templates/release.adoc.liquid +++ b/lib/releasehx/rhyml/templates/release.adoc.liquid @@ -6,6 +6,8 @@ :icons: font {% embed head-parser.liquid %} +[.release-section] + {%- if release.hash and release.hash != "" and config.links.git and config.links.git.trim != "" %} link:{{ config.links.git | render: release }}[icon:{{ config.links.git_icon | default: 'code-fork' }}[]{{ release.hash | slice: 0, 7 }}] {%- endif %} diff --git a/lib/releasehx/rhyml/templates/release.md.liquid b/lib/releasehx/rhyml/templates/release.md.liquid index db1ca30..c0e90a2 100644 --- a/lib/releasehx/rhyml/templates/release.md.liquid +++ b/lib/releasehx/rhyml/templates/release.md.liquid @@ -1,12 +1,13 @@ -{% assign format = "markdown" -%} -{% assign config = vars.config -%} -{% assign release = vars.release -%} -{% assign changes = vars.changes -%} -{% assign sorted = vars.sorted -%} +{%- assign format = "markdown" -%} +{%- assign config = vars.config -%} +{%- assign release = vars.release -%} +{%- assign changes = vars.changes -%} +{%- assign sorted = vars.sorted -%} {%- embed head-parser.liquid %} -{% if release.memo -%} + +{%- if release.memo -%} {{ release.memo }} -{% endif -%} +{%- endif -%} {% if release.hash and release.hash != "" and config.links.git and config.links.git.trim != "" -%} **Git:** [{{ release.hash | slice: 0, 7 }}]({{ config.links.git | render: release }}) diff --git a/lib/releasehx/rhyml/templates/rhyml-change.yaml.liquid b/lib/releasehx/rhyml/templates/rhyml-change.yaml.liquid index feed26a..30d8ded 100644 --- a/lib/releasehx/rhyml/templates/rhyml-change.yaml.liquid +++ b/lib/releasehx/rhyml/templates/rhyml-change.yaml.liquid @@ -1,40 +1,40 @@ - {%- assign change = include.change %} - {%- assign config = include.config %} - - chid: {{ change.chid }} - {%- if change.tick %} - tick: {{ change.tick }} - {%- endif %} - {%- if change.type %} - type: {{ change.type }} - {%- endif %} - {%- if change.parts.size > 0 %} - {%- if change.parts.size == 1 %} +{%- assign change = include.change %} +{%- assign config = include.config %} +- chid: {{ change.chid }} +{%- if change.tick %} + tick: {{ change.tick }} +{%- endif %} +{%- if change.type %} + type: {{ change.type }} +{%- endif %} +{%- if change.parts.size > 0 %} + {%- if change.parts.size == 1 %} part: {{ change.parts[0] }} - {%- else %} + {%- else %} parts: - {%- for part in change.parts %} + {%- for part in change.parts %} - {{ part }} - {%- endfor %} - {%- endif %} - {%- endif %} - {%- if change.hash %} - hash: {{ change.hash }} - {%- endif %} - summ: | - {{ change.summ }} - {%- if change.head %} - head: {{ change.head }} - {%- endif %} - {%- if change.note and change.note.strip != "" %} - note: | - {{ change.note | indent: 6 }} - {%- endif %} - {%- if change.tags.size > 0 %} - tags: - {%- for tag in change.tags %} - - {{ tag }} {%- endfor %} - {%- endif %} - {%- if change.lead %} - lead: {{ change.lead }} - {%- endif %} \ No newline at end of file + {%- endif %} +{%- endif %} +{%- if change.hash %} + hash: {{ change.hash }} +{%- endif %} +summ: | +{{ change.summ }} +{%- if change.head %} + head: {{ change.head }} +{%- endif %} +{%- if change.note and change.note.strip != '' %} + note: | + {{ _note | indent: 6 }} +{%- endif %} +{%- if change.tags.size > 0 %} + tags: + {%- for tag in change.tags %} + - {{ tag }} + {%- endfor %} +{%- endif %} +{%- if change.lead %} + lead: {{ change.lead }} +{%- endif %} diff --git a/lib/releasehx/rhyml/templates/wrapper.html.liquid b/lib/releasehx/rhyml/templates/wrapper.html.liquid new file mode 100644 index 0000000..10be250 --- /dev/null +++ b/lib/releasehx/rhyml/templates/wrapper.html.liquid @@ -0,0 +1,103 @@ + + + + + + + {% assign title = config.history.head | default: 'Release History' | escape %} + {{ title }} + + {%- assign styling_mode = config.history.styling.mode | default: 'framework' %} + {%- assign css_url = config.history.styling.css_url %} + {%- assign embed_css = config.history.styling.embed_css | default: false %} + {%- assign theme = config.history.styling.theme | default: 'default' %} + + {%- if styling_mode == 'external' and css_url %} + + {%- elsif styling_mode == 'framework' or styling_mode == 'embedded' %} + {%- if framework == 'bootstrap' %} + {%- case framework_version %} + {%- when '5.3.0' %} + + {%- when '4.6.2' %} + + {%- else %} + + {%- endcase %} + {%- endif %} + + + + {%- comment %}Load Bootstrap overrides (can be customized in _templates/bootstrap-overrides.css){%- endcomment %} + + {%- endif %} + + {%- if styling_mode == 'embedded' or embed_css %} + + {%- elsif styling_mode == 'minimal' %} + + {%- endif %} + + +
+
+ {{ content }} +
+
+ {%- if framework == 'bootstrap' %} + {%- case framework_version %} + {%- when '5.3.0' %} + + + {%- when '4.6.2' %} + + {%- else %} + + {%- endcase %} + {%- endif %} + + diff --git a/lib/releasehx/sgyml/helpers.rb b/lib/releasehx/sgyml/helpers.rb index 1e77221..5a65779 100644 --- a/lib/releasehx/sgyml/helpers.rb +++ b/lib/releasehx/sgyml/helpers.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require_relative '../../schemagraphy/templating' - module ReleaseHx module SgymlHelpers # Precompiles a schema into a set of templates, using the provided data and schema. diff --git a/lib/releasehx/transforms/adf_to_markdown.rb b/lib/releasehx/transforms/adf_to_markdown.rb index 15f5518..52abf33 100644 --- a/lib/releasehx/transforms/adf_to_markdown.rb +++ b/lib/releasehx/transforms/adf_to_markdown.rb @@ -135,7 +135,7 @@ def self.convert_paragraph node, excluded end # Converts a list (bullet or ordered) - def self.convert_list node, excluded, depth, unordered: true + def self.convert_list node, excluded, depth, unordered: true # rubocop:disable Lint/UnusedMethodArgument content = node['content'] || [] items = content.map { |item| convert_node(item, excluded, depth + 1) } "#{items.join}\n" diff --git a/lib/releasehx/version.rb b/lib/releasehx/version.rb index c45487d..e8cbcf9 100644 --- a/lib/releasehx/version.rb +++ b/lib/releasehx/version.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require_relative '../sourcerer' - module ReleaseHx VERSION = ReleaseHx::ATTRIBUTES[:globals]['this_prod_vrsn'] end diff --git a/lib/schemagraphy.rb b/lib/schemagraphy.rb deleted file mode 100644 index 7032b83..0000000 --- a/lib/schemagraphy.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -require_relative 'schemagraphy/loader' -require_relative 'schemagraphy/tag_utils' -require_relative 'schemagraphy/schema_utils' -require_relative 'schemagraphy/templating' -require_relative 'schemagraphy/regexp_utils' -require_relative 'schemagraphy/cfgyml/doc_builder' -require_relative 'schemagraphy/data_query/json_pointer' -require_relative 'schemagraphy/cfgyml/path_reference' - -# SchemaGraphy is a component for working with schema-driven data structures and extending YAML with robust typing and dynamic directives. -# It provides utilities for loading, validating, and transforming data based on -# a schema definition, with a focus on templating and safe expression evaluation. -# This module is under early development and will be spun off as its own gem after ReleaseHx is generally available. -module SchemaGraphy -end diff --git a/lib/schemagraphy/README.adoc b/lib/schemagraphy/README.adoc deleted file mode 100644 index d3d37a4..0000000 --- a/lib/schemagraphy/README.adoc +++ /dev/null @@ -1,3 +0,0 @@ -= SchemaGraphy - -include::../../README.adoc[tag=schemagraphy,leveloffset=-2] \ No newline at end of file diff --git a/lib/schemagraphy/attribute_resolver.rb b/lib/schemagraphy/attribute_resolver.rb deleted file mode 100644 index a0e3620..0000000 --- a/lib/schemagraphy/attribute_resolver.rb +++ /dev/null @@ -1,48 +0,0 @@ -# frozen_string_literal: true - -module SchemaGraphy - # The AttributeResolver module provides methods for resolving AsciiDoc attribute references - # within a schema hash. It is used to substitute placeholders like `\{attribute_name}` - # with actual values. - module AttributeResolver - # Recursively walk a schema Hash and resolve `\{attribute_name}` references - # in 'dflt' values. - # - # @param schema [Hash] The schema or definition hash to process. - # @param attrs [Hash] The key-value pairs from AsciiDoc attributes to use for resolution. - # @return [Hash] The schema with resolved attributes. - def self.resolve_attributes! schema, attrs - case schema - when Hash - schema.transform_values! do |value| - if value.is_a?(Hash) - if value.key?('dflt') && value['dflt'].is_a?(String) - value['dflt'] = resolve_attribute_reference(value['dflt'], attrs) - end - resolve_attributes!(value, attrs) - else - value - end - end - end - schema - end - - # Replace `\{attribute_name}` patterns with corresponding values from the attrs hash. - # - # @param value [String] The string to process. - # @param attrs [Hash] The attributes to use for resolution. - # @return [String] The processed string with attribute references replaced. - def self.resolve_attribute_reference value, attrs - # Handle \{attribute_name} references - if value.match?(/\{[^}]+\}/) - value.gsub(/\{([^}]+)\}/) do |match| - attr_name = ::Regexp.last_match(1) - attrs[attr_name] || match # Keep original if no matching attribute - end - else - value - end - end - end -end diff --git a/lib/schemagraphy/cfgyml/definition.rb b/lib/schemagraphy/cfgyml/definition.rb deleted file mode 100644 index c57cef1..0000000 --- a/lib/schemagraphy/cfgyml/definition.rb +++ /dev/null @@ -1,90 +0,0 @@ -# frozen_string_literal: true - -require_relative '../loader' -require_relative '../schema_utils' - -module SchemaGraphy - # A module for handling CFGYML, a schema-driven configuration system. - module CFGYML - # Represents a configuration definition loaded from a schema file. - # It provides methods for accessing defaults and rendering documentation. - class Definition - # @return [Hash] The loaded schema hash. - attr_reader :schema - - # @return [Hash] The attributes used for resolving placeholders in the schema. - attr_reader :attributes - - # @param schema_path [String] The path to the schema YAML file. - # @param attrs [Hash] A hash of attributes for placeholder resolution. - def initialize schema_path, attrs = {} - @schema = Loader.load_yaml_with_attributes(schema_path, attrs) - @attributes = attrs - end - - # Extract default values from the loaded schema. - # @return [Hash] A hash of default values. - def defaults - SchemaUtils.crawl_defaults(@schema) - end - - # Get the search paths for templates. - # @return [Array] An array of template paths. - def template_paths - @template_paths ||= [ - File.join(File.dirname(__FILE__), '..', 'templates', 'cfgyml'), - *additional_template_paths - ] - end - - # Render a configuration reference or sample in the specified format. - # - # @param format [Symbol] The output format (`:adoc` or `:yaml`). - # @return [String] The rendered output. - # @raise [ArgumentError] if the format is unsupported. - def render_reference format = :adoc - template = case format - when :adoc - 'config-reference.adoc.liquid' - when :yaml - 'sample-config.yaml.liquid' - else - raise ArgumentError, "Unsupported format: #{format}" - end - - render_template(template) - end - - private - - # Render a template using the Liquid engine. - def render_template template_name - template_path = find_template(template_name) - raise "Template not found: #{template_name}" unless template_path - - require 'liquid' - template_content = File.read(template_path) - template = Liquid::Template.parse(template_content) - - template.render( - 'config_def' => @schema, - 'attrs' => @attributes) - end - - # Find a template file in the configured template paths. - def find_template name - template_paths.each do |path| - file = File.join(path, name) - return file if File.exist?(file) - end - nil - end - - # Provides an extension point for subclasses to add more template paths. - def additional_template_paths - # Can be overridden by subclasses - [] - end - end - end -end diff --git a/lib/schemagraphy/cfgyml/doc_builder.rb b/lib/schemagraphy/cfgyml/doc_builder.rb deleted file mode 100644 index da7c758..0000000 --- a/lib/schemagraphy/cfgyml/doc_builder.rb +++ /dev/null @@ -1,52 +0,0 @@ -# frozen_string_literal: true - -require 'json' - -module SchemaGraphy - module CFGYML - # Builds documentation-friendly CFGYML references for machine consumption. - module DocBuilder - module_function - - def call schema, options = {} - pretty = options.fetch(:pretty, true) - data = reference_hash(schema) - pretty ? JSON.pretty_generate(data) : JSON.generate(data) - end - - def reference_hash schema - { - 'format' => 'releasehx-config-reference', - 'version' => 1, - 'properties' => build_properties(schema['properties'], []) - } - end - - def build_properties properties, path - return {} unless properties.is_a?(Hash) - - properties.each_with_object({}) do |(key, definition), acc| - next unless definition.is_a?(Hash) - - current_path = path + [key] - entry = build_entry(current_path, definition) - children = build_properties(definition['properties'], current_path) - entry['properties'] = children unless children.empty? - acc[key] = entry - end - end - - def build_entry path, definition - entry = { - 'path' => path.join('.'), - 'desc' => definition['desc'], - 'docs' => definition['docs'], - 'type' => definition['type'], - 'templating' => definition['templating'], - 'default' => definition.key?('dflt') ? definition['dflt'] : nil - } - entry.compact - end - end - end -end diff --git a/lib/schemagraphy/cfgyml/path_reference.rb b/lib/schemagraphy/cfgyml/path_reference.rb deleted file mode 100644 index 19ae9e1..0000000 --- a/lib/schemagraphy/cfgyml/path_reference.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -require 'json' - -module SchemaGraphy - module CFGYML - # Loads and queries a JSON config reference using JSON Pointer. - class PathReference - def initialize data - @data = data - end - - def self.load path - new(JSON.parse(File.read(path))) - end - - def get pointer - SchemaGraphy::DataQuery::JSONPointer.resolve(@data, pointer) - end - end - - Reference = PathReference - end -end diff --git a/lib/schemagraphy/data_query/json_pointer.rb b/lib/schemagraphy/data_query/json_pointer.rb deleted file mode 100644 index fbccaf8..0000000 --- a/lib/schemagraphy/data_query/json_pointer.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -module SchemaGraphy - module DataQuery - # Resolves JSON Pointer queries against a Hash or Array. - module JSONPointer - module_function - - def resolve data, pointer - return data if pointer.nil? || pointer == '' - raise ArgumentError, "Invalid JSON Pointer: #{pointer}" unless pointer.start_with?('/') - - tokens = pointer.split('/')[1..] - tokens.reduce(data) do |current, token| - key = unescape(token) - resolve_token(current, key, pointer) - end - end - - def resolve_token current, key, pointer - case current - when Array - index = Integer(key, 10) - current.fetch(index) - when Hash - return current.fetch(key) if current.key?(key) - return current.fetch(key.to_sym) if current.key?(key.to_sym) - - raise KeyError, "JSON Pointer not found: #{pointer}" - else - raise KeyError, "JSON Pointer not found: #{pointer}" - end - rescue ArgumentError, IndexError, KeyError - raise KeyError, "JSON Pointer not found: #{pointer}" - end - - def unescape token - token.gsub('~1', '/').gsub('~0', '~') - end - end - end -end diff --git a/lib/schemagraphy/loader.rb b/lib/schemagraphy/loader.rb deleted file mode 100644 index 8eb6ebd..0000000 --- a/lib/schemagraphy/loader.rb +++ /dev/null @@ -1,59 +0,0 @@ -# frozen_string_literal: true - -require 'yaml' -require 'psych' -require_relative 'attribute_resolver' - -module SchemaGraphy - # The Loader class provides methods for loading YAML files while preserving - # custom tags and resolving attribute references. - class Loader - # Load a YAML file and resolve AsciiDoc attribute references like `\{attribute_name}`. - # - # @param path [String] The path to the YAML file. - # @param attrs [Hash] The AsciiDoc attributes to use for resolution. - # @return [Hash] The loaded YAML data with attributes resolved. - def self.load_yaml_with_attributes path, attrs = {} - raw_data = load_yaml_with_tags(path) - AttributeResolver.resolve_attributes!(raw_data, attrs) - raw_data - end - - # Load a YAML file, preserving any custom tags (e.g., `!foo`). - # Custom tags are attached to the data structure. - # - # @param path [String] The path to the YAML file. - # @return [Hash] The loaded YAML data with custom tags attached. - def self.load_yaml_with_tags path - return {} if File.empty?(path) - - data = Psych.load_file(path, aliases: true, permitted_classes: [Date, Time]) - ast = Psych.parse_file(path) - attach_tags(ast.root, data) - data - end - - # Recursively attach YAML tags to the loaded data structure for template processing. - # - # @param node [Psych::Nodes::Node] The current AST node. - # @param data [Object] The data corresponding to the current node. - # @api private - def self.attach_tags node, data - return unless node.is_a?(Psych::Nodes::Mapping) - - node.children.each_slice(2) do |key_node, val_node| - key = key_node.value - - if val_node.respond_to?(:tag) && val_node.tag && data[key].is_a?(String) - normalized_tag = val_node.tag.sub(/^!+/, '').sub(/^.*:/, '') - data[key] = { - 'value' => data[key], - '__tag__' => normalized_tag - } - elsif data[key].is_a?(Hash) - attach_tags(val_node, data[key]) - end - end - end - end -end diff --git a/lib/schemagraphy/regexp_utils.rb b/lib/schemagraphy/regexp_utils.rb deleted file mode 100644 index c58a339..0000000 --- a/lib/schemagraphy/regexp_utils.rb +++ /dev/null @@ -1,235 +0,0 @@ -# frozen_string_literal: true - -require 'to_regexp' - -module SchemaGraphy - # A utility module for robustly parsing and using regular expressions. - # It handles various formats, including literals and plain strings, - # and provides helpers for extracting captured content. - module RegexpUtils - module_function - - # Parse a regex pattern string using the `to_regexp` gem for robust parsing. - # Handles `/pattern/flags`, `%r{pattern}flags`, and plain text formats. - # - # @example - # parse_pattern("/^hello.*$/im") - # # => { pattern: "^hello.*$", flags: "im", regexp: /^hello.*$/im, options: 6 } - # - # @example - # parse_pattern("hello world") - # # => { pattern: "hello world", flags: "", regexp: /hello world/, options: 0 } - # - # @example - # parse_pattern("hello world", "i") - # # => { pattern: "hello world", flags: "i", regexp: /hello world/i, options: 1 } - # - # @param input [String] The input string, e.g., "/pattern/flags" or "plain pattern". - # @param default_flags [String] Default flags to apply if none are specified (default: ""). - # @return [Hash, nil] A hash with `:pattern`, `:flags`, `:regexp`, and `:options`, or `nil`. - def parse_pattern input, default_flags = '' - return nil if input.nil? || input.to_s.strip.empty? - - input_str = input.to_s.strip - - # Remove surrounding quotes that might come from YAML parsing - clean_input = input_str.gsub(/^["']|["']$/, '') - - # Manual parsing for /pattern/flags format (common in YAML configs) - if clean_input =~ %r{^/(.+)/([a-z]*)$} - pattern_str = Regexp.last_match(1) - flags_str = Regexp.last_match(2) - options = flags_to_options(flags_str) - - begin - regexp_obj = Regexp.new(pattern_str, options) - - return { - pattern: pattern_str, - flags: flags_str, - regexp: regexp_obj, - options: options - } - rescue RegexpError => e - raise RegexpError, "Invalid regex pattern '#{input}': #{e.message}" - end - end - - # Heuristic to detect if it's a Regexp literal - is_literal = clean_input.start_with?('%r{') - - if is_literal - # Try to parse as regex literal using to_regexp - begin - regexp_obj = clean_input.to_regexp(detect: true) - - # Extract pattern and flags from the compiled regexp - pattern_str = regexp_obj.source - flags_str = extract_flags_from_regexp(regexp_obj) - - { - pattern: pattern_str, - flags: flags_str, - regexp: regexp_obj, - options: regexp_obj.options - } - rescue RegexpError => e - # Malformed literal is an error - raise RegexpError, "Invalid regex literal '#{input}': #{e.message}" - end - else - # Treat as plain pattern string with default flags - flags_str = default_flags.to_s - options = flags_to_options(flags_str) - - begin - regexp_obj = Regexp.new(clean_input, options) - - { - pattern: clean_input, - flags: flags_str, - regexp: regexp_obj, - options: options - } - rescue RegexpError => e - raise RegexpError, "Invalid regex pattern '#{input}': #{e.message}" - end - end - end - - # @note Not yet implemented. - # Future enhancement to parse structured pattern definitions from a Hash. - # @param pattern_hash [Hash] A hash with 'pattern' and 'flags' keys. - # @raise [NotImplementedError] Always raises this error. - def parse_structured_pattern pattern_hash - # TODO: Implement structured pattern parsing - # pattern_hash should have 'pattern' and 'flags' keys - # flags can be string or array - raise NotImplementedError, 'Structured pattern parsing not yet implemented' - end - - # @note Not yet implemented. - # Future enhancement to parse custom YAML tags for regular expressions. - # @param tagged_input [String] The input string with a YAML tag. - # @param tag_type [Symbol] The type of tag, e.g., `:literal` or `:pattern`. - # @raise [NotImplementedError] Always raises this error. - def parse_tagged_pattern tagged_input, tag_type - # TODO: Implement custom YAML tag parsing - # tag_type would be :literal or :pattern - raise NotImplementedError, 'Tagged pattern parsing not yet implemented' - end - - # Convert a flags string (ex: "im") to a Regexp options integer. - # - # @param flags [String] String containing regex flags. - # @return [Integer] Regexp options integer. - def flags_to_options flags - options = 0 - flags = flags.to_s - - options |= Regexp::IGNORECASE if flags.include?('i') - options |= Regexp::MULTILINE if flags.include?('m') - options |= Regexp::EXTENDED if flags.include?('x') - - # NOTE: 'g' (global) and 'o' (once) are not standard Ruby flags - # encoding flags ('n', 'e', 's', 'u') are handled by to_regexp - - options - end - - # Extract a flags string from a compiled Regexp object. - # - # @param regexp [Regexp] A compiled regexp object. - # @return [String] String representation of the flags (e.g., "im"). - def extract_flags_from_regexp regexp - flags = '' - flags += 'i' if regexp.options.anybits?(Regexp::IGNORECASE) - flags += 'm' if regexp.options.anybits?(Regexp::MULTILINE) - flags += 'x' if regexp.options.anybits?(Regexp::EXTENDED) - flags - end - - # Create a Regexp object from a pattern string and explicit flags. - # - # @param pattern [String] The regex pattern (without delimiters). - # @param flags [String] The flags string (ex: "im"). - # @return [Regexp] The compiled Regexp object. - def create_regexp pattern, flags = '' - options = flags_to_options(flags) - Regexp.new(pattern, options) - end - - # Extract content using named or positional capture groups. - # - # @param text [String] The text to match against. - # @param pattern_info [Hash] The hash result from `parse_pattern`. - # @param capture_name [String] The name of the capture group to extract (optional). - # @return [String, nil] The extracted text, or `nil` if no match is found. - def extract_capture text, pattern_info, capture_name = nil - return nil unless text && pattern_info - - regexp = pattern_info[:regexp] - match = text.match(regexp) - - return nil unless match - - if capture_name && match.names.include?(capture_name.to_s) - # Extract named capture group - match[capture_name.to_s] - elsif match.captures.any? - # Extract first capture group - match[1] - else - # Return the entire match - match[0] - end - end - - # Extract all named capture groups as a hash or positional captures as an array. - # - # @param text [String] The text to match against. - # @param pattern_info [Hash] The hash result from `parse_pattern`. - # @return [Hash, Array, nil] A hash of named captures, an array of positional captures, or `nil`. - def extract_all_captures text, pattern_info - return nil unless text && pattern_info - - regexp = pattern_info[:regexp] - match = text.match(regexp) - - return nil unless match - - if match.names.any? - # Return hash of named captures - match.names.each_with_object({}) do |name, captures| - captures[name] = match[name] - end - else - # Return array of positional captures - match.captures - end - end - - # A convenience method that combines parsing and a single extraction. - # - # @param text [String] The text to match against. - # @param pattern_input [String] The pattern string (with or without /flags/). - # @param capture_name [String] Name of the capture group to extract (optional). - # @param default_flags [String] Default flags if the pattern has no flags. - # @return [String, nil] The extracted text, or `nil` if no match is found. - def parse_and_extract text, pattern_input, capture_name = nil, default_flags = '' - pattern_info = parse_pattern(pattern_input, default_flags) - extract_capture(text, pattern_info, capture_name) - end - - # A convenience method that combines parsing and extraction of all captures. - # - # @param text [String] The text to match against. - # @param pattern_input [String] The pattern string (with or without /flags/). - # @param default_flags [String] Default flags if the pattern has no flags. - # @return [Hash, Array, nil] All captured content, or `nil` if no match is found. - def parse_and_extract_all text, pattern_input, default_flags = '' - pattern_info = parse_pattern(pattern_input, default_flags) - extract_all_captures(text, pattern_info) - end - end -end diff --git a/lib/schemagraphy/safe_expression.rb b/lib/schemagraphy/safe_expression.rb deleted file mode 100644 index 6424ff1..0000000 --- a/lib/schemagraphy/safe_expression.rb +++ /dev/null @@ -1,189 +0,0 @@ -# frozen_string_literal: true - -require 'prism' -require 'timeout' - -module SchemaGraphy - # Provides a simple, deny-by-exception sandbox for mapping expressions. - # It validates code by walking the Abstract Syntax Tree (AST) and blocking - # known dangerous operations, rather than attempting to allowlist safe ones. - class AstGate - # A list of dangerous bareword methods that are blocked. - BLOCKED_BAREWORDS = %w[ - eval instance_eval class_eval module_eval binding - require require_relative load autoload - system exec spawn fork backtick ` - open ObjectSpace GC Thread Process at_exit - ].freeze - - # A list of AST node types that are explicitly disallowed. - DISALLOWED_NODES = %i[ - # Definitions and meta-programming - def_node class_node module_node define_node alias_node undef_node - # Globals and constants paths - global_variable_read_node constant_path_node - # Shell and backticks - x_string_node interpolated_x_string_node - ].freeze - - # A list of constants that are considered dangerous and are blocked. - DANGEROUS_CONSTANTS = %w[ - Kernel Object Module Class File FileUtils IO Dir Process Open3 PTY Thread - SystemSignal Signal Gem Net HTTP TCPSocket UDPSocket Socket ObjectSpace GC - ].freeze - - # Validates the given code by parsing it and walking the AST. - # - # @param code [String] The Ruby code to validate. - # @param context_keys [Array] A list of keys available in the execution context. - # @raise [SyntaxError] if the code has syntax errors. - # @raise [SecurityError] if the code contains disallowed operations. - def self.validate! code, context_keys: [] - result = Prism.parse(code) - raise SyntaxError, result.errors.map(&:message).join(', ') if result.errors.any? - - walk(result.value, context_keys: context_keys) - end - - # @api private - # Recursively walks the AST, checking for disallowed nodes and operations. - # - # @param node [Prism::Node] The current AST node. - # @param context_keys [Array] A list of keys available in the execution context. - # @raise [SecurityError] if a disallowed operation is found. - def self.walk node, context_keys: [] - return unless node.is_a?(Prism::Node) - - type = node.type - raise SecurityError, "node not allowed: #{type}" if DISALLOWED_NODES.include?(type) - - case node - when Prism::CallNode - # Block dangerous barewords (system, eval, etc.) - if node.receiver.nil? && BLOCKED_BAREWORDS.include?(node.name.to_s) - raise SecurityError, "method not allowed: #{node.name}" - end - # Block dangerous constants and constant paths - if node.receiver.is_a?(Prism::ConstantReadNode) && DANGEROUS_CONSTANTS.include?(node.receiver.name.to_s) - raise SecurityError, "unsafe constant: #{node.receiver.name}" - end - raise SecurityError, 'unsafe constant path' if node.receiver.is_a?(Prism::ConstantPathNode) - - when Prism::ConstantReadNode - # Allow only core Ruby constants defined in SafeTransform - const_name = node.name.to_s - unless SafeTransform::CORE_CONSTANTS.key?(const_name.to_sym) - raise SecurityError, "constant not allowed: #{const_name}" - end - when Prism::ConstantPathNode, Prism::GlobalVariableReadNode - raise SecurityError, 'constant paths and global variables are not allowed' - when Prism::DefNode, Prism::ClassNode, Prism::ModuleNode - raise SecurityError, 'method, class, and module definitions are not allowed' - when Prism::BackReferenceReadNode, Prism::XStringNode, Prism::InterpolatedXStringNode - raise SecurityError, 'shell commands and backticks are not allowed' - end - - node.child_nodes.each { |child| walk(child, context_keys: context_keys) if child } - end - end - - # Provides a sandboxed environment for executing Ruby code. - # Inherits from `BasicObject` for a minimal namespace and uses `instance_eval` - # to run code within its own context. All code is validated by {AstGate} before execution. - class SafeTransform < BasicObject - # A minimal set of core Ruby constants exposed to the sandboxed environment. - CORE_CONSTANTS = { - Array: ::Array, - Hash: ::Hash, - String: ::String, - Integer: ::Integer, - Float: ::Float, - TrueClass: ::TrueClass, - FalseClass: ::FalseClass, - NilClass: ::NilClass, - Symbol: ::Symbol, - Numeric: ::Numeric, - Regexp: ::Regexp - }.freeze - - CORE_CONSTANTS.each do |name, ref| - const_set(name, ref) unless const_defined?(name, false) - end - - # @param context [Hash] A hash of data to be made available in the sandbox. - def initialize context = {} - @context = context - end - - # Executes the given code within the sandboxed environment. - # - # @param code [String] The Ruby code to execute. - # @return [Object] The result of the executed code. - # @raise [Timeout::Error] if the execution time exceeds the limit. - # @raise [SecurityError] if the code contains disallowed operations. - def transform code - ::Timeout.timeout(0.25) do - AstGate.validate!(code, context_keys: @context.keys) - instance_eval(code) - end - rescue ::Timeout::Error - ::Kernel.raise ::StandardError, 'transform timed out' - end - - # Adds a key-value pair to the execution context. - # - # @param key [String, Symbol] The key to add. - # @param value [Object] The value to associate with the key. - def add_context key, value - @context[key.to_s] = value - end - - # Safely traverses a nested object using a dot-separated path. - # - # @param obj [Object] The object to traverse. - # @param path [String] The dot-separated path (e.g., "a.b.c"). - # @return [Object, nil] The value at the specified path, or `nil`. - def dig_path obj, path - keys = path.to_s.split('.') - keys.reduce(obj) { |memo, key| memo.respond_to?(:[]) ? memo[key] : nil } - end - - def to_s - '#' - end - - private - - # Handles access to variables in the context. - def method_missing(name, *args, &block) - key = name.to_s - if @context.key?(key) && args.empty? && block.nil? - @context[key] - else - ::Kernel.raise ::NoMethodError, "undefined method `#{name}` for #{self}" - end - end - - def respond_to_missing? name, include_private = false - @context.key?(name.to_s) || super - end - - # Disable methods that could be used to break out of the sandbox. - - def instance_exec(*_args) - ::Kernel.raise ::NoMethodError, 'disabled' - end - - def method(*_args) - ::Kernel.raise ::NoMethodError, 'disabled' - end - - def singleton_class(*_args) - ::Kernel.raise ::NoMethodError, 'disabled' - end - - def define_singleton_method(*_args) - ::Kernel.raise ::NoMethodError, 'disabled' - end - end -end diff --git a/lib/schemagraphy/schema_utils.rb b/lib/schemagraphy/schema_utils.rb deleted file mode 100644 index a8d6471..0000000 --- a/lib/schemagraphy/schema_utils.rb +++ /dev/null @@ -1,124 +0,0 @@ -# frozen_string_literal: true - -module SchemaGraphy - # A utility module for introspecting schema definitions. - # Provides methods for retrieving metadata, default values, and type information - # from a schema hash using a dot-separated path syntax. - module SchemaUtils - module_function - - # Retrieve a nested property definition from a schema using a dot-separated path. - # - # @example Schema Structure - # schema = { - # "$schema": { - # "properties": { - # "property1": { - # "properties": { - # "subproperty1": { - # "default": "value1", - # "type": "String" - # } - # } - # } - # } - # } - # } - # crawl_properties(schema, "property1.subproperty1") - # # => { "default" => "value1", "type" => "String" } - # - # @param schema [Hash] The schema hash to crawl. - # @param path [String] The dot-separated path to the property. - # @return [Hash, nil] The property definition hash, or `nil` if not found. - def crawl_properties schema, path - path_components = path.split('.') - current = schema['$schema'] || schema - - path_components.each do |component| - return nil unless current.is_a?(Hash) - return nil unless current['properties']&.key?(component) - - current = current['properties'][component] - end - - current - end - - # Get the default value for a property from the schema. - # - # @param schema [Hash] The schema hash. - # @param path [String] The dot-separated path to the property. - # @return [Object, nil] The default value, or `nil` if not defined. - def default_for schema, path - property = crawl_properties(schema, path) - return nil unless property.is_a?(Hash) - - property['default'] || property['dflt'] - end - - # Get the type for a property from the schema. - # - # @param schema [Hash] The schema hash. - # @param path [String] The dot-separated path to the property. - # @return [String, nil] The property type, or `nil` if not defined. - def type_for schema, path - property = crawl_properties(schema, path) - return nil unless property.is_a?(Hash) - - property['type'] - end - - # Get the templating configuration for a property from the schema. - # - # @param schema [Hash] The schema hash. - # @param path [String] The dot-separated path to the property. - # @return [Hash] The templating configuration hash. - def templating_config_for schema, path - property = crawl_properties(schema, path) - return {} unless property.is_a?(Hash) - - return property['templating'] if property['templating'] - - if property['type'].to_s.downcase == 'liquid' - { 'default' => 'liquid', 'delay' => true } - elsif property['type'].to_s.downcase == 'erb' - { 'default' => 'erb', 'delay' => true } - else - {} - end - end - - # Check if a property is a templated field. - # - # @param schema [Hash] The schema hash. - # @param path [String] The dot-separated path to the property. - # @return [Boolean] `true` if the field has templating configured, `false` otherwise. - def templated_field? schema, path - property = crawl_properties(schema, path) - return false unless property.is_a?(Hash) - - property.key?('templating') && property['templating'].is_a?(Hash) - end - - # Crawl the schema to find the metadata for a given path. - # - # @param schema [Hash] The schema hash. - # @param path [String, nil] The dot-separated path. - # @return [Hash] The metadata hash. - def self.crawl_meta schema, path = nil - parts = path ? path.split('.') : [] - node = schema['$schema'] || schema - meta = {} - - parts.each do |part| - node = node['properties'][part] if node['properties']&.key?(part) - break unless node.is_a?(Hash) - - # Only update meta if this level has it - meta = node if node['templating'] - end - - meta['$meta'] || meta['sgyml'] || meta['templating'] || {} - end - end -end diff --git a/lib/schemagraphy/tag_utils.rb b/lib/schemagraphy/tag_utils.rb deleted file mode 100644 index 3c0757f..0000000 --- a/lib/schemagraphy/tag_utils.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -module SchemaGraphy - # A utility module for working with the custom tag data structure. - # The structure is a hash with 'value' and '__tag__' keys. - module TagUtils - # Extracts the original value from a tagged data structure. - # - # @param value [Object] The tagged value (a Hash) or any other value. - # @return [Object] The original value, or the value itself if not tagged. - def self.detag value - value.is_a?(Hash) && value.key?('value') ? value['value'] : value - end - - # Retrieves the tag from a tagged data structure. - # - # @param value [Object] The tagged value (a Hash) or any other value. - # @return [String, nil] The tag string, or `nil` if not tagged. - def self.tag_of value - value.is_a?(Hash) ? value['__tag__'] : nil - end - - # Checks if a value has a specific tag. - # - # @param value [Object] The tagged value to check. - # @param tag [String, Symbol] The tag to check for. - # @return [Boolean] `true` if the value has the specified tag, `false` otherwise. - def self.tag? value, tag - tag_of(value)&.to_s == tag.to_s - end - end -end diff --git a/lib/schemagraphy/templates/cfgyml/config-property.adoc.liquid b/lib/schemagraphy/templates/cfgyml/config-property.adoc.liquid deleted file mode 100644 index 8f84afd..0000000 --- a/lib/schemagraphy/templates/cfgyml/config-property.adoc.liquid +++ /dev/null @@ -1,57 +0,0 @@ -{%- assign pname = include.property[0] %} -{%- assign pbody = include.property[1] %} -{%- assign pid = include.ppty_path | replace: "__", "_" | replace: "<", "_" | replace: ">", "_" | replace: "$", "DOLLARSIGN_" | prepend: "conf_ppty_" %} -{%- assign ppath = include.ppty_path | replace: "__", "." | replace: "DOLLARSIGN_", "$" %} -{%- case include.tier %} -{%- when 1 %} -{%- assign dlchars = "::" %} -{%- when 2 %} -{%- assign dlchars = ":::" %} -{%- when 3 %} -{%- assign dlchars = "::::" %} -{%- else %} -{%- assign dlchars = ";;" %} -{%- endcase %} - -[[{{ pid }},config.{{ ppath }}]] -{{ ppath }}{{ dlchars }} -+ --- -{{ pbody.desc }} -{%- if pbody.docs %} -{{ pbody.docs }} -{%- endif %} - -[horizontal] -{%- if pbody.type %} -type;; {{ pbody.type }} -{%- endif %} -{%- if pbody.templating %} -templating;; {% if pbody.templating.default %}{{ pbody.templating.default }}{% if pbody.templating.delay %}, {% endif %}{% endif %}{% if pbody.templating.delay %} delayed{% else %} immediate{% endif %} rendering -{%- endif %} -{%- if pbody.dflt != nil %} -default;; -+ -{%- if pbody.dflt.first %} -{%- if pbody.dflt.size > 0 %} -.... -{%- for item in pbody.dflt %} -- {{ item }} -{%- endfor %} -.... -{%- else %} -[_empty array_] -{%- endif %} -{%- else %} -{%- if pbody.dflt contains ' -' or pbody.dflt.size > 20 %} -.... -{{ pbody.dflt | trim }} -.... -{%- else %} -`+++{{ pbody.dflt | trim }}+++` -{%- endif %} -{%- endif %} -{%- endif %} -path;; `xref:{{ pid }}[{{ ppath | prepend: "config." }}]` --- \ No newline at end of file diff --git a/lib/schemagraphy/templates/cfgyml/config-reference.adoc.liquid b/lib/schemagraphy/templates/cfgyml/config-reference.adoc.liquid deleted file mode 100644 index f60130a..0000000 --- a/lib/schemagraphy/templates/cfgyml/config-reference.adoc.liquid +++ /dev/null @@ -1,33 +0,0 @@ -{%- for property in config_def.properties -%} -{%- assign ppty_key1 = property[0] -%} -{%- assign ppty_path_t1 = ppty_key1 -%} -{%- include cfgyml/config-property.adoc.liquid - test_string="test string" - property=property - tier=1 - ppty_path=ppty_path_t1 -%} - -{%- assign props_t2 = property[1].properties -%} -{%- if props_t2 -%} -{%- for propty in props_t2 -%} -{%- assign ppty_key2 = propty[0] -%} -{%- assign ppty_path_t2 = ppty_path_t1 | append: '__' | append: ppty_key2 -%} -{%- include cfgyml/config-property.adoc.liquid - property=propty - tier=2 - ppty_path=ppty_path_t2 -%} - -{%- assign props_t3 = propty[1].properties -%} -{%- if props_t2 -%} -{%- for ppty in props_t3 -%} -{%- assign ppty_key3 = ppty[0] -%} -{%- assign ppty_path_t3 = ppty_path_t2 | append: '__' | append: ppty_key3 -%} -{%- include cfgyml/config-property.adoc.liquid - property=ppty - tier=3 - ppty_path=ppty_path_t3 -%} -{%- endfor -%} -{%- endif -%} -{%- endfor -%} -{%- endif -%} -{%- endfor %} diff --git a/lib/schemagraphy/templates/cfgyml/sample-config.yaml.liquid b/lib/schemagraphy/templates/cfgyml/sample-config.yaml.liquid deleted file mode 100644 index c70421a..0000000 --- a/lib/schemagraphy/templates/cfgyml/sample-config.yaml.liquid +++ /dev/null @@ -1,46 +0,0 @@ -# Sample configuration file generated from the configuration definition. -# All values are upstream (gem) defaults. -# Properties without defaults are commented out. -{%- for property_t1 in config_def.properties %} -{%- assign key1 = property_t1[0] %} -{%- assign val1 = property_t1[1] %} -{%- assign dflt1 = val1.dflt %} -{%- assign key1_first_char = key1 | slice: 0, 1 %} -{%- if key1_first_char == '<' or dflt1 == nil and val1.properties == nil %} -{%- assign nulled1 = true %} -{%- else %} -{%- assign nulled1 = false %} -{%- endif %} -{%- include cfgyml/sample-property.yaml.liquid property=property_t1 tier=1 nulled=nulled1 %} -{%- assign sub_properties_t2 = val1.properties %} -{%- if sub_properties_t2 %} -{%- for property_t2 in sub_properties_t2 %} -{%- assign key2 = property_t2[0] %} -{%- assign val2 = property_t2[1] %} -{%- assign dflt2 = val2.dflt %} -{%- assign key2_first_char = key2 | slice: 0, 1 %} -{%- if nulled1 or (key2_first_char == '<' or (dflt2 == nil and val2.properties == nil)) %} -{%- assign nulled2 = true %} -{%- else %} -{%- assign nulled2 = false %} -{%- endif %} -{%- include cfgyml/sample-property.yaml.liquid property=property_t2 tier=2 nulled=nulled2 %} - -{%- assign sub_properties_t3 = val2.properties %} -{%- if sub_properties_t3 %} -{%- for property_t3 in sub_properties_t3 %} -{%- assign key3 = property_t3[0] %} -{%- assign val3 = property_t3[1] %} -{%- assign dflt3 = val3.dflt %} -{%- assign key3_first_char = key3 | slice: 0, 1 %} -{%- if nulled2 or (key3_first_char == '<' or (dflt3 == nil and val3.properties == nil)) %} -{%- assign nulled3 = true %} -{%- else %} -{%- assign nulled3 = false %} -{%- endif %} -{%- include cfgyml/sample-property.yaml.liquid property=property_t3 tier=3 nulled=nulled3 %} -{%- endfor %} -{%- endif %} -{%- endfor %} -{%- endif %} -{%- endfor %} diff --git a/lib/schemagraphy/templates/cfgyml/sample-property.yaml.liquid b/lib/schemagraphy/templates/cfgyml/sample-property.yaml.liquid deleted file mode 100644 index b17026e..0000000 --- a/lib/schemagraphy/templates/cfgyml/sample-property.yaml.liquid +++ /dev/null @@ -1,82 +0,0 @@ -{%- assign key = include.property[0] %} -{%- assign val = include.property[1] %} -{%- assign dflt = val.dflt %} -{%- assign tier = include.tier | plus: 0 %} -{%- assign nulled = include.nulled %} -{%- assign indent = '' %} -{%- if tier > 1 %} -{%- for i in (2..tier) %}{% assign indent = indent | append: ' ' %}{% endfor %} -{%- endif %} -{%- if nulled %} - {%- assign pfx = '# ' %} -{%- else %} - {%- assign pfx = '' %} -{%- endif %} -{%- assign cmmt = val.cmmt | default: val.desc | default: '' | strip | split: " -" | first %} -{%- if cmmt and cmmt != "" %} - {%- assign cmmt = cmmt | demarkupify | truncatewords: 20 | prepend: '# ' %} -{%- endif %} -{%- assign dflt_kind = dflt | sgyml_type | split: ":" | first %} -{%- assign dflt_class = dflt | sgyml_type | split: ":" | last %} -{%- assign disp = nil %} -{%- if dflt contains " -" or val.type == "Multiline" %} - {%- assign disp = "multiline" %} -{%- elsif val.type == "String" %} - {%- if dflt.size > 40 %} - {%- assign disp = "multiline" %} - {%- elsif dflt contains ":" or dflt contains "{" %} - {%- assign disp = "quoted" %} - {%- else %} - {%- assign disp = "unquoted" %} - {% endif %} -{%- elsif val.templating or val.type == "Template" or val.type == "Liquid" or val.type == "RegExp" %} - {%- assign disp = "multiline" %} -{%- elsif val.type == "Array" or val.type == "ArrayList" or dflt_class == "ArrayList" %} - {%- if dflt.size < 4 and (val.type == "ArrayList" or dflt_class == "ArrayList") %} - {%- assign disp = "sequence-flow" %} - {%- else %} - {%- assign disp = "sequence-block" %} - {%- endif %} -{%- elsif val.properties or val.type == "Map" %} - {%- assign disp = "mapping-block" %} -{%- elsif !val.type %} - {%- assign disp = "unquoted" %} -{%- elsif val.type == "String" and dflt == "" %} - {%- assign disp = "blank-string" %} -{%- else %} - {%- assign disp = "unquoted" %} -{%- endif %} -{%- case disp %} -{%- when "multiline" %} -{{ pfx }}{{ indent }}{{ key }}: | {{ cmmt }} -{%- assign ml_indent = indent | append: ' ' %} -{%- if nulled %} -{%- assign dflt_lines = dflt | split: " -" -%} -{%- for line in dflt_lines %} -{{ ml_indent }}# {{ line }} -{%- endfor %} -{%- else %} -{%- assign ml_indent_size = indent.size | plus: 2 %} -{{ dflt | indent: ml_indent_size, true }} -{%- endif %} -{%- when "mapping-block" %} -{{ pfx }}{{ indent }}{{ key }}: {{ cmmt }} -{%- when "sequence-block" %} -{{ pfx }}{{ indent }}{{ key }}: {{ cmmt }} -{%- for item in dflt %} -{{ pfx }}{{ indent }} - {{ item }} -{%- endfor %} -{%- when "sequence-flow" %} -{{ pfx }}{{ indent }}{{ key }}: [{{ dflt | join: ', ' }}] {{ cmmt }} -{%- when "blank-string" %} -{{ pfx }}{{ indent }}{{ key }}: "" {{ cmmt }} -{%- when "quoted" %} -{{ pfx }}{{ indent }}{{ key }}: "{{ dflt }}" {{ cmmt }} -{%- when "unquoted" %} -{{ pfx }}{{ indent }}{{ key }}: {{ dflt }} {{ cmmt }} -{%- else %} -{{ pfx }}{{ indent }}# UNIDENTIFIED -{%- endcase %} \ No newline at end of file diff --git a/lib/schemagraphy/templating.rb b/lib/schemagraphy/templating.rb deleted file mode 100644 index 39a3210..0000000 --- a/lib/schemagraphy/templating.rb +++ /dev/null @@ -1,104 +0,0 @@ -# frozen_string_literal: true - -require 'tilt' -require_relative '../sourcerer/templating' - -# frozen_string_literal: true - -module SchemaGraphy - # A module for handling templated fields within a data structure based on a schema or definition. - # It provides methods for pre-compiling and rendering fields using various template engines. - module Templating - extend Sourcerer::Templating - - # Renders a field if it is a template. - # - # @param field [Object] The field to render. - # @param context [Hash] The context to use for rendering. - # @return [Object] The rendered field, or the original field if it's not a template. - def self.resolve_field field, context = {} - render_field_if_template(field, context) - end - - # Recursively pre-compiles templated fields in a data structure based on a schema. - # - # @param data [Hash] The data to process. - # @param schema [Hash] The schema defining which fields are templated. - # @param base_path [String] The base path for the current data level. - # @param scope [Hash] The scope to use for compilation. - def self.precompile_from_schema! data, schema, base_path = '', scope: {} - return unless data.is_a?(Hash) - - data.each do |key, value| - path = [base_path, key].reject(&:empty?).join('.') - - precompile_from_schema!(value, schema, path, scope: scope) if value.is_a?(Hash) - - next unless SchemaGraphy::SchemaUtils.templated_field?(schema, path) - - compile_templated_fields!( - data: data, - schema: schema, - fields: [{ key: key, path: path }], - scope: scope) - end - end - - # An alias for the `Sourcerer::Templating::TemplatedField` class. - TemplatedField = Sourcerer::Templating::TemplatedField - - # Compiles templated fields in the data. - # - # @param data [Hash] The data containing the fields to compile. - # @param schema [Hash] The schema definition. - # @param fields [Array] An array of fields to compile, each with a `:key` and `:path`. - # @param scope [Hash] The scope to use for compilation. - def self.compile_templated_fields! data:, schema:, fields:, scope: {} - fields.each do |entry| - key = entry[:key] - path = entry[:path] - val = data[key] - - next unless val.is_a?(String) || (val.is_a?(Hash) && val['__tag__'] && val['value']) - - raw = val.is_a?(Hash) ? val['value'] : val - tagged = val.is_a?(Hash) - config = SchemaGraphy::SchemaUtils.templating_config_for(schema, path) - engine = tagged ? val['__tag__'] : (config['default'] || 'liquid') - - compiled = Sourcerer::Templating::Engines.compile(raw, engine) - - data[key] = if config['delay'] - Sourcerer::Templating::TemplatedField.new(raw, compiled, engine, tagged, inferred: !tagged) - else - Sourcerer::Templating::Engines.render(compiled, engine, scope) - end - end - end - - # Recursively renders all pre-compiled templated fields in a data structure. - # - # @param data [Hash, Array] The data structure to process. - # @param context [Hash] The context to use for rendering. - def self.render_all_templated_fields! data, context = {} - return unless data.is_a?(Hash) - - data.each do |key, value| - case value - when TemplatedField - data[key] = value.render(context) - when Hash - render_all_templated_fields!(value, context) - when Array - value.each_with_index do |item, idx| - if item.is_a?(TemplatedField) - value[idx] = item.render(context) - elsif item.is_a?(Hash) - render_all_templated_fields!(item, context) - end - end - end - end - end - end -end diff --git a/lib/sourcerer.rb b/lib/sourcerer.rb deleted file mode 100644 index d10a80f..0000000 --- a/lib/sourcerer.rb +++ /dev/null @@ -1,322 +0,0 @@ -# frozen_string_literal: true - -# This module is a pre-alpha version of what I will eventually spin off -# as AsciiSourcery, for single-sourcing documentation AND product data -# in AsciiDoc and YAML files -# It is pretty messy for now as I play around with various ways it might -# get used, including as a build-time generator of artifacts to be used -# in both the app and the docs - -require 'asciidoctor' -require 'fileutils' -require 'yaml' -require_relative 'sourcerer/builder' -require_relative 'sourcerer/plaintext_converter' -require_relative 'sourcerer/templating' -require_relative 'sourcerer/jekyll' -require_relative 'schemagraphy' - -# A tool for single-sourcing documentation and data from AsciiDoc and YAML files. -# It provides methods for extracting data, rendering templates, and generating various outputs. -module Sourcerer - # Loads AsciiDoc attributes from a document header as a Hash. - # - # @param path [String] The path to the AsciiDoc file. - # @return [Hash] A hash of the document attributes. - def self.load_attributes path - doc = Asciidoctor.load_file(path, safe: :unsafe) - doc.attributes - end - - # Loads a snippet from an AsciiDoc file using an `include::` directive. - # - # @param path_to_main_adoc [String] The path to the main AsciiDoc file. - # @param tag [String] A single tag to include. - # @param tags [Array] An array of tags to include. - # @param leveloffset [Integer] The level offset for the include. - # @return [String] The content of the included snippet. - def self.load_include path_to_main_adoc, tag: nil, tags: [], leveloffset: nil - opts = [] - opts << "tag=#{tag}" if tag - opts << "tags=#{tags.join(',')}" if tags.any? - opts << "leveloffset=#{leveloffset}" if leveloffset - - snippet_doc = <<~ADOC - include::#{path_to_main_adoc}[#{opts.join(', ')}] - ADOC - - doc = Asciidoctor.load( - snippet_doc, - safe: :unsafe, - base_dir: File.expand_path('.'), - header_footer: false, - attributes: { 'source-highlighter' => nil }) # disable extras - - # Get raw text from all top-level blocks - doc.blocks.map(&:content).join("\n") - end - - # Extracts tagged content from a file. - # - # @param path_to_tagged_adoc [String] The path to the file with tagged content. - # @param tag [String] A single tag to extract. - # @param tags [Array] An array of tags to extract. - # @param comment_prefix [String] The prefix for comment lines. - # @param comment_suffix [String] The suffix for comment lines. - # @param skip_comments [Boolean] Whether to skip comment lines in the output. - # @return [String] The extracted content. - # rubocop:disable Lint/UnusedMethodArgument - def self.extract_tagged_content path_to_tagged_adoc, tag: nil, tags: [], comment_prefix: '// ', comment_suffix: '', - skip_comments: false - # rubocop:enable Lint/UnusedMethodArgument - # NOTE: comment_suffix parameter is currently unused but kept for future functionality - raise ArgumentError, 'tag and tags cannot coexist' if tag && !tags.empty? - - tags = [tag] if tag - raise ArgumentError, 'at least one tag must be specified' if tags.empty? - raise ArgumentError, 'tags must all be strings' unless tags.is_a?(Array) && tags.all? { |t| t.is_a?(String) } - - tagged_content = [] - open_tags = {} - tag_comment_prefix = comment_prefix.strip || '//' - tag_pattern = /^#{Regexp.escape(tag_comment_prefix)}\s*tag::([\w-]+)\[\]/ - end_pattern = /^#{Regexp.escape(tag_comment_prefix)}\s*end::([\w-]+)\[\]/ - comment_line_init_pattern = /^#{Regexp.escape(tag_comment_prefix)}+/ - collecting = false - File.open(path_to_tagged_adoc, 'r') do |file| - file.each_line do |line| - # check for tag:: line - if line =~ tag_pattern - tag_name = Regexp.last_match(1) - if tags.include?(tag_name) - collecting = true - open_tags[tag_name] = true - end - elsif line =~ end_pattern - tag_name = Regexp.last_match(1) - if open_tags[tag_name] - open_tags.delete(tag_name) - collecting = false if open_tags.empty? - end - elsif collecting - tagged_content << line unless skip_comments && line =~ comment_line_init_pattern - end - end - tagged_content = if tagged_content.empty? - '' - else - # return a string of concatenated lines - tagged_content.join - end - end - - tagged_content - end - - # Generates a manpage from an AsciiDoc source file. - # - # @param source_adoc [String] The path to the source AsciiDoc file. - # @param target_manpage [String] The path to the target manpage file. - def self.generate_manpage source_adoc, target_manpage - FileUtils.mkdir_p File.dirname(target_manpage) - Asciidoctor.convert_file( - source_adoc, - backend: 'manpage', - safe: :unsafe, - standalone: true, - to_file: target_manpage) - end - - # Renders a set of templates based on a configuration. - # - # @param templates_config [Array] An array of template configurations. - def self.render_templates templates_config - render_outputs(templates_config) - end - - # Renders templates or converter outputs based on a configuration. - # - # @param render_config [Array] A list of render configurations. - def self.render_outputs render_config - return if render_config.nil? || render_config.empty? - - render_config.each do |render_entry| - if render_entry[:converter] - render_with_converter(render_entry) - next - end - - data_obj = render_entry[:key] || 'data' - attrs_source = render_entry[:attrs] - engine = render_entry[:engine] || 'liquid' - - render_template( - render_entry[:template], - render_entry[:data], - render_entry[:out], - data_object: data_obj, - attrs_source: attrs_source, - engine: engine) - end - end - - # Renders a single template with data. - # - # @param template_file [String] The path to the template file. - # @param data_file [String] The path to the data file (YAML). - # @param out_file [String] The path to the output file. - # @param data_object [String] The name of the data object in the template. - # @param includes_load_paths [Array] Paths for Liquid includes. - # @param attrs_source [String] The path to an AsciiDoc file for attributes. - # @param engine [String] The template engine to use. - def self.render_template template_file, data_file, out_file, data_object: 'data', includes_load_paths: [], - attrs_source: nil, engine: 'liquid' - data = load_render_data(data_file, attrs_source) - out_file = File.expand_path(out_file) - FileUtils.mkdir_p(File.dirname(out_file)) - - template_path = File.expand_path(template_file) - template_content = File.read(template_path) - - # Prepare context - context = { - data_object => data, - 'include' => { data_object => data } # for compatibility with {% include ... %} expecting include.var - } - - rendered = case engine.to_s - when 'erb' then render_erb(template_content, context) - when 'liquid' then render_liquid(template_file, template_content, context, includes_load_paths) - else raise ArgumentError, "Unsupported template engine: #{engine}" - end - - File.write(out_file, rendered) - end - - def self.render_with_converter render_entry - data_file = render_entry[:data] - out_file = render_entry[:out] - raise ArgumentError, 'render entry missing :data' unless data_file - raise ArgumentError, 'render entry missing :out' unless out_file - - data = load_render_data(data_file, render_entry[:attrs]) - converter = resolve_converter(render_entry[:converter]) - rendered = converter.call(data, render_entry) - raise ArgumentError, 'converter returned non-string output' unless rendered.is_a?(String) - - out_file = File.expand_path(out_file) - FileUtils.mkdir_p(File.dirname(out_file)) - File.write(out_file, rendered) - end - - def self.load_render_data data_file, attrs_source - if attrs_source - attrs = load_attributes(attrs_source) - SchemaGraphy::Loader.load_yaml_with_attributes(data_file, attrs) - else - SchemaGraphy::Loader.load_yaml_with_tags(data_file) - end - end - - def self.resolve_converter converter - return converter if converter.respond_to?(:call) - return Object.const_get(converter) if converter.is_a?(String) - - raise ArgumentError, "Unsupported converter: #{converter.inspect}" - end - - def self.render_erb template_content, context - require 'erb' - ERB.new(template_content, trim_mode: '-').result_with_hash(context) - end - - def self.render_liquid template_file, template_content, context, includes_load_paths - require_relative 'sourcerer/jekyll' - require_relative 'sourcerer/jekyll/liquid/filters' - require_relative 'sourcerer/jekyll/liquid/tags' - require 'liquid' unless defined?(Liquid::Template) - Sourcerer::Jekyll.initialize_liquid_runtime - - # Determine includes root; add template directory to search paths - fallback_templates_dir = File.expand_path('.', Dir.pwd) - template_dir = File.dirname(File.expand_path(template_file)) - # For templates that use includes like cfgyml/config-property.adoc.liquid, - # we need the parent directory of the template's directory as well - template_parent_dir = File.dirname(template_dir) - - paths = if includes_load_paths.any? - includes_load_paths - else - [template_parent_dir, template_dir, fallback_templates_dir] - end - - # Create a fake Jekyll site - site = Sourcerer::Jekyll::Bootstrapper.fake_site( - includes_load_paths: paths, - plugin_dirs: []) - - # Setup file system for includes with multiple paths - file_system = Sourcerer::Jekyll::Liquid::FileSystem.new(paths) - - template = Liquid::Template.parse(template_content) - options = { - registers: { - site: site, - file_system: file_system - } - } - template.render(context, options) - end - - # Extracts commands from listing and literal blocks with a specific role. - # - # @param file_path [String] The path to the AsciiDoc file. - # @param role [String] The role to look for. - # @return [Array] An array of command groups. - def self.extract_commands file_path, role: 'testable' - doc = Asciidoctor.load_file(file_path, safe: :unsafe) - command_groups = [] - current_group = [] - - blocks = doc.find_by(context: :listing) + doc.find_by(context: :literal) - - blocks.each do |block| - next unless block.has_role?(role) - - commands = process_block_content(block.content) - if block.has_role?('testable-newshell') - command_groups << current_group.join("\n") unless current_group.empty? - command_groups << commands.join("\n") unless commands.empty? - current_group = [] - else - current_group.concat(commands) - end - end - - command_groups << current_group.join("\n") unless current_group.empty? - command_groups - end - - # @api private - # Processes the content of a block to extract commands. - # It handles line continuations and skips comments. - # @param content [String] The content of the block. - # @return [Array] An array of commands. - def self.process_block_content content - processed_commands = [] - current_command = '' - content.each_line do |line| - stripped_line = line.strip - next if stripped_line.start_with?('#') # Skip comments - - if stripped_line.end_with?('\\') - current_command += "#{stripped_line.chomp('\\')} " - else - current_command += stripped_line - processed_commands << current_command unless current_command.empty? - current_command = '' - end - end - processed_commands - end -end diff --git a/lib/sourcerer/README.adoc b/lib/sourcerer/README.adoc deleted file mode 100644 index fdfdb20..0000000 --- a/lib/sourcerer/README.adoc +++ /dev/null @@ -1,4 +0,0 @@ -[[sourcerer]] -= Sourcerer - -include::../../README.adoc[tag=sourcerer] \ No newline at end of file diff --git a/lib/sourcerer/builder.rb b/lib/sourcerer/builder.rb deleted file mode 100644 index a21b3b8..0000000 --- a/lib/sourcerer/builder.rb +++ /dev/null @@ -1,120 +0,0 @@ -# lib/sourcerer/builder.rb -# frozen_string_literal: true - -require 'asciidoctor' -require 'fileutils' -require_relative '../sourcerer' - -module Sourcerer - # A build-time code generator that creates assets such as new data, documentation, and even Ruby files from - # data extracted from AsciiDoc files, such as attributes and tagged regions. - module Builder - # Generates a Ruby file at build-time to be used during the build process. - # - # @param generated [Hash] A hash with `:path` and `:module` for the generated file. - # @param attributes [Array] A list of attribute sources to build. - # @param snippets [Array] A list of snippets to build. - # @param regions [Array] A list of tagged regions to build. - # @param templates [Array] A list of templates to build (currently unused). - # @param render [Array] A list of render entries (currently unused). - # rubocop:disable Lint/UnusedMethodArgument - def self.generate_prebuild generated: {}, attributes: [], snippets: [], regions: [], templates: [], render: [] - # rubocop:enable Lint/UnusedMethodArgument - # NOTE: templates/render parameters are accepted from config but handled separately by Sourcerer.render_outputs - attr_result = build_attributes(attributes) - snippet_lookup = build_outputs(snippets, type: :snippet) - region_lookup = build_outputs(regions, type: :region) - - File.write(generated[:path].to_s, <<~RUBY) - # frozen_string_literal: true - # Auto-generated by Sourcerer::Builder - - module #{generated[:module]} - ATTRIBUTES = #{attr_result.inspect} - - SNIPPET_LOOKUP = #{snippet_lookup.inspect} - - REGION_LOOKUP = #{region_lookup.inspect} - - def self.read_built_snippet name - fname = SNIPPET_LOOKUP[name.to_s] || name.to_s - path = File.expand_path("../../../build/snippets/\#{fname}", __FILE__) - raise "Snippet not found: \#{name}" unless File.exist?(path) - File.read(path) - end - end - RUBY - end - - # @api private - # Builds a hash of attributes from the given sources. - # @param attributes [Array] The attribute sources. - # @return [Hash] The built attributes. - def self.build_attributes attributes - attributes.each_with_object({}) do |entry, acc| - source = entry[:source] - name = entry[:name] || File.basename(source, '.adoc').to_sym - acc[name.to_sym] = Sourcerer.load_attributes(source) - end - end - - # @api private - # Builds output files from snippets or regions and returns a lookup hash. - # @param entries [Array] The entries to build. - # @param type [Symbol] The type of output (`:snippet` or `:region`). - # @return [Hash] A lookup hash mapping names to output filenames. - def self.build_outputs entries, type: - lookup = {} - names = [] - outnames = [] - - entries.each do |entry| - source = entry[:source] or raise ArgumentError, "#{type} entry is missing :source" - tag = entry[:tag] - tags = entry[:tags] - - raise ArgumentError, 'use only one of :tag or :tags' if tag && tags - raise ArgumentError, "#{type} must include a :tag or :tags" unless tag || tags - - name = entry[:name] || tag || File.basename(source, '.adoc') - outname = entry[:out] || default_output_name(name, type) - - raise ArgumentError, "name value must be unique; #{name} already used" if names.include? name - raise ArgumentError, "out value must be unique; #{outname} already used" if outnames.include? outname - - names << name - outnames << outname - - tags = [tag] if tag - - text = - case type - when :snippet then Sourcerer.load_include(source, tags: tags) - when :region then Sourcerer.extract_tagged_content(source, tags: tags) - else raise ArgumentError, "Unsupported type: #{type}" - end - - lookup[name.to_s] = outname - - outpath = File.join("build/#{type}s", outname) - FileUtils.mkdir_p File.dirname(outpath) - File.write(outpath, text) - end - - lookup - end - - # @api private - # Determines the default output filename for a given name and type. - # @param name [String] The name of the output. - # @param type [Symbol] The type of output. - # @return [String] The default filename. - def self.default_output_name name, type - case type - when :snippet then "#{name}.txt" - when :region then "#{name}.adoc" - else raise ArgumentError, "Unknown type: #{type}" - end - end - end -end diff --git a/lib/sourcerer/jekyll.rb b/lib/sourcerer/jekyll.rb deleted file mode 100644 index f193487..0000000 --- a/lib/sourcerer/jekyll.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -require_relative 'jekyll/bootstrapper' -require_relative 'jekyll/monkeypatches' -require_relative 'jekyll/liquid/file_system' -require_relative 'jekyll/liquid/filters' -require_relative 'jekyll/liquid/tags' -require 'jekyll-asciidoc' - -module Sourcerer - # This module encapsulates the logic for initializing a Jekyll-like Liquid - # templating environment. It loads necessary plugins, applies monkeypatches, - # and registers custom Liquid filters and tags. - module Jekyll - # Initializes the Liquid templating runtime by loading plugins, - # applying patches, and registering custom filters. - def self.initialize_liquid_runtime - Bootstrapper.load_plugins - Monkeypatches.patch_jekyll - # Ensure Sourcerer filters are registered - ::Liquid::Template.register_filter(::Sourcerer::Jekyll::Liquid::Filters) - # Ensure jekyll-asciidoc filters are registered - # ::Liquid::Template.register_filter(Jekyll::AsciiDoc::Filters) - end - end -end diff --git a/lib/sourcerer/jekyll/bootstrapper.rb b/lib/sourcerer/jekyll/bootstrapper.rb deleted file mode 100644 index 0a7fede..0000000 --- a/lib/sourcerer/jekyll/bootstrapper.rb +++ /dev/null @@ -1,78 +0,0 @@ -# frozen_string_literal: true - -require 'jekyll' -require 'jekyll-asciidoc' - -module Sourcerer - module Jekyll - # This module provides methods for programmatically setting up a Jekyll site - # environment, which is useful for loading plugins or creating a mock site for rendering. - module Bootstrapper - # Loads Jekyll plugins from specified directories. - # - # @param plugin_dirs [Array] A list of directories to search for plugins. - # @return [Jekyll::Site] The initialized Jekyll site object. - def self.load_plugins plugin_dirs: [] - config = ::Jekyll.configuration( - { - 'source' => Dir.pwd, - 'destination' => File.join(Dir.pwd, '_site'), - 'quiet' => true, - 'skip_config_files' => true, - 'plugins_dir' => plugin_dirs.map { |d| File.expand_path(d) }, - 'disable_disk_cache' => true - }) - - site = ::Jekyll::Site.new(config) - site.plugin_manager.conscientious_require - - ::Jekyll::Hooks.trigger :site, :after_init, site - - site - end - - # Creates an ephemeral Jekyll site instance for rendering purposes. - # This is useful for leveraging Jekyll's templating outside of a full site build. - # - # @param includes_load_paths [Array] Paths to load includes from. - # @param plugin_dirs [Array] Paths to load plugins from. - # @return [Jekyll::Site] The initialized fake Jekyll site object. - # rubocop:disable Lint/UnusedMethodArgument - def self.fake_site includes_load_paths: [], plugin_dirs: [] - # NOTE: plugin_dirs parameter is accepted but not yet implemented; reserved for future plugin loading - ::Jekyll.logger.log_level = :error if ::Jekyll.logger.respond_to?(:log_level=) - - config = ::Jekyll.configuration( - 'source' => Dir.pwd, - 'includes_dir' => includes_load_paths.first, - 'includes_load_paths' => includes_load_paths, - 'destination' => File.join(Dir.pwd, '_site'), - 'quiet' => true, - 'skip_config_files' => true, - 'disable_disk_cache' => true) - - site = ::Jekyll::Site.new(config) - - include_paths = site.includes_load_paths || [] - site.inclusions ||= {} - - include_paths.each do |dir| - Dir[File.join(dir, '**/*')].each do |file| - next unless File.file?(file) - - relative_path = file.sub("#{dir}/", '') - site.inclusions[relative_path] = File.read(file) - end - end - - site.instance_variable_set(:@liquid_renderer, ::Jekyll::LiquidRenderer.new(site)) - - plugin_manager = ::Jekyll::PluginManager.new(site) - plugin_manager.conscientious_require - - site - end - # rubocop:enable Lint/UnusedMethodArgument - end - end -end diff --git a/lib/sourcerer/jekyll/liquid/file_system.rb b/lib/sourcerer/jekyll/liquid/file_system.rb deleted file mode 100644 index f5994e7..0000000 --- a/lib/sourcerer/jekyll/liquid/file_system.rb +++ /dev/null @@ -1,74 +0,0 @@ -# frozen_string_literal: true - -require 'liquid' - -module Sourcerer - module Jekyll - module Liquid - # A custom Liquid file system that extends `Liquid::LocalFileSystem` to support - # multiple root paths for template lookups. This allows templates to be - # resolved from a prioritized list of directories. - class FileSystem < ::Liquid::LocalFileSystem - # Initializes the file system with one or more root paths. - # - # @param roots_or_root [String, Array] A single root path or an array of root paths. - # rubocop:disable Lint/MissingSuper - # Intentional: Custom implementation that doesn't need parent's initialization - def initialize roots_or_root - if roots_or_root.is_a?(Array) - @roots = roots_or_root.map { |root| File.expand_path(root) } - @multi_root = true - else - @root = File.expand_path(roots_or_root) - @multi_root = false - end - end - # rubocop:enable Lint/MissingSuper - - # Finds the full path of a template, searching through multiple roots if configured. - # - # @param template_path [String] The path to the template. - # @return [String] The full, validated path to the template. - # @raise [Liquid::FileSystemError] if the template is not found. - def full_path template_path - if @multi_root - @roots.each do |root| - full = File.expand_path(File.join(root, template_path)) - return full if File.exist?(full) && full.start_with?(root) - end - raise ::Liquid::FileSystemError, "Template not found: '#{template_path}' in paths: #{@roots}" - else - full = File.expand_path(File.join(@root, template_path)) - validate_path(full) - end - end - - # Reads the content of a template file. - # - # @param template_path [String] The path to the template. - # @return [String] The content of the template file. - def read_template_file template_path - path = full_path(template_path) - File.read(path) - end - - private - - # Validates that the resolved path is within the allowed root(s). - def validate_path path - if @multi_root - # Check if path starts with any of the allowed roots - unless @roots.any? { |root| path.start_with?(root) } - raise ::Liquid::FileSystemError, "Illegal template path '#{path}'" - end - - else - raise ::Liquid::FileSystemError, "Illegal template path '#{path}'" unless path.start_with?(@root) - - end - path - end - end - end - end -end diff --git a/lib/sourcerer/jekyll/liquid/filters.rb b/lib/sourcerer/jekyll/liquid/filters.rb deleted file mode 100644 index 5c2919c..0000000 --- a/lib/sourcerer/jekyll/liquid/filters.rb +++ /dev/null @@ -1,215 +0,0 @@ -# frozen_string_literal: true - -require 'kramdown-asciidoc' -require 'base64' -require 'cgi' - -module Sourcerer - module Jekyll - module Liquid - # This module provides a set of custom filters for use in Liquid templates. - module Filters - # Renders a Liquid template string with a given scope. - # @param input [String, Object] The Liquid template string or a pre-parsed template object. - # @param vars [Hash] A hash of variables to use as the scope. - # @return [String] The rendered output. - def render input, vars = nil - scope = if vars.is_a?(Hash) - vars.transform_keys(&:to_s) - else - {} - end - - template = - if input.respond_to?(:render) && input.respond_to?(:templated?) && input.templated? - input - else - ::Liquid::Template.parse(input.to_s) - end - - template.render(scope) - end - - # Converts a string into a slug. - # @param input [String] The string to convert. - # @param format [String] The desired format (`kebab`, `snake`, `camel`, `pascal`). - # @return [String] The sluggerized string. - def sluggerize input, format = 'kebab' - return input unless input.is_a? String - - case format - when 'kebab' then input.downcase.gsub(/[\s\-_]/, '-') - when 'snake' then input.downcase.gsub(/[\s\-_]/, '_') - when 'camel' then input.downcase.gsub(/[\s\-_]/, '_').camelize(:lower) - when 'pascal' then input.downcase.gsub(/[\s\-_]/, '_').camelize(:upper) - else input - end - end - - # Replaces double newlines with a newline and a plus sign. - # @param input [String] The input string. - # @return [String] The processed string. - def plusify input - input.gsub(/\n\n+/, "\n+\n") - end - - # Converts a Markdown string to AsciiDoc. - # @param input [String] The Markdown string. - # @param wrap [String] The wrapping option for the converter. - # @return [String] The converted AsciiDoc string. - def md_to_adoc input, wrap = 'ventilate' - options = {} - options[:wrap] = wrap.to_sym if wrap - Kramdoc.convert(input, options) - end - - # Indents a string by a given number of spaces. - # @param input [String] The string to indent. - # @param spaces [Integer] The number of spaces for indentation. - # @param line1 [Boolean] Whether to indent the first line. - # @return [String] The indented string. - def indent input, spaces = 2, line1: false - indent = ' ' * spaces - lines = input.split("\n") - indented = if line1 - lines.map { |line| indent + line } - else - lines.map.with_index { |line, i| i.zero? ? line : indent + line } - end - indented.join("\n") - end - - # Checks the type of a value in the context of SG-YML. - # @param input [Object] The value to check. - # @return [String] A string representing the type. - def sgyml_type_check input - if input.nil? - 'Null:nil' - elsif input.is_a? Array - # if all items in Array are (integer, float, string, boolean) - if input.all? do |item| - item.is_a?(Integer) || item.is_a?(Float) || item.is_a?(String) || - item.is_a?(TrueClass) || item.is_a?(FalseClass) - end - 'Compound:ArrayList' - elsif input.all? { |item| item.is_a?(Hash) && (item.keys.length >= 2) } - 'Compound:ArrayTable' - else - 'Compound:Array' - end - elsif input.is_a? Hash - if input.values.all? { |value| value.is_a?(Hash) && (value.keys.length >= 2) } - 'Compound:MapTable' - else - 'Compound:Map' - end - elsif input.is_a? String - 'Scalar:String' - elsif input.is_a? Integer - 'Scalar:Number' - elsif input.is_a? Time - 'Scalar:DateTime' - elsif input.is_a? Float - 'Scalar:Float' - elsif input.is_a?(TrueClass) || input.is_a?(FalseClass) - 'Scalar:Boolean' - else - 'unknown:unknown' - end - end - - # Returns the Ruby class name of a value. - # @param input [Object] The value. - # @return [String] The class name. - def ruby_class input - input.class.name - end - - # Removes markup from a string. - # @param input [String] The string to demarkupify. - # @return [String] The demarkupified string. - def demarkupify input - return input unless input.is_a? String - - input = input.gsub(/`"|"`/, '"') - input = input.gsub(/'`|`'/, "'") - input = input.gsub(/[*_`]/, '') - # change curly quotes to striaght quotes - input = input.gsub(/[“”]/, '"') - input.gsub(/[‘’]/, "'") - end - - # Dumps a value to YAML format. - # @param input [Object] The value to dump. - # @return [String] The YAML representation. - def inspect_yaml input - require 'yaml' - YAML.dump(input) - end - - # Base64 encodes a string. - # @param input [String] The string to encode. - # @return [String] The Base64-encoded string. - def base64 input - return input unless input.is_a? String - - Base64.strict_encode64(input) - end - - # Decodes a Base64-encoded string. - # @param input [String] The string to decode. - # @return [String] The decoded string. - def base64_decode input - return input unless input.is_a? String - - Base64.strict_decode64(input) - rescue ArgumentError - # Return original input if decoding fails - input - end - - # URL-encodes a string. - # @param input [String] The string to encode. - # @return [String] The URL-encoded string. - def url_encode input - return input unless input.is_a? String - - CGI.escape(input) - end - - # Decodes a URL-encoded string. - # @param input [String] The string to decode. - # @return [String] The decoded string. - def url_decode input - return input unless input.is_a? String - - CGI.unescape(input) - rescue ArgumentError - # Return original input if decoding fails - input - end - - # HTML-escapes a string. - # @param input [String] The string to escape. - # @return [String] The HTML-escaped string. - def html_escape input - return input unless input.is_a? String - - CGI.escapeHTML(input) - end - - # Unescapes an HTML-escaped string. - # @param input [String] The string to unescape. - # @return [String] The unescaped string. - def html_unescape input - return input unless input.is_a? String - - CGI.unescapeHTML(input) - end - end - end - end -end - -# Register the filters automatically -Liquid::Template.register_filter(Sourcerer::Jekyll::Liquid::Filters) diff --git a/lib/sourcerer/jekyll/liquid/tags.rb b/lib/sourcerer/jekyll/liquid/tags.rb deleted file mode 100644 index e95c709..0000000 --- a/lib/sourcerer/jekyll/liquid/tags.rb +++ /dev/null @@ -1,44 +0,0 @@ -# frozen_string_literal: true - -module Sourcerer - module Jekyll - # This module contains custom Liquid tags for the Sourcerer templating environment. - module Tags - # A Liquid tag for embedding and rendering a file within a template. - # It searches for the file in the configured include paths. - class EmbedTag < ::Liquid::Tag - # @param tag_name [String] The name of the tag ('embed'). - # @param markup [String] The name of the partial to embed. - # @param tokens [Array] The list of tokens. - def initialize tag_name, markup, tokens - super - @partial_name = markup.strip - end - - # Renders the embedded file. - # - # @param context [Liquid::Context] The Liquid context. - # @return [String] The rendered content of the embedded file. - # @raise [IOError] if the embed file is not found. - def render context - includes_paths = context.registers[:includes_load_paths] || [] - - found_path = includes_paths.find do |base| - candidate = File.expand_path(@partial_name, base) - File.exist?(candidate) - end - - raise "Embed file not found: #{@partial_name}" unless found_path - - full_path = File.expand_path(@partial_name, found_path) - source = File.read(full_path) - - partial = ::Liquid::Template.parse(source) - partial.render!(context) - end - end - end - end -end - -Liquid::Template.register_tag('embed', Sourcerer::Jekyll::Tags::EmbedTag) diff --git a/lib/sourcerer/jekyll/monkeypatches.rb b/lib/sourcerer/jekyll/monkeypatches.rb deleted file mode 100644 index 3548638..0000000 --- a/lib/sourcerer/jekyll/monkeypatches.rb +++ /dev/null @@ -1,73 +0,0 @@ -# frozen_string_literal: true - -module Sourcerer - module Jekyll - # This module contains monkeypatches for Jekyll to modify or extend its behavior. - module Monkeypatches - # Patches Jekyll's `OptimizedIncludeTag` to modify its behavior. - # The patch enhances include path resolution and context handling to better - # suit the needs of Sourcerer's templating environment. - def self.patch_jekyll - return unless defined?(::Jekyll::Tags::OptimizedIncludeTag) - - ::Jekyll::Tags::OptimizedIncludeTag.class_eval do - define_method :render do |context| - site = context.registers[:site] - file = render_variable(context) || @file - - context.stack do - context['include'] = parse_params(context) if @params - - source = site.inclusions[file] - - unless source - - # Debug lines before attempting path resolution - - # Safe resolution - paths = context.registers[:includes_load_paths] || [] - path = paths - .map { |dir| File.join(dir, file) } - .find { |p| File.file?(p) } - - raise IOError, "Include file not found: #{file}" unless path - - source = File.read(path) - end - - partial = ::Liquid::Template.parse(source) - partial.registers[:site] = context.registers[:site] - partial.assigns['include'] = context['include'] - - ::Liquid::Template.register_filter(::Jekyll::Filters) - ::Liquid::Template.register_filter(::Sourcerer::Jekyll::Liquid::Filters) - - # Use an isolated context so we can inspect and copy assigns - subcontext = ::Liquid::Context.new( - [{ 'include' => context['include'] }], - {}, # Environments - context.registers, - rethrow_errors: true) - - rendered = partial.render!(subcontext) - - # Copy assigns from subcontext to parent context - subcontext.environments.each do |env| - env.each do |k, v| - # Avoid clobbering outer include if reentrant - next if k == 'include' - - context.environments.first[k] = v - end - end - - rendered - end - end - end - - ::Liquid::Template.tags['include'] = ::Jekyll::Tags::OptimizedIncludeTag - end - end - end -end diff --git a/lib/sourcerer/plaintext_converter.rb b/lib/sourcerer/plaintext_converter.rb deleted file mode 100644 index bf55751..0000000 --- a/lib/sourcerer/plaintext_converter.rb +++ /dev/null @@ -1,75 +0,0 @@ -# This module will likely spin off into a gem - -# frozen_string_literal: true - -require 'asciidoctor' - -module Sourcerer - # A custom Asciidoctor converter that outputs plain text. - # It is registered for the "plaintext" backend and can be used to extract - # the raw text content or attributes from an AsciiDoc document. - class PlainTextConverter < Asciidoctor::Converter::Base - # Identify ourselves as a converter for the "plaintext" backend - register_for 'plaintext' - - # The main entry point for the converter. - # It is called by Asciidoctor to convert a node. - # - # @param node [Asciidoctor::AbstractNode] The node to convert. - # @param _transform [String] The transform to apply (unused). - # @param _opts [Hash] Options for the conversion (unused). - # @return [String] The converted plain text output. - def convert node, _transform = nil, _opts = {} - if respond_to?("convert_#{node.node_name}", true) - send("convert_#{node.node_name}", node) - elsif node.respond_to?(:content) - node.content.to_s - elsif node.respond_to?(:text) - node.text.to_s - else - '' - end - end - - private - - # Converts the document node. - def convert_document node - emit_attrs = node.attr('sourcerer_mode') == 'emit_attrs' - - if emit_attrs - # only emit attribute lines directly, nothing else - attrs = node.attributes.select do |k, v| - k.is_a?(String) && !v.nil? && !k.start_with?('backend-', 'safe-mode', 'doctype', 'sourcerer_mode') - end - - formatted_attrs = attrs.map { |k, v| ":#{k}: #{v}" } - formatted_attrs.join("\n") # NO EXTRA SPACES OR LINES - else - node.blocks.map { |block| convert block }.join("\n") - end - end - - # Converts a section node. - def convert_section node - title = node.title? ? node.title : '' - body = node.blocks.map { |block| convert block }.join("\n") - [title, body].reject(&:empty?).join("\n") - end - - # Converts a paragraph node. - def convert_paragraph node - node.lines.join("\n") - end - - # Converts a listing node. - def convert_listing node - node.content - end - - # Converts a literal node. - def convert_literal node - node.content - end - end -end diff --git a/lib/sourcerer/templating.rb b/lib/sourcerer/templating.rb deleted file mode 100644 index 7b6e0fd..0000000 --- a/lib/sourcerer/templating.rb +++ /dev/null @@ -1,190 +0,0 @@ -# frozen_string_literal: true - -require 'liquid' - -module Sourcerer - # This module provides the core templating functionality for Sourcerer. - # It includes modules for template engines, and classes for representing - # templated fields and their context. - module Templating - # This module handles the compilation and rendering of templates. - module Engines - module_function - - # A hash of supported template engines. - SUPPORTED_ENGINES = { - 'liquid' => 'liquid', - 'erb' => 'erb' - }.freeze - - # Compiles a template string using the specified engine. - # - # @param str [String] The template string to compile. - # @param engine [String] The name of the template engine to use. - # @return [Object] The compiled template object. - # @raise [ArgumentError] if the engine is not supported. - def compile str, engine - case engine.to_s - when 'liquid' - Liquid::Template.parse(str) - when 'erb' - require 'erb' - ERB.new(str) - else - raise ArgumentError, "Unsupported engine: #{engine}" - end - end - - # Renders a compiled template with the given variables. - # - # @param compiled [Object] The compiled template object. - # @param engine [String] The name of the template engine. - # @param vars [Hash] A hash of variables to use for rendering. - # @return [String] The rendered output. - def render compiled, engine, vars = {} - case engine.to_s - when 'liquid' - compiled.render(vars) - when 'erb' - compiled.result_with_hash(vars) - else - compiled.to_s - end - end - end - - # Represents a field that will be rendered by a template engine. - class TemplatedField - # @return [String] The raw, un-rendered template string. - attr_reader :raw - # @return [Object] The compiled template object. - attr_reader :compiled - # @return [String] The name of the template engine. - attr_reader :engine - # @return [Boolean] Whether the template was explicitly tagged. - attr_reader :tagged - # @return [Boolean] Whether the template engine was inferred. - attr_reader :inferred - - # @param raw [String] The raw template string. - # @param compiled [Object] The compiled template object. - # @param engine [String] The name of the template engine. - # @param tagged [Boolean] Whether the template was explicitly tagged. - # @param inferred [Boolean] Whether the template engine was inferred. - def initialize raw, compiled, engine, tagged, inferred - @raw = raw - @compiled = compiled - @engine = engine - @tagged = tagged - @inferred = inferred - end - - # @return [true] Always returns true to indicate this is a templated field. - def templated? - true - end - - # @return [Boolean] True if the field is deferred (not yet compiled). - def deferred? - compiled.nil? - end - - # @return [self] Returns self for Liquid compatibility. - def to_liquid - self - end - - # Renders the template with the given context. - # @param context [Hash, Liquid::Context] The context for rendering. - # @return [String] The rendered output. - def render context = {} - scope = context.respond_to?(:environments) ? context.environments.first : context - Engines.render(compiled, engine, scope) - end - - # Renders the template with an empty context. - # @return [String] The rendered output. - def to_s - render({}) - end - end - - # Holds contextual information for templating. - class Context - # @return [Symbol] The rendering stage (e.g., `:load`). - attr_reader :stage - # @return [Boolean] Whether to use strict rendering. - attr_reader :strict - # @return [Hash] A hash of scopes for rendering. - attr_reader :scopes - - # @param stage [Symbol] The rendering stage. - # @param strict [Boolean] Whether to use strict rendering. - # @param scopes [Hash] A hash of scopes. - def initialize stage: :load, strict: false, scopes: {} - @stage = stage.to_sym - @strict = strict - @scopes = scopes.transform_keys(&:to_sym) - end - - # Creates a new Context object from a schema fragment. - # @param schema_fragment [Hash] The schema fragment containing templating info. - # @return [Context] The new Context object. - def self.from_schema schema_fragment - render_conf = schema_fragment['templating'] || {} - - stage = (render_conf['stage'] || :load).to_sym - strict = render_conf['strict'] == true - scopes = (render_conf['scopes'] || {}).transform_keys(&:to_sym) - - new(stage: stage, strict: strict, scopes: scopes) - end - - # Merges all scopes into a single hash. - # @return [Hash] The merged scope. - def merged_scope - scopes.values.reduce({}) { |acc, s| acc.merge(s) } - end - end - - # Compiles templated fields in a data structure. - # @param data [Hash] The data to process. - # @param schema [Hash] The schema defining the fields. - # @param fields [Array] The fields to compile. - # @param scope [Hash] The scope for rendering. - def self.compile_templated_fields! data:, schema:, fields:, scope: {} - fields.each do |field_entry| - key = field_entry[:key] - path = field_entry[:path] - val = data[key] - - next unless val.is_a?(String) || (val.is_a?(Hash) && val['__tag__'] && val['value']) - - raw = val.is_a?(Hash) ? val['value'] : val - tagged = val.is_a?(Hash) - config = SchemaGraphy::SchemaUtils.templating_config_for(schema, path) - engine = tagged ? val['__tag__'] : (config['default'] || 'liquid') - - compiled = Engines.compile(raw, engine) - - data[key] = if config['delay'] - TemplatedField.new(raw, compiled, engine, tagged, inferred: !tagged) - else - Engines.render(compiled, engine, scope) - end - end - end - - # Renders a field if it is a template. - # @param val [Object] The value to render. - # @param context [Hash] The context for rendering. - # @return [Object] The rendered value, or the original value if not a template. - def self.render_field_if_template val, context = {} - if val.respond_to?(:templated?) && val.templated? - val.render(context) - else - val - end - end - end -end diff --git a/releasehx.gemspec b/releasehx.gemspec index 266e34f..b0536a5 100644 --- a/releasehx.gemspec +++ b/releasehx.gemspec @@ -55,7 +55,9 @@ Gem::Specification.new do |spec| spec.add_dependency 'tilt', '~> 2.3' spec.add_dependency 'yaml', '~> 0.4' + spec.add_dependency 'asciidoctor-html5s', '~> 0.5' spec.add_dependency 'asciidoctor-pdf', '~> 2.3' + spec.add_dependency 'asciisourcerer', '~> 0.4' spec.add_dependency 'commonmarker', '~> 0.23' spec.add_dependency 'jekyll', '~> 4.4' spec.add_dependency 'jekyll-asciidoc', '~> 3.0.0' @@ -64,5 +66,6 @@ Gem::Specification.new do |spec| spec.add_dependency 'liquid', '~> 4.0' spec.add_dependency 'mcp', '~> 0.4' spec.add_dependency 'prism', '~> 1.5' + spec.add_dependency 'schemagraphy', '~> 0.1' spec.add_dependency 'to_regexp', '= 0.2.1' end diff --git a/scripts/build-docker.sh b/scripts/build-docker.sh index c93165c..6d79fb1 100755 --- a/scripts/build-docker.sh +++ b/scripts/build-docker.sh @@ -36,7 +36,7 @@ gem_file=$(find pkg -name "${PROJECT_NAME}-*.gem" -type f 2>/dev/null | head -n if [ -z "$gem_file" ]; then echo -e "${RED}❌ Error: No gem found in pkg/ directory${NC}" - echo -e "${YELLOW}Build the gem first with: bundle exec rake build${NC}" + echo -e "${YELLOW}Build the gem first with: bundle exec rake build:gem${NC}" exit 1 fi diff --git a/scripts/build_docs.rb b/scripts/build_docs.rb index 92c3fa6..7a22ac7 100644 --- a/scripts/build_docs.rb +++ b/scripts/build_docs.rb @@ -64,68 +64,66 @@ def self.add_config_reference_front_matter end def self.generate_module_docs version - puts 'Generating modular API docs with YARD...' + puts 'Generating API docs with YARD...' - modules = discover_modules + mod = build_releasehx_module - modules.each do |mod| - puts "--> Generating docs for #{mod[:name]}..." + puts "--> Generating docs for #{mod[:name]}..." - unless File.exist?(mod[:readme]) - puts " Warning: README not found at #{mod[:readme]}, skipping module" - next - end + unless File.exist?(mod[:readme]) + puts " Warning: README not found at #{mod[:readme]}, skipping" + return + end - if mod[:files].empty? - puts " Warning: No Ruby files found for #{mod[:name]}, skipping module" - next - end + if mod[:files].empty? + puts " Warning: No Ruby files found for #{mod[:name]}, skipping" + return + end - readme_dir = File.dirname(mod[:readme]) - readme_content = File.read(mod[:readme]) - processed_readme_html = Asciidoctor.convert( - readme_content, safe: :unsafe, base_dir: readme_dir, header_footer: false) - temp_readme_path = "build/docs/#{mod[:name].downcase}_readme.html" - File.write(temp_readme_path, processed_readme_html) + readme_dir = File.dirname(mod[:readme]) + readme_content = File.read(mod[:readme]) + processed_readme_html = Asciidoctor.convert( + readme_content, safe: :unsafe, base_dir: readme_dir, header_footer: false) + temp_readme_path = "build/docs/#{mod[:name].downcase}_readme.html" + File.write(temp_readme_path, processed_readme_html) - output_dir = "build/docs/docs/api/#{mod[:name].downcase}" - FileUtils.mkdir_p output_dir - file_list = mod[:files].join(' ') + output_dir = "build/docs/docs/api/#{mod[:name].downcase}" + FileUtils.mkdir_p output_dir + file_list = mod[:files].join(' ') - # Use custom YARD templates from docs/yard/templates - template_dir = 'docs/yard/templates' - custom_css = 'docs/yard/assets/css/custom.css' + # Use custom YARD templates from docs/yard/templates + template_dir = 'docs/yard/templates' + custom_css = 'docs/yard/assets/css/custom.css' - yard_cmd = "yard doc --output-dir #{output_dir} --readme #{temp_readme_path} " \ - "--title \"#{mod[:name]} API (v#{version})\" --markup html " \ - "--template-path #{template_dir}" + yard_cmd = "yard doc --output-dir #{output_dir} --readme #{temp_readme_path} " \ + "--title \"#{mod[:name]} API (v#{version})\" --markup html " \ + "--template-path #{template_dir}" - # Add custom CSS if it exists - yard_cmd += " --asset #{custom_css}:css/" if File.exist?(custom_css) + # Add custom CSS if it exists + yard_cmd += " --asset #{custom_css}:css/" if File.exist?(custom_css) - yard_cmd += " #{file_list}" + yard_cmd += " #{file_list}" - puts " Warning: YARD generation failed for #{mod[:name]}" unless system(yard_cmd) + puts " Warning: YARD generation failed for #{mod[:name]}" unless system(yard_cmd) - # Post-process HTML files to add custom CSS with correct relative paths - next unless File.exist?(custom_css) + # Post-process HTML files to add custom CSS with correct relative paths + return unless File.exist?(custom_css) - Dir.glob("#{output_dir}/**/*.html").each do |html_file| - add_custom_css_to_html(html_file, output_dir) - end + Dir.glob("#{output_dir}/**/*.html").each do |html_file| + add_custom_css_to_html(html_file, output_dir) + end - # Fix YARD index file naming: _index.html is the real API index, - # but index.html is generated from README. Rename them appropriately. - yard_index = File.join(output_dir, '_index.html') - readme_index = File.join(output_dir, 'index.html') + # Fix YARD index file naming: _index.html is the real API index, + # but index.html is generated from README. Rename them appropriately. + yard_index = File.join(output_dir, '_index.html') + readme_index = File.join(output_dir, 'index.html') - next unless File.exist?(yard_index) + return unless File.exist?(yard_index) - # Rename README-based index to readme.html - File.rename(readme_index, File.join(output_dir, 'readme.html')) if File.exist?(readme_index) - # Rename _index.html to index.html (this is the real API overview) - File.rename(yard_index, readme_index) - end + # Rename README-based index to readme.html + File.rename(readme_index, File.join(output_dir, 'readme.html')) if File.exist?(readme_index) + # Rename _index.html to index.html (this is the real API overview) + File.rename(yard_index, readme_index) end def self.add_custom_css_to_html html_file, base_output_dir @@ -146,63 +144,22 @@ def self.add_custom_css_to_html html_file, base_output_dir File.write(html_file, updated_content) end - def self.discover_modules - # Find the main gem name from gemspec - # NOTE: We will probably do away with this once - # SchemaGraphy and Sourcerer are spun off - gemspec_file = Dir.glob('*.gemspec').first - gemspec_file ? File.basename(gemspec_file, '.gemspec') : nil - - # Discover all lib subdirectories that contain Ruby files - lib_dirs = Dir.glob('lib/*/').map { |dir| File.basename(dir) } - - modules = [] - base_nav_order = 2 - - lib_dirs.each_with_index do |dir_name, index| - files = Dir.glob(["lib/#{dir_name}.rb", "lib/#{dir_name}/**/*.rb"]) - next if files.empty? - - readme_path = "lib/#{dir_name}/README.adoc" - next unless File.exist?(readme_path) - - # Convert directory name to proper module name (e.g., 'releasehx' -> 'ReleaseHx') - # Unreliable - module_name = dir_name.split('_').map(&:capitalize).join - - # Assign nav_order: only ReleaseHx gets explicit nav_order 4, others get auto-incremented values - nav_order = if dir_name == 'releasehx' - 4 - else - base_nav_order + index + 1 - end - - modules << { - name: module_name, - files: files, - readme: readme_path, - nav_order: nav_order, - title: "#{module_name} API" - } - end - - # Sort by nav_order to ensure main gem comes first - modules.sort_by { |mod| mod[:nav_order] } + def self.build_releasehx_module + # Build ReleaseHx module (schemagraphy and sourcerer are now externalized) + files = Dir.glob(['lib/releasehx.rb', 'lib/releasehx/**/*.rb']) + readme_path = 'lib/releasehx/README.adoc' + + { + name: 'ReleaseHx', + files: files, + readme: readme_path, + title: 'ReleaseHx API' + } end def self.add_jekyll_front_matter puts 'Adding front matter to YARD API docs...' - # Get the modules to build lookup maps - modules = discover_modules - nav_order_map = {} - title_map = {} - - modules.each do |mod| - nav_order_map[mod[:name].downcase] = mod[:nav_order] - title_map[mod[:name].downcase] = mod[:title] - end - # Add front matter to YARD API documentation files api_files = Dir.glob('build/docs/docs/api/**/*.html') return if api_files.empty? @@ -213,18 +170,17 @@ def self.add_jekyll_front_matter content = File.read(file) next if content.start_with?('---') - module_name = Pathname.new(file).each_filename.to_a[-2] - - if File.basename(file) == 'index.html' && title_map.key?(module_name) - page_title = title_map[module_name] + if File.basename(file) == 'index.html' + # Main index gets explicit title and nav_order front_matter = <<~HEREDOC --- layout: null - title: "#{page_title}" - nav_order: #{nav_order_map[module_name]} + title: "ReleaseHx API" + nav_order: 4 --- HEREDOC else + # Other files are auto-excluded from nav doc = Nokogiri::HTML(content) page_title = doc.title page_title = if page_title.nil? || page_title.strip.empty? diff --git a/specs/data/config-def.yml b/specs/data/config-def.yml index 2e85683..2f30c38 100644 --- a/specs/data/config-def.yml +++ b/specs/data/config-def.yml @@ -203,12 +203,59 @@ properties: The origin markup format for notes. May be `markdown` or `asciidoc`. dflt: markdown - engine: - type: String + engines: desc: | - The markup converter to use for the issues. - Defaults to `asciidoctor` for AsciiDoc and `redcarpet` for Markdown. - Options include `asciidoctor`, `redcarpet`, `commonmarker`, `kramdown`, or `pandoc`. + Specifies the conversion engines to use when enriching to various output formats. + + These settings determine which tool processes the conversion from draft formats to rich output. + Intelligent defaults are applied based on source format when engines are not explicitly configured. + properties: + html: + type: String + desc: | + Engine for converting to HTML format. + + Valid engines: + + [horizontal] + `asciidoctor-html5`:: Standard Asciidoctor HTML5 backend (nested div structure) + `asciidoctor-html5s`:: Semantic HTML5 backend (cleaner section-based markup) + `kramdown`:: Kramdown Markdown converter (for Markdown sources) + `pandoc`:: Universal document converter (if available) + + When not specified, intelligent defaults apply: + + - AsciiDoc → HTML: `asciidoctor-html5s` (semantic HTML5) + - Markdown → HTML: `kramdown` + - RHYML → HTML: Liquid templates (no engine needed) + docs: | + The html5s backend produces cleaner, more semantic HTML with `
` elements + instead of nested `
` structures. + + This is particularly useful when you want to apply custom CSS or when generating + HTML that will be further processed or embedded in other systems. + + For backwards compatibility or when you need the traditional Asciidoctor structure, + use `asciidoctor-html5`. + pdf: + type: String + desc: | + Engine for converting to PDF format. + + Valid engines: + + [horizontal] + `asciidoctor-pdf`:: Asciidoctor PDF converter (default for AsciiDoc) + `asciidoctor-web-pdf`:: Web-based PDF converter using headless Chrome + `pandoc`:: Universal document converter (if available) + + When not specified, intelligent defaults apply: + + - AsciiDoc → PDF: `asciidoctor-pdf` + - Markdown → PDF: `pandoc` (if available) + docs: | + The default `asciidoctor-pdf` engine provides excellent typography and layout + for technical documentation with support for themes, fonts, and advanced formatting. extensions: desc: Default file extensions. @@ -837,7 +884,7 @@ properties: desc: | Default settings for `rhx` command executions. properties: - wrapped: + html_wrap: type: Boolean desc: | Include (or exclude) head, header, and footer elements when enriching to HTML. @@ -862,6 +909,7 @@ properties: Include frontmatter in AsciiDoc drafts. Uses the `templates.asciidoc_frontmatter` template. + dflt: false fetch: type: String desc: | @@ -1043,6 +1091,74 @@ properties: It may include `{{ title }}`, `{{ version }}`, `{{ date }}`, as well as any `vars`-scoped variables as you pass in. dflt: *markdown_frontmatter_tplt + html_framework: + type: String + desc: | + The HTML framework to use when enriching to HTML. + + Valid entries: + + [horizontal] + `bare`:: minimal HTML structure + `bootstrap4`:: Bootstrap 4 framework + `bootstrap5`:: Bootstrap 5 framework + dflt: bare + styling: + desc: | + Configuration options for HTML styling and CSS framework integration. + properties: + mode: + type: String + desc: | + The HTML styling approach to use when generating wrapped HTML output. + + Valid options: + + * `minimal` -- Basic semantic HTML with minimal inline styles + * `embedded` -- Include comprehensive CSS in `