Skip to content

Generate the help content map file on the fly #61279

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
May 10, 2025
1 change: 1 addition & 0 deletions help/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ _site
.jekyll-cache
.jekyll-metadata
vendor
_src/helpContentMap.tsx
271 changes: 238 additions & 33 deletions help/_plugins/SitePostRender.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,50 +21,255 @@ def self.process_page(page)
# Parse the page's content for header elements
doc = Nokogiri::HTML(page.output)

# Create an array to store the prefix for each level of header (h2, h3, h4, etc.)
prefix = {}

# Process all <h2>, <h3>, and <h4> elements in order
doc.css('h2, h3, h4, h5').each do |header|
# Check if the header starts with a short title in square brackets
header_text = header.text.strip
if header_text.match(/^\[(.*?)\]/)
# Extract the short title from the square brackets
short_title = header_text.match(/^\[(.*?)\]/)[1]

# Set the `data-toc-title` attribute on the header
header['data-toc-title'] = short_title

# Remove the short title from the visible header text
header_text = header_text.sub(/^\[.*?\]\s*/, '')
header.content = header_text
# Check if the page is a reference page
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's the effort for adding a few tests for the same?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add the tests as a follow up?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure.

if page.path.start_with?("ref/")
@help_mapping ||= {}
@help_mapping[page.path.chomp('index.md')] = doc.at('.product-content')
else
# Create an array to store the prefix for each level of header (h2, h3, h4, etc.)
prefix = {}

# Process all <h2>, <h3>, and <h4> elements in order
doc.css('h2, h3, h4, h5').each do |header|
# Check if the header starts with a short title in square brackets
header_text = header.text.strip
if header_text.match(/^\[(.*?)\]/)
# Extract the short title from the square brackets
short_title = header_text.match(/^\[(.*?)\]/)[1]

# Set the `data-toc-title` attribute on the header
header['data-toc-title'] = short_title

# Remove the short title from the visible header text
header_text = header_text.sub(/^\[.*?\]\s*/, '')
header.content = header_text
end

# Determine the level of the header (h2, h3, h4, or h5)
level = header.name[1].to_i # 'h2' -> 2, 'h3' -> 3, etc.

# Generate the ID for the current header based on its (cleaned) text
clean_text = header_text.downcase.strip
header_id = CGI.escape(clean_text.gsub(/\s+/, '-').gsub(/[^\w\-]/, ''))

# Store the current header's ID in the prefix array
prefix[level] = header_id

# Construct the full hierarchical ID by concatenating IDs for all levels up to the current level
full_id = (2..level).map { |l| prefix[l] }.join('--')

# Assign the generated ID to the header element
header['id'] = full_id

puts " Found h#{level}: '#{header_text}' -> ID: '#{full_id}'"
end

# Determine the level of the header (h2, h3, h4, or h5)
level = header.name[1].to_i # 'h2' -> 2, 'h3' -> 3, etc.
# Log the final output being written
puts " Writing updated HTML for page: #{page.path}"

# Write the updated HTML back to the page
page.output = doc.to_html
end
end


# Generate helpContent.tsx once all pages have been processed
Jekyll::Hooks.register :site, :post_render do |site|
generate_help_content(site)
end

def self.generate_help_content(site)
puts " Generating helpContent.tsx from rendered HTML pages..."

output_dir = File.join(site.source, "_src")
FileUtils.mkdir_p(output_dir) unless Dir.exist?(output_dir)

output_file = File.join(output_dir, "helpContentMap.tsx")

help_content_tree = generate_help_content_tree()

# Generate the ID for the current header based on its (cleaned) text
clean_text = header_text.downcase.strip
header_id = CGI.escape(clean_text.gsub(/\s+/, '-').gsub(/[^\w\-]/, ''))
help_content_string = to_ts_object(help_content_tree)

# Store the current header's ID in the prefix array
prefix[level] = header_id
imports = [
"import type {ReactNode} from 'react';",
"import React from 'react';",
"import {View} from 'react-native';",
]

# Construct the full hierarchical ID by concatenating IDs for all levels up to the current level
full_id = (2..level).map { |l| prefix[l] }.join('--')
# Add conditional imports based on component usage
imports << "import BulletList from '@components/SidePanel/HelpComponents/HelpBulletList';" if help_content_string.include?("<BulletList")
imports << "import Text from '@components/Text';" if help_content_string.include?("<Text")
imports << "import TextLink from '@components/TextLink';" if help_content_string.include?("<TextLink")
imports << "import type {ThemeStyles} from '@styles/index';"

# Assign the generated ID to the header element
header['id'] = full_id
# Join the imports
import_block = imports.join("\n")

puts " Found h#{level}: '#{header_text}' -> ID: '#{full_id}'"
ts_output = <<~TS
/* eslint-disable react/no-unescaped-entities */
/* eslint-disable @typescript-eslint/naming-convention */
#{import_block}

type ContentComponent = (props: {styles: ThemeStyles}) => ReactNode;

type HelpContent = {
/** The content to display for this route */
content: ContentComponent;

/** Any children routes that this route has */
children?: Record<string, HelpContent>;

/** Whether this route is an exact match or displays parent content */
isExact?: boolean;
};

const helpContentMap: HelpContent = #{help_content_string}

export default helpContentMap;
export type {ContentComponent};
TS

File.write(output_file, ts_output)

puts "✅ Successfully generated helpContent.tsx"
end

def self.generate_help_content_tree()
tree = {}

@help_mapping.each do |route, node|
parts = route.sub(/^ref\//, '').sub(/\.md$/, '').split('/')
current = tree

parts.each_with_index do |part, i|
is_dynamic = part.start_with?(':') || part.match?(/^\[.*\]$/)
part_key = is_dynamic ? part : part.to_sym

current[:children] ||= {}
current[:children][part_key] ||= {}

if i == parts.length - 1
jsx_content = html_node_to_RN(node, 1).rstrip

current[:children][part_key][:content] = <<~TS.chomp
({styles}: {styles: ThemeStyles}) => (
#{jsx_content}
)
TS
end

current = current[:children][part_key]
end
end

tree[:content] = <<~JSX
() => null
JSX
tree
end

def self.html_node_to_RN(node, indent_level = 0)
indent = ' ' * indent_level
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should if add a check return "" if node.nil? ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm... Why would it be nil?


case node.name
when 'div'
children = node.children.map do |child|
next if child.text? && child.text.strip.empty?
html_node_to_RN(child, indent_level + 1)
end.compact.join("\n")

"#{indent}<View>\n#{children}\n#{indent}</View>"

when 'h1' then "#{indent}<Text style={[styles.textHeadlineH1, styles.mv4]}>#{node.text.strip}</Text>"
when 'h2' then "#{indent}<Text style={[styles.textHeadlineH2, styles.mv4]}>#{node.text.strip}</Text>"
when 'h3' then "#{indent}<Text style={[styles.textHeadlineH3, styles.mv4]}>#{node.text.strip}</Text>"
when 'h4' then "#{indent}<Text style={[styles.textHeadlineH4, styles.mv4]}>#{node.text.strip}</Text>"
when 'h5' then "#{indent}<Text style={[styles.textHeadlineH5, styles.mv4]}>#{node.text.strip}</Text>"
when 'h6' then "#{indent}<Text style={[styles.textHeadlineH6, styles.mv4]}>#{node.text.strip}</Text>"

when 'p'
inner = node.children.map { |c| html_node_to_RN(c, indent_level + 1) }.join
prev = node.previous_element
next_el = node.next_element

style_classes = ['styles.textNormal']
style_classes << 'styles.mt4' if prev&.name == 'ul'
style_classes << 'styles.mb4' if next_el&.name == 'p'

"#{indent}<Text style={[#{style_classes.join(', ')}]}>#{inner.strip}</Text>"

when 'ul'
items = node.xpath('./li').map do |li|
contains_ul = li.xpath('.//ul').any?

# Log the final output being written
puts " Writing updated HTML for page: #{page.path}"
li_parts = li.children.map { |child| html_node_to_RN(child, 0) }

if contains_ul

# Write the updated HTML back to the page
page.output = doc.to_html
indented_li_parts = li_parts.map do |part|
part.lines.map { |line| "#{' ' * (indent_level + 3)}#{line.rstrip}" }.join("\n")
end.join("\n")

"#{' ' * (indent_level + 2)}<>\n#{indented_li_parts}\n#{' ' * (indent_level + 2)}</>"
else
"#{' ' * (indent_level + 2)}<Text style={styles.textNormal}>#{li_parts.join}</Text>"
end
end

<<~TS.chomp
#{indent}<BulletList
#{indent} styles={styles}
#{indent} items={[
#{items.join(",\n")}
#{indent} ]}
#{indent}/>
TS

when 'li'
'' # handled in <ul>

when 'strong', 'b'
"<Text style={styles.textBold}>#{node.text}</Text>"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think we need to be worried about any kind of nesting here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No. Do you have any concerns?

when 'em', 'i'
"<Text style={styles.textItalic}>#{node.text}</Text>"
when 'a'
href = node['href']
link_text = node.children.map { |child| html_node_to_RN(child, 0) }.join
"<TextLink href=\"#{href}\" style={styles.link}>#{link_text.strip}</TextLink>"

when 'text'
node.text
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return node.text.strip.empty? ? "" : node.text ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why?


else
node.children.map { |child| html_node_to_RN(child, indent_level) }.join
end
end

def self.to_ts_object(obj, indent = 0)
spacing = ' ' * indent
lines = ["{"]

obj.each do |key, value|
key_str = key.is_a?(Symbol) ? key.to_s : key.inspect
key_line_prefix = ' ' * (indent + 1) + "#{key_str}: "

if value.is_a?(Hash)
nested = to_ts_object(value, indent + 1)
lines << key_line_prefix + nested + ","
elsif value.is_a?(String) && value.include?("\n")
value_lines = value.split("\n")
first_line = value_lines.shift
rest_lines = value_lines.map { |l| ' ' * (indent + 1) + l }
lines << ([key_line_prefix + first_line] + rest_lines).join("\n") + ","
else
lines << key_line_prefix + value.inspect + ","
end
end

lines << ' ' * indent + "}"
lines.join("\n")
end

end
end

58 changes: 58 additions & 0 deletions help/ref/home/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
---
layout: product
title: Expensify Chat
---

# Chat

Chat is the foundation of New Expensify. Every expense, expense report, workspace, or member has an associated "chat", which you can use to record additional details, or collaborate with others. Every chat has the following components:

## Header

This shows who you are chatting with (or what you are chatting about). You can press the header for more details on the chat, or additional actions to take upon it.

## Comments

The core of the chat are its comments, which come in many forms:

- **Text** - Rich text messages stored securely and delivered via web, app, email, or SMS.
- **Images & Documents** - Insert photos, screenshots, movies, PDFs, or more, using copy/paste, drag/drop, or the attach button.
- **Expenses** - Share an expense in the chat, either to simply track and document it, or to submit for reimbursement.
- **Tasks** - Record a task, and optionally assign it to someone (or yourself!).

## Actions

Hover (or long press) on a comment to see additional options, including:

- **React** - Throw a ♥️😂🔥 like on anything!
- **Reply in thread** - Go deeper by creating a new chat on any comment.
- **Mark unread** - Flag it for reading later, at your convenience.

## Composer

Use the composer at the bottom to write new messages:

- **Markdown** - Format text using **bold**, *italics*, and [more](https://help.expensify.com/articles/new-expensify/chat/Send-and-format-chat-messages).
- **Mention** - Invite or tag anyone in the world to any chat by putting an @ in front of their email address or phone number (e.g., **@[email protected]**, or **@415-867-5309**).

---

# Inbox

The Inbox is a prioritized "to do" list, highlighting exactly what you need to do next. It consists of:

## Priorities

At the top of the Inbox are the most important tasks you should do first, which include:

- Expense reports waiting on you
- Tasks assigned to you
- Chats that have mentioned you
- Anything you have pinned

## Chats

Beneath the priorities are a list of chats (with unread chats highlighted in bold), in one of two view modes:

- **Most Recent** - Lists every chat, ordered by whichever was most recently active.
- **Focus** - Only lists chats with unread messages, sorted alphabetically.
9 changes: 9 additions & 0 deletions help/ref/r/:concierge/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
layout: product
title: Expensify Chat
---

# Concierge

Concierge is available 24/7 to answer any question you have about anything — whether that's how to get set up, how to fix a problem, or general best practices.
Concierge is a bot, but it's really smart and can escalate you to a human whenever you want. Say hi — it's friendly!
10 changes: 10 additions & 0 deletions help/ref/r/:expense/:expensifyCard/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
layout: product
title: Expensify Chat
---

# Expensify Card

An "Expensify Card" expense corresponds to a "posted" (meaning, finalized by the bank) purchase.

Expensify Card expenses cannot be reimbursed as they are centrally paid by the bank account linked to the workspace.
9 changes: 9 additions & 0 deletions help/ref/r/:expense/:manual/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
layout: product
title: Expensify Chat
---


# Manual

A "manual" expense has had all its details specified by the workspace member. It was not imported from any system, or scanned from a receipt.
12 changes: 12 additions & 0 deletions help/ref/r/:expense/:pendingExpensifyCard/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
layout: product
title: Expensify Chat
---

# Expensify Card (pending)

A "pending" Expensify Card expense represents a purchase that was recently made on the card, but has not yet "posted" – meaning, it has not been formally recognized as a final, complete transaction.

Any changes made to this expense will be preserved when the expense posts, typically 2-7 days later.

Pending transactions cannot be approved, as the final expense amount will not be confirmed until it posts.
Loading
Loading