From abf8dc39f2e6318829d07a4da610657db3cc2c67 Mon Sep 17 00:00:00 2001 From: ReachableCEO Date: Mon, 9 Dec 2024 11:52:00 -0600 Subject: [PATCH] turning my resume build/templates into a proper independent package --- .../CandidateInfoSheet/CandidateInfoSheet.yml | 14 + .../CharlesNWybleCandidateInfo.md | 110 + .../ContactInfo/Contact-Info-Client-Submit.md | 4 + Templates/ContactInfo/Contact-Info.md | 6 + Templates/SkillsAndProjects/Projects.md | 11 + Templates/SkillsAndProjects/Skills.csv | 20 + Templates/WorkHistory/WorkHistory.csv | 11 + ...wnResume-TemplateInfo-EndClientVersion.yml | 7 + .../MarkdownResume-TemplateInfo-JobBoard.yml | 12 + build/build-client-submit-stage1.sh | 66 + build/build-client-submit-stage2.sh | 26 + build/build-jobBoard-stage1.sh | 69 + build/build-jobBoard-stage2.sh | 27 + build/mo | 1997 +++++++++++++++++ build/resume-docx-reference.docx | Bin 0 -> 16716 bytes 15 files changed, 2380 insertions(+) create mode 100644 Templates/CandidateInfoSheet/CandidateInfoSheet.yml create mode 100644 Templates/CandidateInfoSheet/CharlesNWybleCandidateInfo.md create mode 100644 Templates/ContactInfo/Contact-Info-Client-Submit.md create mode 100644 Templates/ContactInfo/Contact-Info.md create mode 100644 Templates/SkillsAndProjects/Projects.md create mode 100644 Templates/SkillsAndProjects/Skills.csv create mode 100644 Templates/WorkHistory/WorkHistory.csv create mode 100644 build/MarkdownResume-TemplateInfo-EndClientVersion.yml create mode 100644 build/MarkdownResume-TemplateInfo-JobBoard.yml create mode 100644 build/build-client-submit-stage1.sh create mode 100644 build/build-client-submit-stage2.sh create mode 100644 build/build-jobBoard-stage1.sh create mode 100644 build/build-jobBoard-stage2.sh create mode 100644 build/mo create mode 100644 build/resume-docx-reference.docx diff --git a/Templates/CandidateInfoSheet/CandidateInfoSheet.yml b/Templates/CandidateInfoSheet/CandidateInfoSheet.yml new file mode 100644 index 0000000..2d72aa8 --- /dev/null +++ b/Templates/CandidateInfoSheet/CandidateInfoSheet.yml @@ -0,0 +1,14 @@ +title: "Charles N Wyble Candidate Details" +titlepage: true +titlepage-logo: "D:/tsys/@ReachableCEO/ReachableCEO.png" +toc: true +toc-own-page: true +date: \today +header-left: "\\hspace{1cm}" +header-center: "\\leftmark" +header-right: "Page \\thepage" +footer-left: "Charles N Wyble" +footer-center: "Tenacity. Velocity. Focus." +footer-right: "[Source code for this file](https://github.com/ReachableCEO/ReachableCEOResume/blob/main/ancillary-support-files/CharlesNWybleCandidateInfo.md)" +urlcolor: blue +page-background: "D:/tsys/@ReachableCEO/ExternalVendorCode/pandoc-latex-template/examples/page-background/backgrounds/background1.pdf" \ No newline at end of file diff --git a/Templates/CandidateInfoSheet/CharlesNWybleCandidateInfo.md b/Templates/CandidateInfoSheet/CharlesNWybleCandidateInfo.md new file mode 100644 index 0000000..47e9c58 --- /dev/null +++ b/Templates/CandidateInfoSheet/CharlesNWybleCandidateInfo.md @@ -0,0 +1,110 @@ +# Charles N Wyble + +## Introduction + +Hello, + +I apologize for the form letter response. + +I receive a high volume of recruiter emails every day and I've found this letter to be the most efficient way to +handle the high volume of emails and reduce back and forth emails/texts/calls. + +If you have any questions/comments/concerns not covered by this document, please let me know via e-mail and I'm happy to address them! + +If you ask me something answered in this document, I will not respond and will not move forward with the opportunity, so please read it in detail! + +## Re: share my ID over email + +I WILL NOT share my (full or redacted) photo ID over email or any other electronic written +communication. If that is "required" then I have no interest in moving forward with this opportunity. + +I am happy to get on a teams/zoom/google meet etc call and show my ID. + +## Re: professional references + +I am happy to provide professional references once an interview with the end client/customer/hiring manager/team has been scheduled. I will NOT provide references up front. If that is "required" then I have no interest in moving forward with this opportunity. + +## Re: relocation + +if the role is not based in **Austin TX** or **Raleigh NC** I will need to re-locate + +| Question | Answer | +|-------------------------------------------|--------| +| Am I open to relocation? | Yes | +| Am I willing to re-locate at own expense? | No | +| Am I open to up to 100% travel | Yes | + +Please be aware that: + +- I will **only re-locate at the employer expense**. +- I will need **two weeks of time** to re-locate. +- The net amount of the re-location benefit **MUST be at least $5,000 USD** to fully compensate me for the time/effort to re-locate. +- The full re-location benefit **must be provided prior to the confirmed start date**. +- I **will NOT** accept a reimbursement based re-location package. +- I am happy to come onsite (at client expense (paid up front)) for training/orientation etc. + +\pagebreak + +## Rate Schedule (compensation expectations) + +For **fully remote** roles only: + +I am open to (at the absolute minimum): **\$50.00 per hour(w2)/\$75.00 per hour (1099/corp to corp)/ $100,000.00 annually (w2)**. + +I have a strong preference for roles that are : **\$65.00 per hour(w2)/\$85.00 per hour (1099/corp to corp)/ $130,000.00 annually (w2)**. + +For **on-site roles**: + +I am open to (at the absolute minimum): **\$75.00 per hour(w2)/\$95.00 per hour (1099/corp to corp)/ $150,000.00 annually (w2)**. + +In regards to compensation structure, I am open to: + +- w2 +- corp to corp (I have my own LLC) +- 1099 + +If you have a rate for any of the compensation options above, send them all. I will pick which one works best for my situation and the opportunity. + +If it's a different rate with/without benefits, send both. + +If the above is in alignment with this opportunity, please feel free to send me an RTR with the best rate you can offer. + +\pagebreak + +## Details needed for submission + +### My resume + +[Download Charles resume(pdf)](https://resume.reachableceo.com/job-board/CharlesNWyble-Resume.pdf) + +I am happy to discuss and make edits to the resume content specific to the opportunity if you feel +they are needed. + +### Candidate details + +Here are my complete candidate details for submission to the role. + +| Question | Answer | +|---------------------------------------|--------------------------------------------------------------------------| +| Full name | Charles Wyble | +| E-mail address | | +| Phone number | 818-280-7059 | +| Preferred form of contact | E-mail will get the fastest response | +| Work authorization | US Citizen | +| Are you employed presently? | No | +| Current location | Austin, Texas | +| Current timezone | CST | +| Timezones I can work in | PST/CST/EST | +| Availability to interview | Immediate | +| Availability to start | Immediate for remote/local, two weeks for relocation | +| Open to in-office/hybrid/remote | Yes | +| Any trips planned in next six months? | No | +| Highest Education | High School (no college/university) | +| Graduated Year | 2002 | +| Name of school | Osborne Christian School | +| Location of school | Los Angeles CA | +| Linkedin Profile | [Linkedin Profile](https://www.linkedin.com/in/charles-wyble-412007337/) | +| Github Profile | [Github Profile](https://www.github.com/ReachableCEO/) | +| Last project | Contract, ended October 2024 | +| DOB | 09/14 | +| Total IT/career experience | 22 years | diff --git a/Templates/ContactInfo/Contact-Info-Client-Submit.md b/Templates/ContactInfo/Contact-Info-Client-Submit.md new file mode 100644 index 0000000..6234b5e --- /dev/null +++ b/Templates/ContactInfo/Contact-Info-Client-Submit.md @@ -0,0 +1,4 @@ +Charles N Wyble +===== + +Senior (**Staff level**) **System Engineer/SRE/Architect** with extensive Linux/Windows/Networking/Cyber security background and experience diff --git a/Templates/ContactInfo/Contact-Info.md b/Templates/ContactInfo/Contact-Info.md new file mode 100644 index 0000000..048294e --- /dev/null +++ b/Templates/ContactInfo/Contact-Info.md @@ -0,0 +1,6 @@ +Charles N Wyble +===== + +Senior (**Staff level**) **System Engineer/SRE/Architect** with extensive Linux/Windows/Networking/Cyber security background and experience + +[ [Github Profile](https://github.com/reachableceo) ] . [ [Linkedin Profile](https://www.linkedin.com/in/charles-wyble-412007337) ] . [ reachableceo@reachableceo.com ] . [ 818 280 7059 ] . [ Austin TX / Raleigh NC / Remote ] diff --git a/Templates/SkillsAndProjects/Projects.md b/Templates/SkillsAndProjects/Projects.md new file mode 100644 index 0000000..2831084 --- /dev/null +++ b/Templates/SkillsAndProjects/Projects.md @@ -0,0 +1,11 @@ +- Developed and implemented a process to switch thousands of desktops providing digital signage functionality from Fedora to Debian in a completely automated fashion using a custom initrd. +- Developed and implemented an internal private cloud orchestration and provisioning system for a hardware development engineering team that handled the entire provisioning lifecycle for physical and virtual systems. +- Developed and implemented standardized language and procedures and incident investigation automation for a large technical support organization with high turnover. +- Developed and implemented an automated order status and payment handling interactive voice response application using Angel.ccm with a backend web service returning Voice XML. This allows call center personnel to focus on revenue generating opportunities instead of administrative matters. +- Provided technician support to a team of electrical engineers building the power system for the radar of FrankenSAM in Ukraine. Handled high / low voltage wiring and plumbing and documentation of those systems. +- Provided root cause analysis , mitigation and remediation of security breaches by advanced persistent threat actors at high value targets. +- Project managed a successful brand new data center build from bare dirt to serving content in 86 days. Oversaw 8 billion dollars of capital deployment. +- Led and consulted tier 1 payment compliance industry (PCI) implementations for some of the worlds largest brands (including at a payment processor). +- Rolled out centralized Active Directory authentication, deployed Dell OpenManage, and upgraded network equipment. +- Deployed password vault, Active Directory PKI, and implemented a ground-up network redesign. +- Designed VmWare NSX network. diff --git a/Templates/SkillsAndProjects/Skills.csv b/Templates/SkillsAndProjects/Skills.csv new file mode 100644 index 0000000..721f234 --- /dev/null +++ b/Templates/SkillsAndProjects/Skills.csv @@ -0,0 +1,20 @@ +Linux|22 years|RHEL,Debian,Ubuntu,kickstart,PXE, LDAP,SSSD,RPM/Deb package creation, quotas,extended permissions, clustering,NFS,Samba +Unix|5 years|HPUX/Solaris +Windows|22 years|Server (2008 2016),Windows client automated deployment (7,8,10,11),Active Directory,Group Policy,WSUS,Certificate Services,AD DNS,AD DHCP,complex multiple forest and domain setups +Free/Libre/Open Source software|22 years|Apache,Postfix,Qmail,Dovecot,Courier IMAP,Nginx,Matamo,Discourse,Wordpress, Mautic,Dolibarr,Revive Ad Server,Firefly,Cloudron,Coolify,Gitea, HomeAssistant, Jenkins,Rundeck,N8N, LetsEncrypt,ACME,cfssl +Databases|22 years| MySQL,PostgreSQL, Dbeaver,PHPMyAdmin,PostGIS +Cyber Security|22 years|PCI Compliance (tier 1 implementations),OpenVAS<,Lynis,security hardening,audits,breach response and mitigation, patch and vulnerability management. AppArmor, SeLinux, Centrify, Tripwire, Integrit, OSSEC +Virtualization|22 years|VmWare,Parallels,HyperV,KVM,Xen +Networking|22 years|Linux Virtual Server(LVS),HAProxy,Ubiquiti Unifi,Opnsense,Pfsense,DNS,DHCP,IPAM,PXE,IPS,IDS,GRE,IPSEC.Wireguard,OpenVPN,Nebula,Tailscale,RADIUS. Mostly layer2 data center/campus/access some WAN,firewall,layer3 +Monitoring|22 years|Uptime Kuma,Librenms,Zabbix,Zenoss,Nagios,Elasticsearch,Logstash,Kibana(ELK) +Storage|22 years|Netapp,EMC,EqualLogic,3par,MSA,TrueNAS/ZFS,iscsi,S3,Azure Storage +Cloud|5 years|AWS,Azure,Kubernetes,Helm,Docker +Containerization|15 years|LXC,Docker,OpenVZ +Configuration management/InfrastructureAsCode(IAC)|22 years|FetchApply,Terraform/OpenTOfU,Ansible,AWX,Hashicorp Packer/Vault +Ticket / incident / project management| 22 years| Jira,ServiceNow,Redmine,RT +Git|15 years|Branching,merging,multiple teams,external vendors,submodules +SRE|5 years| Grafana,Prometheus,Signoz,Wazuh +LLM|2 years|OpenWebUI,QA/validation,RAG,data cleaning/prep +Programming|5 years|J2ME,PHP,Ruby,TCL/TK,Java,C,C++ +Automation|22 years|Bash,YAML,TOML,PowerShell,Perl +Embedded development|5 years|Raspberry pi,arduino,seeduino,Lego Mindstorms \ No newline at end of file diff --git a/Templates/WorkHistory/WorkHistory.csv b/Templates/WorkHistory/WorkHistory.csv new file mode 100644 index 0000000..8e5c361 --- /dev/null +++ b/Templates/WorkHistory/WorkHistory.csv @@ -0,0 +1,11 @@ +CDK Global,Senior System Engineer,July 2024 - October 2024 +Apple Computer,Senior System Administrator,March 2024 - July 2024 +SHEIN,Staff Site Reliability Engineer,December 2022 - August 2023 +3M,Senior Site Reliability Engineer,March 2020 - November 2022 +TippingPoint,Staff System Architect,March 2012 - June 2019 +HostGator.com,Automation and Escalation Engineer,March 2011 - May 2012 +RippleTV,System Engineer,October 2008 - January 2010 +Walt Disney Internet Group,Site Reliability Engineer,August 2006 - September 2007 +Electronic Clearing House,Senior System Administrator,April 2005 - July 2006 +GSI Commerce,System Administrator,March 2002 - February 2005 +ReachableCEO Enterprises,Freelancer,January 2001 - December 2024 \ No newline at end of file diff --git a/build/MarkdownResume-TemplateInfo-EndClientVersion.yml b/build/MarkdownResume-TemplateInfo-EndClientVersion.yml new file mode 100644 index 0000000..d9bcaeb --- /dev/null +++ b/build/MarkdownResume-TemplateInfo-EndClientVersion.yml @@ -0,0 +1,7 @@ +title: "{{Candidate Name}} Resume" +header-left: "\\hspace{1cm}" +header-center: "\\leftmark" +header-right: "Page \\thepage" +footer-left: "{{Candidate Name}}" +urlcolor: blue +page-background: "D:/tsys/@ReachableCEO/ExternalVendorCode/pandoc-latex-template/examples/page-background/backgrounds/background5.pdf" \ No newline at end of file diff --git a/build/MarkdownResume-TemplateInfo-JobBoard.yml b/build/MarkdownResume-TemplateInfo-JobBoard.yml new file mode 100644 index 0000000..d2f5b73 --- /dev/null +++ b/build/MarkdownResume-TemplateInfo-JobBoard.yml @@ -0,0 +1,12 @@ +title: "Charles N Wyble Resume" +titlepage: true +titlepage-logo: "D:/tsys/@ReachableCEO/ReachableCEO.png" +date: \today +header-left: "\\hspace{1cm}" +header-center: "\\leftmark" +header-right: "Page \\thepage" +footer-left: "Charles N Wyble" +footer-center: "Tenacity. Velocity. Focus." +footer-right: "[Source code for this resume](https://git.knownelement.com/reachableceo/ReachableCEOResume) " +urlcolor: blue +page-background: "D:/tsys/@ReachableCEO/ExternalVendorCode/pandoc-latex-template/examples/page-background/backgrounds/background5.pdf" \ No newline at end of file diff --git a/build/build-client-submit-stage1.sh b/build/build-client-submit-stage1.sh new file mode 100644 index 0000000..9ca2203 --- /dev/null +++ b/build/build-client-submit-stage1.sh @@ -0,0 +1,66 @@ +#!/bin/bash + +MarkdownOutputFile="../MarkdownOutput/client-submit/CharlesNWybleResume.md" +rm $MarkdownOutputFile + +# Combine markdown files into single intermediate markdown file + +#Pull in my contact info +cat "../boilerplate/Contact-Info-Client-Submit.md" >> $MarkdownOutputFile +echo " " >> $MarkdownOutputFile + +echo "## Highlights from my 22 year IT career" >> $MarkdownOutputFile + +cat ../SkillsAndProjects/Projects.md >> "$MarkdownOutputFile" + +echo "\pagebreak" >> $MarkdownOutputFile + +echo " " >> $MarkdownOutputFile +echo "## Employment History" >> $MarkdownOutputFile +echo " " >> $MarkdownOutputFile + +#And here we do some magic... +#Pull in my : + +# employer +# title +# start/end dates of employment +# long form position summary data from each position + +IFS=$'\n\t' +for position in \ +$(cat ../WorkHistory/WorkHistory.csv); do + +COMPANY="$(echo $position|awk -F ',' '{print $1}')" +TITLE="$(echo $position|awk -F ',' '{print $2}')" +DATEOFEMPLOY="$(echo $position|awk -F ',' '{print $3}')" + +echo "**$COMPANY | $TITLE | $DATEOFEMPLOY**" >> $MarkdownOutputFile +echo " " >> "$MarkdownOutputFile" + +cat ../EmployerItems/$COMPANY.md >> "$MarkdownOutputFile" +echo " " >> "$MarkdownOutputFile" +done +unset IFS + +#Pull in my skills and generate a beautiful table. + +echo "\pagebreak" >> $MarkdownOutputFile +echo " " >> "$MarkdownOutputFile" +echo "## Skills" >> "$MarkdownOutputFile" +echo " " >> "$MarkdownOutputFile" + +#Table heading + +echo "|Skill|Experience|Skill Details|" >> $MarkdownOutputFile +echo "|---|---|---|" >> $MarkdownOutputFile +#Table rows +IFS=$'\n\t' +for skill in \ +$(cat ../SkillsAndProjects/Skills.csv); do +SKILL_NAME="$(echo $skill|awk -F '|' '{print $1}')" +SKILL_YEARS="$(echo $skill|awk -F '|' '{print $2}')" +SKILL_DETAIL="$(echo $skill|awk -F '|' '{print $3}')" +echo "|**$SKILL_NAME**|$SKILL_YEARS|$SKILL_DETAIL|" >> $MarkdownOutputFile +done +unset IFS \ No newline at end of file diff --git a/build/build-client-submit-stage2.sh b/build/build-client-submit-stage2.sh new file mode 100644 index 0000000..d48e32c --- /dev/null +++ b/build/build-client-submit-stage2.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +echo "Generating PDF output for client submission version..." + +PDFOutputFile="D:/tsys/@Reachableceo/resume.reachableceo.com/client-submit/CharlesNWyble-Resume.pdf" +MSWordOutputFile="D:/tsys/@Reachableceo/resume.reachableceo.com/client-submit/CharlesNWyble-Resume.doc" +MarkdownInputFile="../MarkdownOutput/client-submit/CharlesNWybleResume.md " +PandocMetadataFile="./CharlesNWyble-ClientSubmit.yml" + +pandoc \ +"$MarkdownInputFile" \ +--template eisvogel \ +--metadata-file="$PandocMetadataFile" \ +--from markdown \ +--to=pdf \ +--output $PDFOutputFile + +echo "Generating MSWord output for client submission version..." + +pandoc \ +"$MarkdownInputFile" \ +--metadata-file="$PandocMetadataFile" \ +--from markdown \ +--to=docx \ +--reference-doc=resume-docx-reference.docx \ +--output $MSWordOutputFile \ No newline at end of file diff --git a/build/build-jobBoard-stage1.sh b/build/build-jobBoard-stage1.sh new file mode 100644 index 0000000..140a3e5 --- /dev/null +++ b/build/build-jobBoard-stage1.sh @@ -0,0 +1,69 @@ +#!/bin/bash + + +echo "Cleaning up from previous runs..." + +MarkdownOutputFile="../MarkdownOutput/job-board/CharlesNWybleResume.md" +rm $MarkdownOutputFile + +echo "Combining markdown files into single input file for pandoc..." + +#Pull in my contact info +cat "../boilerplate/Contact-Info.md" >> $MarkdownOutputFile +echo " " >> $MarkdownOutputFile + +echo "## Highlights from my 22 year IT career" >> $MarkdownOutputFile + +cat ../SkillsAndProjects/Projects.md >> "$MarkdownOutputFile" + +echo "\pagebreak" >> $MarkdownOutputFile + +echo " " >> $MarkdownOutputFile +echo "## Employment History" >> $MarkdownOutputFile +echo " " >> $MarkdownOutputFile + +#And here we do some magic... +#Pull in my : + +# employer +# title +# start/end dates of employment +# long form position summary data from each position + +IFS=$'\n\t' +for position in \ +$(cat ../WorkHistory/WorkHistory.csv); do + +COMPANY="$(echo $position|awk -F ',' '{print $1}')" +TITLE="$(echo $position|awk -F ',' '{print $2}')" +DATEOFEMPLOY="$(echo $position|awk -F ',' '{print $3}')" + +echo "**$COMPANY | $TITLE | $DATEOFEMPLOY**" >> $MarkdownOutputFile +echo " " >> "$MarkdownOutputFile" + +cat ../EmployerItems/$COMPANY.md >> "$MarkdownOutputFile" +echo " " >> "$MarkdownOutputFile" +done +unset IFS + +#Pull in my skills and generate a beautiful table. + +echo "\pagebreak" >> $MarkdownOutputFile +echo " " >> "$MarkdownOutputFile" +echo "## Skills" >> "$MarkdownOutputFile" +echo " " >> "$MarkdownOutputFile" + +#Table heading + +echo "|Skill|Experience|Skill Details|" >> $MarkdownOutputFile +echo "|---|---|---|" >> $MarkdownOutputFile +#Table rows +IFS=$'\n\t' +for skill in \ +$(cat ../SkillsAndProjects/Skills.csv); do +SKILL_NAME="$(echo $skill|awk -F '|' '{print $1}')" +SKILL_YEARS="$(echo $skill|awk -F '|' '{print $2}')" +SKILL_DETAIL="$(echo $skill|awk -F '|' '{print $3}')" +echo "|**$SKILL_NAME**|$SKILL_YEARS|$SKILL_DETAIL|" >> $MarkdownOutputFile +done +unset IFS \ No newline at end of file diff --git a/build/build-jobBoard-stage2.sh b/build/build-jobBoard-stage2.sh new file mode 100644 index 0000000..a122be1 --- /dev/null +++ b/build/build-jobBoard-stage2.sh @@ -0,0 +1,27 @@ +#!/bin/bash + + +echo "Generating PDF output for job board version..." + +PDFOutputFile="D:/tsys/@Reachableceo/resume.reachableceo.com/job-board/CharlesNWyble-Resume.pdf" +MSWordOutputFile="D:/tsys/@Reachableceo/resume.reachableceo.com/job-board/CharlesNWyble-Resume.doc" +MarkdownInputFile="../MarkdownOutput/job-board/CharlesNWybleResume.md " +PandocMetadataFile="./CharlesNWyble-JobBoard.yml" + +pandoc \ +"$MarkdownInputFile" \ +--template eisvogel \ +--metadata-file="$PandocMetadataFile" \ +--from markdown \ +--to=pdf \ +--output $PDFOutputFile + +echo "Generating MSWord output for client submission version..." + +pandoc \ +"$MarkdownInputFile" \ +--metadata-file="$PandocMetadataFile" \ +--from markdown \ +--to=docx \ +--reference-doc=resume-docx-reference.docx \ +--output $MSWordOutputFile \ No newline at end of file diff --git a/build/mo b/build/mo new file mode 100644 index 0000000..b53d48a --- /dev/null +++ b/build/mo @@ -0,0 +1,1997 @@ +#!/usr/bin/env bash +# +#/ Mo is a mustache template rendering software written in bash. It inserts +#/ environment variables into templates. +#/ +#/ Simply put, mo will change {{VARIABLE}} into the value of that +#/ environment variable. You can use {{#VARIABLE}}content{{/VARIABLE}} to +#/ conditionally display content or iterate over the values of an array. +#/ +#/ Learn more about mustache templates at https://mustache.github.io/ +#/ +#/ Simple usage: +#/ +#/ mo [OPTIONS] filenames... +#/ +#/ Options: +#/ +#/ --allow-function-arguments +#/ Permit functions to be called with additional arguments. Otherwise, +#/ the only way to get access to the arguments is to use the +#/ MO_FUNCTION_ARGS environment variable. +#/ -d, --debug +#/ Enable debug logging to stderr. +#/ -u, --fail-not-set +#/ Fail upon expansion of an unset variable. Will silently ignore by +#/ default. Alternately, set MO_FAIL_ON_UNSET to a non-empty value. +#/ -x, --fail-on-function +#/ Fail when a function returns a non-zero status code instead of +#/ silently ignoring it. Alternately, set MO_FAIL_ON_FUNCTION to a +#/ non-empty value. +#/ -f, --fail-on-file +#/ Fail when a file (from command-line or partial) does not exist. +#/ Alternately, set MO_FAIL_ON_FILE to a non-empty value. +#/ -e, --false +#/ Treat the string "false" as empty for conditionals. Alternately, +#/ set MO_FALSE_IS_EMPTY to a non-empty value. +#/ -h, --help +#/ This message. +#/ -s=FILE, --source=FILE +#/ Load FILE into the environment before processing templates. +#/ Can be used multiple times. The file must be a valid shell script +#/ and should only contain variable assignments. +#/ -o=DELIM, --open=DELIM +#/ Set the opening delimiter. Default is "{{". +#/ -c=DELIM, --close=DELIM +#/ Set the closing delimiter. Default is "}}". +#/ -- Indicate the end of options. All arguments after this will be +#/ treated as filenames only. Use when filenames may start with +#/ hyphens. +#/ +#/ Mo uses the following environment variables: +#/ +#/ MO_ALLOW_FUNCTION_ARGUMENTS - When set to a non-empty value, this allows +#/ functions referenced in templates to receive additional options and +#/ arguments. +#/ MO_CLOSE_DELIMITER - The string used when closing a tag. Defaults to "}}". +#/ Used internally. +#/ MO_CLOSE_DELIMITER_DEFAULT - The default value of MO_CLOSE_DELIMITER. Used +#/ when resetting the close delimiter, such as when parsing a partial. +#/ MO_CURRENT - Variable name to use for ".". +#/ MO_DEBUG - When set to a non-empty value, additional debug information is +#/ written to stderr. +#/ MO_FUNCTION_ARGS - Arguments passed to the function. +#/ MO_FAIL_ON_FILE - If a filename from the command-line is missing or a +#/ partial does not exist, abort with an error. +#/ MO_FAIL_ON_FUNCTION - If a function returns a non-zero status code, abort +#/ with an error. +#/ MO_FAIL_ON_UNSET - When set to a non-empty value, expansion of an unset env +#/ variable will be aborted with an error. +#/ MO_FALSE_IS_EMPTY - When set to a non-empty value, the string "false" will +#/ be treated as an empty value for the purposes of conditionals. +#/ MO_OPEN_DELIMITER - The string used when opening a tag. Defaults to "{{". +#/ Used internally. +#/ MO_OPEN_DELIMITER_DEFAULT - The default value of MO_OPEN_DELIMITER. Used +#/ when resetting the open delimiter, such as when parsing a partial. +#/ MO_ORIGINAL_COMMAND - Used to find the `mo` program in order to generate a +#/ help message. +#/ MO_PARSED - Content that has made it through the template engine. +#/ MO_STANDALONE_CONTENT - The unparsed content that preceeded the current tag. +#/ When a standalone tag is encountered, this is checked to see if it only +#/ contains whitespace. If this and the whitespace condition after a tag is +#/ met, then this will be reset to $'\n'. +#/ MO_UNPARSED - Template content yet to make it through the parser. +#/ +#/ Mo is under a MIT style licence with an additional non-advertising clause. +#/ See LICENSE.md for the full text. +#/ +#/ This is open source! Please feel free to contribute. +#/ +#/ https://github.com/tests-always-included/mo + +#: Disable these warnings for the entire file +#: +#: VAR_NAME was modified in a subshell. That change might be lost. +# shellcheck disable=SC2031 +#: +#: Modification of VAR_NAME is local (to subshell caused by (..) group). +# shellcheck disable=SC2030 + +# Public: Template parser function. Writes templates to stdout. +# +# $0 - Name of the mo file, used for getting the help message. +# $@ - Filenames to parse. +# +# Returns nothing. +mo() ( + local moSource moFiles moDoubleHyphens moParsed moContent + + #: This function executes in a subshell; IFS is reset at the end. + IFS=$' \n\t' + + #: Enable a strict mode. This is also reset at the end. + set -eEu -o pipefail + moFiles=() + moDoubleHyphens=false + MO_OPEN_DELIMITER_DEFAULT="{{" + MO_CLOSE_DELIMITER_DEFAULT="}}" + MO_FUNCTION_CACHE_HIT=() + MO_FUNCTION_CACHE_MISS=() + + if [[ $# -gt 0 ]]; then + for arg in "$@"; do + if $moDoubleHyphens; then + #: After we encounter two hyphens together, all the rest + #: of the arguments are files. + moFiles=(${moFiles[@]+"${moFiles[@]}"} "$arg") + else + case "$arg" in + -h|--h|--he|--hel|--help|-\?) + mo::usage "$0" + exit 0 + ;; + + --allow-function-arguments) + MO_ALLOW_FUNCTION_ARGUMENTS=true + ;; + + -u | --fail-not-set) + MO_FAIL_ON_UNSET=true + ;; + + -x | --fail-on-function) + MO_FAIL_ON_FUNCTION=true + ;; + + -p | --fail-on-file) + MO_FAIL_ON_FILE=true + ;; + + -e | --false) + MO_FALSE_IS_EMPTY=true + ;; + + -s=* | --source=*) + if [[ "$arg" == --source=* ]]; then + moSource="${arg#--source=}" + else + moSource="${arg#-s=}" + fi + + if [[ -e "$moSource" ]]; then + # shellcheck disable=SC1090 + . "$moSource" + else + echo "No such file: $moSource" >&2 + exit 1 + fi + ;; + + -o=* | --open=*) + if [[ "$arg" == --open=* ]]; then + MO_OPEN_DELIMITER_DEFAULT="${arg#--open=}" + else + MO_OPEN_DELIMITER_DEFAULT="${arg#-o=}" + fi + ;; + + -c=* | --close=*) + if [[ "$arg" == --close=* ]]; then + MO_CLOSE_DELIMITER_DEFAULT="${arg#--close=}" + else + MO_CLOSE_DELIMITER_DEFAULT="${arg#-c=}" + fi + ;; + + -d | --debug) + MO_DEBUG=true + ;; + + --) + #: Set a flag indicating we've encountered double hyphens + moDoubleHyphens=true + ;; + + -*) + mo::error "Unknown option: $arg (See --help for options)" + ;; + + *) + #: Every arg that is not a flag or a option should be a file + moFiles=(${moFiles[@]+"${moFiles[@]}"} "$arg") + ;; + esac + fi + done + fi + + mo::debug "Debug enabled" + MO_OPEN_DELIMITER="$MO_OPEN_DELIMITER_DEFAULT" + MO_CLOSE_DELIMITER="$MO_CLOSE_DELIMITER_DEFAULT" + mo::content moContent ${moFiles[@]+"${moFiles[@]}"} || return 1 + mo::parse moParsed "$moContent" + echo -n "$moParsed" +) + + +# Internal: Show a debug message +# +# $1 - The debug message to show +# +# Returns nothing. +mo::debug() { + if [[ -n "${MO_DEBUG:-}" ]]; then + echo "DEBUG ${FUNCNAME[1]:-?} - $1" >&2 + fi +} + + +# Internal: Show a debug message and internal state information +# +# No arguments +# +# Returns nothing. +mo::debugShowState() { + if [[ -z "${MO_DEBUG:-}" ]]; then + return + fi + + local moState moTemp moIndex moDots + + mo::escape moTemp "$MO_OPEN_DELIMITER" + moState="open: $moTemp" + mo::escape moTemp "$MO_CLOSE_DELIMITER" + moState="$moState close: $moTemp" + mo::escape moTemp "$MO_STANDALONE_CONTENT" + moState="$moState standalone: $moTemp" + mo::escape moTemp "$MO_CURRENT" + moState="$moState current: $moTemp" + moIndex=$((${#MO_PARSED} - 20)) + moDots=... + + if [[ "$moIndex" -lt 0 ]]; then + moIndex=0 + moDots= + fi + + mo::escape moTemp "${MO_PARSED:$moIndex}" + moState="$moState parsed: $moDots$moTemp" + + moDots=... + + if [[ "${#MO_UNPARSED}" -le 20 ]]; then + moDots= + fi + + mo::escape moTemp "${MO_UNPARSED:0:20}$moDots" + moState="$moState unparsed: $moTemp" + + echo "DEBUG ${FUNCNAME[1]:-?} - $moState" >&2 +} + +# Internal: Show an error message and exit +# +# $1 - The error message to show +# $2 - Error code +# +# Returns nothing. Exits the program. +mo::error() { + echo "ERROR: $1" >&2 + exit "${2:-1}" +} + + +# Internal: Show an error message with a snippet of context and exit +# +# $1 - The error message to show +# $2 - The starting point +# $3 - Error code +# +# Returns nothing. Exits the program. +mo::errorNear() { + local moEscaped + + mo::escape moEscaped "${2:0:40}" + echo "ERROR: $1" >&2 + echo "ERROR STARTS NEAR: $moEscaped" + exit "${3:-1}" +} + + +# Internal: Displays the usage for mo. Pulls this from the file that +# contained the `mo` function. Can only work when the right filename +# comes is the one argument, and that only happens when `mo` is called +# with `$0` set to this file. +# +# $1 - Filename that has the help message +# +# Returns nothing. +mo::usage() { + while read -r line; do + if [[ "${line:0:2}" == "#/" ]]; then + echo "${line:3}" + fi + done < "$MO_ORIGINAL_COMMAND" + echo "" + echo "MO_VERSION=$MO_VERSION" +} + + +# Internal: Fetches the content to parse into MO_UNPARSED. Can be a list of +# partials for files or the content from stdin. +# +# $1 - Destination variable name +# $2-@ - File names (optional), read from stdin otherwise +# +# Returns nothing. +mo::content() { + local moTarget moContent moFilename + + moTarget=$1 + shift + moContent="" + + if [[ "${#@}" -gt 0 ]]; then + for moFilename in "$@"; do + mo::debug "Using template to load content from file: $moFilename" + #: This is so relative paths work from inside template files + moContent="$moContent$MO_OPEN_DELIMITER>$moFilename$MO_CLOSE_DELIMITER" + done + else + mo::debug "Will read content from stdin" + mo::contentFile moContent || return 1 + fi + + local "$moTarget" && mo::indirect "$moTarget" "$moContent" +} + + +# Internal: Read a file into MO_UNPARSED. +# +# $1 - Destination variable name. +# $2 - Filename to load - if empty, defaults to /dev/stdin +# +# Returns nothing. +mo::contentFile() { + local moFile moResult moContent + + #: The subshell removes any trailing newlines. We forcibly add + #: a dot to the content to preserve all newlines. Reading from + #: stdin with a `read` loop does not work as expected, so `cat` + #: needs to stay. + moFile=${2:-/dev/stdin} + + if [[ -e "$moFile" ]]; then + mo::debug "Loading content: $moFile" + moContent=$( + set +Ee + cat -- "$moFile" + moResult=$? + echo -n '.' + exit "$moResult" + ) || return 1 + moContent=${moContent%.} #: Remove last dot + elif [[ -n "${MO_FAIL_ON_FILE-}" ]]; then + mo::error "No such file: $moFile" + else + mo::debug "File does not exist: $moFile" + moContent="" + fi + + local "$1" && mo::indirect "$1" "$moContent" +} + + +# Internal: Send a variable up to the parent of the caller of this function. +# +# $1 - Variable name +# $2 - Value +# +# Examples +# +# callFunc () { +# local "$1" && mo::indirect "$1" "the value" +# } +# callFunc dest +# echo "$dest" # writes "the value" +# +# Returns nothing. +mo::indirect() { + unset -v "$1" + printf -v "$1" '%s' "$2" +} + + +# Internal: Send an array as a variable up to caller of a function +# +# $1 - Variable name +# $2-@ - Array elements +# +# Examples +# +# callFunc () { +# local myArray=(one two three) +# local "$1" && mo::indirectArray "$1" "${myArray[@]}" +# } +# callFunc dest +# echo "${dest[@]}" # writes "one two three" +# +# Returns nothing. +mo::indirectArray() { + unset -v "$1" + + #: IFS must be set to a string containing space or unset in order for + #: the array slicing to work regardless of the current IFS setting on + #: bash 3. This is detailed further at + #: https://github.com/fidian/gg-core/pull/7 + eval "$(printf "IFS= %s=(\"\${@:2}\") IFS=%q" "$1" "$IFS")" +} + + +# Internal: Trim leading characters from MO_UNPARSED +# +# Returns nothing. +mo::trimUnparsed() { + local moI moC + + moI=0 + moC=${MO_UNPARSED:0:1} + + while [[ "$moC" == " " || "$moC" == $'\r' || "$moC" == $'\n' || "$moC" == $'\t' ]]; do + moI=$((moI + 1)) + moC=${MO_UNPARSED:$moI:1} + done + + if [[ "$moI" != 0 ]]; then + MO_UNPARSED=${MO_UNPARSED:$moI} + fi +} + + +# Internal: Remove whitespace and content after whitespace +# +# $1 - Name of the destination variable +# $2 - The string to chomp +# +# Returns nothing. +mo::chomp() { + local moTemp moR moN moT + + moR=$'\r' + moN=$'\n' + moT=$'\t' + moTemp=${2%% *} + moTemp=${moTemp%%"$moR"*} + moTemp=${moTemp%%"$moN"*} + moTemp=${moTemp%%"$moT"*} + + local "$1" && mo::indirect "$1" "$moTemp" +} + + +# Public: Parses text, interpolates mustache tags. Utilizes the current value +# of MO_OPEN_DELIMITER, MO_CLOSE_DELIMITER, and MO_STANDALONE_CONTENT. Those +# three variables shouldn't be changed by user-defined functions. +# +# $1 - Destination variable name - where to store the finished content +# $2 - Content to parse +# $3 - Preserve standalone status/content - truthy if not empty. When set to a +# value, that becomes the standalone content value +# +# Returns nothing. +mo::parse() { + local moOldParsed moOldStandaloneContent moOldUnparsed moResult + + #: The standalone content is a trick to make the standalone tag detection + #: possible. When it's set to content with a newline and if the tag supports + #: it, the standalone content check happens. This check ensures only + #: whitespace is after the last newline up to the tag, and only whitespace + #: is after the tag up to the next newline. If that is the case, remove + #: whitespace and the trailing newline. By setting this to $'\n', we're + #: saying we are at the beginning of content. + mo::debug "Starting parse of ${#2} bytes" + moOldParsed=${MO_PARSED:-} + moOldUnparsed=${MO_UNPARSED:-} + MO_PARSED="" + MO_UNPARSED="$2" + + if [[ -z "${3:-}" ]]; then + moOldStandaloneContent=${MO_STANDALONE_CONTENT:-} + MO_STANDALONE_CONTENT=$'\n' + else + MO_STANDALONE_CONTENT=$3 + fi + + MO_CURRENT=${MO_CURRENT:-} + mo::parseInternal + moResult="$MO_PARSED$MO_UNPARSED" + MO_PARSED=$moOldParsed + MO_UNPARSED=$moOldUnparsed + + if [[ -z "${3:-}" ]]; then + MO_STANDALONE_CONTENT=$moOldStandaloneContent + fi + + local "$1" && mo::indirect "$1" "$moResult" +} + + +# Internal: Parse MO_UNPARSED, writing content to MO_PARSED. Interpolates +# mustache tags. +# +# No arguments +# +# Returns nothing. +mo::parseInternal() { + local moChunk + + mo::debug "Starting parse" + + while [[ -n "$MO_UNPARSED" ]]; do + mo::debugShowState + moChunk=${MO_UNPARSED%%"$MO_OPEN_DELIMITER"*} + MO_PARSED="$MO_PARSED$moChunk" + MO_STANDALONE_CONTENT="$MO_STANDALONE_CONTENT$moChunk" + MO_UNPARSED=${MO_UNPARSED:${#moChunk}} + + if [[ -n "$MO_UNPARSED" ]]; then + MO_UNPARSED=${MO_UNPARSED:${#MO_OPEN_DELIMITER}} + mo::trimUnparsed + + case "$MO_UNPARSED" in + '#'*) + #: Loop, if/then, or pass content through function + mo::parseBlock false + ;; + + '^'*) + #: Display section if named thing does not exist + mo::parseBlock true + ;; + + '>'*) + #: Load partial - get name of file relative to cwd + mo::parsePartial + ;; + + '/'*) + #: Closing tag + mo::errorNear "Unbalanced close tag" "$MO_UNPARSED" + ;; + + '!'*) + #: Comment - ignore the tag content entirely + mo::parseComment + ;; + + '='*) + #: Change delimiters + #: Any two non-whitespace sequences separated by whitespace. + mo::parseDelimiter + ;; + + '&'*) + #: Unescaped - mo doesn't escape/unescape + MO_UNPARSED=${MO_UNPARSED#&} + mo::trimUnparsed + mo::parseValue + ;; + + *) + #: Normal environment variable, string, subexpression, + #: current value, key, or function call + mo::parseValue + ;; + esac + fi + done +} + + +# Internal: Handle parsing a block +# +# $1 - Invert condition ("true" or "false") +# +# Returns nothing +mo::parseBlock() { + local moInvertBlock moTokens moTokensString + + moInvertBlock=$1 + MO_UNPARSED=${MO_UNPARSED:1} + mo::tokenizeTagContents moTokens "$MO_CLOSE_DELIMITER" + MO_UNPARSED=${MO_UNPARSED#"$MO_CLOSE_DELIMITER"} + mo::tokensToString moTokensString "${moTokens[@]:1}" + mo::debug "Parsing block: $moTokensString" + + if mo::standaloneCheck; then + mo::standaloneProcess + fi + + if [[ "${moTokens[1]}" == "NAME" ]] && mo::isFunction "${moTokens[2]}"; then + mo::parseBlockFunction "$moInvertBlock" "$moTokensString" "${moTokens[@]:1}" + elif [[ "${moTokens[1]}" == "NAME" ]] && mo::isArray "${moTokens[2]}"; then + mo::parseBlockArray "$moInvertBlock" "$moTokensString" "${moTokens[@]:1}" + else + mo::parseBlockValue "$moInvertBlock" "$moTokensString" "${moTokens[@]:1}" + fi +} + + +# Internal: Handle parsing a block whose first argument is a function +# +# $1 - Invert condition ("true" or "false") +# $2-@ - The parsed tokens from inside the block tags +# +# Returns nothing +mo::parseBlockFunction() { + local moTarget moInvertBlock moTokens moTemp moUnparsed moTokensString + + moInvertBlock=$1 + moTokensString=$2 + shift 2 + moTokens=(${@+"$@"}) + mo::debug "Parsing block function: $moTokensString" + mo::getContentUntilClose moTemp "$moTokensString" + #: Pass unparsed content to the function. + #: Keep the updated delimiters if they changed. + + if [[ "$moInvertBlock" != "true" ]]; then + mo::evaluateFunction moResult "$moTemp" "${moTokens[@]:1}" + MO_PARSED="$MO_PARSED$moResult" + fi + + mo::debug "Done parsing block function: $moTokensString" +} + + +# Internal: Handle parsing a block whose first argument is an array +# +# $1 - Invert condition ("true" or "false") +# $2-@ - The parsed tokens from inside the block tags +# +# Returns nothing +mo::parseBlockArray() { + local moInvertBlock moTokens moResult moArrayName moArrayIndexes moArrayIndex moTemp moUnparsed moOpenDelimiterBefore moCloseDelimiterBefore moOpenDelimiterAfter moCloseDelimiterAfter moParsed moTokensString moCurrent + + moInvertBlock=$1 + moTokensString=$2 + shift 2 + moTokens=(${@+"$@"}) + mo::debug "Parsing block array: $moTokensString" + moOpenDelimiterBefore=$MO_OPEN_DELIMITER + moCloseDelimiterBefore=$MO_CLOSE_DELIMITER + mo::getContentUntilClose moTemp "$moTokensString" + moOpenDelimiterAfter=$MO_OPEN_DELIMITER + moCloseDelimiterAfter=$MO_CLOSE_DELIMITER + moArrayName=${moTokens[1]} + eval "moArrayIndexes=(\"\${!${moArrayName}[@]}\")" + + if [[ "${#moArrayIndexes[@]}" -lt 1 ]]; then + #: No elements + if [[ "$moInvertBlock" == "true" ]]; then + #: Restore the delimiter before parsing + MO_OPEN_DELIMITER=$moOpenDelimiterBefore + MO_CLOSE_DELIMITER=$moCloseDelimiterBefore + moCurrent=$MO_CURRENT + MO_CURRENT=$moArrayName + mo::parse moParsed "$moTemp" "blockArrayInvert$MO_STANDALONE_CONTENT" + MO_CURRENT=$moCurrent + MO_PARSED="$MO_PARSED$moParsed" + fi + else + if [[ "$moInvertBlock" != "true" ]]; then + #: Process for each element in the array + moUnparsed=$MO_UNPARSED + + for moArrayIndex in "${moArrayIndexes[@]}"; do + #: Restore the delimiter before parsing + MO_OPEN_DELIMITER=$moOpenDelimiterBefore + MO_CLOSE_DELIMITER=$moCloseDelimiterBefore + moCurrent=$MO_CURRENT + MO_CURRENT=$moArrayName.$moArrayIndex + mo::debug "Iterate over array using element: $MO_CURRENT" + mo::parse moParsed "$moTemp" "blockArray$MO_STANDALONE_CONTENT" + MO_CURRENT=$moCurrent + MO_PARSED="$MO_PARSED$moParsed" + done + + MO_UNPARSED=$moUnparsed + fi + fi + + MO_OPEN_DELIMITER=$moOpenDelimiterAfter + MO_CLOSE_DELIMITER=$moCloseDelimiterAfter + mo::debug "Done parsing block array: $moTokensString" +} + + +# Internal: Handle parsing a block whose first argument is a value +# +# $1 - Invert condition ("true" or "false") +# $2-@ - The parsed tokens from inside the block tags +# +# Returns nothing +mo::parseBlockValue() { + local moInvertBlock moTokens moResult moUnparsed moOpenDelimiterBefore moOpenDelimiterAfter moCloseDelimiterBefore moCloseDelimiterAfter moParsed moTemp moTokensString moCurrent + + moInvertBlock=$1 + moTokensString=$2 + shift 2 + moTokens=(${@+"$@"}) + mo::debug "Parsing block value: $moTokensString" + moOpenDelimiterBefore=$MO_OPEN_DELIMITER + moCloseDelimiterBefore=$MO_CLOSE_DELIMITER + mo::getContentUntilClose moTemp "$moTokensString" + moOpenDelimiterAfter=$MO_OPEN_DELIMITER + moCloseDelimiterAfter=$MO_CLOSE_DELIMITER + + #: Variable, value, or list of mixed things + mo::evaluateListOfSingles moResult "${moTokens[@]}" + + if mo::isTruthy "$moResult" "$moInvertBlock"; then + mo::debug "Block is truthy: $moResult" + #: Restore the delimiter before parsing + MO_OPEN_DELIMITER=$moOpenDelimiterBefore + MO_CLOSE_DELIMITER=$moCloseDelimiterBefore + moCurrent=$MO_CURRENT + MO_CURRENT=${moTokens[1]} + mo::parse moParsed "$moTemp" "blockValue$MO_STANDALONE_CONTENT" + MO_PARSED="$MO_PARSED$moParsed" + MO_CURRENT=$moCurrent + fi + + MO_OPEN_DELIMITER=$moOpenDelimiterAfter + MO_CLOSE_DELIMITER=$moCloseDelimiterAfter + mo::debug "Done parsing block value: $moTokensString" +} + + +# Internal: Handle parsing a partial +# +# No arguments. +# +# Indentation will be applied to the entire partial's contents before parsing. +# This indentation is based on the whitespace that ends the previously parsed +# content. +# +# Returns nothing +mo::parsePartial() { + local moFilename moResult moIndentation moN moR moTemp moT + + MO_UNPARSED=${MO_UNPARSED:1} + mo::trimUnparsed + mo::chomp moFilename "${MO_UNPARSED%%"$MO_CLOSE_DELIMITER"*}" + MO_UNPARSED="${MO_UNPARSED#*"$MO_CLOSE_DELIMITER"}" + moIndentation="" + + if mo::standaloneCheck; then + moN=$'\n' + moR=$'\r' + moT=$'\t' + moIndentation="$moN${MO_PARSED//"$moR"/"$moN"}" + moIndentation=${moIndentation##*"$moN"} + moTemp=${moIndentation// } + moTemp=${moTemp//"$moT"} + + if [[ -n "$moTemp" ]]; then + moIndentation= + fi + + mo::debug "Adding indentation to partial: '$moIndentation'" + mo::standaloneProcess + fi + + mo::debug "Parsing partial: $moFilename" + + #: Execute in subshell to preserve current cwd and environment + moResult=$( + #: It would be nice to remove `dirname` and use a function instead, + #: but that is difficult when only given filenames. + cd "$(dirname -- "$moFilename")" || exit 1 + echo "$( + local moPartialContent moPartialParsed + + if ! mo::contentFile moPartialContent "${moFilename##*/}"; then + exit 1 + fi + + #: Reset delimiters before parsing + mo::indentLines moPartialContent "$moIndentation" "$moPartialContent" + MO_OPEN_DELIMITER="$MO_OPEN_DELIMITER_DEFAULT" + MO_CLOSE_DELIMITER="$MO_CLOSE_DELIMITER_DEFAULT" + mo::parse moPartialParsed "$moPartialContent" + + #: Fix bash handling of subshells and keep trailing whitespace. + echo -n "$moPartialParsed." + )" || exit 1 + ) || exit 1 + + if [[ -z "$moResult" ]]; then + mo::debug "Error detected when trying to read the file" + exit 1 + fi + + MO_PARSED="$MO_PARSED${moResult%.}" +} + + +# Internal: Handle parsing a comment +# +# No arguments. +# +# Returns nothing +mo::parseComment() { + local moContent moContent + + MO_UNPARSED=${MO_UNPARSED#*"$MO_CLOSE_DELIMITER"} + mo::debug "Parsing comment" + + if mo::standaloneCheck; then + mo::standaloneProcess + fi +} + + +# Internal: Handle parsing the change of delimiters +# +# No arguments. +# +# Returns nothing +mo::parseDelimiter() { + local moContent moOpen moClose + + MO_UNPARSED=${MO_UNPARSED:1} + mo::trimUnparsed + mo::chomp moOpen "$MO_UNPARSED" + MO_UNPARSED=${MO_UNPARSED:${#moOpen}} + mo::trimUnparsed + mo::chomp moClose "${MO_UNPARSED%%="$MO_CLOSE_DELIMITER"*}" + MO_UNPARSED=${MO_UNPARSED#*="$MO_CLOSE_DELIMITER"} + mo::debug "Parsing delimiters: $moOpen $moClose" + + if mo::standaloneCheck; then + mo::standaloneProcess + fi + + MO_OPEN_DELIMITER="$moOpen" + MO_CLOSE_DELIMITER="$moClose" +} + + +# Internal: Handle parsing value or function call +# +# No arguments. +# +# Returns nothing +mo::parseValue() { + local moUnparsedOriginal moTokens + + moUnparsedOriginal=$MO_UNPARSED + mo::tokenizeTagContents moTokens "$MO_CLOSE_DELIMITER" + mo::evaluate moResult "${moTokens[@]:1}" + MO_PARSED="$MO_PARSED$moResult" + + if [[ "${MO_UNPARSED:0:${#MO_CLOSE_DELIMITER}}" != "$MO_CLOSE_DELIMITER" ]]; then + mo::errorNear "Did not find closing tag" "$moUnparsedOriginal" + fi + + if mo::standaloneCheck; then + mo::standaloneProcess + fi + + MO_UNPARSED=${MO_UNPARSED:${#MO_CLOSE_DELIMITER}} +} + + +# Internal: Determine if the given name is a defined function. +# +# $1 - Function name to check +# +# Be extremely careful. Even if strict mode is enabled, it is not honored +# in newer versions of Bash. Any errors that crop up here will not be +# caught automatically. +# +# Examples +# +# moo () { +# echo "This is a function" +# } +# if mo::isFunction moo; then +# echo "moo is a defined function" +# fi +# +# Returns 0 if the name is a function, 1 otherwise. +mo::isFunction() { + local moFunctionName + + for moFunctionName in "${MO_FUNCTION_CACHE_HIT[@]}"; do + if [[ "$moFunctionName" == "$1" ]]; then + return 0 + fi + done + + for moFunctionName in "${MO_FUNCTION_CACHE_MISS[@]}"; do + if [[ "$moFunctionName" == "$1" ]]; then + return 1 + fi + done + + if declare -F "$1" &> /dev/null; then + MO_FUNCTION_CACHE_HIT=( ${MO_FUNCTION_CACHE_HIT[@]+"${MO_FUNCTION_CACHE_HIT[@]}"} "$1" ) + + return 0 + fi + + MO_FUNCTION_CACHE_MISS=( ${MO_FUNCTION_CACHE_MISS[@]+"${MO_FUNCTION_CACHE_MISS[@]}"} "$1" ) + + return 1 +} + + +# Internal: Determine if a given environment variable exists and if it is +# an array. +# +# $1 - Name of environment variable +# +# Be extremely careful. Even if strict mode is enabled, it is not honored +# in newer versions of Bash. Any errors that crop up here will not be +# caught automatically. +# +# Examples +# +# var=(abc) +# if moIsArray var; then +# echo "This is an array" +# echo "Make sure you don't accidentally use \$var" +# fi +# +# Returns 0 if the name is not empty, 1 otherwise. +mo::isArray() { + #: Namespace this variable so we don't conflict with what we're testing. + local moTestResult + + moTestResult=$(declare -p "$1" 2>/dev/null) || return 1 + [[ "${moTestResult:0:10}" == "declare -a" ]] && return 0 + [[ "${moTestResult:0:10}" == "declare -A" ]] && return 0 + + return 1 +} + + +# Internal: Determine if an array index exists. +# +# $1 - Variable name to check +# $2 - The index to check +# +# Has to check if the variable is an array and if the index is valid for that +# type of array. +# +# Returns true (0) if everything was ok, 1 if there's any condition that fails. +mo::isArrayIndexValid() { + local moDeclare moTest + + moDeclare=$(declare -p "$1") + moTest="" + + if [[ "${moDeclare:0:10}" == "declare -a" ]]; then + #: Numerically indexed array - must check if the index looks like a + #: number because using a string to index a numerically indexed array + #: will appear like it worked. + if [[ "$2" == "0" ]] || [[ "$2" =~ ^[1-9][0-9]*$ ]]; then + #: Index looks like a number + eval "moTest=\"\${$1[$2]+ok}\"" + fi + elif [[ "${moDeclare:0:10}" == "declare -A" ]]; then + #: Associative array + eval "moTest=\"\${$1[$2]+ok}\"" + fi + + if [[ -n "$moTest" ]]; then + return 0; + fi + + return 1 +} + + +# Internal: Determine if a variable is assigned, even if it is assigned an empty +# value. +# +# $1 - Variable name to check. +# +# Can not use logic like this in case invalid variable names are passed. +# [[ "${!1-a}" == "${!1-b}" ]] +# +# Using logic like this gives false positives. +# [[ -v "$a" ]] +# +# Declaring a variable is not the same as assigning the variable. +# export x +# declare -p x # Output: declare -x x +# export y="" +# declare -p y # Output: declare -x y="" +# unset z +# declare -p z # Error code 1 and output: bash: declare: z: not found +# +# Returns true (0) if the variable is set, 1 if the variable is unset. +mo::isVarSet() { + if declare -p "$1" &> /dev/null && [[ -v "$1" ]]; then + return 0 + fi + + return 1 +} + + +# Internal: Determine if a value is considered truthy. +# +# $1 - The value to test +# $2 - Invert the value, either "true" or "false" +# +# Returns true (0) if truthy, 1 otherwise. +mo::isTruthy() { + local moTruthy + + moTruthy=true + + if [[ -z "${1-}" ]]; then + moTruthy=false + elif [[ -n "${MO_FALSE_IS_EMPTY-}" ]] && [[ "${1-}" == "false" ]]; then + moTruthy=false + fi + + #: XOR the results + #: moTruthy inverse desiredResult + #: true false true + #: true true false + #: false false false + #: false true true + if [[ "$moTruthy" == "$2" ]]; then + mo::debug "Value is falsy, test result: $moTruthy inverse: $2" + return 1 + fi + + mo::debug "Value is truthy, test result: $moTruthy inverse: $2" + return 0 +} + + +# Internal: Convert token list to values +# +# $1 - Destination variable name +# $2-@ - Tokens to convert +# +# Sample call: +# +# mo::evaluate dest NAME username VALUE abc123 PAREN 2 +# +# Returns nothing. +mo::evaluate() { + local moTarget moStack moValue moType moIndex moCombined moResult + + moTarget=$1 + shift + + #: Phase 1 - remove all command tokens (PAREN, BRACE) + moStack=() + + while [[ $# -gt 0 ]]; do + case "$1" in + PAREN|BRACE) + moType=$1 + moValue=$2 + mo::debug "Combining $moValue tokens" + moIndex=$((${#moStack[@]} - (2 * moValue))) + mo::evaluateListOfSingles moCombined "${moStack[@]:$moIndex}" + + if [[ "$moType" == "PAREN" ]]; then + moStack=("${moStack[@]:0:$moIndex}" NAME "$moCombined") + else + moStack=("${moStack[@]:0:$moIndex}" VALUE "$moCombined") + fi + ;; + + *) + moStack=(${moStack[@]+"${moStack[@]}"} "$1" "$2") + ;; + esac + + shift 2 + done + + #: Phase 2 - check if this is a function or if we should just concatenate values + if [[ "${moStack[0]:-}" == "NAME" ]] && mo::isFunction "${moStack[1]}"; then + #: Special case - if the first argument is a function, then the rest are + #: passed to the function. + mo::debug "Evaluating function: ${moStack[1]}" + mo::evaluateFunction moResult "" "${moStack[@]:1}" + else + #: Concatenate + mo::debug "Concatenating ${#moStack[@]} stack items" + mo::evaluateListOfSingles moResult ${moStack[@]+"${moStack[@]}"} + fi + + local "$moTarget" && mo::indirect "$moTarget" "$moResult" +} + + +# Internal: Convert an argument list to individual values. +# +# $1 - Destination variable name +# $2-@ - A list of argument types and argument name/value. +# +# This assumes each value is separate from the rest. In contrast, mo::evaluate +# will pass all arguments to a function if the first value is a function. +# +# Sample call: +# +# mo::evaluateListOfSingles dest NAME username VALUE abc123 +# +# Returns nothing. +mo::evaluateListOfSingles() { + local moResult moTarget moTemp + + moTarget=$1 + shift + moResult="" + + while [[ $# -gt 1 ]]; do + mo::evaluateSingle moTemp "$1" "$2" + moResult="$moResult$moTemp" + shift 2 + done + + mo::debug "Evaluated list of singles: $moResult" + + local "$moTarget" && mo::indirect "$moTarget" "$moResult" +} + + +# Internal: Evaluate a single argument +# +# $1 - Name of variable for result +# $2 - Type of argument, either NAME or VALUE +# $3 - Argument +# +# Returns nothing +mo::evaluateSingle() { + local moResult moType moArg + + moType=$2 + moArg=$3 + mo::debug "Evaluating $moType: $moArg ($MO_CURRENT)" + + if [[ "$moType" == "VALUE" ]]; then + moResult=$moArg + elif [[ "$moArg" == "." ]]; then + mo::evaluateVariable moResult "" + elif [[ "$moArg" == "@key" ]]; then + mo::evaluateKey moResult + elif mo::isFunction "$moArg"; then + mo::evaluateFunction moResult "" "$moArg" + else + mo::evaluateVariable moResult "$moArg" + fi + + local "$1" && mo::indirect "$1" "$moResult" +} + + +# Internal: Return the value for @key based on current's name +# +# $1 - Name of variable for result +# +# Returns nothing +mo::evaluateKey() { + local moResult + + if [[ "$MO_CURRENT" == *.* ]]; then + moResult="${MO_CURRENT#*.}" + else + moResult="${MO_CURRENT}" + fi + + local "$1" && mo::indirect "$1" "$moResult" +} + + +# Internal: Handle a variable name +# +# $1 - Destination variable name +# $2 - Variable name +# +# Returns nothing. +mo::evaluateVariable() { + local moResult moArg moNameParts + + moArg=$2 + moResult="" + mo::findVariableName moNameParts "$moArg" + mo::debug "Evaluate variable ($moArg, $MO_CURRENT): ${moNameParts[*]}" + + if [[ -z "${moNameParts[1]}" ]]; then + if mo::isArray "${moNameParts[0]}"; then + eval mo::join moResult "," "\${${moNameParts[0]}[@]}" + else + if mo::isVarSet "${moNameParts[0]}"; then + moResult=${moNameParts[0]} + moResult="${!moResult}" + elif [[ -n "${MO_FAIL_ON_UNSET-}" ]]; then + mo::error "Environment variable not set: ${moNameParts[0]}" + fi + fi + else + if mo::isArray "${moNameParts[0]}"; then + eval "set +u;moResult=\"\${${moNameParts[0]}[${moNameParts[1]%%.*}]}\"" + else + mo::error "Unable to index a scalar as an array: $moArg" + fi + fi + + local "$1" && mo::indirect "$1" "$moResult" +} + + +# Internal: Find the name of a variable to use +# +# $1 - Destination variable name, receives an array +# $2 - Variable name from the template +# +# The array contains the following values +# [0] - Variable name +# [1] - Array index, or empty string +# +# Example variables +# a="a" +# b="b" +# c=("c.0" "c.1") +# d=([b]="d.b" [d]="d.d") +# +# Given these inputs (function input, current value), produce these outputs +# a c => a +# a c.0 => a +# b d => d.b +# b d.d => d.b +# a d => d.a +# a d.d => d.a +# c.0 d => c.0 +# d.b d => d.b +# '' c => c +# '' c.0 => c.0 +# Returns nothing. +mo::findVariableName() { + local moVar moNameParts moResultBase moResultIndex moCurrent + + moVar=$2 + moResultBase=$moVar + moResultIndex="" + + if [[ -z "$moVar" ]]; then + moResultBase=${MO_CURRENT%%.*} + + if [[ "$MO_CURRENT" == *.* ]]; then + moResultIndex=${MO_CURRENT#*.} + fi + elif [[ "$moVar" == *.* ]]; then + mo::debug "Find variable name; name has dot: $moVar" + moResultBase=${moVar%%.*} + moResultIndex=${moVar#*.} + elif [[ -n "$MO_CURRENT" ]]; then + moCurrent=${MO_CURRENT%%.*} + mo::debug "Find variable name; look in array: $moCurrent" + + if mo::isArrayIndexValid "$moCurrent" "$moVar"; then + moResultBase=$moCurrent + moResultIndex=$moVar + fi + fi + + local "$1" && mo::indirectArray "$1" "$moResultBase" "$moResultIndex" +} + + +# Internal: Join / implode an array +# +# $1 - Variable name to receive the joined content +# $2 - Joiner +# $3-@ - Elements to join +# +# Returns nothing. +mo::join() { + local joiner part result target + + target=$1 + joiner=$2 + result=$3 + shift 3 + + for part in "$@"; do + result="$result$joiner$part" + done + + local "$target" && mo::indirect "$target" "$result" +} + + +# Internal: Call a function. +# +# $1 - Variable for output +# $2 - Content to pass +# $3 - Function to call +# $4-@ - Additional arguments as list of type, value/name +# +# Returns nothing. +mo::evaluateFunction() { + local moArgs moContent moFunctionResult moTarget moFunction moTemp moFunctionCall + + moTarget=$1 + moContent=$2 + moFunction=$3 + shift 3 + moArgs=() + + while [[ $# -gt 1 ]]; do + mo::evaluateSingle moTemp "$1" "$2" + moArgs=(${moArgs[@]+"${moArgs[@]}"} "$moTemp") + shift 2 + done + + mo::escape moFunctionCall "$moFunction" + + if [[ -n "${MO_ALLOW_FUNCTION_ARGUMENTS-}" ]]; then + mo::debug "Function arguments are allowed" + + if [[ ${#moArgs[@]} -gt 0 ]]; then + for moTemp in "${moArgs[@]}"; do + mo::escape moTemp "$moTemp" + moFunctionCall="$moFunctionCall $moTemp" + done + fi + fi + + mo::debug "Calling function: $moFunctionCall" + + #: Call the function in a subshell for safety. Employ the trick to preserve + #: whitespace at the end of the output. + moContent=$( + export MO_FUNCTION_ARGS=(${moArgs[@]+"${moArgs[@]}"}) + echo -n "$moContent" | eval "$moFunctionCall ; moFunctionResult=\$? ; echo -n '.' ; exit \"\$moFunctionResult\"" + ) || { + moFunctionResult=$? + if [[ -n "${MO_FAIL_ON_FUNCTION-}" && "$moFunctionResult" != 0 ]]; then + mo::error "Function failed with status code $moFunctionResult: $moFunctionCall" "$moFunctionResult" + fi + } + + local "$moTarget" && mo::indirect "$moTarget" "${moContent%.}" +} + + +# Internal: Check if a tag appears to have only whitespace before it and after +# it on a line. There must be a new line before and there must be a newline +# after or the end of a string +# +# No arguments. +# +# Returns 0 if this is a standalone tag, 1 otherwise. +mo::standaloneCheck() { + local moContent moN moR moT + + moN=$'\n' + moR=$'\r' + moT=$'\t' + + #: Check the content before + moContent=${MO_STANDALONE_CONTENT//"$moR"/"$moN"} + + #: By default, signal to the next check that this one failed + MO_STANDALONE_CONTENT="" + + if [[ "$moContent" != *"$moN"* ]]; then + mo::debug "Not a standalone tag - no newline before" + + return 1 + fi + + moContent=${moContent##*"$moN"} + moContent=${moContent//"$moT"/} + moContent=${moContent// /} + + if [[ -n "$moContent" ]]; then + mo::debug "Not a standalone tag - non-whitespace detected before tag" + + return 1 + fi + + #: Check the content after + moContent=${MO_UNPARSED//"$moR"/"$moN"} + moContent=${moContent%%"$moN"*} + moContent=${moContent//"$moT"/} + moContent=${moContent// /} + + if [[ -n "$moContent" ]]; then + mo::debug "Not a standalone tag - non-whitespace detected after tag" + + return 1 + fi + + #: Signal to the next check that this tag removed content + MO_STANDALONE_CONTENT=$'\n' + + return 0 +} + + +# Internal: Process content before and after a tag. Remove prior whitespace up +# to the previous newline. Remove following whitespace up to and including the +# next newline. +# +# No arguments. +# +# Returns nothing. +mo::standaloneProcess() { + local moI moTemp + + mo::debug "Standalone tag - processing content before and after tag" + moI=$((${#MO_PARSED} - 1)) + mo::debug "zero done ${#MO_PARSED}" + mo::escape moTemp "$MO_PARSED" + mo::debug "$moTemp" + + while [[ "${MO_PARSED:$moI:1}" == " " || "${MO_PARSED:$moI:1}" == $'\t' ]]; do + moI=$((moI - 1)) + done + + if [[ $((moI + 1)) != "${#MO_PARSED}" ]]; then + MO_PARSED="${MO_PARSED:0:${moI}+1}" + fi + + moI=0 + + while [[ "${MO_UNPARSED:${moI}:1}" == " " || "${MO_UNPARSED:${moI}:1}" == $'\t' ]]; do + moI=$((moI + 1)) + done + + if [[ "${MO_UNPARSED:${moI}:1}" == $'\r' ]]; then + moI=$((moI + 1)) + fi + + if [[ "${MO_UNPARSED:${moI}:1}" == $'\n' ]]; then + moI=$((moI + 1)) + fi + + if [[ "$moI" != 0 ]]; then + MO_UNPARSED=${MO_UNPARSED:${moI}} + fi +} + + +# Internal: Apply indentation before any line that has content in MO_UNPARSED. +# +# $1 - Destination variable name. +# $2 - The indentation string. +# $3 - The content that needs the indentation string prepended on each line. +# +# Returns nothing. +mo::indentLines() { + local moContent moIndentation moResult moN moR moChunk + + moIndentation=$2 + moContent=$3 + + if [[ -z "$moIndentation" ]]; then + mo::debug "Not applying indentation, empty indentation" + + local "$1" && mo::indirect "$1" "$moContent" + return + fi + + if [[ -z "$moContent" ]]; then + mo::debug "Not applying indentation, empty contents" + + local "$1" && mo::indirect "$1" "$moContent" + return + fi + + moResult= + moN=$'\n' + moR=$'\r' + + mo::debug "Applying indentation: '${moIndentation}'" + + while [[ -n "$moContent" ]]; do + moChunk=${moContent%%"$moN"*} + moChunk=${moChunk%%"$moR"*} + moContent=${moContent:${#moChunk}} + + if [[ -n "$moChunk" ]]; then + moResult="$moResult$moIndentation$moChunk" + fi + + moResult="$moResult${moContent:0:1}" + moContent=${moContent:1} + done + + local "$1" && mo::indirect "$1" "$moResult" +} + + +# Internal: Escape a value +# +# $1 - Destination variable name +# $2 - Value to escape +# +# Returns nothing +mo::escape() { + local moResult + + moResult=$2 + moResult=$(declare -p moResult) + moResult=${moResult#*=} + + local "$1" && mo::indirect "$1" "$moResult" +} + + +# Internal: Get the content up to the end of the block by minimally parsing and +# balancing blocks. Returns the content before the end tag to the caller and +# removes the content + the end tag from MO_UNPARSED. This can change the +# delimiters, adjusting MO_OPEN_DELIMITER and MO_CLOSE_DELIMITER. +# +# $1 - Destination variable name +# $2 - Token string to match for a closing tag +# +# Returns nothing. +mo::getContentUntilClose() { + local moChunk moResult moTemp moTokensString moTokens moTarget moTagStack moResultTemp + + moTarget=$1 + moTagStack=("$2") + mo::debug "Get content until close tag: ${moTagStack[0]}" + moResult="" + + while [[ -n "$MO_UNPARSED" ]] && [[ "${#moTagStack[@]}" -gt 0 ]]; do + moChunk=${MO_UNPARSED%%"$MO_OPEN_DELIMITER"*} + moResult="$moResult$moChunk" + MO_UNPARSED=${MO_UNPARSED:${#moChunk}} + + if [[ -n "$MO_UNPARSED" ]]; then + moResultTemp="$MO_OPEN_DELIMITER" + MO_UNPARSED=${MO_UNPARSED:${#MO_OPEN_DELIMITER}} + mo::getContentTrim moTemp + moResultTemp="$moResultTemp$moTemp" + mo::debug "First character within tag: ${MO_UNPARSED:0:1}" + + case "$MO_UNPARSED" in + '#'*) + #: Increase block + moResultTemp="$moResultTemp${MO_UNPARSED:0:1}" + MO_UNPARSED=${MO_UNPARSED:1} + mo::getContentTrim moTemp + mo::getContentWithinTag moTemp "$MO_CLOSE_DELIMITER" + moResultTemp="$moResultTemp${moTemp[0]}" + moTagStack=("${moTemp[1]}" "${moTagStack[@]}") + ;; + + '^'*) + #: Increase block + moResultTemp="$moResultTemp${MO_UNPARSED:0:1}" + MO_UNPARSED=${MO_UNPARSED:1} + mo::getContentTrim moTemp + mo::getContentWithinTag moTemp "$MO_CLOSE_DELIMITER" + moResultTemp="$moResultTemp${moTemp[0]}" + moTagStack=("${moTemp[1]}" "${moTagStack[@]}") + ;; + + '>'*) + #: Partial - ignore + moResultTemp="$moResultTemp${MO_UNPARSED:0:1}" + MO_UNPARSED=${MO_UNPARSED:1} + mo::getContentTrim moTemp + mo::getContentWithinTag moTemp "$MO_CLOSE_DELIMITER" + moResultTemp="$moResultTemp${moTemp[0]}" + ;; + + '/'*) + #: Decrease block + moResultTemp="$moResultTemp${MO_UNPARSED:0:1}" + MO_UNPARSED=${MO_UNPARSED:1} + mo::getContentTrim moTemp + mo::getContentWithinTag moTemp "$MO_CLOSE_DELIMITER" + + if [[ "${moTagStack[0]}" == "${moTemp[1]}" ]]; then + moResultTemp="$moResultTemp${moTemp[0]}" + moTagStack=("${moTagStack[@]:1}") + + if [[ "${#moTagStack[@]}" -eq 0 ]]; then + #: Erase all portions of the close tag + moResultTemp="" + fi + else + mo::errorNear "Unbalanced closing tag, expected: ${moTagStack[0]}" "${moTemp[0]}${MO_UNPARSED}" + fi + ;; + + '!'*) + #: Comment - ignore + mo::getContentComment moTemp + moResultTemp="$moResultTemp$moTemp" + ;; + + '='*) + #: Change delimiters + mo::getContentDelimiter moTemp + moResultTemp="$moResultTemp$moTemp" + ;; + + '&'*) + #: Unescaped - bypass one then ignore + moResultTemp="$moResultTemp${MO_UNPARSED:0:1}" + MO_UNPARSED=${MO_UNPARSED:1} + mo::getContentTrim moTemp + moResultTemp="$moResultTemp$moTemp" + mo::getContentWithinTag moTemp "$MO_CLOSE_DELIMITER" + moResultTemp="$moResultTemp${moTemp[0]}" + ;; + + *) + #: Normal variable - ignore + mo::getContentWithinTag moTemp "$MO_CLOSE_DELIMITER" + moResultTemp="$moResultTemp${moTemp[0]}" + ;; + esac + + moResult="$moResult$moResultTemp" + fi + done + + MO_STANDALONE_CONTENT="$MO_STANDALONE_CONTENT$moResult" + + if mo::standaloneCheck; then + moResultTemp=$MO_PARSED + MO_PARSED=$moResult + mo::standaloneProcess + moResult=$MO_PARSED + MO_PARSED=$moResultTemp + fi + + local "$moTarget" && mo::indirect "$moTarget" "$moResult" +} + + +# Internal: Convert a list of tokens to a string +# +# $1 - Destination variable for the string +# $2-$@ - Token list +# +# Returns nothing. +mo::tokensToString() { + local moTarget moString moTokens + + moTarget=$1 + shift 1 + moTokens=("$@") + moString=$(declare -p moTokens) + moString=${moString#*=} + + local "$moTarget" && mo::indirect "$moTarget" "$moString" +} + + +# Internal: Trims content from MO_UNPARSED, returns trimmed content. +# +# $1 - Destination variable +# +# Returns nothing. +mo::getContentTrim() { + local moChar moResult + + moChar=${MO_UNPARSED:0:1} + moResult="" + + while [[ "$moChar" == " " ]] || [[ "$moChar" == $'\r' ]] || [[ "$moChar" == $'\t' ]] || [[ "$moChar" == $'\n' ]]; do + moResult="$moResult$moChar" + MO_UNPARSED=${MO_UNPARSED:1} + moChar=${MO_UNPARSED:0:1} + done + + local "$1" && mo::indirect "$1" "$moResult" +} + + +# Get the content up to and including a close tag +# +# $1 - Destination variable +# +# Returns nothing. +mo::getContentComment() { + local moResult + + mo::debug "Getting content for comment" + moResult=${MO_UNPARSED%%"$MO_CLOSE_DELIMITER"*} + MO_UNPARSED=${MO_UNPARSED:${#moResult}} + + if [[ "$MO_UNPARSED" == "$MO_CLOSE_DELIMITER"* ]]; then + moResult="$moResult$MO_CLOSE_DELIMITER" + MO_UNPARSED=${MO_UNPARSED#"$MO_CLOSE_DELIMITER"} + fi + + local "$1" && mo::indirect "$1" "$moResult" +} + + +# Get the content up to and including a close tag. First two non-whitespace +# tokens become the new open and close tag. +# +# $1 - Destination variable +# +# Returns nothing. +mo::getContentDelimiter() { + local moResult moTemp moOpen moClose + + mo::debug "Getting content for delimiter" + moResult="" + mo::getContentTrim moTemp + moResult="$moResult$moTemp" + mo::chomp moOpen "$MO_UNPARSED" + MO_UNPARSED="${MO_UNPARSED:${#moOpen}}" + moResult="$moResult$moOpen" + mo::getContentTrim moTemp + moResult="$moResult$moTemp" + mo::chomp moClose "${MO_UNPARSED%%="$MO_CLOSE_DELIMITER"*}" + MO_UNPARSED="${MO_UNPARSED:${#moClose}}" + moResult="$moResult$moClose" + mo::getContentTrim moTemp + moResult="$moResult$moTemp" + MO_OPEN_DELIMITER="$moOpen" + MO_CLOSE_DELIMITER="$moClose" + + local "$1" && mo::indirect "$1" "$moResult" +} + + +# Get the content up to and including a close tag. First two non-whitespace +# tokens become the new open and close tag. +# +# $1 - Destination variable, an array +# $2 - Terminator string +# +# The array contents: +# [0] The raw content within the tag +# [1] The parsed tokens as a single string +# +# Returns nothing. +mo::getContentWithinTag() { + local moUnparsed moTokens + + moUnparsed=${MO_UNPARSED} + mo::tokenizeTagContents moTokens "$MO_CLOSE_DELIMITER" + MO_UNPARSED=${MO_UNPARSED#"$MO_CLOSE_DELIMITER"} + mo::tokensToString moTokensString "${moTokens[@]:1}" + moParsed=${moUnparsed:0:$((${#moUnparsed} - ${#MO_UNPARSED}))} + + local "$1" && mo::indirectArray "$1" "$moParsed" "$moTokensString" +} + + +# Internal: Parse MO_UNPARSED and retrieve the content within the tag +# delimiters. Converts everything into an array of string values. +# +# $1 - Destination variable for the array of contents. +# $2 - Stop processing when this content is found. +# +# The list of tokens are in RPN form. The first item in the resulting array is +# the number of actual tokens (after combining command tokens) in the list. +# +# Given: a 'bc' "de\"\n" (f {g 'h'}) +# Result: ([0]=4 [1]=NAME [2]=a [3]=VALUE [4]=bc [5]=VALUE [6]=$'de\"\n' +# [7]=NAME [8]=f [9]=NAME [10]=g [11]=VALUE [12]=h +# [13]=BRACE [14]=2 [15]=PAREN [16]=2 +# +# Returns nothing +mo::tokenizeTagContents() { + local moResult moTerminator moTemp moUnparsedOriginal moTokenCount + + moTerminator=$2 + moResult=() + moUnparsedOriginal=$MO_UNPARSED + moTokenCount=0 + mo::debug "Tokenizing tag contents until terminator: $moTerminator" + + while true; do + mo::trimUnparsed + + case "$MO_UNPARSED" in + "") + mo::errorNear "Did not find matching terminator: $moTerminator" "$moUnparsedOriginal" + ;; + + "$moTerminator"*) + mo::debug "Found terminator" + local "$1" && mo::indirectArray "$1" "$moTokenCount" ${moResult[@]+"${moResult[@]}"} + return + ;; + + '('*) + #: Do not tokenize the open paren - treat this as RPL + MO_UNPARSED=${MO_UNPARSED:1} + mo::tokenizeTagContents moTemp ')' + moResult=(${moResult[@]+"${moResult[@]}"} "${moTemp[@]:1}" PAREN "${moTemp[0]}") + MO_UNPARSED=${MO_UNPARSED:1} + ;; + + '{'*) + #: Do not tokenize the open brace - treat this as RPL + MO_UNPARSED=${MO_UNPARSED:1} + mo::tokenizeTagContents moTemp '}' + moResult=(${moResult[@]+"${moResult[@]}"} "${moTemp[@]:1}" BRACE "${moTemp[0]}") + MO_UNPARSED=${MO_UNPARSED:1} + ;; + + ')'* | '}'*) + mo::errorNear "Unbalanced closing parenthesis or brace" "$MO_UNPARSED" + ;; + + "'"*) + mo::tokenizeTagContentsSingleQuote moTemp + moResult=(${moResult[@]+"${moResult[@]}"} "${moTemp[@]}") + ;; + + '"'*) + mo::tokenizeTagContentsDoubleQuote moTemp + moResult=(${moResult[@]+"${moResult[@]}"} "${moTemp[@]}") + ;; + + *) + mo::tokenizeTagContentsName moTemp + moResult=(${moResult[@]+"${moResult[@]}"} "${moTemp[@]}") + ;; + esac + + mo::debug "Got chunk: ${moTemp[0]} ${moTemp[1]}" + moTokenCount=$((moTokenCount + 1)) + done +} + + +# Internal: Get the contents of a variable name. +# +# $1 - Destination variable name for the token list (array of strings) +# +# Returns nothing +mo::tokenizeTagContentsName() { + local moTemp + + mo::chomp moTemp "${MO_UNPARSED%%"$MO_CLOSE_DELIMITER"*}" + moTemp=${moTemp%%(*} + moTemp=${moTemp%%)*} + moTemp=${moTemp%%\{*} + moTemp=${moTemp%%\}*} + MO_UNPARSED=${MO_UNPARSED:${#moTemp}} + mo::trimUnparsed + mo::debug "Parsed default token: $moTemp" + + local "$1" && mo::indirectArray "$1" "NAME" "$moTemp" +} + + +# Internal: Get the contents of a tag in double quotes. Parses the backslash +# sequences. +# +# $1 - Destination variable name for the token list (array of strings) +# +# Returns nothing. +mo::tokenizeTagContentsDoubleQuote() { + local moResult moUnparsedOriginal + + moUnparsedOriginal=$MO_UNPARSED + MO_UNPARSED=${MO_UNPARSED:1} + moResult= + mo::debug "Getting double quoted tag contents" + + while true; do + if [[ -z "$MO_UNPARSED" ]]; then + mo::errorNear "Unbalanced double quote" "$moUnparsedOriginal" + fi + + case "$MO_UNPARSED" in + '"'*) + MO_UNPARSED=${MO_UNPARSED:1} + local "$1" && mo::indirectArray "$1" "VALUE" "$moResult" + return + ;; + + \\b*) + moResult="$moResult"$'\b' + MO_UNPARSED=${MO_UNPARSED:2} + ;; + + \\e*) + #: Note, \e is ESC, but in Bash $'\E' is ESC. + moResult="$moResult"$'\E' + MO_UNPARSED=${MO_UNPARSED:2} + ;; + + \\f*) + moResult="$moResult"$'\f' + MO_UNPARSED=${MO_UNPARSED:2} + ;; + + \\n*) + moResult="$moResult"$'\n' + MO_UNPARSED=${MO_UNPARSED:2} + ;; + + \\r*) + moResult="$moResult"$'\r' + MO_UNPARSED=${MO_UNPARSED:2} + ;; + + \\t*) + moResult="$moResult"$'\t' + MO_UNPARSED=${MO_UNPARSED:2} + ;; + + \\v*) + moResult="$moResult"$'\v' + MO_UNPARSED=${MO_UNPARSED:2} + ;; + + \\*) + moResult="$moResult${MO_UNPARSED:1:1}" + MO_UNPARSED=${MO_UNPARSED:2} + ;; + + *) + moResult="$moResult${MO_UNPARSED:0:1}" + MO_UNPARSED=${MO_UNPARSED:1} + ;; + esac + done +} + + +# Internal: Get the contents of a tag in single quotes. Only gets the raw +# value. +# +# $1 - Destination variable name for the token list (array of strings) +# +# Returns nothing. +mo::tokenizeTagContentsSingleQuote() { + local moResult moUnparsedOriginal + + moUnparsedOriginal=$MO_UNPARSED + MO_UNPARSED=${MO_UNPARSED:1} + moResult= + mo::debug "Getting single quoted tag contents" + + while true; do + if [[ -z "$MO_UNPARSED" ]]; then + mo::errorNear "Unbalanced single quote" "$moUnparsedOriginal" + fi + + case "$MO_UNPARSED" in + "'"*) + MO_UNPARSED=${MO_UNPARSED:1} + local "$1" && mo::indirectArray "$1" VALUE "$moResult" + return + ;; + + *) + moResult="$moResult${MO_UNPARSED:0:1}" + MO_UNPARSED=${MO_UNPARSED:1} + ;; + esac + done +} + + +# Save the original command's path for usage later +MO_ORIGINAL_COMMAND="$(cd "${BASH_SOURCE[0]%/*}" || exit 1; pwd)/${BASH_SOURCE[0]##*/}" +MO_VERSION="3.0.7" + +# If sourced, load all functions. +# If executed, perform the actions as expected. +if [[ "$0" == "${BASH_SOURCE[0]}" ]] || [[ -z "${BASH_SOURCE[0]}" ]]; then + mo "$@" +fi diff --git a/build/resume-docx-reference.docx b/build/resume-docx-reference.docx new file mode 100644 index 0000000000000000000000000000000000000000..f631233c87107679084ef87ae526a99832f938e3 GIT binary patch literal 16716 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 literal 0 HcmV?d00001