UNPKG

4.55 kBPlain TextView Raw
1<!-- eslint-disable vue/multi-word-component-names -->
2<script>
3/* eslint-disable no-continue */
4import { has, isString } from 'lodash';
5
6const PREFIX = '%{';
7const SUFFIX = '}';
8const START_SUFFIX = 'Start';
9const END_SUFFIX = 'End';
10const PLACE_HOLDER_REGEX = new RegExp(`(${PREFIX}[a-z]+[\\w-]*[a-z0-9]+${SUFFIX})`, 'gi');
11
12function groupPlaceholdersByStartTag(placeholders = {}) {
13 return Object.entries(placeholders).reduce((acc, [slotName, [startTag, endTag]]) => {
14 acc[startTag] = { slotName, endTag };
15 return acc;
16 }, {});
17}
18
19function getPlaceholderDefinition(chunk, placeholdersByStartTag) {
20 const tagName = chunk.slice(PREFIX.length, -SUFFIX.length);
21
22 if (has(placeholdersByStartTag, tagName)) {
23 // Use provided custom placeholder definition
24 return {
25 ...placeholdersByStartTag[tagName],
26 tagName,
27 };
28 }
29
30 if (tagName.endsWith(START_SUFFIX)) {
31 // Tag conforms to default start/end tag naming convention
32 const slotName = tagName.slice(0, -START_SUFFIX.length);
33
34 return {
35 slotName,
36 endTag: `${slotName}${END_SUFFIX}`,
37 tagName,
38 };
39 }
40
41 return {
42 slotName: tagName,
43 endTag: undefined,
44 tagName,
45 };
46}
47
48export default {
49 functional: true,
50 props: {
51 /**
52 * A translated string with named placeholders, e.g., "Written by %{author}".
53 */
54 message: {
55 type: String,
56 required: true,
57 },
58 /**
59 * An object mapping slot names to custom start/end placeholders. Use this
60 * to avoid changing an existing message, and in turn invalidating existing
61 * translations, in the case it uses non-default placeholders.
62 */
63 placeholders: {
64 type: Object,
65 required: false,
66 default: undefined,
67 validator: (value) =>
68 Object.values(value).every(
69 (tagPair) => Array.isArray(tagPair) && tagPair.length === 2 && tagPair.every(isString)
70 ),
71 },
72 },
73 /**
74 * Available slots are determined by the placeholders in the provided
75 * message prop. For example, a message of "Written by %{author}" has
76 * a slot called "author", and its content is used to replace "%{author}"
77 * in the rendered output. When two placeholders indicate a start and an
78 * end region in the message, e.g., "%{linkStart}foo%{linkEnd}", the common
79 * base name can be used as a scoped slot, where the content between the
80 * placeholders is passed via the `content` scoped slot prop.
81 * @slot * (arbitrary)
82 * @binding {string} content The content to place between start and end placeholders.
83 */
84 render(createElement, context) {
85 // While a functional style is generally preferred, an imperative style is
86 // used here, as it lends itself better to the message parsing algorithm.
87 // This approach is also more performant, as it minimizes (relatively) object
88 // creation/garbage collection, which is important given how frequently this
89 // code may run on a given page.
90
91 let i = 0;
92 const vnodes = [];
93 const slots = context.scopedSlots;
94 const chunks = context.props.message.split(PLACE_HOLDER_REGEX);
95 const placeholdersByStartTag = groupPlaceholdersByStartTag(context.props.placeholders);
96
97 while (i < chunks.length) {
98 const chunk = chunks[i];
99 // Skip past this chunk now we have it
100 i += 1;
101
102 if (!PLACE_HOLDER_REGEX.test(chunk)) {
103 // Not a placeholder, so pass through as-is
104 vnodes.push(chunk);
105 continue;
106 }
107
108 const { slotName, endTag, tagName } = getPlaceholderDefinition(chunk, placeholdersByStartTag);
109
110 if (endTag) {
111 // Peek ahead to find end placeholder, if any
112 const indexOfEnd = chunks.indexOf(`${PREFIX}${endTag}${SUFFIX}`, i);
113 if (indexOfEnd > -1) {
114 // We have a valid start/end placeholder pair! Extract the content
115 // between them and skip past the end placeholder
116 const content = chunks.slice(i, indexOfEnd);
117 i = indexOfEnd + 1;
118
119 if (!has(slots, slotName)) {
120 // Slot hasn't been provided; return placeholders and content as-is
121 vnodes.push(chunk, ...content, chunks[indexOfEnd]);
122 continue;
123 }
124
125 // Provide content to provided scoped slot
126 vnodes.push(slots[slotName]({ content: content.join('') }));
127 continue;
128 }
129 }
130
131 // By process of elimination, chunk must be a plain placeholder
132 vnodes.push(has(slots, tagName) ? slots[tagName]() : chunk);
133 continue;
134 }
135
136 return vnodes;
137 },
138};
139</script>