Public: anyone can read this; the crew can change it.
New app turns a sentence into a six-word hostname #228
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/186-new-app-turns-a-sentence-into-a-six-word → main 1 commit fork
2 files
changed +146
−13
src/web/pages/apps-name.ts +112 −6
| @@ -6,16 +6,111 @@ | ||
| 6 | 6 | |
| 7 | 7 | import { SLUG_MAX } from "../../core/apps.ts"; |
| 8 | 8 | |
| 9 | /** Words considered from the front of the sentence, before filtering. */ | |
| 9 | 10 | const MAX_WORDS = 6; |
| 10 | 11 | |
| 12 | /** Meaningful words kept in the name, after the small words are dropped. */ | |
| 13 | const MAX_NAME_WORDS = 2; | |
| 14 | ||
| 11 | 15 | /** |
| 12 | * A lowercase, hyphenated slug of the sentence's first few words — the same | |
| 13 | * shape a hostname label takes (`core/apps.ts`'s `SLUG_MAX`). Undefined | |
| 14 | * when nothing survives: punctuation only, or a script this ASCII slug | |
| 15 | * cannot carry. | |
| 16 | * Leading articles and the small connecting words nobody would name an app | |
| 17 | * after: dropped from the window before the name is built. Lowercase, | |
| 18 | * ASCII, no punctuation — matched against a word with its own punctuation | |
| 19 | * stripped, so "words," and "words" are the same lookup. | |
| 20 | */ | |
| 21 | export const STOP_WORDS = new Set([ | |
| 22 | "a", | |
| 23 | "an", | |
| 24 | "the", | |
| 25 | "of", | |
| 26 | "for", | |
| 27 | "to", | |
| 28 | "in", | |
| 29 | "on", | |
| 30 | "at", | |
| 31 | "by", | |
| 32 | "with", | |
| 33 | "about", | |
| 34 | "into", | |
| 35 | "onto", | |
| 36 | "from", | |
| 37 | "over", | |
| 38 | "under", | |
| 39 | "between", | |
| 40 | "and", | |
| 41 | "or", | |
| 42 | "but", | |
| 43 | "nor", | |
| 44 | "my", | |
| 45 | "your", | |
| 46 | "our", | |
| 47 | "their", | |
| 48 | "his", | |
| 49 | "her", | |
| 50 | "its", | |
| 51 | "this", | |
| 52 | "that", | |
| 53 | "these", | |
| 54 | "those", | |
| 55 | "it", | |
| 56 | "i", | |
| 57 | "you", | |
| 58 | "he", | |
| 59 | "she", | |
| 60 | "we", | |
| 61 | "they", | |
| 62 | "is", | |
| 63 | "are", | |
| 64 | "was", | |
| 65 | "were", | |
| 66 | "be", | |
| 67 | "been", | |
| 68 | "being", | |
| 69 | "do", | |
| 70 | "does", | |
| 71 | "did", | |
| 72 | "has", | |
| 73 | "have", | |
| 74 | "had", | |
| 75 | "will", | |
| 76 | "would", | |
| 77 | "can", | |
| 78 | "could", | |
| 79 | "should", | |
| 80 | "shall", | |
| 81 | "may", | |
| 82 | "might", | |
| 83 | "must", | |
| 84 | "as", | |
| 85 | "so", | |
| 86 | "than", | |
| 87 | "then", | |
| 88 | "not", | |
| 89 | "no", | |
| 90 | ]); | |
| 91 | ||
| 92 | /** | |
| 93 | * The first few meaningful words of the sentence: the stop words dropped | |
| 94 | * from a window at its front, capped to a couple of words. A sentence that | |
| 95 | * is only stop words falls back to the window verbatim, so the name is | |
| 96 | * never built from nothing. | |
| 97 | */ | |
| 98 | function contentWords(sentence: string): string[] { | |
| 99 | const front = sentence.trim().split(/\s+/).slice(0, MAX_WORDS); | |
| 100 | const meaningful = front.filter( | |
| 101 | (word) => !STOP_WORDS.has(word.toLowerCase().replace(/[^a-z0-9]/g, "")), | |
| 102 | ); | |
| 103 | return (meaningful.length > 0 ? meaningful : front).slice(0, MAX_NAME_WORDS); | |
| 104 | } | |
| 105 | ||
| 106 | /** | |
| 107 | * A lowercase, hyphenated slug of the sentence's first couple of meaningful | |
| 108 | * words — the same shape a hostname label takes (`core/apps.ts`'s | |
| 109 | * `SLUG_MAX`). Undefined when nothing survives: punctuation only, or a | |
| 110 | * script this ASCII slug cannot carry. | |
| 16 | 111 | */ |
| 17 | 112 | export function deriveAppName(sentence: string): string | undefined { |
| 18 | const words = sentence.trim().split(/\s+/).slice(0, MAX_WORDS).join(" "); | |
| 113 | const words = contentWords(sentence).join(" "); | |
| 19 | 114 | const slug = words |
| 20 | 115 | .toLowerCase() |
| 21 | 116 | .replace(/[^a-z0-9]+/g, "-") |
| @@ -30,6 +125,9 @@ export function deriveAppName(sentence: string): string | undefined { | ||
| 30 | 125 | * a preview only. The name actually used is derived again on the server |
| 31 | 126 | * (`deriveAppName`), which never trusts what this put in the field: the |
| 32 | 127 | * script blanks it on submit unless the person edited it themselves. |
| 128 | * | |
| 129 | * Generated from `deriveAppName`'s own constants (`MAX_WORDS`, | |
| 130 | * `MAX_NAME_WORDS`, `STOP_WORDS`) so the two never drift apart. | |
| 33 | 131 | */ |
| 34 | 132 | export const appNameJs = /* js */ ` |
| 35 | 133 | (function () { |
| @@ -39,8 +137,16 @@ export const appNameJs = /* js */ ` | ||
| 39 | 137 | if (!desc || !name || !form) return; |
| 40 | 138 | var touched = name.value.trim() !== ''; |
| 41 | 139 | name.addEventListener('input', function () { touched = true; }); |
| 140 | var stopWords = ${JSON.stringify(Array.from(STOP_WORDS))}; | |
| 141 | function contentWords(s) { | |
| 142 | var front = s.trim().split(/\\s+/).slice(0, ${MAX_WORDS}); | |
| 143 | var meaningful = front.filter(function (word) { | |
| 144 | return stopWords.indexOf(word.toLowerCase().replace(/[^a-z0-9]/g, '')) === -1; | |
| 145 | }); | |
| 146 | return (meaningful.length > 0 ? meaningful : front).slice(0, ${MAX_NAME_WORDS}); | |
| 147 | } | |
| 42 | 148 | function slugify(s) { |
| 43 | var words = s.trim().split(/\\s+/).slice(0, ${MAX_WORDS}).join(' '); | |
| 149 | var words = contentWords(s).join(' '); | |
| 44 | 150 | return words |
| 45 | 151 | .toLowerCase() |
| 46 | 152 | .replace(/[^a-z0-9]+/g, '-') |
test/apps.test.ts +34 −7
| @@ -76,6 +76,11 @@ async function sh(args: string[], cwd: string) { | ||
| 76 | 76 | import { identityKey } from "../src/apps/proxy.ts"; |
| 77 | 77 | import { scaffoldFiles } from "../src/apps/scaffold.ts"; |
| 78 | 78 | import { parseCheck } from "../src/apps/manifest.ts"; |
| 79 | import { | |
| 80 | appNameJs, | |
| 81 | deriveAppName, | |
| 82 | STOP_WORDS, | |
| 83 | } from "../src/web/pages/apps-name.ts"; | |
| 79 | 84 | describe("apps: production, previews, data, gates, crew", () => { |
| 80 | 85 | let dataDir: string; |
| 81 | 86 | let work: string; |
| @@ -1447,14 +1452,12 @@ describe("New app: the form asks for the sentence, and names it (issue #60)", () | ||
| 1447 | 1452 | }); |
| 1448 | 1453 | } |
| 1449 | 1454 | |
| 1450 | test("a sentence alone creates an app named its slug", async () => { | |
| 1455 | test("a sentence alone creates an app named its slug, articles dropped", async () => { | |
| 1451 | 1456 | const res = await submitNewApp({ |
| 1452 | 1457 | description: "Keep a shared grocery list", |
| 1453 | 1458 | }); |
| 1454 | 1459 | expect(res.status).toBe(303); |
| 1455 | expect(res.headers.get("location")).toBe( | |
| 1456 | "/nadia/keep-a-shared-grocery-list", | |
| 1457 | ); | |
| 1460 | expect(res.headers.get("location")).toBe("/nadia/keep-shared"); | |
| 1458 | 1461 | }); |
| 1459 | 1462 | |
| 1460 | 1463 | test("a second app from the same sentence gets a distinct name", async () => { |
| @@ -1462,9 +1465,7 @@ describe("New app: the form asks for the sentence, and names it (issue #60)", () | ||
| 1462 | 1465 | description: "Keep a shared grocery list", |
| 1463 | 1466 | }); |
| 1464 | 1467 | expect(res.status).toBe(303); |
| 1465 | expect(res.headers.get("location")).toBe( | |
| 1466 | "/nadia/keep-a-shared-grocery-list-2", | |
| 1467 | ); | |
| 1468 | expect(res.headers.get("location")).toBe("/nadia/keep-shared-2"); | |
| 1468 | 1469 | }); |
| 1469 | 1470 | |
| 1470 | 1471 | test("a sentence a slug cannot carry asks for a name, sentence intact", async () => { |
| @@ -1485,6 +1486,32 @@ describe("New app: the form asks for the sentence, and names it (issue #60)", () | ||
| 1485 | 1486 | }); |
| 1486 | 1487 | }); |
| 1487 | 1488 | |
| 1489 | describe("deriveAppName: a sentence yields a short, ordinary name (issue #186)", () => { | |
| 1490 | const cases: Array<[string, string | undefined]> = [ | |
| 1491 | // Leading articles and small connecting words are dropped. | |
| 1492 | ["a notepad that counts words", "notepad-counts"], | |
| 1493 | ["an app for tracking my plants", "app-tracking"], | |
| 1494 | // Already short: nothing to drop. | |
| 1495 | ["logs", "logs"], | |
| 1496 | // Only stop words: falls back to the window itself, never empty. | |
| 1497 | ["the a of it", "the-a"], | |
| 1498 | // A script this ASCII slug cannot carry: no usable name. | |
| 1499 | ["猫を飼っています", undefined], | |
| 1500 | // Punctuation only: unchanged from before. | |
| 1501 | ["!!! ??? ...", undefined], | |
| 1502 | ]; | |
| 1503 | ||
| 1504 | for (const [sentence, expected] of cases) { | |
| 1505 | test(`${JSON.stringify(sentence)} -> ${JSON.stringify(expected)}`, () => { | |
| 1506 | expect(deriveAppName(sentence)).toBe(expected); | |
| 1507 | }); | |
| 1508 | } | |
| 1509 | ||
| 1510 | test("appNameJs embeds the same stop words, window and cap as deriveAppName", () => { | |
| 1511 | expect(appNameJs).toContain(JSON.stringify(Array.from(STOP_WORDS))); | |
| 1512 | }); | |
| 1513 | }); | |
| 1514 | ||
| 1488 | 1515 | describe("New app lands on the app's page, not the flash (issue #133)", () => { |
| 1489 | 1516 | let dataDir: string; |
| 1490 | 1517 | let app: App; |
new above
offline