Public: anyone can read this; the crew can change it.

New app turns a sentence into a six-word hostname #228

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/186-new-app-turns-a-sentence-into-a-six-word → main 1 commit fork

  • Merged. scout/186-new-app-turns-a-sentence-into-a-six-word was merged into main as d8393c3.

Review

Sign in to review this change.
2 files changed +146 −13
src/web/pages/apps-name.ts +112 −6
@@ -6,16 +6,111 @@
66
77 import { SLUG_MAX } from "../../core/apps.ts";
88
9 /** Words considered from the front of the sentence, before filtering. */
910 const MAX_WORDS = 6;
1011
12 /** Meaningful words kept in the name, after the small words are dropped. */
13 const MAX_NAME_WORDS = 2;
14
1115 /**
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.
16111 */
17112 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(" ");
19114 const slug = words
20115 .toLowerCase()
21116 .replace(/[^a-z0-9]+/g, "-")
@@ -30,6 +125,9 @@ export function deriveAppName(sentence: string): string | undefined {
30125 * a preview only. The name actually used is derived again on the server
31126 * (`deriveAppName`), which never trusts what this put in the field: the
32127 * 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.
33131 */
34132 export const appNameJs = /* js */ `
35133 (function () {
@@ -39,8 +137,16 @@ export const appNameJs = /* js */ `
39137 if (!desc || !name || !form) return;
40138 var touched = name.value.trim() !== '';
41139 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 }
42148 function slugify(s) {
43 var words = s.trim().split(/\\s+/).slice(0, ${MAX_WORDS}).join(' ');
149 var words = contentWords(s).join(' ');
44150 return words
45151 .toLowerCase()
46152 .replace(/[^a-z0-9]+/g, '-')
test/apps.test.ts +34 −7
@@ -76,6 +76,11 @@ async function sh(args: string[], cwd: string) {
7676 import { identityKey } from "../src/apps/proxy.ts";
7777 import { scaffoldFiles } from "../src/apps/scaffold.ts";
7878 import { parseCheck } from "../src/apps/manifest.ts";
79 import {
80 appNameJs,
81 deriveAppName,
82 STOP_WORDS,
83 } from "../src/web/pages/apps-name.ts";
7984 describe("apps: production, previews, data, gates, crew", () => {
8085 let dataDir: string;
8186 let work: string;
@@ -1447,14 +1452,12 @@ describe("New app: the form asks for the sentence, and names it (issue #60)", ()
14471452 });
14481453 }
14491454
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 () => {
14511456 const res = await submitNewApp({
14521457 description: "Keep a shared grocery list",
14531458 });
14541459 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");
14581461 });
14591462
14601463 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)", ()
14621465 description: "Keep a shared grocery list",
14631466 });
14641467 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");
14681469 });
14691470
14701471 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)", ()
14851486 });
14861487 });
14871488
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
14881515 describe("New app lands on the app's page, not the flash (issue #133)", () => {
14891516 let dataDir: string;
14901517 let app: App;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs