parse-html-dom 1.0.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/LICENSE +22 -0
- package/README.md +127 -0
- package/lib/character-reference-table.js +2249 -0
- package/lib/character-references.js +164 -0
- package/lib/document.js +125 -0
- package/lib/element.js +258 -0
- package/lib/html-tags.js +129 -0
- package/lib/node.js +259 -0
- package/lib/parse-html.js +96 -0
- package/lib/selector-matcher.js +315 -0
- package/lib/selector-parser.js +565 -0
- package/lib/serialize.js +142 -0
- package/lib/tokenizer.js +481 -0
- package/lib/tree-builder.js +319 -0
- package/mod.js +9 -0
- package/package.json +43 -0
package/lib/tokenizer.js
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module tokenizer
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { RAW_TEXT_ELEMENTS, RCDATA_ELEMENTS } from './html-tags.js';
|
|
6
|
+
import { decodeCharacterReferences } from './character-references.js';
|
|
7
|
+
|
|
8
|
+
function isAsciiLetter(char) {
|
|
9
|
+
return Boolean(char) && /^[A-Za-z]$/.test(char);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isAsciiWhitespace(char) {
|
|
13
|
+
return char === ' ' || char === '\t' || char === '\n' || char === '\f' || char === '\r';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function skipWhitespace(source, cursor) {
|
|
17
|
+
let index = cursor;
|
|
18
|
+
|
|
19
|
+
while (index < source.length && isAsciiWhitespace(source[index])) {
|
|
20
|
+
index += 1;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return index;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseErrorToken(code, message, offset) {
|
|
27
|
+
return { type: 'parseError', code, message, offset };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Scans a comment body starting where "<!--" ends and returns the closing
|
|
31
|
+
// delimiter's position, or -1 when the source runs out first.
|
|
32
|
+
function findCommentEnd(source, contentStart) {
|
|
33
|
+
return source.indexOf('-->', contentStart);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function scanComment(source, index) {
|
|
37
|
+
const contentStart = index + 4;
|
|
38
|
+
const endIndex = findCommentEnd(source, contentStart);
|
|
39
|
+
|
|
40
|
+
if (endIndex === -1) {
|
|
41
|
+
return {
|
|
42
|
+
tokens: [
|
|
43
|
+
{ type: 'comment', data: source.slice(contentStart), offset: index },
|
|
44
|
+
parseErrorToken('eof-in-comment', 'Unexpected end of input inside a comment.', index),
|
|
45
|
+
],
|
|
46
|
+
nextCursor: source.length,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
tokens: [
|
|
52
|
+
{ type: 'comment', data: source.slice(contentStart, endIndex), offset: index },
|
|
53
|
+
],
|
|
54
|
+
nextCursor: endIndex + 3,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Consumes source up to (and including) the next ">" for markup this
|
|
59
|
+
// tokenizer treats as a bogus comment: malformed "<!...>", CDATA sections,
|
|
60
|
+
// and "</" not followed by a letter. All three degrade to a comment node
|
|
61
|
+
// carrying the raw text between the opening delimiter and ">".
|
|
62
|
+
function scanBogusComment(source, index, dataStart, code, message) {
|
|
63
|
+
const gtIndex = source.indexOf('>', dataStart);
|
|
64
|
+
|
|
65
|
+
if (gtIndex === -1) {
|
|
66
|
+
return {
|
|
67
|
+
tokens: [
|
|
68
|
+
{ type: 'comment', data: source.slice(dataStart), offset: index },
|
|
69
|
+
parseErrorToken(code, message, index),
|
|
70
|
+
],
|
|
71
|
+
nextCursor: source.length,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
tokens: [
|
|
77
|
+
{ type: 'comment', data: source.slice(dataStart, gtIndex), offset: index },
|
|
78
|
+
parseErrorToken(code, message, index),
|
|
79
|
+
],
|
|
80
|
+
nextCursor: gtIndex + 1,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Reads a single-quoted or double-quoted string starting at `cursor`, which
|
|
85
|
+
// must point at the opening quote. Returns null when the quote is never
|
|
86
|
+
// closed, so the caller can decide how to recover.
|
|
87
|
+
function scanQuotedString(source, cursor) {
|
|
88
|
+
const quote = source[cursor];
|
|
89
|
+
const contentStart = cursor + 1;
|
|
90
|
+
const endIndex = source.indexOf(quote, contentStart);
|
|
91
|
+
|
|
92
|
+
if (endIndex === -1) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { value: source.slice(contentStart, endIndex), nextCursor: endIndex + 1 };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function scanDoctype(source, index) {
|
|
100
|
+
let cursor = skipWhitespace(source, index + 9);
|
|
101
|
+
|
|
102
|
+
let nameEnd = cursor;
|
|
103
|
+
while (nameEnd < source.length && !isAsciiWhitespace(source[nameEnd]) && source[nameEnd] !== '>') {
|
|
104
|
+
nameEnd += 1;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const name = source.slice(cursor, nameEnd).toLowerCase() || null;
|
|
108
|
+
cursor = skipWhitespace(source, nameEnd);
|
|
109
|
+
|
|
110
|
+
let publicId = null;
|
|
111
|
+
let systemId = null;
|
|
112
|
+
|
|
113
|
+
const keyword = source.slice(cursor, cursor + 6).toUpperCase();
|
|
114
|
+
|
|
115
|
+
if (keyword === 'PUBLIC') {
|
|
116
|
+
cursor = skipWhitespace(source, cursor + 6);
|
|
117
|
+
if (source[cursor] === '"' || source[cursor] === '\'') {
|
|
118
|
+
const quoted = scanQuotedString(source, cursor);
|
|
119
|
+
if (quoted) {
|
|
120
|
+
publicId = quoted.value;
|
|
121
|
+
cursor = skipWhitespace(source, quoted.nextCursor);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (source[cursor] === '"' || source[cursor] === '\'') {
|
|
125
|
+
const quoted = scanQuotedString(source, cursor);
|
|
126
|
+
if (quoted) {
|
|
127
|
+
systemId = quoted.value;
|
|
128
|
+
cursor = quoted.nextCursor;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
} else if (keyword === 'SYSTEM') {
|
|
132
|
+
cursor = skipWhitespace(source, cursor + 6);
|
|
133
|
+
if (source[cursor] === '"' || source[cursor] === '\'') {
|
|
134
|
+
const quoted = scanQuotedString(source, cursor);
|
|
135
|
+
if (quoted) {
|
|
136
|
+
systemId = quoted.value;
|
|
137
|
+
cursor = quoted.nextCursor;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const gtIndex = source.indexOf('>', cursor);
|
|
143
|
+
const doctypeToken = {
|
|
144
|
+
type: 'doctype', name, publicId, systemId, offset: index,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
if (gtIndex === -1) {
|
|
148
|
+
return {
|
|
149
|
+
tokens: [
|
|
150
|
+
doctypeToken,
|
|
151
|
+
parseErrorToken('eof-in-doctype', 'Unexpected end of input inside a doctype.', index),
|
|
152
|
+
],
|
|
153
|
+
nextCursor: source.length,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return { tokens: [ doctypeToken ], nextCursor: gtIndex + 1 };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function scanEndTag(source, index) {
|
|
161
|
+
if (!isAsciiLetter(source[index + 2])) {
|
|
162
|
+
return scanBogusComment(
|
|
163
|
+
source,
|
|
164
|
+
index,
|
|
165
|
+
index + 2,
|
|
166
|
+
'invalid-first-character-of-tag-name',
|
|
167
|
+
'An end tag name must start with an ASCII letter.',
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let cursor = index + 2;
|
|
172
|
+
const nameStart = cursor;
|
|
173
|
+
|
|
174
|
+
while (cursor < source.length && !isAsciiWhitespace(source[cursor]) && source[cursor] !== '/' && source[cursor] !== '>') {
|
|
175
|
+
cursor += 1;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const rawName = source.slice(nameStart, cursor);
|
|
179
|
+
const name = rawName.toLowerCase();
|
|
180
|
+
|
|
181
|
+
// Attributes on an end tag are non-standard and ignored, but their
|
|
182
|
+
// quoted values may contain ">"; skip them without splitting the tag.
|
|
183
|
+
while (cursor < source.length && source[cursor] !== '>') {
|
|
184
|
+
if (source[cursor] === '"' || source[cursor] === '\'') {
|
|
185
|
+
const quoted = scanQuotedString(source, cursor);
|
|
186
|
+
cursor = quoted ? quoted.nextCursor : source.length;
|
|
187
|
+
} else {
|
|
188
|
+
cursor += 1;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (cursor >= source.length) {
|
|
193
|
+
return {
|
|
194
|
+
tokens: [
|
|
195
|
+
parseErrorToken('eof-in-tag', 'Unexpected end of input inside a tag.', index),
|
|
196
|
+
],
|
|
197
|
+
nextCursor: source.length,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
tokens: [
|
|
203
|
+
{ type: 'endTag', name, rawName, offset: index },
|
|
204
|
+
],
|
|
205
|
+
nextCursor: cursor + 1,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Parses one attribute starting at `cursor`, which must not be whitespace,
|
|
210
|
+
// ">", or "/". Returns null only when the name is empty, which happens for
|
|
211
|
+
// a stray "=" the caller should skip past to keep the scan progressing.
|
|
212
|
+
function scanAttribute(source, cursor) {
|
|
213
|
+
const nameStart = cursor;
|
|
214
|
+
let index = cursor;
|
|
215
|
+
|
|
216
|
+
while (index < source.length
|
|
217
|
+
&& !isAsciiWhitespace(source[index])
|
|
218
|
+
&& source[index] !== '/'
|
|
219
|
+
&& source[index] !== '>'
|
|
220
|
+
&& source[index] !== '=') {
|
|
221
|
+
index += 1;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (index === nameStart) {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const rawName = source.slice(nameStart, index);
|
|
229
|
+
const name = rawName.toLowerCase();
|
|
230
|
+
|
|
231
|
+
index = skipWhitespace(source, index);
|
|
232
|
+
|
|
233
|
+
let value = '';
|
|
234
|
+
|
|
235
|
+
if (source[index] === '=') {
|
|
236
|
+
index = skipWhitespace(source, index + 1);
|
|
237
|
+
|
|
238
|
+
if (source[index] === '"' || source[index] === '\'') {
|
|
239
|
+
const quoted = scanQuotedString(source, index);
|
|
240
|
+
if (quoted) {
|
|
241
|
+
value = quoted.value;
|
|
242
|
+
index = quoted.nextCursor;
|
|
243
|
+
} else {
|
|
244
|
+
value = source.slice(index + 1);
|
|
245
|
+
index = source.length;
|
|
246
|
+
}
|
|
247
|
+
} else {
|
|
248
|
+
const valueStart = index;
|
|
249
|
+
while (index < source.length && !isAsciiWhitespace(source[index]) && source[index] !== '>') {
|
|
250
|
+
index += 1;
|
|
251
|
+
}
|
|
252
|
+
value = source.slice(valueStart, index);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
value = decodeCharacterReferences(value);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return { name, rawName, value, nextCursor: index };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function scanStartTag(source, index) {
|
|
262
|
+
let cursor = index + 1;
|
|
263
|
+
const nameStart = cursor;
|
|
264
|
+
|
|
265
|
+
while (cursor < source.length && !isAsciiWhitespace(source[cursor]) && source[cursor] !== '/' && source[cursor] !== '>') {
|
|
266
|
+
cursor += 1;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const rawName = source.slice(nameStart, cursor);
|
|
270
|
+
const name = rawName.toLowerCase();
|
|
271
|
+
|
|
272
|
+
const attributes = [];
|
|
273
|
+
const errors = [];
|
|
274
|
+
let selfClosing = false;
|
|
275
|
+
|
|
276
|
+
for (;;) {
|
|
277
|
+
cursor = skipWhitespace(source, cursor);
|
|
278
|
+
|
|
279
|
+
if (cursor >= source.length) {
|
|
280
|
+
return {
|
|
281
|
+
tokens: [
|
|
282
|
+
...errors,
|
|
283
|
+
parseErrorToken('eof-in-tag', 'Unexpected end of input inside a tag.', index),
|
|
284
|
+
],
|
|
285
|
+
nextCursor: source.length,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (source[cursor] === '>') {
|
|
290
|
+
cursor += 1;
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (source[cursor] === '/') {
|
|
295
|
+
if (source[cursor + 1] === '>') {
|
|
296
|
+
selfClosing = true;
|
|
297
|
+
cursor += 2;
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
cursor += 1;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const attribute = scanAttribute(source, cursor);
|
|
305
|
+
|
|
306
|
+
if (!attribute) {
|
|
307
|
+
// A stray "=" with no name before it; skip one character so the
|
|
308
|
+
// scan always makes progress.
|
|
309
|
+
cursor += 1;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (attributes.some((existing) => existing.name === attribute.name)) {
|
|
314
|
+
errors.push(parseErrorToken(
|
|
315
|
+
'duplicate-attribute',
|
|
316
|
+
`Attribute "${ attribute.name }" already appeared on this tag.`,
|
|
317
|
+
cursor,
|
|
318
|
+
));
|
|
319
|
+
} else {
|
|
320
|
+
attributes.push({ name: attribute.name, rawName: attribute.rawName, value: attribute.value });
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
cursor = attribute.nextCursor;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const startTagToken = {
|
|
327
|
+
type: 'startTag', name, rawName, attributes, selfClosing, offset: index,
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
return { tokens: [ ...errors, startTagToken ], nextCursor: cursor };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Finds the next "</name" end tag for a raw text / RCDATA element,
|
|
334
|
+
// matching the name case-insensitively and requiring it be followed by
|
|
335
|
+
// whitespace, "/", ">", or end of input, per the tokenizer's raw text
|
|
336
|
+
// end tag open state.
|
|
337
|
+
function findRawTextEndTag(source, searchStart, name) {
|
|
338
|
+
let searchIndex = searchStart;
|
|
339
|
+
|
|
340
|
+
for (;;) {
|
|
341
|
+
const candidate = source.indexOf('</', searchIndex);
|
|
342
|
+
if (candidate === -1) {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const nameStart = candidate + 2;
|
|
347
|
+
const nameEnd = nameStart + name.length;
|
|
348
|
+
const candidateName = source.slice(nameStart, nameEnd);
|
|
349
|
+
const delimiter = source[nameEnd];
|
|
350
|
+
const isDelimiterValid = delimiter === undefined
|
|
351
|
+
|| isAsciiWhitespace(delimiter)
|
|
352
|
+
|| delimiter === '/'
|
|
353
|
+
|| delimiter === '>';
|
|
354
|
+
|
|
355
|
+
if (candidateName.toLowerCase() === name && isDelimiterValid) {
|
|
356
|
+
return candidate;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
searchIndex = candidate + 2;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function scanRawTextContent(source, contentStart, name, decode) {
|
|
364
|
+
const endTagIndex = findRawTextEndTag(source, contentStart, name);
|
|
365
|
+
|
|
366
|
+
if (endTagIndex === null) {
|
|
367
|
+
const data = source.slice(contentStart);
|
|
368
|
+
return {
|
|
369
|
+
tokens: data ? [ { type: 'text', data: decode ? decodeCharacterReferences(data) : data, offset: contentStart } ] : [],
|
|
370
|
+
nextCursor: source.length,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const data = source.slice(contentStart, endTagIndex);
|
|
375
|
+
const tokens = data
|
|
376
|
+
? [ { type: 'text', data: decode ? decodeCharacterReferences(data) : data, offset: contentStart } ]
|
|
377
|
+
: [];
|
|
378
|
+
|
|
379
|
+
const endTag = scanEndTag(source, endTagIndex);
|
|
380
|
+
tokens.push(...endTag.tokens);
|
|
381
|
+
|
|
382
|
+
return { tokens, nextCursor: endTag.nextCursor };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Classifies the markup construct starting at `index`, where
|
|
386
|
+
// source[index] === '<'. Returns null when nothing recognizable follows,
|
|
387
|
+
// so the caller treats the "<" as literal text.
|
|
388
|
+
function scanConstruct(source, index) {
|
|
389
|
+
const next = source[index + 1];
|
|
390
|
+
|
|
391
|
+
if (next === '!') {
|
|
392
|
+
if (source.startsWith('<!--', index)) {
|
|
393
|
+
return scanComment(source, index);
|
|
394
|
+
}
|
|
395
|
+
if (source.slice(index + 2, index + 9).toLowerCase() === 'doctype') {
|
|
396
|
+
return scanDoctype(source, index);
|
|
397
|
+
}
|
|
398
|
+
if (source.startsWith('<![CDATA[', index)) {
|
|
399
|
+
return scanBogusComment(
|
|
400
|
+
source,
|
|
401
|
+
index,
|
|
402
|
+
index + 2,
|
|
403
|
+
'cdata-in-html-content',
|
|
404
|
+
'CDATA sections are only recognized inside foreign content.',
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
return scanBogusComment(
|
|
408
|
+
source,
|
|
409
|
+
index,
|
|
410
|
+
index + 2,
|
|
411
|
+
'incorrectly-opened-comment',
|
|
412
|
+
'Expected "<!--" to start a comment.',
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (next === '/') {
|
|
417
|
+
return scanEndTag(source, index);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (isAsciiLetter(next)) {
|
|
421
|
+
const startTag = scanStartTag(source, index);
|
|
422
|
+
|
|
423
|
+
const tagToken = startTag.tokens[startTag.tokens.length - 1];
|
|
424
|
+
const isRawText = RAW_TEXT_ELEMENTS.has(tagToken.name);
|
|
425
|
+
const isRcdata = RCDATA_ELEMENTS.has(tagToken.name);
|
|
426
|
+
|
|
427
|
+
if (isRawText || isRcdata) {
|
|
428
|
+
const content = scanRawTextContent(source, startTag.nextCursor, tagToken.name, isRcdata);
|
|
429
|
+
return {
|
|
430
|
+
tokens: [ ...startTag.tokens, ...content.tokens ],
|
|
431
|
+
nextCursor: content.nextCursor,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return startTag;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Scans an HTML string into a stream of tokens. Never throws: malformed
|
|
443
|
+
* markup yields a `parseError` token describing the recovery and scanning
|
|
444
|
+
* continues.
|
|
445
|
+
* @param {string} source - Preprocessed HTML source (BOM stripped, line
|
|
446
|
+
* endings normalized).
|
|
447
|
+
* @returns {Generator<Object>} Tokens in source order. See the module
|
|
448
|
+
* documentation for the shape of each `type`.
|
|
449
|
+
*/
|
|
450
|
+
export function* tokenize(source) {
|
|
451
|
+
let cursor = 0;
|
|
452
|
+
let textStart = 0;
|
|
453
|
+
|
|
454
|
+
while (cursor < source.length) {
|
|
455
|
+
const ltIndex = source.indexOf('<', cursor);
|
|
456
|
+
|
|
457
|
+
if (ltIndex === -1) {
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const construct = scanConstruct(source, ltIndex);
|
|
462
|
+
|
|
463
|
+
if (!construct) {
|
|
464
|
+
cursor = ltIndex + 1;
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (ltIndex > textStart) {
|
|
469
|
+
yield { type: 'text', data: decodeCharacterReferences(source.slice(textStart, ltIndex)), offset: textStart };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
yield* construct.tokens;
|
|
473
|
+
|
|
474
|
+
cursor = construct.nextCursor;
|
|
475
|
+
textStart = cursor;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (textStart < source.length) {
|
|
479
|
+
yield { type: 'text', data: decodeCharacterReferences(source.slice(textStart)), offset: textStart };
|
|
480
|
+
}
|
|
481
|
+
}
|