“If you hear a voice within you say ‘you cannot paint’, then by all means paint and that voice will be silenced,” Vincent van Gogh.
We have a plain text file data/prayeronline.txt that stores prayer website recommendations. The file is formatted like this:
https://www.sacredspace.com:Sacred Space:Daily Ignatian prayer by the Irish Jesuits...
https://pray-as-you-go.org:Pray As You Go:Daily audio prayer from Jesuit Media...
Each line has three pieces of information separated by colons:
But notice the problem: the URL itself contains a colon (https://). If we do a simple line.split(":"), the URL gets cut up:
"https://www.sacredspace.com:Sacred Space:Daily Ignatian prayer...".split(":")
// Result: ["https", "//www.sacredspace.com", "Sacred Space", "Daily Ignatian prayer..."]
// ↑ part of URL ↑ part of URL ↑ name ↑ description
We need a way to split on only the correct colons – the ones that actually separate the three fields.
Our goal is to write a pure parsing function that takes a line and returns a clean object { url, name, description }.
Before we dive into parsing, let’s understand a language feature we’ll use later.
Destructuring lets you “unpack” values from arrays or objects into distinct variables, making code much cleaner.
const parts = ["https://example.com", "Title", "Description"];
const [url, name, description] = parts;
// url = "https://example.com", name = "Title", description = "Description"
If you only need some items, skip with commas:
const [, secondItem] = [1, 2, 3]; // secondItem = 2
This is often seen with regular expression matches:
const match = "abc123".match(/([a-z]+)(\d+)/);
// match[0] is the whole match, match[1] is first group, match[2] second group.
const [, letters, numbers] = match; // ignore match[0]
const site = { url: "https://...", name: "Sacred Space", description: "..." };
const { url, name } = site; // creates variables url and name
We’ll use both styles when we write tests and when we build our result array.
❌ Approach A: split on spaces
If the format used a space after the URL, we could just split on the first space. But our file uses colons, so this doesn’t apply.
❌ Approach B: regular expression
const match = line.match(/^(https?:\/\/[^:]+):([^:]+):(.+)$/);
if (match) {
const [, url, name, description] = match;
}
This works, but the regex is hard to read and modify later. For a beginner, explicit string methods are often clearer.
✅ Approach C: indexOf + slice (our winner)
We manually find the position of the colons that separate the three fields. Here’s the key insight:
// URL : Name : Description
// But the URL itself contains a colon because of the scheme:
https://www.sacredspace.com:Sacred Space:Daily prayer...
↑ ↑
colon after "://" colon separating URL from name
The first colon is part of ://. The second colon (the one after the :// and after the domain) is our first real separator. So we:
:// ends.This is perfectly readable and gives us full control.
// ── Approach C: indexOf for the 3rd colon ──────────────────────────────────
// Find the position of the third colon manually, then slice.
// This is the most readable and reliable for our exact format.
function indexOfNth(str: string, char: string, n: number): number {
let count = 0;
for (let i = 0; i < str.length; i++) {
if (str[i] === char) {
count++;
if (count === n) return i;
}
}
return -1;
}
const thirdColon = indexOfNth(line, ":", 3);
// Find the position of ://
// This tells you where the scheme ends.
const schemeEnd = line.indexOf("://"); // → 5 for "https://"
// Skip past the :// and find the next colon
// This colon is the URL → Name separator.
// Why +3? Because :// is exactly three characters long.
const urlEnd = line.indexOf(":", schemeEnd + 3);
// "https://www.sacredspace.com:Sacred Space:Daily prayer"
// ↑schemeEnd=5 ↑urlEnd=28
// Slice out the URL. Everything before that colon is the URL.
const url = line.slice(0, urlEnd); // "https://www.sacredspace.com"
// The rest is name:description
const rest = line.slice(urlEnd + 1); // "Sacred Space:Daily prayer..."
// Split the remainder on its first colon
// Because the remainder has exactly one colon separating name and description.
const colonInRest = rest.indexOf(":");
const name = rest.slice(0, colonInRest); // "Sacred Space"
const desc = rest.slice(colonInRest + 1); // "Daily prayer..."
src/services/onlineService.tsThis file is responsible for reading a list of online prayer websites from a simple text file and turning each line into a structured object that the rest of the app can use. The file acts like a “recipe” – it knows the format of the raw data and knows how to break each line into three pieces: the web address (URL), the name of the site, and a short description.
// src/services/onlineService.ts
//
// ONLINE PRAYER SERVICE
// ──────────────────────
// Loads and parses the prayer site list from data/prayeronline.txt.
//
// FILE FORMAT:
// # comment lines start with #
// url:name:description
//
// Example:
// https://www.sacredspace.com:Sacred Space:Daily Ignatian prayer...
// https://pray-as-you-go.org:Pray As You Go:Daily audio prayer...
//
// PARSING CHALLENGE:
// URLs contain "://" so a naive split(":") produces too many parts.
// We find the colon AFTER the "://" scheme to locate the first separator.
//
// USAGE:
// import { loadPrayerSites } from "../services/onlineService";
// const sites = await loadPrayerSites();
// // sites[0] = { url: "https://...", name: "Sacred Space", description: "..." }
// readLines – a helper function that reads a file and returns its contents as an array of strings, one per line.
// It lives in a utilities folder so other parts of the app can use it too.
import { readLines } from "../utils/assetReader";
// PrayerSite – a TypeScript type that describes exactly what shape a prayer site object must have.
import type { PrayerSite } from "../db/types";
// ══════════════════════════════════════════════════════════════════════════════
// PARSER
// ══════════════════════════════════════════════════════════════════════════════
/**
* Find the index of the Nth occurrence of a character `char` inside a string `str`.
* Returns -1 if `char` does not appear N times.
*
* @example
* For example, in the string "a:b:c:d", the first colon is at position 1, the second at 3, and so on.
* indexOfNth("a:b:c:d", ":", 2) // → 3 (second colon)
* indexOfNth("a:b:c:d", ":", 4) // → -1 (only 3 colons)
*/
function indexOfNth(str: string, char: string, n: number): number {
let found = 0;
// The function walks through the string one character at a time using a loop.
for (let i = 0; i < str.length; i++) {
// Every time it encounters the character we’re looking for, it increments a counter.
if (str[i] === char) {
found++;
// When that counter equals the requested n, it returns the index (position) immediately.
if (found === n) return i;
}
}
// If the loop finishes and we never found enough occurrences, it returns -1 to say “not found”.
return -1;
}
/**
* It takes one line from the text file (prayeronline.txt) and tries to pull out a valid PrayerSite object.
*
* Line format: url:name:description
* The URL contains "://" so we skip past the scheme before searching
* for the separator colon.
*
* @param line A non-empty, non-comment line from the file
* @returns PrayerSite object, or null if the line cannot be parsed
*
* @example
* parseSiteLine("https://www.sacredspace.com:Sacred Space:Daily Ignatian prayer")
* // → { url: "https://www.sacredspace.com",
* // name: "Sacred Space",
* // description: "Daily Ignatian prayer" }
*
* parseSiteLine("https://example.com:My Site")
* // → { url: "https://example.com", name: "My Site", description: "" }
*/
export function parseSiteLine(line: string): PrayerSite | null {
// 1. Trim and skip comments or empty lines.
// If the line is a comment, empty, or malformed, it returns null (meaning “nothing useful here”).
// line.trim() removes any spaces, tabs, or newline characters from the start and end of the line.
const trimmed = line.trim();
// If after trimming the line is empty (!trimmed is true) or if it starts with a # (comments), we immediately bail out with null.
if (!trimmed || trimmed.startsWith("#")) return null;
// 2. Verify the line starts with http:// or https://
// A valid entry must be a website address, so it has to begin with http:// or https://.
// If not, we log a warning (for developers) and return null.
// The warning only shows the first 40 characters of the line to avoid flooding the console with a huge URL.
if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) {
console.warn(`[OnlineService] Skipping non-URL line: "${trimmed.slice(0, 40)}"`);
return null;
}
// 3. Find the end of the "://" scheme — always at position 5 (http) or 6 (https).
// indexOf("://") finds the position of the colon that is part of the protocol (e.g., https).
// This is always the first colon in the string.
const schemeEnd = trimmed.indexOf("://");
// If the line doesn’t even contain ://, it’s not a valid URL —so we give up.
if (schemeEnd < 0) return null;
// 4. Find the separator colon after the URL — this separates url from name, e.g., https://example.com:My Site:Description
// We want the first colon that appears after the protocol part (after ://) — add 3 to skip past "://".
// That colon is what separates the URL from the name. schemeEnd is the index of the : in ://.
// e.g., schemeEnd is at position 5; schemeEnd + 3 is position 8 (right after the second slash).
// The next colon is at position 19, right after example.com. That’s the one we want.
const urlSepPos = trimmed.indexOf(":", schemeEnd + 3);
// 5. Handle missing separator (no name/description).
if (urlSepPos < 0) {
// If there is no colon after the URL, the whole line is just a web address, no name or description.
// In that case, we store the URL as both the url and the name (so the UI has something to show),
// and set the description to an empty string. This is a fallback for minimal entries.
const url = trimmed;
return { url, name: url, description: "" };
}
// 6. Extract the URL.
// Using the separator position, we take everything from the start of the line up to (but not including) that colon.
// That’s the clean URL.
const url = trimmed.slice(0, urlSepPos);
// 7. Extract the rest: name and optional description.
// The remainder is everything after the first separator colon. It might be just a name, or name:description.
const remainder = trimmed.slice(urlSepPos + 1);
// We look for another colon inside it.
// If found, we split there; otherwise the whole remainder becomes the name and description stays empty.
// "Sacred Space:Daily Ignatian prayer..." → ["Sacred Space", "Daily Ignatian..."]
const nameSepPos = remainder.indexOf(":");
let name: string;
let description: string;
if (nameSepPos < 0) {
// No second colon — line has url:name but no description
name = remainder.trim();
description = "";
} else {
name = remainder.slice(0, nameSepPos).trim();
description = remainder.slice(nameSepPos + 1).trim();
}
// 8. Validation checks.
// We do two final sanity checks:
// A. The name must not be empty after trimming. Otherwise, the line is useless.
if (!name) {
console.warn(`[OnlineService] Skipping line with empty name: "${trimmed.slice(0, 60)}"`);
return null;
}
// B. The URL must be at least 10 characters long and must contain a dot (.).
// This catches strings that look like URLs but are obviously broken, like http://a or just http://.
// It’s not a full URL validator, but a reasonable guard.
// If either check fails, we log a warning and return null.
if (url.length < 10 || !url.includes(".")) {
console.warn(`[OnlineService] Skipping line with suspicious URL: "${url}"`);
return null;
}
// 9. Return the parsed object
// If we got this far, we have all three pieces and they look okay,
// so we return a new object that matches the PrayerSite type.
return { url, name, description };
}
// ══════════════════════════════════════════════════════════════════════════════
// LOAD FUNCTION
// ══════════════════════════════════════════════════════════════════════════════
/**
* Read the file data/prayeronline.txt, break it into lines,
* turn each valid line into a PrayerSite object, and return an array of those objects.
* If anything goes wrong (file missing, bad format, etc.),
* it logs an error and returns an empty array instead of crashing.
*
* @returns Array of PrayerSite objects. Empty array on error.
*
* @example
* const sites = await loadPrayerSites();
* console.log(sites[0].name); // "Sacred Space"
* console.log(sites[0].url); // "https://www.sacredspace.com"
* console.log(sites[0].description); // "Daily Ignatian prayer..."
*/
// export means this function can be used in other files (imported).
// async means this function always returns a Promise.
// Inside, we can use await to pause until an asynchronous operation (like reading a file) finishes.
// The return type is Promise<PrayerSite[]>.
// A Promise is an object that eventually resolves to a value (here, an array of PrayerSite objects).
export async function loadPrayerSites(): Promise<PrayerSite[]> {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
// require("../../data/prayeronline.txt") is a Webpack/Node‑style import of a raw text file.
// This approach embeds the file content directly into the app at build time,
// so the file doesn’t need to be fetched separately at runtime.
// readLines takes a big string and splits it into an array of lines, removing any trailing whitespace.
// await waits for readLines to finish.
const lines = await readLines(require("../../data/prayeronline.txt"));
// Transforming and filtering the lines
// .map(parseSiteLine) calls the function parseSiteLine on every line.
// parseSiteLine takes a string line and returns either a PrayerSite object (if the line is a valid prayer site entry)
// or null (if the line is a comment, empty, or malformed).
// .filter((s): s is PrayerSite => s !== null) then removes all the null values.
// The type annotation (s): s is PrayerSite => ... is a type predicate.
// It tells TypeScript: “If this callback returns true, then the item s is definitely a PrayerSite, not null.”
// This refines the type of the resulting array from (PrayerSite | null)[] to PrayerSite[].
// The final variable sites is an array containing only the valid prayer site objects.
const sites: PrayerSite[] = lines
.map(parseSiteLine)
.filter((s): s is PrayerSite => s !== null);
// After processing, it logs how many sites were loaded and returns the array
console.log(`[OnlineService] Loaded ${sites.length} site(s) from prayeronline.txt`);
return sites;
} catch (e) {
console.error("[OnlineService] Failed to load prayeronline.txt:", e);
return [];
}
}
// ══════════════════════════════════════════════════════════════════════════════
// PARSER SELF-TEST
// Run with: node -e "require('./src/services/onlineService')" (compiled)
// Or see the inline tests in the test screen below.
// ══════════════════════════════════════════════════════════════════════════════
/**
* This function does not load a file.
* Instead, it runs a set of pre‑defined test lines through the same parseSiteLine function
* and checks whether the output matches the expected result.
* This is invaluable during development: you can change the parser and immediately see if any edge cases break.
*
* @example
* const summary = runParserTests();
* console.log(summary);
*/
// It returns an object with:
// passed: number of tests that succeeded.
// failed: number of tests that failed.
// results: an array of strings, each describing one test outcome (with ✅ or ❌).
export function runParserTests(): { passed: number; failed: number; results: string[] } {
// This defines the shape of each test case.
// input is the raw line, expected is what parseSiteLine should return (a site object or null),
// and label is a human‑readable description.
type TestCase = {
input: string;
expected: PrayerSite | null;
label: string;
};
// cases is a list of TestCase objects covering various scenarios:
const cases: TestCase[] = [
// Full entry: URL, name, and description separated by colons.
{
label: "full https URL with name and description",
input: "https://www.sacredspace.com:Sacred Space:Daily Ignatian prayer",
expected: { url: "https://www.sacredspace.com", name: "Sacred Space",
description: "Daily Ignatian prayer" },
},
// Missing description: only URL and name, expecting description to be an empty string.
{
label: "URL with name but no description",
input: "https://example.com:My Prayer Site",
expected: { url: "https://example.com", name: "My Prayer Site",
description: "" },
},
// http:// URL: shows that the parser accepts both http and https.
{
label: "http (not https) URL",
input: "http://example.com:Old Site:Works with http too",
expected: { url: "http://example.com", name: "Old Site",
description: "Works with http too" },
},
// URL with a port number (e.g., https://example.com:8080):
// because a colon already appears in the URL,
// the parsing logic must skip over that colon and look for the next colon as the delimiter
{
label: "URL with port number (extra colon in URL)",
input: "https://example.com:8080:My Site:Description here",
expected: { url: "https://example.com:8080", name: "My Site",
description: "Description here" },
},
// Description that contains colons:
// the parser must only split on the first two colons (after the protocol) to leave the rest as the description.
{
label: "description contains colons",
input: "https://example.com:Site Name:Description with: colon in it",
expected: { url: "https://example.com", name: "Site Name",
description: "Description with: colon in it" },
},
// Comment line (# ...) and empty/whitespace line: both should be skipped (return null).
{
label: "comment line",
input: "# this is a comment",
expected: null,
},
{
label: "empty line",
input: " ",
expected: null,
},
// Non‑URL line: e.g., "not-a-url:Name:Desc".
// It does not start with http:// or https://, so it’s rejected (null).
{
label: "non-URL line",
input: "not-a-url:Name:Desc",
expected: null,
},
// URL only: no name or description provided.
// In this case the test expects name to be set to the URL itself and description to be empty.
{
label: "URL only (no name)",
input: "https://example.com",
expected: { url: "https://example.com", name: "https://example.com",
description: "" },
},
];
// Running the tests
let passed = 0;
let failed = 0;
const results: string[] = [];
for (const tc of cases) {
// actual is what the current parser produces for the input line.
const actual = parseSiteLine(tc.input);
// The comparison is done by converting both actual and expected to JSON strings with JSON.stringify.
// This is a simple way to do deep equality:
// it turns objects/arrays into strings and compares them character by character.
// If they are identical, the test passes.
const ok = JSON.stringify(actual) === JSON.stringify(tc.expected);
// Depending on whether ok is true or false,
// the code increments passed or failed and adds a descriptive result string to the results array.
if (ok) {
passed++;
results.push(`✅ ${tc.label}`);
} else {
failed++;
// If a test fails, the result string will show both the expected and the actual values, helping us debug.
results.push(
`❌ ${tc.label}\n` +
` Expected: ${JSON.stringify(tc.expected)}\n` +
` Got: ${JSON.stringify(actual)}`
);
}
}
return { passed, failed, results };
}
app/(tabs)/online.tsx with the test screenThis is the Online Prayer tab screen for our app. It checks whether the site‑loading and parser‑testing code works correctly.
// app/(tabs)/online.tsx
//
// ONLINE PRAYER TAB
//
// Tests loadPrayerSites() and runParserTests():
// - Load and display the list of prayer websites with url, name, description.
// - Run built‑in parser tests and show which ones passed and failed.
// - Shows the Metro log entry for confirmation.
//
// Will be replaced with the full WebView browser UI in future posts.
// Import Statements – What’s Being Brought In
// React: the library that lets us write components.
// useEffect – a React Hook that runs code when the component first appears (or when certain values change).
// useState – a Hook that lets a component remember information (state) between renders.
import React, { useEffect, useState } from "react";
// These are standard React Native building blocks:
// View – like a DIV in HTML; it groups things together.
// Text – for displaying any text.
// ScrollView – a container that can scroll if the content is taller than the screen.
// StyleSheet – a helper to create style objects.
// ActivityIndicator – a spinning wheel to show loading.
// TouchableOpacity – a wrapper that makes its children pressable and dims them when touched.
import {
View, Text, ScrollView, StyleSheet,
ActivityIndicator, TouchableOpacity,
} from "react-native";
import {
// An asynchronous function that reads the sites file and returns an array of PrayerSite objects.
loadPrayerSites,
// A synchronous function that runs a set of tests on the parser logic and returns an object with the results.
runParserTests,
} from "../../src/services/onlineService";
// This imports only the type PrayerSite from the types file.
// TypeScript uses it to check that our data has the right shape
// (e.g., each site must have url, name, and optionally a description).
// It doesn’t generate any JavaScript at runtime.
import type { PrayerSite } from "../../src/db/types";
// The Component – OnlineScreen
// This is the main component.
// export default means this is the default thing that gets imported when someone writes import OnlineScreen from "...".
export default function OnlineScreen() {
// State Variables (the component’s memory).
// sites – an array of PrayerSite objects. Starts as an empty array [].
const [sites, setSites] = useState<PrayerSite[]>([]);
// loading – a boolean. Starts as true because we’ll immediately start loading the site list.
const [loading, setLoading] = useState(true);
// error – either a string describing what went wrong, or null if there was no error.
const [error, setError] = useState<string | null>(null);
// tests – the result of calling runParserTests().
// The type is ReturnType<typeof runParserTests>, which means
// “whatever type the runParserTests function returns.” That return type is an object like:
// { passed: number, failed: number, results: string[] }
// Initially it’s null because we haven’t run the tests yet.
const [tests, setTests] = useState<ReturnType<typeof runParserTests> | null>(null);
// The useEffect – Doing Work When the Screen Appears
useEffect(() => {
// 2. runParserTests() runs immediately and returns the test results object.
// Those results are saved into the "tests" state using setTests.
// Because this is synchronous, we don’t need await or .then().
setTests(runParserTests());
// 3. Load the sites file.
// This function is asynchronous (it returns a Promise). We call .then(setSites).
loadPrayerSites()
// When the Promise resolves (the file is read and parsed), setSites will receive the array of PrayerSite objects
// and update the "sites" state.
.then(setSites)
// If something goes wrong, .catch(e => setError(String(e)))
// will save the error message as a string into the "error" state.
.catch(e => setError(String(e)))
// .finally(() => setLoading(false)) runs whether we succeeded or failed,
// and it sets loading to false so the loading indicator disappears.
.finally(() => setLoading(false));
// 1. Deps. This code runs once, right after the component mounts
// (the empty array [] as the second argument means “run only on mount”).
}, []);
// Because of this order, the parser tests appear instantly,
// and the site list appears shortly after (when the file reading finishes).
// Conditional Loading Screen
// If loading is still true, the component returns only a loading view.
// Because we return early, none of the rest of the component is rendered until loading becomes false.
if (loading) {
return (
// Everything is centered because of s.center.
<View style={s.center}>
// A large golden spinner (#c9a84c is a gold‑yellow colour).
<ActivityIndicator size="large" color="#c9a84c" />
// The text “Loading sites…” in a muted style.
<Text style={s.muted}>Loading sites…</Text>
</View>
);
}
/* Preparing for the Main Display
If tests is not null (the tests have ALREADY run),
allTestsPassed will be true ONLY if tests.failed is exactly 0. Otherwise it’s false.
If tests is null (shouldn’t normally happen because useEffect always sets it, but TypeScript makes us handle it),
allTestsPassed is false. */
const allTestsPassed = tests ? tests.failed === 0 : false;
/* The Main Scrollable Content.
A ScrollView wraps everything so the user can scroll if the list of sites is long. */
return (
/* style={s.scroll} applies to the scroll view itself.
contentContainerStyle={s.content} applies to the inner container that holds all the children. */
<ScrollView style={s.scroll} contentContainerStyle={s.content}>
{/* Status Card – Load Result.
The outer View has a base style s.statusCard,
and then either s.statusError or s.statusOk on top of it, depending on whether error exists. */}
<View style={[s.statusCard, error ? s.statusError : s.statusOk]}>
/* The Text inside also switches colour using the same condition. */
<Text style={error ? s.textError : s.textOk}>
{error
// If there is an error, the text shows a red cross (❌) followed by the error message.
? `❌ ${error}`
// If no error, the text shows a green check (✅) and how many sites were loaded,
// with correct singular/plural (“1 site” vs “2 sites”).
: `✅ ${sites.length} site${sites.length !== 1 ? "s" : ""} loaded`}
</Text>
</View>
{/* Parser Test Results Card.
{tests && ( ... )} – this is a short‑circuit trick.
If tests is null, nothing is rendered. Only when tests has a value do we show the test card. */}
{tests && (
// The card’s style changes based on allTestsPassed: s.testOk if all passed, s.testFail if any failed.
<View style={[s.testCard, allTestsPassed ? s.testOk : s.testFail]}>
// The title line shows the number of passed and failed tests.
<Text style={s.testTitle}>
Parser tests: {tests.passed} passed, {tests.failed} failed
</Text>
// Then we use .map() on the results array (which contains strings, one per test) to render each result line.
// (r, i) – r is the result string, i is the index.
// We use key={i} to help React keep track of the items
// (normally you’d use a unique ID, but index is fine for a static list).
{tests.results.map((r, i) => (
<Text key={i} style={s.testResult}>{r}</Text>
))}
</View>
)}
{/* Site List Title. Just a heading before the list of sites. */}
<Text style={s.sectionTitle}>Loaded from prayeronline.txt</Text>
// Mapping Over the Sites
// We loop over the sites array. For each site, we create a card.
// The key uses site.url because every site should have a unique URL
{sites.map((site, i) => (
<View key={site.url} style={s.siteCard}>
// Each card contains:
{/* Header with index badge and site name.
The index is i + 1 so it starts at 1 instead of 0. */}
<View style={s.siteHeader}>
<Text style={s.siteIndex}>{i + 1}</Text>
<Text style={s.siteName}>{site.name}</Text>
</View>
{/* URL row.
A label “URL” and the full URL. numberOfLines={1} makes the URL cut off with an ellipsis if it’s too long. */}
<View style={s.siteRow}>
<Text style={s.siteRowLabel}>URL</Text>
<Text style={s.siteUrl} numberOfLines={1}>{site.url}</Text>
</View>
{/* Description row (conditionally rendered).
Only shown if site.description exists (truthy). */}
{site.description ? (
<View style={s.siteRow}>
// Label “Desc” and the description, limited to 2 lines.
<Text style={s.siteRowLabel}>Desc</Text>
<Text style={s.siteDesc} numberOfLines={2}>{site.description}</Text>
</View>
) : null}
</View>
))}
{/* Destructuring Note – An Educational Card.
This is not part of the final app – it’s a learning note for you, the developer. */}
<View style={s.hintCard}>
<Text style={s.hintTitle}>TypeScript destructuring used here</Text>
<Text style={s.hintCode}>
{
// Array destructuring – splitting a string into parts.
"// Array destructuring\n" +
"const [id, ...rest] = line.split(/\\s+/);\n\n" +
// Skipping an element – putting a comma before the first variable to ignore the first array element.
"// With index skip (unused first element)\n" +
"const [, url, name, description] = match;\n\n" +
// Object destructuring – pulling out url, name, and description from a PrayerSite object.
"// Object destructuring from PrayerSite\n" +
"const { url, name, description } = site;\n\n" +
// Renaming while destructuring – e.g., site.url becomes siteUrl in the local variable.
"// Rename while destructuring\n" +
"const { url: siteUrl, name: siteName } = site;"
}
</Text>
</View>
/* Footer – Chunk Label.
A simple status reminder that this screen is temporary and will be upgraded in future posts. */
<Text style={s.chunkLabel}>
Full WebView browser in future posts.
</Text>
</ScrollView>
);
}
const s = StyleSheet.create({
scroll: { flex: 1, backgroundColor: "#1a1f2e" },
content: { padding: 20, paddingBottom: 40 },
center: { flex: 1, backgroundColor: "#1a1f2e",
alignItems: "center", justifyContent: "center", gap: 12 },
muted: { color: "#9ca3af", fontSize: 14 },
statusCard: { borderRadius: 10, padding: 14, marginBottom: 16,
borderWidth: 1, alignItems: "center" },
statusOk: { backgroundColor: "#1a2e1a", borderColor: "#2d4a2d" },
statusError: { backgroundColor: "#2e1a1a", borderColor: "#4a2d2d" },
textOk: { color: "#86efac", fontSize: 14, fontWeight: "600" },
textError: { color: "#f87171", fontSize: 13 },
testCard: { borderRadius: 10, padding: 14, marginBottom: 16,
borderWidth: 1 },
testOk: { backgroundColor: "#1a2e1a", borderColor: "#2d4a2d" },
testFail: { backgroundColor: "#2e1a1a", borderColor: "#4a2d2d" },
testTitle: { fontSize: 12, fontWeight: "700", color: "#dde4f0",
marginBottom: 8 },
testResult: { fontSize: 10, color: "#9ca3af", fontFamily: "monospace",
lineHeight: 16 },
sectionTitle: { fontSize: 11, color: "#6b7280", fontWeight: "600",
textTransform: "uppercase", letterSpacing: 0.5,
marginBottom: 10 },
siteCard: { backgroundColor: "#222840", borderRadius: 10, padding: 14,
marginBottom: 10, borderWidth: 1, borderColor: "#2d3550" },
siteHeader: { flexDirection: "row", alignItems: "center", gap: 10,
marginBottom: 10 },
siteIndex: { width: 24, height: 24, borderRadius: 12,
backgroundColor: "#5b8fd4", textAlign: "center",
lineHeight: 24, fontSize: 11, color: "#fff",
fontWeight: "700", flexShrink: 0 },
siteName: { flex: 1, fontSize: 15, fontWeight: "700", color: "#c9a84c" },
siteRow: { flexDirection: "row", alignItems: "flex-start",
paddingTop: 4, borderTopWidth: 1,
borderTopColor: "#1a1f2e", gap: 8 },
siteRowLabel: { width: 36, fontSize: 9, color: "#6b7280", fontWeight: "600",
textTransform: "uppercase", letterSpacing: 0.3,
paddingTop: 2, flexShrink: 0 },
siteUrl: { flex: 1, fontSize: 11, color: "#5b8fd4",
fontFamily: "monospace" },
siteDesc: { flex: 1, fontSize: 12, color: "#9ca3af", lineHeight: 18 },
hintCard: { backgroundColor: "#1a1f2e", borderRadius: 8, padding: 14,
marginBottom: 16, borderWidth: 1, borderColor: "#2d3550",
marginTop: 8 },
hintTitle: { fontSize: 11, color: "#6b7280", fontWeight: "600",
textTransform: "uppercase", letterSpacing: 0.5,
marginBottom: 8 },
hintCode: { fontSize: 10, color: "#4b5563", fontFamily: "monospace",
lineHeight: 16 },
chunkLabel: { fontSize: 10, color: "#4b5563", textAlign: "center",
fontFamily: "monospace" },
});
npx expo start --clear.✅ 8 sites loaded
Parser tests: 9 passed, 0 failed
✅ full https URL with name and description
✅ URL with name but no description
✅ http (not https) URL
✅ URL with port number (extra colon in URL)
✅ description contains colons
✅ comment line
✅ empty line
✅ non-URL line
✅ URL only (no name)
Loaded from prayeronline.txt
1 Sacred Space
URL https://www.sacredspace.com
Desc Daily Ignatian prayer by the Irish Jesuits...
2 Pray As You Go
...