-
Notifications
You must be signed in to change notification settings - Fork 3.2k
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
Changes from 7 commits
35a8257
a2b9031
210b99f
725e9ae
980efb4
5b829b5
a5607a4
56067c1
9f39bb2
186a89f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,3 +3,4 @@ _site | |
.jekyll-cache | ||
.jekyll-metadata | ||
vendor | ||
_src/helpContentMap.tsx |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
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..." | ||
allroundexperts marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
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") | ||
allroundexperts marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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 | ||
allroundexperts marked this conversation as resolved.
Show resolved
Hide resolved
|
||
/* 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should if add a check There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hm... Why would it be |
||
|
||
case node.name | ||
when 'div' | ||
allroundexperts marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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>" | ||
allroundexperts marked this conversation as resolved.
Show resolved
Hide resolved
|
||
when 'h2' then "#{indent}<Text style={[styles.textHeadlineH2, styles.mv4]}>#{node.text.strip}</Text>" | ||
allroundexperts marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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 | ||
allroundexperts marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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| | ||
allroundexperts marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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>" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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' | ||
allroundexperts marked this conversation as resolved.
Show resolved
Hide resolved
|
||
node.text | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. return node.text.strip.empty? ? "" : node.text ? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
allroundexperts marked this conversation as resolved.
Show resolved
Hide resolved
|
||
lines = ["{"] | ||
|
||
obj.each do |key, value| | ||
allroundexperts marked this conversation as resolved.
Show resolved
Hide resolved
|
||
key_str = key.is_a?(Symbol) ? key.to_s : key.inspect | ||
key_line_prefix = ' ' * (indent + 1) + "#{key_str}: " | ||
|
||
if value.is_a?(Hash) | ||
allroundexperts marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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 | ||
|
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. |
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! |
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. |
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. |
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. |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure.