shelving 1.50.2 → 1.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/markup/rules.d.ts CHANGED
@@ -1,8 +1,4 @@
1
- import type { MarkupRule, MarkupRuleMatcher } from "./types.js";
2
- export declare const getMarkupMatcher: (regexp: RegExp) => MarkupRuleMatcher;
3
- export declare const getMarkupBlockMatcher: (middle?: string, end?: string, start?: string) => MarkupRuleMatcher;
4
- export declare const getMarkupLineMatcher: (middle?: string, end?: string, start?: string) => MarkupRuleMatcher;
5
- export declare const getMarkupWrapMatcher: (chars: string, middle?: string) => MarkupRuleMatcher;
1
+ import type { MarkupRule } from "./types.js";
6
2
  /**
7
3
  * Headings are single line only (don't allow multiline).
8
4
  * - 1-6 hashes then 1+ spaces, then the title.
package/markup/rules.js CHANGED
@@ -1,23 +1,10 @@
1
- import { formatUrl, toURL } from "../util/index.js";
1
+ import { formatUrl, toURL, getLineRegExp, MATCH_LINE, getBlockRegExp, MATCH_BLOCK, getWrapRegExp } from "../util/index.js";
2
2
  // Regular expression partials (`\` slashes must be escaped as `\\`).
3
- const LINE = "[^\\n]*"; // Match line of content (anything that's not a newline).
4
- const LINE_START = "^\\n*|\\n+"; // Starts at start of line (one or more linebreak or start of string).
5
- const LINE_END = "\\n+|$"; // Ends at end of line (one or more linebreak or end of string).
6
- const BLOCK = "[\\s\\S]*?"; // Match block of content (including newlines so don't be greedy).
7
- const BLOCK_START = "^\\n*|\\n+"; // Starts at start of a block (one or more linebreak or start of string).
8
- const BLOCK_END = "\\n*$|\\n\\n+"; // End of a block (two or more linebreaks or end of string).
9
3
  const BULLETS = "-*•+"; // Anything that can be a bullet (used for unordered lists and horizontal rules).
10
- const WORDS = `\\S(?:[\\s\\S]*?\\S)?`; // Run of text that starts and ends with non-space characters (possibly multi-line).
11
4
  // Regular expressions.
12
5
  const REPLACE_INDENT = /^ {1,2}/gm;
13
6
  // Regular expression makers.
14
- export const getMarkupMatcher = regexp => content => content.match(regexp);
15
- export const getMarkupBlockMatcher = (middle = BLOCK, end = BLOCK_END, start = BLOCK_START) => getMarkupMatcher(new RegExp(`(?:${start})${middle}(?:${end})`));
16
- export const getMarkupLineMatcher = (middle = LINE, end = LINE_END, start = LINE_START) => getMarkupMatcher(new RegExp(`(?:${start})${middle}(?:${end})`));
17
- export const getMarkupWrapMatcher = (chars, middle = WORDS) => {
18
- const regexp = new RegExp(`(${chars})(${middle})\\1`);
19
- return content => content.match(regexp);
20
- };
7
+ const getMatcher = regexp => content => content.match(regexp);
21
8
  /**
22
9
  * Headings are single line only (don't allow multiline).
23
10
  * - 1-6 hashes then 1+ spaces, then the title.
@@ -25,7 +12,7 @@ export const getMarkupWrapMatcher = (chars, middle = WORDS) => {
25
12
  * - Markdown's underline syntax is not supported (for simplification).
26
13
  */
27
14
  export const HEADING_RULE = {
28
- match: getMarkupLineMatcher(`(#{1,6}) +(${LINE})`),
15
+ match: getMatcher(getLineRegExp(`(#{1,6}) +(${MATCH_LINE})`)),
29
16
  render: ([, prefix = "", children = ""]) => ({ type: `h${prefix.length}`, key: null, props: { children } }),
30
17
  contexts: ["block"],
31
18
  childContext: "inline",
@@ -38,7 +25,7 @@ export const HEADING_RULE = {
38
25
  * - Might have infinite number of spaces between the characters.
39
26
  */
40
27
  export const HORIZONTAL_RULE = {
41
- match: getMarkupLineMatcher(`([${BULLETS}])(?: *\\1){2,}`),
28
+ match: getMatcher(getLineRegExp(`([${BULLETS}])(?: *\\1){2,}`)),
42
29
  render: () => ({ type: "hr", key: null, props: {} }),
43
30
  contexts: ["block"],
44
31
  };
@@ -51,7 +38,7 @@ export const HORIZONTAL_RULE = {
51
38
  */
52
39
  const UNORDERED = `[${BULLETS}] +`; // Anything that can be a bullet (used for unordered lists and horizontal rules).
53
40
  export const UNORDERED_LIST_RULE = {
54
- match: getMarkupBlockMatcher(`${UNORDERED}(${BLOCK})`),
41
+ match: getMatcher(getBlockRegExp(`${UNORDERED}(${MATCH_BLOCK})`)),
55
42
  render: ([, list = ""]) => {
56
43
  const children = list.split(SPLIT_UL_ITEMS).map(mapUnorderedItem);
57
44
  return { type: "ul", key: null, props: { children } };
@@ -71,7 +58,7 @@ const mapUnorderedItem = (item, key) => {
71
58
  */
72
59
  const ORDERED = "[0-9]+[.):] +"; // Number for a numbered list (e.g. `1.` or `2)` or `3:`)
73
60
  export const ORDERED_LIST_RULE = {
74
- match: getMarkupBlockMatcher(`(${ORDERED}${BLOCK})`),
61
+ match: getMatcher(getBlockRegExp(`(${ORDERED}${MATCH_BLOCK})`)),
75
62
  render: ([, list = ""]) => {
76
63
  const children = list.split(SPLIT_OL_ITEMS).map(mapOrderedItem);
77
64
  return { type: "ol", key: null, props: { children } };
@@ -96,7 +83,7 @@ const mapOrderedItem = (item, key) => {
96
83
  * - Quote indent symbol can be followed by zero or more spaces.
97
84
  */
98
85
  export const BLOCKQUOTE_RULE = {
99
- match: getMarkupLineMatcher(`(>${LINE}(?:\\n>${LINE})*)`),
86
+ match: getMatcher(getLineRegExp(`(>${MATCH_LINE}(?:\\n>${MATCH_LINE})*)`)),
100
87
  render: ([, quote = ""]) => ({
101
88
  type: "blockquote",
102
89
  key: null,
@@ -115,7 +102,7 @@ const BLOCKQUOTE_LINES = /^>/gm;
115
102
  */
116
103
  export const FENCED_CODE_RULE = {
117
104
  // Matcher has its own end that only stops when it reaches a matching closing fence or the end of the string.
118
- match: getMarkupBlockMatcher(`(\`{3,}|~{3,}) *(${LINE})\\n(${BLOCK})`, `\\n\\1\\n+|\\n\\1$|$`),
105
+ match: getMatcher(getBlockRegExp(`(\`{3,}|~{3,}) *(${MATCH_LINE})\\n(${MATCH_BLOCK})`, `\\n\\1\\n+|\\n\\1$|$`)),
119
106
  render: ([, , file, children]) => ({
120
107
  type: "pre",
121
108
  key: null,
@@ -134,7 +121,7 @@ export const FENCED_CODE_RULE = {
134
121
  * - When ordering rules, paragraph should go after other "block" context elements (because it has a very generous capture).
135
122
  */
136
123
  export const PARAGRAPH_RULE = {
137
- match: getMarkupBlockMatcher(` *(${BLOCK})`),
124
+ match: getMatcher(getBlockRegExp(` *(${MATCH_BLOCK})`)),
138
125
  render: ([, children]) => ({ type: `p`, key: null, props: { children } }),
139
126
  contexts: ["block"],
140
127
  childContext: "inline",
@@ -205,7 +192,7 @@ const MATCH_AUTOLINK = /([a-z][a-z0-9-]*[a-z0-9]:\S+)(?: +(?:\(([^)]*?)\)|\[([^\
205
192
  * - Same as Markdown syntax.
206
193
  */
207
194
  export const CODE_RULE = {
208
- match: getMarkupWrapMatcher("`+", BLOCK),
195
+ match: getMatcher(getWrapRegExp("`+", MATCH_BLOCK)),
209
196
  render: ([, , children]) => ({ type: "code", key: null, props: { children } }),
210
197
  contexts: ["inline", "list"],
211
198
  priority: 10, // Higher priority than other inlines so it matches first before e.g. `strong` or `em` (from CommonMark spec: "Code span backticks have higher precedence than any other inline constructs except HTML tags and autolinks.")
@@ -219,7 +206,7 @@ export const CODE_RULE = {
219
206
  * - Different to Markdown: strong is always surrounded by `*asterisks*` and emphasis is always surrounded by `_underscores_` (strong isn't 'double emphasis').
220
207
  */
221
208
  export const STRONG_MARKUP = {
222
- match: getMarkupWrapMatcher("\\*+"),
209
+ match: getMatcher(getWrapRegExp("\\*+")),
223
210
  render: ([, , children]) => ({ type: "strong", key: null, props: { children } }),
224
211
  contexts: ["inline", "list", "link"],
225
212
  childContext: "inline",
@@ -233,7 +220,7 @@ export const STRONG_MARKUP = {
233
220
  * - Different to Markdown: strong is always surrounded by `*asterisks*` and emphasis is always surrounded by `_underscores_` (strong isn't 'double emphasis').
234
221
  */
235
222
  export const EMPHASIS_RULE = {
236
- match: getMarkupWrapMatcher("_+"),
223
+ match: getMatcher(getWrapRegExp("_+")),
237
224
  render: ([, , children]) => ({ type: "em", key: null, props: { children } }),
238
225
  contexts: ["inline", "list", "link"],
239
226
  childContext: "inline",
@@ -247,7 +234,7 @@ export const EMPHASIS_RULE = {
247
234
  * - Markdown doesn't have this.
248
235
  */
249
236
  export const INSERT_RULE = {
250
- match: getMarkupWrapMatcher("\\+\\++"),
237
+ match: getMatcher(getWrapRegExp("\\+\\++")),
251
238
  render: ([, , children]) => ({ type: "ins", key: null, props: { children } }),
252
239
  contexts: ["inline", "list", "link"],
253
240
  childContext: "inline",
@@ -261,7 +248,7 @@ export const INSERT_RULE = {
261
248
  * - Markdown doesn't have this.
262
249
  */
263
250
  export const DELETE_RULE = {
264
- match: getMarkupWrapMatcher("--+|~~+"),
251
+ match: getMatcher(getWrapRegExp("--+|~~+")),
265
252
  render: ([, , children]) => ({ type: "del", key: null, props: { children } }),
266
253
  contexts: ["inline", "list", "link"],
267
254
  childContext: "inline",
@@ -275,7 +262,7 @@ export const DELETE_RULE = {
275
262
  * - This works better with textareas that wrap text (since manually breaking up long lines is no longer necessary).
276
263
  */
277
264
  export const LINEBREAK_RULE = {
278
- match: getMarkupMatcher(/\n/),
265
+ match: getMatcher(/\n/),
279
266
  render: () => ({ type: "br", key: null, props: {} }),
280
267
  contexts: ["inline", "list", "link"],
281
268
  childContext: "inline",
package/package.json CHANGED
@@ -11,7 +11,7 @@
11
11
  "state-management",
12
12
  "query-builder"
13
13
  ],
14
- "version": "1.50.2",
14
+ "version": "1.51.0",
15
15
  "repository": "https://github.com/dhoulb/shelving",
16
16
  "author": "Dave Houlbrooke <dave@shax.com>",
17
17
  "license": "0BSD",
package/util/search.d.ts CHANGED
@@ -1,25 +1,37 @@
1
1
  import type { ImmutableArray } from "./array.js";
2
2
  import { Matchable } from "./filter.js";
3
3
  /**
4
- * Match a string against a regular expression.
4
+ * Convert a string to a regular expression that matches that string.
5
5
  *
6
- * @param item The item to search for the regexp in.
7
- * - Item is an array: recurse into each item of the array to look for strings that match.
8
- * - Item is an object: recurse into each property of the object to look for strings that match.
9
- * - Item is string: match the string against the regular expression.
10
- * - Item is anything else: return false (can't be matched).
11
- *
12
- * @param regexp The `RegExp` instance to match the
6
+ * @param str The input string.
7
+ * @param flags RegExp flags that are passed into the created RegExp.
13
8
  */
14
- export declare function MATCHES(item: unknown, regexp: RegExp): boolean;
9
+ export declare const toRegExp: (str: string, flags?: string) => RegExp;
10
+ /** Escape special characters in a string regular expression. */
11
+ export declare const escapeRegExp: (str: string) => string;
12
+ export declare const MATCH_LINE = "[^\\n]*";
13
+ export declare const MATCH_LINE_START = "^\\n*|\\n+";
14
+ export declare const MATCH_LINE_END = "\\n+|$";
15
+ export declare const MATCH_BLOCK = "[\\s\\S]*?";
16
+ export declare const MATCH_BLOCK_START = "^\\n*|\\n+";
17
+ export declare const MATCH_BLOCK_END = "\\n*$|\\n\\n+";
18
+ export declare const MATCH_WORDS = "\\S(?:[\\s\\S]*?\\S)?";
19
+ /** Create regular expression that matches a block of content. */
20
+ export declare const getBlockRegExp: (middle?: string, end?: string, start?: string) => RegExp;
21
+ /** Create regular expression that matches a line of content. */
22
+ export declare const getLineRegExp: (middle?: string, end?: string, start?: string) => RegExp;
23
+ /** Create regular expression that matches piece of text wrapped by a set of characters. */
24
+ export declare const getWrapRegExp: (chars: string, middle?: string) => RegExp;
15
25
  /**
16
- * Match an item matching all words in a query.
17
- *
18
- * @param item The item to search for the query in.
19
- * @param query The query string to search for.
20
- * - Supports `"compound queries"` with quotes.
26
+ * Convert a string query to the corresponding set of case-insensitive regular expressions.
27
+ * - Splies the query into words (respecting "quoted phrases").
28
+ * - Return a regex for each word or quoted phrase.
29
+ * - Unquoted words match partially (starting with a word boundary).
30
+ * - Quoted phrases match fully (starting and ending with a word boundary).
21
31
  */
22
- export declare function MATCHES_ALL(item: unknown, regexps: ImmutableArray<RegExp>): boolean;
32
+ export declare const toWordRegExps: (query: string) => ImmutableArray<RegExp>;
33
+ /** Convert a string to a regular expression matching the start of a word boundary. */
34
+ export declare const toWordRegExp: (word: string) => RegExp;
23
35
  /**
24
36
  * Match an item matching all words in a query.
25
37
  *
@@ -27,17 +39,14 @@ export declare function MATCHES_ALL(item: unknown, regexps: ImmutableArray<RegEx
27
39
  * @param query The query string to search for.
28
40
  * - Supports `"compound queries"` with quotes.
29
41
  */
30
- export declare function MATCHES_ANY(item: unknown, regexps: ImmutableArray<RegExp>): boolean;
42
+ export declare function matchesAll(item: unknown, regexps: Iterable<RegExp>): boolean;
31
43
  /**
32
- * Convert a string query to the corresponding set of case-insensitive regular expressions.
33
- * - Splies the query into words (respecting "quoted phrases").
34
- * - Return a regex for each word or quoted phrase.
35
- * - Unquoted words match partially (starting with a word boundary).
36
- * - Quoted phrases match fully (starting and ending with a word boundary).
44
+ * Match an item any of several regular expressions.
45
+ *
46
+ * @param item The item to search for the query in.
47
+ * @param regexps An iterable set of regular expressions.
37
48
  */
38
- export declare const toWordRegExps: (query: string) => ImmutableArray<RegExp>;
39
- /** Convert a string to a regular expression matching the start of a word boundary. */
40
- export declare const toWordRegExp: (word: string) => RegExp;
49
+ export declare function matchesAny(item: unknown, regexps: Iterable<RegExp>): boolean;
41
50
  /** Matcher that matches any words in a string. */
42
51
  export declare class MatchAnyWord implements Matchable<unknown, void> {
43
52
  private _regexps;
package/util/search.js CHANGED
@@ -1,18 +1,38 @@
1
- import { toWords, escapeRegExp, normalizeString } from "./string.js";
1
+ import { toWords, normalizeString } from "./string.js";
2
2
  /**
3
- * Match a string against a regular expression.
3
+ * Convert a string to a regular expression that matches that string.
4
4
  *
5
- * @param item The item to search for the regexp in.
6
- * - Item is an array: recurse into each item of the array to look for strings that match.
7
- * - Item is an object: recurse into each property of the object to look for strings that match.
8
- * - Item is string: match the string against the regular expression.
9
- * - Item is anything else: return false (can't be matched).
10
- *
11
- * @param regexp The `RegExp` instance to match the
5
+ * @param str The input string.
6
+ * @param flags RegExp flags that are passed into the created RegExp.
12
7
  */
13
- export function MATCHES(item, regexp) {
14
- return typeof item === "string" && !!item.match(regexp);
15
- }
8
+ export const toRegExp = (str, flags = "") => new RegExp(escapeRegExp(str), flags);
9
+ /** Escape special characters in a string regular expression. */
10
+ export const escapeRegExp = (str) => str.replace(REPLACE_ESCAPED, "\\$&");
11
+ const REPLACE_ESCAPED = /[-[\]/{}()*+?.\\^$|]/g;
12
+ // Regular expression partials (`\` slashes must be escaped as `\\`).
13
+ export const MATCH_LINE = "[^\\n]*"; // Match line of content (anything that's not a newline).
14
+ export const MATCH_LINE_START = "^\\n*|\\n+"; // Starts at start of line (one or more linebreak or start of string).
15
+ export const MATCH_LINE_END = "\\n+|$"; // Ends at end of line (one or more linebreak or end of string).
16
+ export const MATCH_BLOCK = "[\\s\\S]*?"; // Match block of content (including newlines so don't be greedy).
17
+ export const MATCH_BLOCK_START = "^\\n*|\\n+"; // Starts at start of a block (one or more linebreak or start of string).
18
+ export const MATCH_BLOCK_END = "\\n*$|\\n\\n+"; // End of a block (two or more linebreaks or end of string).
19
+ export const MATCH_WORDS = `\\S(?:[\\s\\S]*?\\S)?`; // Run of text that starts and ends with non-space characters (possibly multi-line).
20
+ /** Create regular expression that matches a block of content. */
21
+ export const getBlockRegExp = (middle = MATCH_BLOCK, end = MATCH_BLOCK_END, start = MATCH_BLOCK_START) => new RegExp(`(?:${start})${middle}(?:${end})`);
22
+ /** Create regular expression that matches a line of content. */
23
+ export const getLineRegExp = (middle = MATCH_LINE, end = MATCH_LINE_END, start = MATCH_LINE_START) => new RegExp(`(?:${start})${middle}(?:${end})`);
24
+ /** Create regular expression that matches piece of text wrapped by a set of characters. */
25
+ export const getWrapRegExp = (chars, middle = MATCH_WORDS) => new RegExp(`(${chars})(${middle})\\1`);
26
+ /**
27
+ * Convert a string query to the corresponding set of case-insensitive regular expressions.
28
+ * - Splies the query into words (respecting "quoted phrases").
29
+ * - Return a regex for each word or quoted phrase.
30
+ * - Unquoted words match partially (starting with a word boundary).
31
+ * - Quoted phrases match fully (starting and ending with a word boundary).
32
+ */
33
+ export const toWordRegExps = (query) => toWords(query).map(toWordRegExp);
34
+ /** Convert a string to a regular expression matching the start of a word boundary. */
35
+ export const toWordRegExp = (word) => new RegExp(`\\b${escapeRegExp(normalizeString(word))}`, "i");
16
36
  /**
17
37
  * Match an item matching all words in a query.
18
38
  *
@@ -20,46 +40,37 @@ export function MATCHES(item, regexp) {
20
40
  * @param query The query string to search for.
21
41
  * - Supports `"compound queries"` with quotes.
22
42
  */
23
- export function MATCHES_ALL(item, regexps) {
24
- if (typeof item === "string" && regexps.length) {
25
- for (const regexp of regexps)
26
- if (!item.match(regexp))
43
+ export function matchesAll(item, regexps) {
44
+ let count = 0;
45
+ if (typeof item === "string") {
46
+ for (const regexp of regexps) {
47
+ count++;
48
+ if (!regexp.test(item))
27
49
  return false;
28
- return true;
50
+ }
29
51
  }
30
- return false;
52
+ return count ? true : false;
31
53
  }
32
54
  /**
33
- * Match an item matching all words in a query.
55
+ * Match an item any of several regular expressions.
34
56
  *
35
57
  * @param item The item to search for the query in.
36
- * @param query The query string to search for.
37
- * - Supports `"compound queries"` with quotes.
58
+ * @param regexps An iterable set of regular expressions.
38
59
  */
39
- export function MATCHES_ANY(item, regexps) {
60
+ export function matchesAny(item, regexps) {
40
61
  if (typeof item === "string")
41
62
  for (const regexp of regexps)
42
- if (MATCHES(item, regexp))
63
+ if (regexp.test(item))
43
64
  return true;
44
65
  return false;
45
66
  }
46
- /**
47
- * Convert a string query to the corresponding set of case-insensitive regular expressions.
48
- * - Splies the query into words (respecting "quoted phrases").
49
- * - Return a regex for each word or quoted phrase.
50
- * - Unquoted words match partially (starting with a word boundary).
51
- * - Quoted phrases match fully (starting and ending with a word boundary).
52
- */
53
- export const toWordRegExps = (query) => toWords(query).map(toWordRegExp);
54
- /** Convert a string to a regular expression matching the start of a word boundary. */
55
- export const toWordRegExp = (word) => new RegExp(`\\b${escapeRegExp(normalizeString(word))}`, "i");
56
67
  /** Matcher that matches any words in a string. */
57
68
  export class MatchAnyWord {
58
69
  constructor(words) {
59
70
  this._regexps = toWordRegExps(words);
60
71
  }
61
72
  match(value) {
62
- return MATCHES_ANY(value, this._regexps);
73
+ return matchesAny(value, this._regexps);
63
74
  }
64
75
  }
65
76
  /** Matcher that matches all words in a string. */
@@ -68,7 +79,7 @@ export class MatchAllWords {
68
79
  this._regexps = toWordRegExps(words);
69
80
  }
70
81
  match(value) {
71
- return MATCHES_ALL(value, this._regexps);
82
+ return matchesAll(value, this._regexps);
72
83
  }
73
84
  }
74
85
  /** Matcher that matches an exact phrase. */
@@ -77,6 +88,6 @@ export class MatchWord {
77
88
  this._regexp = toWordRegExp(phrase);
78
89
  }
79
90
  match(value) {
80
- return MATCHES(value, this._regexp);
91
+ return this._regexp.test(value);
81
92
  }
82
93
  }
package/util/string.d.ts CHANGED
@@ -77,15 +77,6 @@ export declare const toSlug: (str: string) => string;
77
77
  export declare const toWords: (str: string) => ImmutableArray<string>;
78
78
  /** Find and iterate over the words in a string. */
79
79
  export declare function yieldWords(value: string): Generator<string, void, void>;
80
- /**
81
- * Convert a string to a regular expression that matches that string.
82
- *
83
- * @param str The input string.
84
- * @param flags RegExp flags that are passed into the created RegExp.
85
- */
86
- export declare const toRegExp: (str: string, flags?: string) => RegExp;
87
- /** Escape special characters in a string regular expression. */
88
- export declare const escapeRegExp: (str: string) => string;
89
80
  /** Is the first character of a string an uppercase letter? */
90
81
  export declare const isUppercaseLetter: (str: string) => boolean;
91
82
  /** Is the first character of a string a lowercase letter? */
package/util/string.js CHANGED
@@ -135,16 +135,6 @@ export function* yieldWords(value) {
135
135
  }
136
136
  }
137
137
  const MATCH_WORD = /[^\s"]+|"([^"]*)"/g; // Runs of characters without spaces, or "quoted phrases"
138
- /**
139
- * Convert a string to a regular expression that matches that string.
140
- *
141
- * @param str The input string.
142
- * @param flags RegExp flags that are passed into the created RegExp.
143
- */
144
- export const toRegExp = (str, flags = "") => new RegExp(escapeRegExp(str), flags);
145
- /** Escape special characters in a string regular expression. */
146
- export const escapeRegExp = (str) => str.replace(REPLACE_ESCAPED, "\\$&");
147
- const REPLACE_ESCAPED = /[-[\]/{}()*+?.\\^$|]/g;
148
138
  /** Is the first character of a string an uppercase letter? */
149
139
  export const isUppercaseLetter = (str) => isBetween(str.charCodeAt(0), 65, 90);
150
140
  /** Is the first character of a string a lowercase letter? */