mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-14 06:03:45 +01:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f39fd05e1 | ||
|
|
2d1c61650a | ||
|
|
91bebe3cbf | ||
|
|
1e407e34e5 | ||
|
|
5d2febba7d | ||
|
|
83719f84c2 | ||
|
|
1fbbfe176f | ||
|
|
e992cfca64 | ||
|
|
b748a664a8 | ||
|
|
a7ddd99339 | ||
|
|
24fb73810d |
@@ -1,25 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
|
||||
[*.go]
|
||||
indent_style = tab
|
||||
|
||||
[*.{pug,coffee}]
|
||||
indent_style = tab
|
||||
|
||||
[*.{pug,patch}]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[Jenkinsfile]
|
||||
insert_final_newline = false
|
||||
max_line_length = off
|
||||
@@ -0,0 +1,44 @@
|
||||
<!--
|
||||
|
||||
Note: If you are using www.overleaf.com and have a problem,
|
||||
or if you would like to request a new feature please contact
|
||||
the support team at support@overleaf.com
|
||||
|
||||
This form should only be used to report bugs in the
|
||||
Community Edition release of Overleaf.
|
||||
|
||||
-->
|
||||
|
||||
|
||||
|
||||
<!-- BUG REPORT TEMPLATE -->
|
||||
|
||||
## Steps to Reproduce
|
||||
<!-- Describe the steps leading up to when / where you found the bug. -->
|
||||
<!-- Screenshots may be helpful here. -->
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Expected Behaviour
|
||||
<!-- What should have happened when you completed the steps above? -->
|
||||
|
||||
## Observed Behaviour
|
||||
<!-- What actually happened when you completed the steps above? -->
|
||||
<!-- Screenshots may be helpful here. -->
|
||||
|
||||
## Context
|
||||
<!-- How has this issue affected you? What were you trying to accomplish? -->
|
||||
|
||||
## Technical Info
|
||||
<!-- Provide any technical details that may be applicable (or N/A if not applicable). -->
|
||||
|
||||
* URL:
|
||||
* Browser Name and version:
|
||||
* Operating System and version (desktop or mobile):
|
||||
* Signed in as:
|
||||
* Project and/or file:
|
||||
|
||||
## Analysis
|
||||
<!--- Optionally, document investigation of / suggest a fix for the bug, e.g. 'comes from this line / commit' -->
|
||||
@@ -1,52 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Report a bug
|
||||
title: ''
|
||||
labels: type:bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
<!--
|
||||
|
||||
Note: This form should only be used to report bugs in the
|
||||
Extended CE release of Overleaf.
|
||||
|
||||
-->
|
||||
|
||||
<!-- BUG REPORT TEMPLATE -->
|
||||
|
||||
## Steps to Reproduce
|
||||
|
||||
<!-- Describe the steps leading up to when / where you found the bug. -->
|
||||
<!-- Screenshots may be helpful here. -->
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Expected Behaviour
|
||||
|
||||
<!-- What should have happened when you completed the steps above? -->
|
||||
|
||||
## Observed Behaviour
|
||||
|
||||
<!-- What actually happened when you completed the steps above? -->
|
||||
<!-- Screenshots may be helpful here. -->
|
||||
|
||||
## Context
|
||||
|
||||
<!-- How has this issue affected you? What were you trying to accomplish? -->
|
||||
|
||||
## Technical Info
|
||||
|
||||
<!-- Provide any technical details that may be applicable (or N/A if not applicable). -->
|
||||
|
||||
- URL:
|
||||
- Browser Name and version:
|
||||
- Operating System and version (desktop or mobile):
|
||||
- Signed in as:
|
||||
- Project and/or file:
|
||||
|
||||
## Analysis
|
||||
|
||||
<!--- Optionally, document investigation of / suggest a fix for the bug, e.g. 'comes from this line / commit' -->
|
||||
@@ -1,7 +1,11 @@
|
||||
## Description
|
||||
|
||||
<!-- Goal of the pull request -->
|
||||
|
||||
## Related issues / Pull Requests
|
||||
|
||||
## Related issues / Pull Requests
|
||||
<!-- Fixes #xyz, Contributes to #xyz, Related to #xyz-->
|
||||
|
||||
|
||||
## Contributor Agreement
|
||||
|
||||
- [ ] I confirm I have signed the [Contributor License Agreement](https://github.com/overleaf/overleaf/blob/master/CONTRIBUTING.md#contributor-license-agreement)
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
diff --git a/dist/index.mjs b/dist/index.mjs
|
||||
index b781835b37a1262510bdd66d8d3b399a076e9d68..312c314186d85bb3bc2ab2620e99bca259ef7167 100644
|
||||
--- a/dist/index.mjs
|
||||
+++ b/dist/index.mjs
|
||||
@@ -44,10 +44,9 @@ import { z as z2 } from "zod/v4";
|
||||
|
||||
// src/tool/types.ts
|
||||
import { z } from "zod/v4";
|
||||
-var LATEST_PROTOCOL_VERSION = "2025-11-25";
|
||||
+var LATEST_PROTOCOL_VERSION = "2025-06-18";
|
||||
var SUPPORTED_PROTOCOL_VERSIONS = [
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
- "2025-06-18",
|
||||
"2025-03-26",
|
||||
"2024-11-05"
|
||||
];
|
||||
@@ -1,831 +0,0 @@
|
||||
diff --git a/dist/index.cjs b/dist/index.cjs
|
||||
index 39215ae..b44cb76 100644
|
||||
--- a/dist/index.cjs
|
||||
+++ b/dist/index.cjs
|
||||
@@ -187,16 +187,23 @@ Helper function that returns a transaction spec which inserts a
|
||||
completion's text in the main selection range, and any other
|
||||
selection range that has the same text in front of it.
|
||||
*/
|
||||
-function insertCompletionText(state$1, text, from, to) {
|
||||
+function insertCompletionText(state$1, text, from, to, extend) {
|
||||
let { main } = state$1.selection, fromOff = from - main.from, toOff = to - main.from;
|
||||
return Object.assign(Object.assign({}, state$1.changeByRange(range => {
|
||||
if (range != main && from != to &&
|
||||
state$1.sliceDoc(range.from + fromOff, range.from + toOff) != state$1.sliceDoc(from, to))
|
||||
return { range };
|
||||
- let lines = state$1.toText(text);
|
||||
+ let change = {
|
||||
+ from: range.from + fromOff,
|
||||
+ to: to == main.from ? range.to : range.from + toOff,
|
||||
+ insert: text instanceof state.Text ? text : state$1.toText(text),
|
||||
+ };
|
||||
+ if (extend) {
|
||||
+ extend(state$1, change);
|
||||
+ }
|
||||
return {
|
||||
- changes: { from: range.from + fromOff, to: to == main.from ? range.to : range.from + toOff, insert: lines },
|
||||
- range: state.EditorSelection.cursor(range.from + fromOff + lines.length)
|
||||
+ changes: change,
|
||||
+ range: state.EditorSelection.cursor(change.from + change.insert.length)
|
||||
};
|
||||
})), { scrollIntoView: true, userEvent: "input.complete" });
|
||||
}
|
||||
@@ -389,7 +396,9 @@ const completionConfig = state.Facet.define({
|
||||
filterStrict: false,
|
||||
compareCompletions: (a, b) => a.label.localeCompare(b.label),
|
||||
interactionDelay: 75,
|
||||
- updateSyncTime: 100
|
||||
+ updateSyncTime: 100,
|
||||
+ // overleaf: default to at top which is default CM6 behaviour
|
||||
+ unfilteredResultsAtEnd: false
|
||||
}, {
|
||||
defaultKeymap: (a, b) => a && b,
|
||||
closeOnBlur: (a, b) => a && b,
|
||||
@@ -744,6 +753,7 @@ function score(option) {
|
||||
(option.type ? 1 : 0);
|
||||
}
|
||||
function sortOptions(active, state) {
|
||||
+ var _a;
|
||||
let options = [];
|
||||
let sections = null;
|
||||
let addOption = (option) => {
|
||||
@@ -763,7 +773,8 @@ function sortOptions(active, state) {
|
||||
let getMatch = a.result.getMatch;
|
||||
if (a.result.filter === false) {
|
||||
for (let option of a.result.options) {
|
||||
- addOption(new Option(option, a.source, getMatch ? getMatch(option) : [], 1e9 - options.length));
|
||||
+ let defaultScore = conf.unfilteredResultsAtEnd ? -1e9 : 1e9;
|
||||
+ addOption(new Option(option, a.source, getMatch ? getMatch(option) : [], defaultScore - options.length));
|
||||
}
|
||||
}
|
||||
else {
|
||||
@@ -790,15 +801,42 @@ function sortOptions(active, state) {
|
||||
}
|
||||
}
|
||||
let result = [], prev = null;
|
||||
+ const priorityIndices = new Map();
|
||||
let compare = conf.compareCompletions;
|
||||
for (let opt of options.sort((a, b) => (b.score - a.score) || compare(a.completion, b.completion))) {
|
||||
+ // overleaf: Deduplicate results with dedup options
|
||||
+ // The goal is to keep only the highest priority option, in the
|
||||
+ // highest scoring position.
|
||||
+ const key = (_a = opt.completion.deduplicate) === null || _a === void 0 ? void 0 : _a.key;
|
||||
+ if (key) {
|
||||
+ // Handle merging specifically for deduplicated items item
|
||||
+ const currentOptionIndex = priorityIndices.get(key);
|
||||
+ if (currentOptionIndex === undefined) {
|
||||
+ priorityIndices.set(key, result.length);
|
||||
+ result.push(opt);
|
||||
+ prev = opt.completion;
|
||||
+ }
|
||||
+ else {
|
||||
+ if (result[currentOptionIndex].completion.deduplicate.priority < opt.completion.deduplicate.priority) {
|
||||
+ result[currentOptionIndex] = opt;
|
||||
+ if (currentOptionIndex === result.length - 1) {
|
||||
+ prev = opt.completion;
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ continue;
|
||||
+ }
|
||||
+ // overleaf: end
|
||||
let cur = opt.completion;
|
||||
- if (!prev || prev.label != cur.label || prev.detail != cur.detail ||
|
||||
- (prev.type != null && cur.type != null && prev.type != cur.type) ||
|
||||
- prev.apply != cur.apply || prev.boost != cur.boost)
|
||||
+ if (!prev || prev.label != cur.label)
|
||||
+ result.push(opt);
|
||||
+ // overleaf: we're already handling deduplication, so skip extra merges
|
||||
+ else if (prev.deduplicate)
|
||||
result.push(opt);
|
||||
else if (score(opt.completion) > score(prev))
|
||||
result[result.length - 1] = opt;
|
||||
+ else if (opt.completion.info)
|
||||
+ result[result.length - 1] = opt;
|
||||
prev = opt.completion;
|
||||
}
|
||||
return result;
|
||||
@@ -817,8 +855,9 @@ class CompletionDialog {
|
||||
: new CompletionDialog(this.options, makeAttrs(id, selected), this.tooltip, this.timestamp, selected, this.disabled);
|
||||
}
|
||||
static build(active, state, id, prev, conf, didSetActive) {
|
||||
- if (prev && !didSetActive && active.some(s => s.isPending))
|
||||
- return prev.setDisabled();
|
||||
+ // Overleaf: avoid setting the previous completion state to disabled while completion sources are pending
|
||||
+ // if (prev && !didSetActive && active.some(s => s.isPending))
|
||||
+ // return prev.setDisabled()
|
||||
let options = sortOptions(active, state);
|
||||
if (!options.length)
|
||||
return prev && active.some(a => a.isPending) ? prev.setDisabled() : null;
|
||||
@@ -1017,13 +1056,14 @@ const completionState = state.StateField.define({
|
||||
view.EditorView.contentAttributes.from(f, state => state.attrs)
|
||||
]
|
||||
});
|
||||
+const getCompletionTooltip = (state) => { var _a; return (_a = state.field(completionState, false)) === null || _a === void 0 ? void 0 : _a.tooltip; };
|
||||
function applyCompletion(view, option) {
|
||||
const apply = option.completion.apply || option.completion.label;
|
||||
let result = view.state.field(completionState).active.find(a => a.source == option.source);
|
||||
if (!(result instanceof ActiveResult))
|
||||
return false;
|
||||
if (typeof apply == "string")
|
||||
- view.dispatch(Object.assign(Object.assign({}, insertCompletionText(view.state, apply, result.from, result.to)), { annotations: pickedCompletion.of(option.completion) }));
|
||||
+ view.dispatch(Object.assign(Object.assign({}, insertCompletionText(view.state, apply, result.from, result.to, option.completion.extend)), { annotations: pickedCompletion.of(option.completion) }));
|
||||
else
|
||||
apply(view, option.completion, result.from, result.to);
|
||||
return true;
|
||||
@@ -1559,20 +1599,42 @@ interpreted as indicating a placeholder.
|
||||
function snippet(template) {
|
||||
let snippet = Snippet.parse(template);
|
||||
return (editor, completion, from, to) => {
|
||||
- let { text, ranges } = snippet.instantiate(editor.state, from);
|
||||
- let { main } = editor.state.selection;
|
||||
- let spec = {
|
||||
- changes: { from, to: to == main.from ? main.to : to, insert: state.Text.of(text) },
|
||||
- scrollIntoView: true,
|
||||
- annotations: completion ? [pickedCompletion.of(completion), state.Transaction.userEvent.of("input.complete")] : undefined
|
||||
- };
|
||||
+ let { main } = editor.state.selection, fromOff = from - main.from, toOff = to - main.from;
|
||||
+ let ranges = [];
|
||||
+ let totalOffset = 0;
|
||||
+ let spec = Object.assign(Object.assign({}, editor.state.changeByRange(range => {
|
||||
+ if (range != main && from != to &&
|
||||
+ editor.state.sliceDoc(range.from + fromOff, range.from + toOff) != editor.state.sliceDoc(from, to))
|
||||
+ return { range };
|
||||
+ let { text, ranges: fieldRanges } = snippet.instantiate(editor.state, range.from + fromOff);
|
||||
+ let change = {
|
||||
+ from: range.from + fromOff,
|
||||
+ to: range.from + toOff,
|
||||
+ insert: state.Text.of(text)
|
||||
+ };
|
||||
+ let originalTo = change.to;
|
||||
+ let offset = change.insert.length + fromOff;
|
||||
+ if (completion.extend) {
|
||||
+ completion.extend(editor.state, change);
|
||||
+ offset += originalTo - change.to;
|
||||
+ }
|
||||
+ for (const fieldRange of fieldRanges) {
|
||||
+ ranges.push(new FieldRange(fieldRange.field, fieldRange.from + totalOffset, fieldRange.to + totalOffset));
|
||||
+ }
|
||||
+ totalOffset += offset;
|
||||
+ return {
|
||||
+ changes: change,
|
||||
+ range: state.EditorSelection.cursor(change.from + change.insert.length)
|
||||
+ };
|
||||
+ })), { scrollIntoView: true, annotations: completion ? [pickedCompletion.of(completion), state.Transaction.userEvent.of("input.complete")] : undefined, effects: [] });
|
||||
if (ranges.length)
|
||||
spec.selection = fieldSelection(ranges, 0);
|
||||
if (ranges.some(r => r.field > 0)) {
|
||||
let active = new ActiveSnippet(ranges, 0);
|
||||
- let effects = spec.effects = [setActive.of(active)];
|
||||
- if (editor.state.field(snippetState, false) === undefined)
|
||||
- effects.push(state.StateEffect.appendConfig.of([snippetState, addSnippetKeymap, snippetPointerHandler, baseTheme]));
|
||||
+ spec.effects.push(setActive.of(active));
|
||||
+ if (editor.state.field(snippetState, false) === undefined) {
|
||||
+ spec.effects.push(state.StateEffect.appendConfig.of([snippetState, addSnippetKeymap, snippetPointerHandler, baseTheme]));
|
||||
+ }
|
||||
}
|
||||
editor.dispatch(editor.state.update(spec));
|
||||
};
|
||||
@@ -1746,7 +1808,8 @@ const completeAnyWord = context => {
|
||||
const defaults = {
|
||||
brackets: ["(", "[", "{", "'", '"'],
|
||||
before: ")]}:;>",
|
||||
- stringPrefixes: []
|
||||
+ stringPrefixes: [],
|
||||
+ buildInsert: (state, range, open, close) => open + close,
|
||||
};
|
||||
const closeBracketEffect = state.StateEffect.define({
|
||||
map(value, mapping) {
|
||||
@@ -1854,8 +1917,8 @@ function insertBracket(state$1, bracket) {
|
||||
for (let tok of tokens) {
|
||||
let closed = closing(state.codePointAt(tok, 0));
|
||||
if (bracket == tok)
|
||||
- return closed == tok ? handleSame(state$1, tok, tokens.indexOf(tok + tok + tok) > -1, conf)
|
||||
- : handleOpen(state$1, tok, closed, conf.before || defaults.before);
|
||||
+ return closed == tok ? handleSame(state$1, tok, tokens.indexOf(tok + tok) > -1, tokens.indexOf(tok + tok + tok) > -1, conf)
|
||||
+ : handleOpen(state$1, tok, closed, conf.before || defaults.before, conf);
|
||||
if (bracket == closed && closedBracketAt(state$1, state$1.selection.main.from))
|
||||
return handleClose(state$1, tok, closed);
|
||||
}
|
||||
@@ -1877,17 +1940,21 @@ function prevChar(doc, pos) {
|
||||
let prev = doc.sliceString(pos - 2, pos);
|
||||
return state.codePointSize(state.codePointAt(prev, 0)) == prev.length ? prev : prev.slice(1);
|
||||
}
|
||||
-function handleOpen(state$1, open, close, closeBefore) {
|
||||
+function handleOpen(state$1, open, close, closeBefore, config) {
|
||||
+ let buildInsert = config.buildInsert || defaults.buildInsert;
|
||||
let dont = null, changes = state$1.changeByRange(range => {
|
||||
+ var _a;
|
||||
if (!range.empty)
|
||||
return { changes: [{ insert: open, from: range.from }, { insert: close, from: range.to }],
|
||||
effects: closeBracketEffect.of(range.to + open.length),
|
||||
range: state.EditorSelection.range(range.anchor + open.length, range.head + open.length) };
|
||||
let next = nextChar(state$1.doc, range.head);
|
||||
- if (!next || /\s/.test(next) || closeBefore.indexOf(next) > -1)
|
||||
- return { changes: { insert: open + close, from: range.head },
|
||||
- effects: closeBracketEffect.of(range.head + open.length),
|
||||
+ if (!next || /\s/.test(next) || closeBefore.indexOf(next) > -1) {
|
||||
+ const insert = (_a = buildInsert(state$1, range, open, close)) !== null && _a !== void 0 ? _a : open + close;
|
||||
+ return { changes: { insert, from: range.head },
|
||||
+ effects: insert === open ? [] : closeBracketEffect.of(range.head + open.length),
|
||||
range: state.EditorSelection.cursor(range.head + open.length) };
|
||||
+ }
|
||||
return { range: dont = range };
|
||||
});
|
||||
return dont ? null : state$1.update(changes, {
|
||||
@@ -1909,18 +1976,36 @@ function handleClose(state$1, _open, close) {
|
||||
}
|
||||
// Handles cases where the open and close token are the same, and
|
||||
// possibly triple quotes (as in `"""abc"""`-style quoting).
|
||||
-function handleSame(state$1, token, allowTriple, config) {
|
||||
+function handleSame(state$1, token, allowDouble, allowTriple, config) {
|
||||
let stringPrefixes = config.stringPrefixes || defaults.stringPrefixes;
|
||||
+ let buildInsert = config.buildInsert || defaults.buildInsert;
|
||||
let dont = null, changes = state$1.changeByRange(range => {
|
||||
+ var _a, _b, _c;
|
||||
if (!range.empty)
|
||||
return { changes: [{ insert: token, from: range.from }, { insert: token, from: range.to }],
|
||||
effects: closeBracketEffect.of(range.to + token.length),
|
||||
range: state.EditorSelection.range(range.anchor + token.length, range.head + token.length) };
|
||||
let pos = range.head, next = nextChar(state$1.doc, pos), start;
|
||||
- if (next == token) {
|
||||
+ if (allowTriple && state$1.sliceDoc(pos - 2 * token.length, pos) == token + token &&
|
||||
+ (start = canStartStringAt(state$1, pos - 2 * token.length, stringPrefixes)) > -1 &&
|
||||
+ nodeStart(state$1, start)) {
|
||||
+ return { changes: { insert: token + token + token + token, from: pos },
|
||||
+ effects: closeBracketEffect.of(pos + token.length),
|
||||
+ range: state.EditorSelection.cursor(pos + token.length) };
|
||||
+ }
|
||||
+ else if (allowDouble && state$1.sliceDoc(pos - token.length, pos) == token &&
|
||||
+ (start = canStartStringAt(state$1, pos - token.length, stringPrefixes)) > -1 &&
|
||||
+ nodeStart(state$1, start)) {
|
||||
+ let insert = (_a = buildInsert(state$1, range, token, token)) !== null && _a !== void 0 ? _a : token + token;
|
||||
+ return { changes: { insert, from: pos },
|
||||
+ effects: insert === token ? [] : closeBracketEffect.of(pos + token.length),
|
||||
+ range: state.EditorSelection.cursor(pos + token.length) };
|
||||
+ }
|
||||
+ else if (next == token) {
|
||||
if (nodeStart(state$1, pos)) {
|
||||
- return { changes: { insert: token + token, from: pos },
|
||||
- effects: closeBracketEffect.of(pos + token.length),
|
||||
+ let insert = (_b = buildInsert(state$1, range, token, token)) !== null && _b !== void 0 ? _b : token + token;
|
||||
+ return { changes: { insert, from: pos },
|
||||
+ effects: insert === token ? [] : closeBracketEffect.of(pos + token.length),
|
||||
range: state.EditorSelection.cursor(pos + token.length) };
|
||||
}
|
||||
else if (closedBracketAt(state$1, pos)) {
|
||||
@@ -1930,18 +2015,13 @@ function handleSame(state$1, token, allowTriple, config) {
|
||||
range: state.EditorSelection.cursor(pos + content.length) };
|
||||
}
|
||||
}
|
||||
- else if (allowTriple && state$1.sliceDoc(pos - 2 * token.length, pos) == token + token &&
|
||||
- (start = canStartStringAt(state$1, pos - 2 * token.length, stringPrefixes)) > -1 &&
|
||||
- nodeStart(state$1, start)) {
|
||||
- return { changes: { insert: token + token + token + token, from: pos },
|
||||
- effects: closeBracketEffect.of(pos + token.length),
|
||||
- range: state.EditorSelection.cursor(pos + token.length) };
|
||||
- }
|
||||
else if (state$1.charCategorizer(pos)(next) != state.CharCategory.Word) {
|
||||
- if (canStartStringAt(state$1, pos, stringPrefixes) > -1 && !probablyInString(state$1, pos, token, stringPrefixes))
|
||||
- return { changes: { insert: token + token, from: pos },
|
||||
- effects: closeBracketEffect.of(pos + token.length),
|
||||
+ if (canStartStringAt(state$1, pos, stringPrefixes) > -1 && !probablyInString(state$1, pos, token, stringPrefixes)) {
|
||||
+ const insert = (_c = buildInsert(state$1, range, token, token)) !== null && _c !== void 0 ? _c : token + token;
|
||||
+ return { changes: { insert, from: pos },
|
||||
+ effects: insert === token ? [] : closeBracketEffect.of(pos + token.length),
|
||||
range: state.EditorSelection.cursor(pos + token.length) };
|
||||
+ }
|
||||
}
|
||||
return { range: dont = range };
|
||||
});
|
||||
@@ -2086,6 +2166,7 @@ exports.completionKeymap = completionKeymap;
|
||||
exports.completionStatus = completionStatus;
|
||||
exports.currentCompletions = currentCompletions;
|
||||
exports.deleteBracketPair = deleteBracketPair;
|
||||
+exports.getCompletionTooltip = getCompletionTooltip;
|
||||
exports.hasNextSnippetField = hasNextSnippetField;
|
||||
exports.hasPrevSnippetField = hasPrevSnippetField;
|
||||
exports.ifIn = ifIn;
|
||||
@@ -2093,8 +2174,10 @@ exports.ifNotIn = ifNotIn;
|
||||
exports.insertBracket = insertBracket;
|
||||
exports.insertCompletionText = insertCompletionText;
|
||||
exports.moveCompletionSelection = moveCompletionSelection;
|
||||
+exports.nextChar = nextChar;
|
||||
exports.nextSnippetField = nextSnippetField;
|
||||
exports.pickedCompletion = pickedCompletion;
|
||||
+exports.prevChar = prevChar;
|
||||
exports.prevSnippetField = prevSnippetField;
|
||||
exports.selectedCompletion = selectedCompletion;
|
||||
exports.selectedCompletionIndex = selectedCompletionIndex;
|
||||
diff --git a/dist/index.d.cts b/dist/index.d.cts
|
||||
index b57b8f6..fce47ab 100644
|
||||
--- a/dist/index.d.cts
|
||||
+++ b/dist/index.d.cts
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as _codemirror_state from '@codemirror/state';
|
||||
-import { EditorState, ChangeDesc, TransactionSpec, Transaction, StateCommand, Facet, Extension, StateEffect } from '@codemirror/state';
|
||||
-import { EditorView, Rect, KeyBinding, Command } from '@codemirror/view';
|
||||
+import { EditorState, Text, ChangeDesc, TransactionSpec, StateCommand, Transaction, Facet, SelectionRange, Extension, StateEffect } from '@codemirror/state';
|
||||
+import { EditorView, Rect, KeyBinding, Tooltip, Command } from '@codemirror/view';
|
||||
import * as _lezer_common from '@lezer/common';
|
||||
|
||||
/**
|
||||
@@ -73,6 +73,19 @@ interface Completion {
|
||||
a `{name}` object.
|
||||
*/
|
||||
section?: string | CompletionSection;
|
||||
+ /**
|
||||
+ Can be used to alter the change created when the completion is applied
|
||||
+ */
|
||||
+ extend?: ExtendCompletion;
|
||||
+ /**
|
||||
+ If multiple sources return the same result, use this field to specifiy a
|
||||
+ deduplication key as well as a priority. For each unique key, only the
|
||||
+ completion with the highest priority will be shown.
|
||||
+ */
|
||||
+ deduplicate?: {
|
||||
+ key: string;
|
||||
+ priority: number;
|
||||
+ };
|
||||
}
|
||||
/**
|
||||
The type returned from
|
||||
@@ -306,12 +319,17 @@ This annotation is added to transactions that are produced by
|
||||
picking a completion.
|
||||
*/
|
||||
declare const pickedCompletion: _codemirror_state.AnnotationType<Completion>;
|
||||
+type ExtendCompletion = (state: EditorState, change: {
|
||||
+ from: number;
|
||||
+ to: number;
|
||||
+ insert: string | Text;
|
||||
+}) => void;
|
||||
/**
|
||||
Helper function that returns a transaction spec which inserts a
|
||||
completion's text in the main selection range, and any other
|
||||
selection range that has the same text in front of it.
|
||||
*/
|
||||
-declare function insertCompletionText(state: EditorState, text: string, from: number, to: number): TransactionSpec;
|
||||
+declare function insertCompletionText(state: EditorState, text: string | Text, from: number, to: number, extend?: ExtendCompletion): TransactionSpec;
|
||||
|
||||
interface CompletionConfig {
|
||||
/**
|
||||
@@ -441,6 +459,10 @@ interface CompletionConfig {
|
||||
milliseconds.
|
||||
*/
|
||||
updateSyncTime?: number;
|
||||
+ /**
|
||||
+ overleaf: Move unfiltered results after the filtered ones
|
||||
+ */
|
||||
+ unfilteredResultsAtEnd?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -514,6 +536,8 @@ applies the snippet.
|
||||
*/
|
||||
declare function snippetCompletion(template: string, completion: Completion): Completion;
|
||||
|
||||
+declare const getCompletionTooltip: (state: EditorState) => Tooltip | undefined | null;
|
||||
+
|
||||
/**
|
||||
Returns a command that moves the completion selection forward or
|
||||
backward by the given amount.
|
||||
@@ -562,6 +586,11 @@ interface CloseBracketConfig {
|
||||
these prefixes before the opening quote.
|
||||
*/
|
||||
stringPrefixes?: string[];
|
||||
+ /**
|
||||
+ An optional callback for overriding the content that's inserted
|
||||
+ based on surrounding characters
|
||||
+ */
|
||||
+ buildInsert?: (state: EditorState, range: SelectionRange, open: string, close: string) => string;
|
||||
}
|
||||
/**
|
||||
Extension to enable bracket-closing behavior. When a closeable
|
||||
@@ -593,6 +622,8 @@ to programmatically insert brackets—the
|
||||
take care of running this for user input.)
|
||||
*/
|
||||
declare function insertBracket(state: EditorState, bracket: string): Transaction | null;
|
||||
+declare function nextChar(doc: Text, pos: number): string;
|
||||
+declare function prevChar(doc: Text, pos: number): string;
|
||||
|
||||
/**
|
||||
Returns an extension that enables autocompletion.
|
||||
@@ -636,4 +667,5 @@ the currently selected completion.
|
||||
*/
|
||||
declare function setSelectedCompletion(index: number): StateEffect<unknown>;
|
||||
|
||||
-export { type CloseBracketConfig, type Completion, CompletionContext, type CompletionInfo, type CompletionResult, type CompletionSection, type CompletionSource, acceptCompletion, autocompletion, clearSnippet, closeBrackets, closeBracketsKeymap, closeCompletion, completeAnyWord, completeFromList, completionKeymap, completionStatus, currentCompletions, deleteBracketPair, hasNextSnippetField, hasPrevSnippetField, ifIn, ifNotIn, insertBracket, insertCompletionText, moveCompletionSelection, nextSnippetField, pickedCompletion, prevSnippetField, selectedCompletion, selectedCompletionIndex, setSelectedCompletion, snippet, snippetCompletion, snippetKeymap, startCompletion };
|
||||
+export { CompletionContext, acceptCompletion, autocompletion, clearSnippet, closeBrackets, closeBracketsKeymap, closeCompletion, completeAnyWord, completeFromList, completionKeymap, completionStatus, currentCompletions, deleteBracketPair, getCompletionTooltip, hasNextSnippetField, hasPrevSnippetField, ifIn, ifNotIn, insertBracket, insertCompletionText, moveCompletionSelection, nextChar, nextSnippetField, pickedCompletion, prevChar, prevSnippetField, selectedCompletion, selectedCompletionIndex, setSelectedCompletion, snippet, snippetCompletion, snippetKeymap, startCompletion };
|
||||
+export type { CloseBracketConfig, Completion, CompletionInfo, CompletionResult, CompletionSection, CompletionSource };
|
||||
diff --git a/dist/index.d.ts b/dist/index.d.ts
|
||||
index b57b8f6..fce47ab 100644
|
||||
--- a/dist/index.d.ts
|
||||
+++ b/dist/index.d.ts
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as _codemirror_state from '@codemirror/state';
|
||||
-import { EditorState, ChangeDesc, TransactionSpec, Transaction, StateCommand, Facet, Extension, StateEffect } from '@codemirror/state';
|
||||
-import { EditorView, Rect, KeyBinding, Command } from '@codemirror/view';
|
||||
+import { EditorState, Text, ChangeDesc, TransactionSpec, StateCommand, Transaction, Facet, SelectionRange, Extension, StateEffect } from '@codemirror/state';
|
||||
+import { EditorView, Rect, KeyBinding, Tooltip, Command } from '@codemirror/view';
|
||||
import * as _lezer_common from '@lezer/common';
|
||||
|
||||
/**
|
||||
@@ -73,6 +73,19 @@ interface Completion {
|
||||
a `{name}` object.
|
||||
*/
|
||||
section?: string | CompletionSection;
|
||||
+ /**
|
||||
+ Can be used to alter the change created when the completion is applied
|
||||
+ */
|
||||
+ extend?: ExtendCompletion;
|
||||
+ /**
|
||||
+ If multiple sources return the same result, use this field to specifiy a
|
||||
+ deduplication key as well as a priority. For each unique key, only the
|
||||
+ completion with the highest priority will be shown.
|
||||
+ */
|
||||
+ deduplicate?: {
|
||||
+ key: string;
|
||||
+ priority: number;
|
||||
+ };
|
||||
}
|
||||
/**
|
||||
The type returned from
|
||||
@@ -306,12 +319,17 @@ This annotation is added to transactions that are produced by
|
||||
picking a completion.
|
||||
*/
|
||||
declare const pickedCompletion: _codemirror_state.AnnotationType<Completion>;
|
||||
+type ExtendCompletion = (state: EditorState, change: {
|
||||
+ from: number;
|
||||
+ to: number;
|
||||
+ insert: string | Text;
|
||||
+}) => void;
|
||||
/**
|
||||
Helper function that returns a transaction spec which inserts a
|
||||
completion's text in the main selection range, and any other
|
||||
selection range that has the same text in front of it.
|
||||
*/
|
||||
-declare function insertCompletionText(state: EditorState, text: string, from: number, to: number): TransactionSpec;
|
||||
+declare function insertCompletionText(state: EditorState, text: string | Text, from: number, to: number, extend?: ExtendCompletion): TransactionSpec;
|
||||
|
||||
interface CompletionConfig {
|
||||
/**
|
||||
@@ -441,6 +459,10 @@ interface CompletionConfig {
|
||||
milliseconds.
|
||||
*/
|
||||
updateSyncTime?: number;
|
||||
+ /**
|
||||
+ overleaf: Move unfiltered results after the filtered ones
|
||||
+ */
|
||||
+ unfilteredResultsAtEnd?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -514,6 +536,8 @@ applies the snippet.
|
||||
*/
|
||||
declare function snippetCompletion(template: string, completion: Completion): Completion;
|
||||
|
||||
+declare const getCompletionTooltip: (state: EditorState) => Tooltip | undefined | null;
|
||||
+
|
||||
/**
|
||||
Returns a command that moves the completion selection forward or
|
||||
backward by the given amount.
|
||||
@@ -562,6 +586,11 @@ interface CloseBracketConfig {
|
||||
these prefixes before the opening quote.
|
||||
*/
|
||||
stringPrefixes?: string[];
|
||||
+ /**
|
||||
+ An optional callback for overriding the content that's inserted
|
||||
+ based on surrounding characters
|
||||
+ */
|
||||
+ buildInsert?: (state: EditorState, range: SelectionRange, open: string, close: string) => string;
|
||||
}
|
||||
/**
|
||||
Extension to enable bracket-closing behavior. When a closeable
|
||||
@@ -593,6 +622,8 @@ to programmatically insert brackets—the
|
||||
take care of running this for user input.)
|
||||
*/
|
||||
declare function insertBracket(state: EditorState, bracket: string): Transaction | null;
|
||||
+declare function nextChar(doc: Text, pos: number): string;
|
||||
+declare function prevChar(doc: Text, pos: number): string;
|
||||
|
||||
/**
|
||||
Returns an extension that enables autocompletion.
|
||||
@@ -636,4 +667,5 @@ the currently selected completion.
|
||||
*/
|
||||
declare function setSelectedCompletion(index: number): StateEffect<unknown>;
|
||||
|
||||
-export { type CloseBracketConfig, type Completion, CompletionContext, type CompletionInfo, type CompletionResult, type CompletionSection, type CompletionSource, acceptCompletion, autocompletion, clearSnippet, closeBrackets, closeBracketsKeymap, closeCompletion, completeAnyWord, completeFromList, completionKeymap, completionStatus, currentCompletions, deleteBracketPair, hasNextSnippetField, hasPrevSnippetField, ifIn, ifNotIn, insertBracket, insertCompletionText, moveCompletionSelection, nextSnippetField, pickedCompletion, prevSnippetField, selectedCompletion, selectedCompletionIndex, setSelectedCompletion, snippet, snippetCompletion, snippetKeymap, startCompletion };
|
||||
+export { CompletionContext, acceptCompletion, autocompletion, clearSnippet, closeBrackets, closeBracketsKeymap, closeCompletion, completeAnyWord, completeFromList, completionKeymap, completionStatus, currentCompletions, deleteBracketPair, getCompletionTooltip, hasNextSnippetField, hasPrevSnippetField, ifIn, ifNotIn, insertBracket, insertCompletionText, moveCompletionSelection, nextChar, nextSnippetField, pickedCompletion, prevChar, prevSnippetField, selectedCompletion, selectedCompletionIndex, setSelectedCompletion, snippet, snippetCompletion, snippetKeymap, startCompletion };
|
||||
+export type { CloseBracketConfig, Completion, CompletionInfo, CompletionResult, CompletionSection, CompletionSource };
|
||||
diff --git a/dist/index.js b/dist/index.js
|
||||
index 4729223..9361a53 100644
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -1,4 +1,4 @@
|
||||
-import { Annotation, StateEffect, EditorSelection, codePointAt, codePointSize, fromCodePoint, Facet, combineConfig, StateField, Prec, Text, Transaction, MapMode, RangeValue, RangeSet, CharCategory } from '@codemirror/state';
|
||||
+import { Annotation, StateEffect, Text, EditorSelection, codePointAt, codePointSize, fromCodePoint, Facet, combineConfig, StateField, Prec, Transaction, MapMode, RangeValue, RangeSet, CharCategory } from '@codemirror/state';
|
||||
import { Direction, logException, showTooltip, EditorView, ViewPlugin, getTooltip, Decoration, WidgetType, keymap } from '@codemirror/view';
|
||||
import { syntaxTree, indentUnit } from '@codemirror/language';
|
||||
|
||||
@@ -185,16 +185,23 @@ Helper function that returns a transaction spec which inserts a
|
||||
completion's text in the main selection range, and any other
|
||||
selection range that has the same text in front of it.
|
||||
*/
|
||||
-function insertCompletionText(state, text, from, to) {
|
||||
+function insertCompletionText(state, text, from, to, extend) {
|
||||
let { main } = state.selection, fromOff = from - main.from, toOff = to - main.from;
|
||||
return Object.assign(Object.assign({}, state.changeByRange(range => {
|
||||
if (range != main && from != to &&
|
||||
state.sliceDoc(range.from + fromOff, range.from + toOff) != state.sliceDoc(from, to))
|
||||
return { range };
|
||||
- let lines = state.toText(text);
|
||||
+ let change = {
|
||||
+ from: range.from + fromOff,
|
||||
+ to: to == main.from ? range.to : range.from + toOff,
|
||||
+ insert: text instanceof Text ? text : state.toText(text),
|
||||
+ };
|
||||
+ if (extend) {
|
||||
+ extend(state, change);
|
||||
+ }
|
||||
return {
|
||||
- changes: { from: range.from + fromOff, to: to == main.from ? range.to : range.from + toOff, insert: lines },
|
||||
- range: EditorSelection.cursor(range.from + fromOff + lines.length)
|
||||
+ changes: change,
|
||||
+ range: EditorSelection.cursor(change.from + change.insert.length)
|
||||
};
|
||||
})), { scrollIntoView: true, userEvent: "input.complete" });
|
||||
}
|
||||
@@ -387,7 +394,9 @@ const completionConfig = /*@__PURE__*/Facet.define({
|
||||
filterStrict: false,
|
||||
compareCompletions: (a, b) => a.label.localeCompare(b.label),
|
||||
interactionDelay: 75,
|
||||
- updateSyncTime: 100
|
||||
+ updateSyncTime: 100,
|
||||
+ // overleaf: default to at top which is default CM6 behaviour
|
||||
+ unfilteredResultsAtEnd: false
|
||||
}, {
|
||||
defaultKeymap: (a, b) => a && b,
|
||||
closeOnBlur: (a, b) => a && b,
|
||||
@@ -742,6 +751,7 @@ function score(option) {
|
||||
(option.type ? 1 : 0);
|
||||
}
|
||||
function sortOptions(active, state) {
|
||||
+ var _a;
|
||||
let options = [];
|
||||
let sections = null;
|
||||
let addOption = (option) => {
|
||||
@@ -761,7 +771,8 @@ function sortOptions(active, state) {
|
||||
let getMatch = a.result.getMatch;
|
||||
if (a.result.filter === false) {
|
||||
for (let option of a.result.options) {
|
||||
- addOption(new Option(option, a.source, getMatch ? getMatch(option) : [], 1e9 - options.length));
|
||||
+ let defaultScore = conf.unfilteredResultsAtEnd ? -1e9 : 1e9;
|
||||
+ addOption(new Option(option, a.source, getMatch ? getMatch(option) : [], defaultScore - options.length));
|
||||
}
|
||||
}
|
||||
else {
|
||||
@@ -788,15 +799,42 @@ function sortOptions(active, state) {
|
||||
}
|
||||
}
|
||||
let result = [], prev = null;
|
||||
+ const priorityIndices = new Map();
|
||||
let compare = conf.compareCompletions;
|
||||
for (let opt of options.sort((a, b) => (b.score - a.score) || compare(a.completion, b.completion))) {
|
||||
+ // overleaf: Deduplicate results with dedup options
|
||||
+ // The goal is to keep only the highest priority option, in the
|
||||
+ // highest scoring position.
|
||||
+ const key = (_a = opt.completion.deduplicate) === null || _a === void 0 ? void 0 : _a.key;
|
||||
+ if (key) {
|
||||
+ // Handle merging specifically for deduplicated items item
|
||||
+ const currentOptionIndex = priorityIndices.get(key);
|
||||
+ if (currentOptionIndex === undefined) {
|
||||
+ priorityIndices.set(key, result.length);
|
||||
+ result.push(opt);
|
||||
+ prev = opt.completion;
|
||||
+ }
|
||||
+ else {
|
||||
+ if (result[currentOptionIndex].completion.deduplicate.priority < opt.completion.deduplicate.priority) {
|
||||
+ result[currentOptionIndex] = opt;
|
||||
+ if (currentOptionIndex === result.length - 1) {
|
||||
+ prev = opt.completion;
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ continue;
|
||||
+ }
|
||||
+ // overleaf: end
|
||||
let cur = opt.completion;
|
||||
- if (!prev || prev.label != cur.label || prev.detail != cur.detail ||
|
||||
- (prev.type != null && cur.type != null && prev.type != cur.type) ||
|
||||
- prev.apply != cur.apply || prev.boost != cur.boost)
|
||||
+ if (!prev || prev.label != cur.label)
|
||||
+ result.push(opt);
|
||||
+ // overleaf: we're already handling deduplication, so skip extra merges
|
||||
+ else if (prev.deduplicate)
|
||||
result.push(opt);
|
||||
else if (score(opt.completion) > score(prev))
|
||||
result[result.length - 1] = opt;
|
||||
+ else if (opt.completion.info)
|
||||
+ result[result.length - 1] = opt;
|
||||
prev = opt.completion;
|
||||
}
|
||||
return result;
|
||||
@@ -815,8 +853,9 @@ class CompletionDialog {
|
||||
: new CompletionDialog(this.options, makeAttrs(id, selected), this.tooltip, this.timestamp, selected, this.disabled);
|
||||
}
|
||||
static build(active, state, id, prev, conf, didSetActive) {
|
||||
- if (prev && !didSetActive && active.some(s => s.isPending))
|
||||
- return prev.setDisabled();
|
||||
+ // Overleaf: avoid setting the previous completion state to disabled while completion sources are pending
|
||||
+ // if (prev && !didSetActive && active.some(s => s.isPending))
|
||||
+ // return prev.setDisabled()
|
||||
let options = sortOptions(active, state);
|
||||
if (!options.length)
|
||||
return prev && active.some(a => a.isPending) ? prev.setDisabled() : null;
|
||||
@@ -1015,13 +1054,14 @@ const completionState = /*@__PURE__*/StateField.define({
|
||||
EditorView.contentAttributes.from(f, state => state.attrs)
|
||||
]
|
||||
});
|
||||
+const getCompletionTooltip = (state) => { var _a; return (_a = state.field(completionState, false)) === null || _a === void 0 ? void 0 : _a.tooltip; };
|
||||
function applyCompletion(view, option) {
|
||||
const apply = option.completion.apply || option.completion.label;
|
||||
let result = view.state.field(completionState).active.find(a => a.source == option.source);
|
||||
if (!(result instanceof ActiveResult))
|
||||
return false;
|
||||
if (typeof apply == "string")
|
||||
- view.dispatch(Object.assign(Object.assign({}, insertCompletionText(view.state, apply, result.from, result.to)), { annotations: pickedCompletion.of(option.completion) }));
|
||||
+ view.dispatch(Object.assign(Object.assign({}, insertCompletionText(view.state, apply, result.from, result.to, option.completion.extend)), { annotations: pickedCompletion.of(option.completion) }));
|
||||
else
|
||||
apply(view, option.completion, result.from, result.to);
|
||||
return true;
|
||||
@@ -1557,20 +1597,42 @@ interpreted as indicating a placeholder.
|
||||
function snippet(template) {
|
||||
let snippet = Snippet.parse(template);
|
||||
return (editor, completion, from, to) => {
|
||||
- let { text, ranges } = snippet.instantiate(editor.state, from);
|
||||
- let { main } = editor.state.selection;
|
||||
- let spec = {
|
||||
- changes: { from, to: to == main.from ? main.to : to, insert: Text.of(text) },
|
||||
- scrollIntoView: true,
|
||||
- annotations: completion ? [pickedCompletion.of(completion), Transaction.userEvent.of("input.complete")] : undefined
|
||||
- };
|
||||
+ let { main } = editor.state.selection, fromOff = from - main.from, toOff = to - main.from;
|
||||
+ let ranges = [];
|
||||
+ let totalOffset = 0;
|
||||
+ let spec = Object.assign(Object.assign({}, editor.state.changeByRange(range => {
|
||||
+ if (range != main && from != to &&
|
||||
+ editor.state.sliceDoc(range.from + fromOff, range.from + toOff) != editor.state.sliceDoc(from, to))
|
||||
+ return { range };
|
||||
+ let { text, ranges: fieldRanges } = snippet.instantiate(editor.state, range.from + fromOff);
|
||||
+ let change = {
|
||||
+ from: range.from + fromOff,
|
||||
+ to: range.from + toOff,
|
||||
+ insert: Text.of(text)
|
||||
+ };
|
||||
+ let originalTo = change.to;
|
||||
+ let offset = change.insert.length + fromOff;
|
||||
+ if (completion.extend) {
|
||||
+ completion.extend(editor.state, change);
|
||||
+ offset += originalTo - change.to;
|
||||
+ }
|
||||
+ for (const fieldRange of fieldRanges) {
|
||||
+ ranges.push(new FieldRange(fieldRange.field, fieldRange.from + totalOffset, fieldRange.to + totalOffset));
|
||||
+ }
|
||||
+ totalOffset += offset;
|
||||
+ return {
|
||||
+ changes: change,
|
||||
+ range: EditorSelection.cursor(change.from + change.insert.length)
|
||||
+ };
|
||||
+ })), { scrollIntoView: true, annotations: completion ? [pickedCompletion.of(completion), Transaction.userEvent.of("input.complete")] : undefined, effects: [] });
|
||||
if (ranges.length)
|
||||
spec.selection = fieldSelection(ranges, 0);
|
||||
if (ranges.some(r => r.field > 0)) {
|
||||
let active = new ActiveSnippet(ranges, 0);
|
||||
- let effects = spec.effects = [setActive.of(active)];
|
||||
- if (editor.state.field(snippetState, false) === undefined)
|
||||
- effects.push(StateEffect.appendConfig.of([snippetState, addSnippetKeymap, snippetPointerHandler, baseTheme]));
|
||||
+ spec.effects.push(setActive.of(active));
|
||||
+ if (editor.state.field(snippetState, false) === undefined) {
|
||||
+ spec.effects.push(StateEffect.appendConfig.of([snippetState, addSnippetKeymap, snippetPointerHandler, baseTheme]));
|
||||
+ }
|
||||
}
|
||||
editor.dispatch(editor.state.update(spec));
|
||||
};
|
||||
@@ -1744,7 +1806,8 @@ const completeAnyWord = context => {
|
||||
const defaults = {
|
||||
brackets: ["(", "[", "{", "'", '"'],
|
||||
before: ")]}:;>",
|
||||
- stringPrefixes: []
|
||||
+ stringPrefixes: [],
|
||||
+ buildInsert: (state, range, open, close) => open + close,
|
||||
};
|
||||
const closeBracketEffect = /*@__PURE__*/StateEffect.define({
|
||||
map(value, mapping) {
|
||||
@@ -1852,8 +1915,8 @@ function insertBracket(state, bracket) {
|
||||
for (let tok of tokens) {
|
||||
let closed = closing(codePointAt(tok, 0));
|
||||
if (bracket == tok)
|
||||
- return closed == tok ? handleSame(state, tok, tokens.indexOf(tok + tok + tok) > -1, conf)
|
||||
- : handleOpen(state, tok, closed, conf.before || defaults.before);
|
||||
+ return closed == tok ? handleSame(state, tok, tokens.indexOf(tok + tok) > -1, tokens.indexOf(tok + tok + tok) > -1, conf)
|
||||
+ : handleOpen(state, tok, closed, conf.before || defaults.before, conf);
|
||||
if (bracket == closed && closedBracketAt(state, state.selection.main.from))
|
||||
return handleClose(state, tok, closed);
|
||||
}
|
||||
@@ -1875,17 +1938,21 @@ function prevChar(doc, pos) {
|
||||
let prev = doc.sliceString(pos - 2, pos);
|
||||
return codePointSize(codePointAt(prev, 0)) == prev.length ? prev : prev.slice(1);
|
||||
}
|
||||
-function handleOpen(state, open, close, closeBefore) {
|
||||
+function handleOpen(state, open, close, closeBefore, config) {
|
||||
+ let buildInsert = config.buildInsert || defaults.buildInsert;
|
||||
let dont = null, changes = state.changeByRange(range => {
|
||||
+ var _a;
|
||||
if (!range.empty)
|
||||
return { changes: [{ insert: open, from: range.from }, { insert: close, from: range.to }],
|
||||
effects: closeBracketEffect.of(range.to + open.length),
|
||||
range: EditorSelection.range(range.anchor + open.length, range.head + open.length) };
|
||||
let next = nextChar(state.doc, range.head);
|
||||
- if (!next || /\s/.test(next) || closeBefore.indexOf(next) > -1)
|
||||
- return { changes: { insert: open + close, from: range.head },
|
||||
- effects: closeBracketEffect.of(range.head + open.length),
|
||||
+ if (!next || /\s/.test(next) || closeBefore.indexOf(next) > -1) {
|
||||
+ const insert = (_a = buildInsert(state, range, open, close)) !== null && _a !== void 0 ? _a : open + close;
|
||||
+ return { changes: { insert, from: range.head },
|
||||
+ effects: insert === open ? [] : closeBracketEffect.of(range.head + open.length),
|
||||
range: EditorSelection.cursor(range.head + open.length) };
|
||||
+ }
|
||||
return { range: dont = range };
|
||||
});
|
||||
return dont ? null : state.update(changes, {
|
||||
@@ -1907,18 +1974,36 @@ function handleClose(state, _open, close) {
|
||||
}
|
||||
// Handles cases where the open and close token are the same, and
|
||||
// possibly triple quotes (as in `"""abc"""`-style quoting).
|
||||
-function handleSame(state, token, allowTriple, config) {
|
||||
+function handleSame(state, token, allowDouble, allowTriple, config) {
|
||||
let stringPrefixes = config.stringPrefixes || defaults.stringPrefixes;
|
||||
+ let buildInsert = config.buildInsert || defaults.buildInsert;
|
||||
let dont = null, changes = state.changeByRange(range => {
|
||||
+ var _a, _b, _c;
|
||||
if (!range.empty)
|
||||
return { changes: [{ insert: token, from: range.from }, { insert: token, from: range.to }],
|
||||
effects: closeBracketEffect.of(range.to + token.length),
|
||||
range: EditorSelection.range(range.anchor + token.length, range.head + token.length) };
|
||||
let pos = range.head, next = nextChar(state.doc, pos), start;
|
||||
- if (next == token) {
|
||||
+ if (allowTriple && state.sliceDoc(pos - 2 * token.length, pos) == token + token &&
|
||||
+ (start = canStartStringAt(state, pos - 2 * token.length, stringPrefixes)) > -1 &&
|
||||
+ nodeStart(state, start)) {
|
||||
+ return { changes: { insert: token + token + token + token, from: pos },
|
||||
+ effects: closeBracketEffect.of(pos + token.length),
|
||||
+ range: EditorSelection.cursor(pos + token.length) };
|
||||
+ }
|
||||
+ else if (allowDouble && state.sliceDoc(pos - token.length, pos) == token &&
|
||||
+ (start = canStartStringAt(state, pos - token.length, stringPrefixes)) > -1 &&
|
||||
+ nodeStart(state, start)) {
|
||||
+ let insert = (_a = buildInsert(state, range, token, token)) !== null && _a !== void 0 ? _a : token + token;
|
||||
+ return { changes: { insert, from: pos },
|
||||
+ effects: insert === token ? [] : closeBracketEffect.of(pos + token.length),
|
||||
+ range: EditorSelection.cursor(pos + token.length) };
|
||||
+ }
|
||||
+ else if (next == token) {
|
||||
if (nodeStart(state, pos)) {
|
||||
- return { changes: { insert: token + token, from: pos },
|
||||
- effects: closeBracketEffect.of(pos + token.length),
|
||||
+ let insert = (_b = buildInsert(state, range, token, token)) !== null && _b !== void 0 ? _b : token + token;
|
||||
+ return { changes: { insert, from: pos },
|
||||
+ effects: insert === token ? [] : closeBracketEffect.of(pos + token.length),
|
||||
range: EditorSelection.cursor(pos + token.length) };
|
||||
}
|
||||
else if (closedBracketAt(state, pos)) {
|
||||
@@ -1928,18 +2013,13 @@ function handleSame(state, token, allowTriple, config) {
|
||||
range: EditorSelection.cursor(pos + content.length) };
|
||||
}
|
||||
}
|
||||
- else if (allowTriple && state.sliceDoc(pos - 2 * token.length, pos) == token + token &&
|
||||
- (start = canStartStringAt(state, pos - 2 * token.length, stringPrefixes)) > -1 &&
|
||||
- nodeStart(state, start)) {
|
||||
- return { changes: { insert: token + token + token + token, from: pos },
|
||||
- effects: closeBracketEffect.of(pos + token.length),
|
||||
- range: EditorSelection.cursor(pos + token.length) };
|
||||
- }
|
||||
else if (state.charCategorizer(pos)(next) != CharCategory.Word) {
|
||||
- if (canStartStringAt(state, pos, stringPrefixes) > -1 && !probablyInString(state, pos, token, stringPrefixes))
|
||||
- return { changes: { insert: token + token, from: pos },
|
||||
- effects: closeBracketEffect.of(pos + token.length),
|
||||
+ if (canStartStringAt(state, pos, stringPrefixes) > -1 && !probablyInString(state, pos, token, stringPrefixes)) {
|
||||
+ const insert = (_c = buildInsert(state, range, token, token)) !== null && _c !== void 0 ? _c : token + token;
|
||||
+ return { changes: { insert, from: pos },
|
||||
+ effects: insert === token ? [] : closeBracketEffect.of(pos + token.length),
|
||||
range: EditorSelection.cursor(pos + token.length) };
|
||||
+ }
|
||||
}
|
||||
return { range: dont = range };
|
||||
});
|
||||
@@ -2071,4 +2151,4 @@ function setSelectedCompletion(index) {
|
||||
return setSelectedEffect.of(index);
|
||||
}
|
||||
|
||||
-export { CompletionContext, acceptCompletion, autocompletion, clearSnippet, closeBrackets, closeBracketsKeymap, closeCompletion, completeAnyWord, completeFromList, completionKeymap, completionStatus, currentCompletions, deleteBracketPair, hasNextSnippetField, hasPrevSnippetField, ifIn, ifNotIn, insertBracket, insertCompletionText, moveCompletionSelection, nextSnippetField, pickedCompletion, prevSnippetField, selectedCompletion, selectedCompletionIndex, setSelectedCompletion, snippet, snippetCompletion, snippetKeymap, startCompletion };
|
||||
+export { CompletionContext, acceptCompletion, autocompletion, clearSnippet, closeBrackets, closeBracketsKeymap, closeCompletion, completeAnyWord, completeFromList, completionKeymap, completionStatus, currentCompletions, deleteBracketPair, getCompletionTooltip, hasNextSnippetField, hasPrevSnippetField, ifIn, ifNotIn, insertBracket, insertCompletionText, moveCompletionSelection, nextChar, nextSnippetField, pickedCompletion, prevChar, prevSnippetField, selectedCompletion, selectedCompletionIndex, setSelectedCompletion, snippet, snippetCompletion, snippetKeymap, startCompletion };
|
||||
@@ -1,381 +0,0 @@
|
||||
diff --git a/dist/index.cjs b/dist/index.cjs
|
||||
index 46231ae..fb0f9aa 100644
|
||||
--- a/dist/index.cjs
|
||||
+++ b/dist/index.cjs
|
||||
@@ -592,6 +592,7 @@ class SearchQuery {
|
||||
this.valid = !!this.search && (!this.regexp || validRegExp(this.search));
|
||||
this.unquoted = this.unquote(this.search);
|
||||
this.wholeWord = !!config.wholeWord;
|
||||
+ this.scope = config.scope;
|
||||
}
|
||||
/**
|
||||
@internal
|
||||
@@ -606,7 +607,7 @@ class SearchQuery {
|
||||
eq(other) {
|
||||
return this.search == other.search && this.replace == other.replace &&
|
||||
this.caseSensitive == other.caseSensitive && this.regexp == other.regexp &&
|
||||
- this.wholeWord == other.wholeWord;
|
||||
+ this.wholeWord == other.wholeWord && this.scope == other.scope;
|
||||
}
|
||||
/**
|
||||
@internal
|
||||
@@ -631,7 +632,12 @@ class QueryType {
|
||||
}
|
||||
}
|
||||
function stringCursor(spec, state, from, to) {
|
||||
- return new SearchCursor(state.doc, spec.unquoted, from, to, spec.caseSensitive ? undefined : x => x.toLowerCase(), spec.wholeWord ? stringWordTest(state.doc, state.charCategorizer(state.selection.main.head)) : undefined);
|
||||
+ const test = spec.wholeWord ? stringWordTest(state.doc, state.charCategorizer(state.selection.main.head)) : undefined;
|
||||
+ const testWithinScope = (from, to, buffer, bufferPos) => {
|
||||
+ return (!test || test(from, to, buffer, bufferPos))
|
||||
+ && (!spec.scope || spec.scope.some(range => from >= range.from && from <= range.to && to >= range.from && to <= range.to));
|
||||
+ };
|
||||
+ return new SearchCursor(state.doc, spec.unquoted, from, to, spec.caseSensitive ? undefined : x => x.toLowerCase(), testWithinScope);
|
||||
}
|
||||
function stringWordTest(doc, categorizer) {
|
||||
return (from, to, buf, bufPos) => {
|
||||
@@ -695,9 +701,14 @@ class StringQuery extends QueryType {
|
||||
}
|
||||
}
|
||||
function regexpCursor(spec, state, from, to) {
|
||||
+ const test = spec.wholeWord ? regexpWordTest(state.charCategorizer(state.selection.main.head)) : undefined;
|
||||
+ const testWithinScope = (from, to, match) => {
|
||||
+ return (!test || test(from, to, match))
|
||||
+ && (!spec.scope || spec.scope.some(range => from >= range.from && from <= range.to && to >= range.from && to <= range.to));
|
||||
+ };
|
||||
return new RegExpCursor(state.doc, spec.search, {
|
||||
ignoreCase: !spec.caseSensitive,
|
||||
- test: spec.wholeWord ? regexpWordTest(state.charCategorizer(state.selection.main.head)) : undefined
|
||||
+ test: testWithinScope,
|
||||
}, from, to);
|
||||
}
|
||||
function charBefore(str, index) {
|
||||
@@ -737,10 +748,18 @@ class RegExpQuery extends QueryType {
|
||||
this.prevMatchInRange(state, curTo, state.doc.length);
|
||||
}
|
||||
getReplacement(result) {
|
||||
- return this.spec.unquote(this.spec.replace).replace(/\$([$&\d+])/g, (m, i) => i == "$" ? "$"
|
||||
- : i == "&" ? result.match[0]
|
||||
- : i != "0" && +i < result.match.length ? result.match[i]
|
||||
- : m);
|
||||
+ return this.spec.unquote(this.spec.replace).replace(/\$([$&]|\d+)/g, (m, i) => {
|
||||
+ if (i == "&")
|
||||
+ return result.match[0];
|
||||
+ if (i == "$")
|
||||
+ return "$";
|
||||
+ for (let l = i.length; l > 0; l--) {
|
||||
+ let n = +i.slice(0, l);
|
||||
+ if (n > 0 && n < result.match.length)
|
||||
+ return result.match[n] + i.slice(l);
|
||||
+ }
|
||||
+ return m;
|
||||
+ });
|
||||
}
|
||||
matchAll(state, limit) {
|
||||
let cursor = regexpCursor(this.spec, state, 0, state.doc.length), ranges = [];
|
||||
@@ -1227,7 +1246,9 @@ const searchExtensions = [
|
||||
exports.RegExpCursor = RegExpCursor;
|
||||
exports.SearchCursor = SearchCursor;
|
||||
exports.SearchQuery = SearchQuery;
|
||||
+exports.StringQuery = StringQuery;
|
||||
exports.closeSearchPanel = closeSearchPanel;
|
||||
+exports.createSearchPanel = createSearchPanel;
|
||||
exports.findNext = findNext;
|
||||
exports.findPrevious = findPrevious;
|
||||
exports.getSearchQuery = getSearchQuery;
|
||||
@@ -1242,4 +1263,6 @@ exports.searchPanelOpen = searchPanelOpen;
|
||||
exports.selectMatches = selectMatches;
|
||||
exports.selectNextOccurrence = selectNextOccurrence;
|
||||
exports.selectSelectionMatches = selectSelectionMatches;
|
||||
+exports.selectWord = selectWord;
|
||||
exports.setSearchQuery = setSearchQuery;
|
||||
+exports.togglePanel = togglePanel;
|
||||
diff --git a/dist/index.d.cts b/dist/index.d.cts
|
||||
index 08f5696..663d192 100644
|
||||
--- a/dist/index.d.cts
|
||||
+++ b/dist/index.d.cts
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as _codemirror_state from '@codemirror/state';
|
||||
import { Text, Extension, StateCommand, EditorState, SelectionRange, StateEffect } from '@codemirror/state';
|
||||
-import { Command, KeyBinding, EditorView, Panel } from '@codemirror/view';
|
||||
+import { Command, EditorView, Panel, KeyBinding } from '@codemirror/view';
|
||||
|
||||
/**
|
||||
A search cursor provides an iterator over text matches in a
|
||||
@@ -161,6 +161,7 @@ the `"cm-selectionMatch"` class for the highlighting. When
|
||||
itself will be highlighted with `"cm-selectionMatch-main"`.
|
||||
*/
|
||||
declare function highlightSelectionMatches(options?: HighlightOptions): Extension;
|
||||
+declare const selectWord: StateCommand;
|
||||
/**
|
||||
Select next occurrence of the current selection. Expand selection
|
||||
to the surrounding word when the selection is empty.
|
||||
@@ -264,6 +265,13 @@ declare class SearchQuery {
|
||||
*/
|
||||
readonly wholeWord: boolean;
|
||||
/**
|
||||
+ When set, only include search matches within these ranges
|
||||
+ */
|
||||
+ readonly scope?: Readonly<{
|
||||
+ from: number;
|
||||
+ to: number;
|
||||
+ }[]>;
|
||||
+ /**
|
||||
Create a query object.
|
||||
*/
|
||||
constructor(config: {
|
||||
@@ -293,6 +301,13 @@ declare class SearchQuery {
|
||||
Enable whole-word matching.
|
||||
*/
|
||||
wholeWord?: boolean;
|
||||
+ /**
|
||||
+ The ranges to match within
|
||||
+ */
|
||||
+ scope?: Readonly<{
|
||||
+ from: number;
|
||||
+ to: number;
|
||||
+ }[]>;
|
||||
});
|
||||
/**
|
||||
Compare this query to another query.
|
||||
@@ -307,6 +322,34 @@ declare class SearchQuery {
|
||||
to: number;
|
||||
}>;
|
||||
}
|
||||
+type SearchResult = typeof SearchCursor.prototype.value;
|
||||
+declare abstract class QueryType<Result extends SearchResult = SearchResult> {
|
||||
+ readonly spec: SearchQuery;
|
||||
+ constructor(spec: SearchQuery);
|
||||
+ abstract nextMatch(state: EditorState, curFrom: number, curTo: number): Result | null;
|
||||
+ abstract prevMatch(state: EditorState, curFrom: number, curTo: number): Result | null;
|
||||
+ abstract getReplacement(result: Result): string;
|
||||
+ abstract matchAll(state: EditorState, limit: number): readonly Result[] | null;
|
||||
+ abstract highlight(state: EditorState, from: number, to: number, add: (from: number, to: number) => void): void;
|
||||
+}
|
||||
+declare class StringQuery extends QueryType<SearchResult> {
|
||||
+ constructor(spec: SearchQuery);
|
||||
+ nextMatch(state: EditorState, curFrom: number, curTo: number): {
|
||||
+ from: number;
|
||||
+ to: number;
|
||||
+ } | null;
|
||||
+ private prevMatchInRange;
|
||||
+ prevMatch(state: EditorState, curFrom: number, curTo: number): {
|
||||
+ from: number;
|
||||
+ to: number;
|
||||
+ } | null;
|
||||
+ getReplacement(_result: SearchResult): string;
|
||||
+ matchAll(state: EditorState, limit: number): {
|
||||
+ from: number;
|
||||
+ to: number;
|
||||
+ }[] | null;
|
||||
+ highlight(state: EditorState, from: number, to: number, add: (from: number, to: number) => void): void;
|
||||
+}
|
||||
/**
|
||||
A state effect that updates the current search query. Note that
|
||||
this only has an effect if the search state has been initialized
|
||||
@@ -315,6 +358,7 @@ by running [`openSearchPanel`](https://codemirror.net/6/docs/ref/#search.openSea
|
||||
once).
|
||||
*/
|
||||
declare const setSearchQuery: _codemirror_state.StateEffectType<SearchQuery>;
|
||||
+declare const togglePanel: _codemirror_state.StateEffectType<boolean>;
|
||||
/**
|
||||
Get the current search query from an editor state.
|
||||
*/
|
||||
@@ -353,6 +397,7 @@ Replace all instances of the search query with the given
|
||||
replacement.
|
||||
*/
|
||||
declare const replaceAll: Command;
|
||||
+declare function createSearchPanel(view: EditorView): Panel;
|
||||
/**
|
||||
Make sure the search panel is open and focused.
|
||||
*/
|
||||
@@ -372,4 +417,4 @@ Default search-related key bindings.
|
||||
*/
|
||||
declare const searchKeymap: readonly KeyBinding[];
|
||||
|
||||
-export { RegExpCursor, SearchCursor, SearchQuery, closeSearchPanel, findNext, findPrevious, getSearchQuery, gotoLine, highlightSelectionMatches, openSearchPanel, replaceAll, replaceNext, search, searchKeymap, searchPanelOpen, selectMatches, selectNextOccurrence, selectSelectionMatches, setSearchQuery };
|
||||
+export { RegExpCursor, SearchCursor, SearchQuery, StringQuery, closeSearchPanel, createSearchPanel, findNext, findPrevious, getSearchQuery, gotoLine, highlightSelectionMatches, openSearchPanel, replaceAll, replaceNext, search, searchKeymap, searchPanelOpen, selectMatches, selectNextOccurrence, selectSelectionMatches, selectWord, setSearchQuery, togglePanel };
|
||||
diff --git a/dist/index.d.ts b/dist/index.d.ts
|
||||
index 08f5696..663d192 100644
|
||||
--- a/dist/index.d.ts
|
||||
+++ b/dist/index.d.ts
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as _codemirror_state from '@codemirror/state';
|
||||
import { Text, Extension, StateCommand, EditorState, SelectionRange, StateEffect } from '@codemirror/state';
|
||||
-import { Command, KeyBinding, EditorView, Panel } from '@codemirror/view';
|
||||
+import { Command, EditorView, Panel, KeyBinding } from '@codemirror/view';
|
||||
|
||||
/**
|
||||
A search cursor provides an iterator over text matches in a
|
||||
@@ -161,6 +161,7 @@ the `"cm-selectionMatch"` class for the highlighting. When
|
||||
itself will be highlighted with `"cm-selectionMatch-main"`.
|
||||
*/
|
||||
declare function highlightSelectionMatches(options?: HighlightOptions): Extension;
|
||||
+declare const selectWord: StateCommand;
|
||||
/**
|
||||
Select next occurrence of the current selection. Expand selection
|
||||
to the surrounding word when the selection is empty.
|
||||
@@ -264,6 +265,13 @@ declare class SearchQuery {
|
||||
*/
|
||||
readonly wholeWord: boolean;
|
||||
/**
|
||||
+ When set, only include search matches within these ranges
|
||||
+ */
|
||||
+ readonly scope?: Readonly<{
|
||||
+ from: number;
|
||||
+ to: number;
|
||||
+ }[]>;
|
||||
+ /**
|
||||
Create a query object.
|
||||
*/
|
||||
constructor(config: {
|
||||
@@ -293,6 +301,13 @@ declare class SearchQuery {
|
||||
Enable whole-word matching.
|
||||
*/
|
||||
wholeWord?: boolean;
|
||||
+ /**
|
||||
+ The ranges to match within
|
||||
+ */
|
||||
+ scope?: Readonly<{
|
||||
+ from: number;
|
||||
+ to: number;
|
||||
+ }[]>;
|
||||
});
|
||||
/**
|
||||
Compare this query to another query.
|
||||
@@ -307,6 +322,34 @@ declare class SearchQuery {
|
||||
to: number;
|
||||
}>;
|
||||
}
|
||||
+type SearchResult = typeof SearchCursor.prototype.value;
|
||||
+declare abstract class QueryType<Result extends SearchResult = SearchResult> {
|
||||
+ readonly spec: SearchQuery;
|
||||
+ constructor(spec: SearchQuery);
|
||||
+ abstract nextMatch(state: EditorState, curFrom: number, curTo: number): Result | null;
|
||||
+ abstract prevMatch(state: EditorState, curFrom: number, curTo: number): Result | null;
|
||||
+ abstract getReplacement(result: Result): string;
|
||||
+ abstract matchAll(state: EditorState, limit: number): readonly Result[] | null;
|
||||
+ abstract highlight(state: EditorState, from: number, to: number, add: (from: number, to: number) => void): void;
|
||||
+}
|
||||
+declare class StringQuery extends QueryType<SearchResult> {
|
||||
+ constructor(spec: SearchQuery);
|
||||
+ nextMatch(state: EditorState, curFrom: number, curTo: number): {
|
||||
+ from: number;
|
||||
+ to: number;
|
||||
+ } | null;
|
||||
+ private prevMatchInRange;
|
||||
+ prevMatch(state: EditorState, curFrom: number, curTo: number): {
|
||||
+ from: number;
|
||||
+ to: number;
|
||||
+ } | null;
|
||||
+ getReplacement(_result: SearchResult): string;
|
||||
+ matchAll(state: EditorState, limit: number): {
|
||||
+ from: number;
|
||||
+ to: number;
|
||||
+ }[] | null;
|
||||
+ highlight(state: EditorState, from: number, to: number, add: (from: number, to: number) => void): void;
|
||||
+}
|
||||
/**
|
||||
A state effect that updates the current search query. Note that
|
||||
this only has an effect if the search state has been initialized
|
||||
@@ -315,6 +358,7 @@ by running [`openSearchPanel`](https://codemirror.net/6/docs/ref/#search.openSea
|
||||
once).
|
||||
*/
|
||||
declare const setSearchQuery: _codemirror_state.StateEffectType<SearchQuery>;
|
||||
+declare const togglePanel: _codemirror_state.StateEffectType<boolean>;
|
||||
/**
|
||||
Get the current search query from an editor state.
|
||||
*/
|
||||
@@ -353,6 +397,7 @@ Replace all instances of the search query with the given
|
||||
replacement.
|
||||
*/
|
||||
declare const replaceAll: Command;
|
||||
+declare function createSearchPanel(view: EditorView): Panel;
|
||||
/**
|
||||
Make sure the search panel is open and focused.
|
||||
*/
|
||||
@@ -372,4 +417,4 @@ Default search-related key bindings.
|
||||
*/
|
||||
declare const searchKeymap: readonly KeyBinding[];
|
||||
|
||||
-export { RegExpCursor, SearchCursor, SearchQuery, closeSearchPanel, findNext, findPrevious, getSearchQuery, gotoLine, highlightSelectionMatches, openSearchPanel, replaceAll, replaceNext, search, searchKeymap, searchPanelOpen, selectMatches, selectNextOccurrence, selectSelectionMatches, setSearchQuery };
|
||||
+export { RegExpCursor, SearchCursor, SearchQuery, StringQuery, closeSearchPanel, createSearchPanel, findNext, findPrevious, getSearchQuery, gotoLine, highlightSelectionMatches, openSearchPanel, replaceAll, replaceNext, search, searchKeymap, searchPanelOpen, selectMatches, selectNextOccurrence, selectSelectionMatches, selectWord, setSearchQuery, togglePanel };
|
||||
diff --git a/dist/index.js b/dist/index.js
|
||||
index 22172ef..08a9974 100644
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -590,6 +590,7 @@ class SearchQuery {
|
||||
this.valid = !!this.search && (!this.regexp || validRegExp(this.search));
|
||||
this.unquoted = this.unquote(this.search);
|
||||
this.wholeWord = !!config.wholeWord;
|
||||
+ this.scope = config.scope;
|
||||
}
|
||||
/**
|
||||
@internal
|
||||
@@ -604,7 +605,7 @@ class SearchQuery {
|
||||
eq(other) {
|
||||
return this.search == other.search && this.replace == other.replace &&
|
||||
this.caseSensitive == other.caseSensitive && this.regexp == other.regexp &&
|
||||
- this.wholeWord == other.wholeWord;
|
||||
+ this.wholeWord == other.wholeWord && this.scope == other.scope;
|
||||
}
|
||||
/**
|
||||
@internal
|
||||
@@ -629,7 +630,12 @@ class QueryType {
|
||||
}
|
||||
}
|
||||
function stringCursor(spec, state, from, to) {
|
||||
- return new SearchCursor(state.doc, spec.unquoted, from, to, spec.caseSensitive ? undefined : x => x.toLowerCase(), spec.wholeWord ? stringWordTest(state.doc, state.charCategorizer(state.selection.main.head)) : undefined);
|
||||
+ const test = spec.wholeWord ? stringWordTest(state.doc, state.charCategorizer(state.selection.main.head)) : undefined;
|
||||
+ const testWithinScope = (from, to, buffer, bufferPos) => {
|
||||
+ return (!test || test(from, to, buffer, bufferPos))
|
||||
+ && (!spec.scope || spec.scope.some(range => from >= range.from && from <= range.to && to >= range.from && to <= range.to));
|
||||
+ };
|
||||
+ return new SearchCursor(state.doc, spec.unquoted, from, to, spec.caseSensitive ? undefined : x => x.toLowerCase(), testWithinScope);
|
||||
}
|
||||
function stringWordTest(doc, categorizer) {
|
||||
return (from, to, buf, bufPos) => {
|
||||
@@ -693,9 +699,14 @@ class StringQuery extends QueryType {
|
||||
}
|
||||
}
|
||||
function regexpCursor(spec, state, from, to) {
|
||||
+ const test = spec.wholeWord ? regexpWordTest(state.charCategorizer(state.selection.main.head)) : undefined;
|
||||
+ const testWithinScope = (from, to, match) => {
|
||||
+ return (!test || test(from, to, match))
|
||||
+ && (!spec.scope || spec.scope.some(range => from >= range.from && from <= range.to && to >= range.from && to <= range.to));
|
||||
+ };
|
||||
return new RegExpCursor(state.doc, spec.search, {
|
||||
ignoreCase: !spec.caseSensitive,
|
||||
- test: spec.wholeWord ? regexpWordTest(state.charCategorizer(state.selection.main.head)) : undefined
|
||||
+ test: testWithinScope,
|
||||
}, from, to);
|
||||
}
|
||||
function charBefore(str, index) {
|
||||
@@ -735,10 +746,18 @@ class RegExpQuery extends QueryType {
|
||||
this.prevMatchInRange(state, curTo, state.doc.length);
|
||||
}
|
||||
getReplacement(result) {
|
||||
- return this.spec.unquote(this.spec.replace).replace(/\$([$&\d+])/g, (m, i) => i == "$" ? "$"
|
||||
- : i == "&" ? result.match[0]
|
||||
- : i != "0" && +i < result.match.length ? result.match[i]
|
||||
- : m);
|
||||
+ return this.spec.unquote(this.spec.replace).replace(/\$([$&]|\d+)/g, (m, i) => {
|
||||
+ if (i == "&")
|
||||
+ return result.match[0];
|
||||
+ if (i == "$")
|
||||
+ return "$";
|
||||
+ for (let l = i.length; l > 0; l--) {
|
||||
+ let n = +i.slice(0, l);
|
||||
+ if (n > 0 && n < result.match.length)
|
||||
+ return result.match[n] + i.slice(l);
|
||||
+ }
|
||||
+ return m;
|
||||
+ });
|
||||
}
|
||||
matchAll(state, limit) {
|
||||
let cursor = regexpCursor(this.spec, state, 0, state.doc.length), ranges = [];
|
||||
@@ -1222,4 +1241,4 @@ const searchExtensions = [
|
||||
baseTheme
|
||||
];
|
||||
|
||||
-export { RegExpCursor, SearchCursor, SearchQuery, closeSearchPanel, findNext, findPrevious, getSearchQuery, gotoLine, highlightSelectionMatches, openSearchPanel, replaceAll, replaceNext, search, searchKeymap, searchPanelOpen, selectMatches, selectNextOccurrence, selectSelectionMatches, setSearchQuery };
|
||||
+export { RegExpCursor, SearchCursor, SearchQuery, StringQuery, closeSearchPanel, createSearchPanel, findNext, findPrevious, getSearchQuery, gotoLine, highlightSelectionMatches, openSearchPanel, replaceAll, replaceNext, search, searchKeymap, searchPanelOpen, selectMatches, selectNextOccurrence, selectSelectionMatches, selectWord, setSearchQuery, togglePanel };
|
||||
@@ -1,23 +0,0 @@
|
||||
diff --git a/lib/saml.js b/lib/saml.js
|
||||
index 64180fdd7fe6302bf8da7d1ca65c01bb1ea5e2c1..7015c3b73603f624c15195a0a38c2fbf750a4c60 100644
|
||||
--- a/lib/saml.js
|
||||
+++ b/lib/saml.js
|
||||
@@ -327,7 +327,8 @@ class SAML {
|
||||
const requestOrResponse = request || response;
|
||||
(0, utility_1.assertRequired)(requestOrResponse, "either request or response is required");
|
||||
let buffer;
|
||||
- if (this.options.skipRequestCompression) {
|
||||
+ // logout requestOrResponse must be compressed anyway
|
||||
+ if (this.options.skipRequestCompression && operation !== "logout") {
|
||||
buffer = Buffer.from(requestOrResponse, "utf8");
|
||||
}
|
||||
else {
|
||||
@@ -510,7 +511,7 @@ class SAML {
|
||||
try {
|
||||
xml = Buffer.from(container.SAMLResponse, "base64").toString("utf8");
|
||||
doc = await (0, xml_1.parseDomFromString)(xml);
|
||||
- const inResponseToNodes = xml_1.xpath.selectAttributes(doc, "/*[local-name()='Response']/@InResponseTo");
|
||||
+ const inResponseToNodes = xml_1.xpath.selectAttributes(doc, "/*[local-name()='Response' or local-name()='LogoutResponse']/@InResponseTo");
|
||||
if (inResponseToNodes) {
|
||||
inResponseTo = inResponseToNodes.length ? inResponseToNodes[0].nodeValue : null;
|
||||
await this.validateInResponseTo(inResponseTo);
|
||||
@@ -1,44 +0,0 @@
|
||||
diff --git a/lib/read.js b/lib/read.js
|
||||
index fce6283..6131c31 100644
|
||||
--- a/lib/read.js
|
||||
+++ b/lib/read.js
|
||||
@@ -18,7 +18,7 @@ var iconv = require('iconv-lite')
|
||||
var onFinished = require('on-finished')
|
||||
var unpipe = require('unpipe')
|
||||
var zlib = require('zlib')
|
||||
-
|
||||
+var Stream = require('stream')
|
||||
/**
|
||||
* Module exports.
|
||||
*/
|
||||
@@ -166,25 +166,25 @@ function contentstream (req, debug, inflate) {
|
||||
case 'deflate':
|
||||
stream = zlib.createInflate()
|
||||
debug('inflate body')
|
||||
- req.pipe(stream)
|
||||
+ // req.pipe(stream)
|
||||
break
|
||||
case 'gzip':
|
||||
stream = zlib.createGunzip()
|
||||
debug('gunzip body')
|
||||
- req.pipe(stream)
|
||||
+ // req.pipe(stream)
|
||||
break
|
||||
case 'identity':
|
||||
stream = req
|
||||
stream.length = length
|
||||
- break
|
||||
+ return req
|
||||
default:
|
||||
throw createError(415, 'unsupported content encoding "' + encoding + '"', {
|
||||
encoding: encoding,
|
||||
type: 'encoding.unsupported'
|
||||
})
|
||||
}
|
||||
-
|
||||
- return stream
|
||||
+ var pass = new Stream.PassThrough(); Stream.pipeline(req, stream, pass, () => {})
|
||||
+ return pass
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1,13 +0,0 @@
|
||||
diff --git a/lib/MultiReporters.js b/lib/MultiReporters.js
|
||||
index 98dc4ef..b2a97bf 100644
|
||||
--- a/lib/MultiReporters.js
|
||||
+++ b/lib/MultiReporters.js
|
||||
@@ -160,7 +160,7 @@ MultiReporters.prototype.getCustomOptions = function (options) {
|
||||
debug('options file (custom)', customOptionsFile);
|
||||
|
||||
try {
|
||||
- if ('.js' === path.extname(customOptionsFile)) {
|
||||
+ if (['.js', '.cjs'].includes(path.extname(customOptionsFile))) {
|
||||
customOptions = require(customOptionsFile);
|
||||
}
|
||||
else {
|
||||
@@ -1,13 +0,0 @@
|
||||
diff --git a/index.js b/index.js
|
||||
index b2b6bdd..75e6254 100644
|
||||
--- a/index.js
|
||||
+++ b/index.js
|
||||
@@ -46,7 +46,7 @@ function forwarded (req) {
|
||||
function getSocketAddr (req) {
|
||||
return req.socket
|
||||
? req.socket.remoteAddress
|
||||
- : req.connection.remoteAddress
|
||||
+ : req.connection && req.connection.remoteAddress
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1,73 +0,0 @@
|
||||
diff --git a/lib/ldapauth.js b/lib/ldapauth.js
|
||||
index 88398ed31afee34c8958ad5e7a53653e1b5f6f82..0334177af8a65c7c5f9de7570924ab0f359e3f38 100644
|
||||
--- a/lib/ldapauth.js
|
||||
+++ b/lib/ldapauth.js
|
||||
@@ -87,6 +87,7 @@ function LdapAuth(opts) {
|
||||
this.opts.bindProperty || (this.opts.bindProperty = 'dn');
|
||||
this.opts.groupSearchScope || (this.opts.groupSearchScope = 'sub');
|
||||
this.opts.groupDnProperty || (this.opts.groupDnProperty = 'dn');
|
||||
+ this.opts.tlsStarted = false;
|
||||
|
||||
EventEmitter.call(this);
|
||||
|
||||
@@ -126,21 +127,7 @@ function LdapAuth(opts) {
|
||||
this._userClient.on('error', this._handleError.bind(this));
|
||||
|
||||
var self = this;
|
||||
- if (this.opts.starttls) {
|
||||
- // When starttls is enabled, this callback supplants the 'connect' callback
|
||||
- this._adminClient.starttls(this.opts.tlsOptions, this._adminClient.controls, function (err) {
|
||||
- if (err) {
|
||||
- self._handleError(err);
|
||||
- } else {
|
||||
- self._onConnectAdmin();
|
||||
- }
|
||||
- });
|
||||
- this._userClient.starttls(this.opts.tlsOptions, this._userClient.controls, function (err) {
|
||||
- if (err) {
|
||||
- self._handleError(err);
|
||||
- }
|
||||
- });
|
||||
- } else if (opts.reconnect) {
|
||||
+ if (opts.reconnect && !this.opts.starttls) {
|
||||
this.once('_installReconnectListener', function () {
|
||||
self.log && self.log.trace('install reconnect listener');
|
||||
self._adminClient.on('connect', function () {
|
||||
@@ -401,6 +388,37 @@ LdapAuth.prototype._findGroups = function (user, callback) {
|
||||
*/
|
||||
LdapAuth.prototype.authenticate = function (username, password, callback) {
|
||||
var self = this;
|
||||
+ if (this.opts.starttls && !this.opts.tlsStarted) {
|
||||
+ // When starttls is enabled, this callback supplants the 'connect' callback
|
||||
+ this._adminClient.starttls(this.opts.tlsOptions, this._adminClient.controls, function (err) {
|
||||
+ if (err) {
|
||||
+ self._handleError(err);
|
||||
+ } else {
|
||||
+ self._onConnectAdmin(function(){self._handleAuthenticate(username, password, callback);});
|
||||
+ }
|
||||
+ });
|
||||
+ this._userClient.starttls(this.opts.tlsOptions, this._userClient.controls, function (err) {
|
||||
+ if (err) {
|
||||
+ self._handleError(err);
|
||||
+ }
|
||||
+ });
|
||||
+ } else {
|
||||
+ self._handleAuthenticate(username, password, callback);
|
||||
+ }
|
||||
+};
|
||||
+
|
||||
+/**
|
||||
+ * Authenticate given credentials against LDAP server
|
||||
+ *
|
||||
+ * @private
|
||||
+ * @param {string} username - The username to authenticate
|
||||
+ * @param {string} password - The password to verify
|
||||
+ * @param {resultCallback} callback - Result handling callback
|
||||
+ * @returns {undefined}
|
||||
+ */
|
||||
+LdapAuth.prototype._handleAuthenticate = function (username, password, callback) {
|
||||
+ this.opts.tlsStarted = true;
|
||||
+ var self = this;
|
||||
|
||||
if (typeof password === 'undefined' || password === null || password === '') {
|
||||
return callback(new Error('no password given'));
|
||||
@@ -1,13 +0,0 @@
|
||||
diff --git a/lib/MultiReporters.js b/lib/MultiReporters.js
|
||||
index d61e019711d5ac7f82c0fb90548bb0eb41ebbb85..e7a9515e05287621301b831191884a84d72ad0a1 100644
|
||||
--- a/lib/MultiReporters.js
|
||||
+++ b/lib/MultiReporters.js
|
||||
@@ -153,7 +153,7 @@ MultiReporters.prototype.getCustomOptions = function (options) {
|
||||
debug('options file (custom)', customOptionsFile);
|
||||
|
||||
try {
|
||||
- if ('.js' === path.extname(customOptionsFile)) {
|
||||
+ if (['.js', '.cjs'].includes(path.extname(customOptionsFile))) {
|
||||
customOptions = require(customOptionsFile);
|
||||
}
|
||||
else {
|
||||
@@ -1,13 +0,0 @@
|
||||
diff --git a/lib/make-middleware.js b/lib/make-middleware.js
|
||||
index ee50988..de77364 100644
|
||||
--- a/lib/make-middleware.js
|
||||
+++ b/lib/make-middleware.js
|
||||
@@ -164,7 +164,7 @@ function makeMiddleware (setup) {
|
||||
if (fieldname == null) return abortWithCode('MISSING_FIELD_NAME')
|
||||
|
||||
// don't attach to the files object, if there is no file
|
||||
- if (!filename) return fileStream.resume()
|
||||
+ if (!filename) filename = 'undefined'
|
||||
|
||||
// Work around bug in Busboy (https://github.com/mscdex/busboy/issues/6)
|
||||
if (limits && Object.prototype.hasOwnProperty.call(limits, 'fieldNameSize')) {
|
||||
@@ -1,76 +0,0 @@
|
||||
diff --git a/lib/index.js b/lib/index.js
|
||||
index 567ff5d..8eb45f7 100644
|
||||
--- a/lib/index.js
|
||||
+++ b/lib/index.js
|
||||
@@ -545,8 +545,8 @@ function clone(instance) {
|
||||
// tee instance body
|
||||
p1 = new PassThrough();
|
||||
p2 = new PassThrough();
|
||||
- body.pipe(p1);
|
||||
- body.pipe(p2);
|
||||
+ Stream.pipeline(body, p1, () => {});
|
||||
+ Stream.pipeline(body, p2, () => {});
|
||||
// set instance body to teed body and return the other teed body
|
||||
instance[INTERNALS].body = p1;
|
||||
body = p2;
|
||||
@@ -648,14 +648,14 @@ function writeToStream(dest, instance) {
|
||||
// body is null
|
||||
dest.end();
|
||||
} else if (isBlob(body)) {
|
||||
- body.stream().pipe(dest);
|
||||
+ Stream.pipeline(body.stream(), dest, () => {});
|
||||
} else if (Buffer.isBuffer(body)) {
|
||||
// body is buffer
|
||||
dest.write(body);
|
||||
dest.end();
|
||||
} else {
|
||||
// body is stream
|
||||
- body.pipe(dest);
|
||||
+ Stream.pipeline(body, dest, () => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1638,7 +1638,7 @@ function fetch(url, opts) {
|
||||
res.once('end', function () {
|
||||
if (signal) signal.removeEventListener('abort', abortAndFinalize);
|
||||
});
|
||||
- let body = res.pipe(new PassThrough$1());
|
||||
+ let body = Stream.pipeline(res, new PassThrough(), error => { if (error) reject(error); });
|
||||
|
||||
const response_options = {
|
||||
url: request.url,
|
||||
@@ -1679,7 +1679,7 @@ function fetch(url, opts) {
|
||||
|
||||
// for gzip
|
||||
if (codings == 'gzip' || codings == 'x-gzip') {
|
||||
- body = body.pipe(zlib.createGunzip(zlibOptions));
|
||||
+ body = Stream.pipeline(body, zlib.createGunzip(zlibOptions), error => { if (error) reject(error); });
|
||||
response = new Response(body, response_options);
|
||||
resolve(response);
|
||||
return;
|
||||
@@ -1689,13 +1689,13 @@ function fetch(url, opts) {
|
||||
if (codings == 'deflate' || codings == 'x-deflate') {
|
||||
// handle the infamous raw deflate response from old servers
|
||||
// a hack for old IIS and Apache servers
|
||||
- const raw = res.pipe(new PassThrough$1());
|
||||
+ const raw = Stream.pipeline(res, new PassThrough(), error => { if (error) reject(error); });
|
||||
raw.once('data', function (chunk) {
|
||||
// see http://stackoverflow.com/questions/37519828
|
||||
if ((chunk[0] & 0x0F) === 0x08) {
|
||||
- body = body.pipe(zlib.createInflate());
|
||||
+ body = Stream.pipeline(body, zlib.createInflate(), error => { if (error) reject(error); });
|
||||
} else {
|
||||
- body = body.pipe(zlib.createInflateRaw());
|
||||
+ body = Stream.pipeline(body, zlib.createInflateRaw(), error => { if (error) reject(error); });
|
||||
}
|
||||
response = new Response(body, response_options);
|
||||
resolve(response);
|
||||
@@ -1712,7 +1712,7 @@ function fetch(url, opts) {
|
||||
|
||||
// for br
|
||||
if (codings == 'br' && typeof zlib.createBrotliDecompress === 'function') {
|
||||
- body = body.pipe(zlib.createBrotliDecompress());
|
||||
+ body = Stream.pipeline(body, zlib.createBrotliDecompress(), error => { if (error) reject(error); });
|
||||
response = new Response(body, response_options);
|
||||
resolve(response);
|
||||
return;
|
||||
@@ -1,13 +0,0 @@
|
||||
diff --git a/lib/utils.js b/lib/utils.js
|
||||
index 486f9e1..4584507 100644
|
||||
--- a/lib/utils.js
|
||||
+++ b/lib/utils.js
|
||||
@@ -24,7 +24,7 @@ exports.originalURL = function(req, options) {
|
||||
var trustProxy = options.proxy;
|
||||
|
||||
var proto = (req.headers['x-forwarded-proto'] || '').toLowerCase()
|
||||
- , tls = req.connection.encrypted || (trustProxy && 'https' == proto.split(/\s*,\s*/)[0])
|
||||
+ , tls = (req.connection && req.connection.encrypted) || (trustProxy && 'https' == proto.split(/\s*,\s*/)[0])
|
||||
, host = (trustProxy && req.headers['x-forwarded-host']) || req.headers.host
|
||||
, protocol = tls ? 'https' : 'http'
|
||||
, path = req.url || '';
|
||||
@@ -1,22 +0,0 @@
|
||||
diff --git a/build/pdf.worker.mjs b/build/pdf.worker.mjs
|
||||
index 6c5c6f1..bb6b7d1 100644
|
||||
--- a/build/pdf.worker.mjs
|
||||
+++ b/build/pdf.worker.mjs
|
||||
@@ -1830,7 +1830,7 @@ async function __wbg_init(module_or_path) {
|
||||
}
|
||||
}
|
||||
if (typeof module_or_path === 'undefined') {
|
||||
- module_or_path = new URL('qcms_bg.wasm', import.meta.url);
|
||||
+ module_or_path = new URL(/* webpackIgnore: true */ 'qcms_bg.wasm', import.meta.url);
|
||||
}
|
||||
const imports = __wbg_get_imports();
|
||||
if (typeof module_or_path === 'string' || typeof Request === 'function' && module_or_path instanceof Request || typeof URL === 'function' && module_or_path instanceof URL) {
|
||||
@@ -5358,7 +5358,7 @@ var OpenJPEG = (() => {
|
||||
if (Module["locateFile"]) {
|
||||
return locateFile("openjpeg.wasm");
|
||||
}
|
||||
- return new URL("openjpeg.wasm", import.meta.url).href;
|
||||
+ return new URL(/* webpackIgnore: true */ "openjpeg.wasm", import.meta.url).href;
|
||||
}
|
||||
function getBinarySync(file) {
|
||||
if (file == wasmBinaryFile && wasmBinary) {
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,30 +0,0 @@
|
||||
diff --git a/index.js b/index.js
|
||||
index 2fae107d03b30aff9320d135ec79c049c51f298a..32ec707ddbf8937e3e130c3deac1060c308bf439 100644
|
||||
--- a/index.js
|
||||
+++ b/index.js
|
||||
@@ -1,6 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
-const {PassThrough} = require('stream');
|
||||
+const {PassThrough, pipeline} = require('stream');
|
||||
const extend = require('extend');
|
||||
|
||||
let debug = () => {};
|
||||
@@ -185,7 +185,7 @@ function retryRequest(requestOpts, opts, callback) {
|
||||
.on('complete', (...params) => handleFinish(params))
|
||||
.on('finish', (...params) => handleFinish(params));
|
||||
|
||||
- requestStream.pipe(delayStream);
|
||||
+ pipeline(requestStream, delayStream, () => {});
|
||||
} else {
|
||||
activeRequest = opts.request(requestOpts, onResponse);
|
||||
}
|
||||
@@ -251,7 +251,7 @@ function retryRequest(requestOpts, opts, callback) {
|
||||
// No more attempts need to be made, just continue on.
|
||||
if (streamMode) {
|
||||
retryStream.emit('response', response);
|
||||
- delayStream.pipe(retryStream);
|
||||
+ pipeline(delayStream, retryStream, () => {});
|
||||
requestStream.on('error', err => {
|
||||
retryStream.destroy(err);
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
diff --git a/index.js b/index.js
|
||||
index 298a351097d70a7fb005b6961f4d58247e391d8f..6a809ace0349d40cb2b6732ad66fd8ad208698ca 100644
|
||||
--- a/index.js
|
||||
+++ b/index.js
|
||||
@@ -1,6 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
-const {PassThrough} = require('stream');
|
||||
+const {PassThrough, pipeline} = require('stream');
|
||||
const extend = require('extend');
|
||||
|
||||
let debug = () => {};
|
||||
@@ -185,7 +185,7 @@ function retryRequest(requestOpts, opts, callback) {
|
||||
.on('complete', (...params) => handleFinish(params))
|
||||
.on('finish', (...params) => handleFinish(params));
|
||||
|
||||
- requestStream.pipe(delayStream);
|
||||
+ pipeline(requestStream, delayStream, () => {});
|
||||
} else {
|
||||
activeRequest = opts.request(requestOpts, onResponse);
|
||||
}
|
||||
@@ -251,7 +251,7 @@ function retryRequest(requestOpts, opts, callback) {
|
||||
// No more attempts need to be made, just continue on.
|
||||
if (streamMode) {
|
||||
retryStream.emit('response', response);
|
||||
- delayStream.pipe(retryStream);
|
||||
+ pipeline(delayStream, retryStream, () => {});
|
||||
requestStream.on('error', err => {
|
||||
retryStream.destroy(err);
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
diff --git a/lib/sandboxed_module.js b/lib/sandboxed_module.js
|
||||
index 1cd6743..4718b97 100644
|
||||
--- a/lib/sandboxed_module.js
|
||||
+++ b/lib/sandboxed_module.js
|
||||
@@ -4,7 +4,7 @@ var Module = require('module');
|
||||
var fs = require('fs');
|
||||
var vm = require('vm');
|
||||
var path = require('path');
|
||||
-var builtinModules = require('./builtin_modules.json');
|
||||
+var builtinModules = Module.builtinModules || require('./builtin_modules.json');
|
||||
var parent = module.parent;
|
||||
var globalOptions = {};
|
||||
var registeredBuiltInSourceTransformers = ['coffee'];
|
||||
@@ -157,12 +157,20 @@ SandboxedModule.prototype._createRecursiveRequireProxy = function() {
|
||||
var cache = Object.create(null);
|
||||
var required = this._getRequires();
|
||||
for (var key in required) {
|
||||
- var injectedFilename = requireLike(this.filename).resolve(key);
|
||||
- cache[injectedFilename] = required[key];
|
||||
+ // Under Yarn PnP, resolution from a transitive dependency's context may fail
|
||||
+ // for packages not declared in that dependency's package.json. Silently skip
|
||||
+ // cache pre-population on failure; the mock will still be injected via the
|
||||
+ // inject map in requireInterceptor or resolved via RecursiveRequireProxy fallback.
|
||||
+ try {
|
||||
+ var injectedFilename = requireLike(this.filename).resolve(key);
|
||||
+ cache[injectedFilename] = required[key];
|
||||
+ } catch (e) {}
|
||||
}
|
||||
cache[this.filename] = this.exports;
|
||||
var globals = this.globals;
|
||||
|
||||
+ // Store the top-level module's filename for PnP fallback resolution
|
||||
+ var topLevelFilename = this.filename;
|
||||
var options;
|
||||
if(!this._options.sourceTransformersSingleOnly && this._options.sourceTransformers){
|
||||
options = {
|
||||
@@ -208,8 +216,18 @@ SandboxedModule.prototype._createRecursiveRequireProxy = function() {
|
||||
if (request in cache) return cache[request];
|
||||
return require(request);
|
||||
}
|
||||
- // cached modules
|
||||
- var requestedFilename = requireLike(this.filename).resolve(request);
|
||||
+ // Resolve the requested module filename.
|
||||
+ // Under Yarn PnP, packages can only resolve their declared dependencies.
|
||||
+ // When sandboxed-module loads a transitive dependency, the resolution context
|
||||
+ // may not have access to all needed packages. Fall back to resolving from
|
||||
+ // the top-level module's context (the module under test).
|
||||
+ var requestedFilename;
|
||||
+ try {
|
||||
+ requestedFilename = requireLike(this.filename).resolve(request);
|
||||
+ } catch (e) {
|
||||
+ if (this.filename === topLevelFilename) throw e;
|
||||
+ requestedFilename = requireLike(topLevelFilename).resolve(request);
|
||||
+ }
|
||||
if (requestedFilename in cache) return cache[requestedFilename];
|
||||
var sandboxedModule = createInnerSandboxedModule(requestedFilename)
|
||||
return sandboxedModule.exports;
|
||||
@@ -1,57 +0,0 @@
|
||||
diff --git a/index.js b/index.js
|
||||
index 768f8ca..a882f4d 100644
|
||||
--- a/index.js
|
||||
+++ b/index.js
|
||||
@@ -788,29 +788,29 @@ SendStream.prototype.stream = function stream (path, options) {
|
||||
// pipe
|
||||
var stream = fs.createReadStream(path, options)
|
||||
this.emit('stream', stream)
|
||||
- stream.pipe(res)
|
||||
-
|
||||
- // cleanup
|
||||
- function cleanup () {
|
||||
- destroy(stream, true)
|
||||
- }
|
||||
-
|
||||
- // response finished, cleanup
|
||||
- onFinished(res, cleanup)
|
||||
-
|
||||
- // error handling
|
||||
- stream.on('error', function onerror (err) {
|
||||
- // clean up stream early
|
||||
- cleanup()
|
||||
-
|
||||
- // error
|
||||
- self.onStatError(err)
|
||||
- })
|
||||
-
|
||||
- // end
|
||||
- stream.on('end', function onend () {
|
||||
- self.emit('end')
|
||||
- })
|
||||
+ Stream.pipeline(stream, res, err => { if (err) { self.onStatError(err) } else { self.emit('end') } })
|
||||
+
|
||||
+ // // cleanup
|
||||
+ // function cleanup () {
|
||||
+ // destroy(stream, true)
|
||||
+ // }
|
||||
+ //
|
||||
+ // // response finished, cleanup
|
||||
+ // onFinished(res, cleanup)
|
||||
+ //
|
||||
+ // // error handling
|
||||
+ // stream.on('error', function onerror (err) {
|
||||
+ // // clean up stream early
|
||||
+ // cleanup()
|
||||
+ //
|
||||
+ // // error
|
||||
+ // self.onStatError(err)
|
||||
+ // })
|
||||
+ //
|
||||
+ // // end
|
||||
+ // stream.on('end', function onend () {
|
||||
+ // self.emit('end')
|
||||
+ // })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1,57 +0,0 @@
|
||||
diff --git a/build/src/index.js b/build/src/index.js
|
||||
index a101736..a87f6b9 100644
|
||||
--- a/build/src/index.js
|
||||
+++ b/build/src/index.js
|
||||
@@ -130,6 +130,9 @@ function createMultipartStream(boundary, multipart) {
|
||||
}
|
||||
else {
|
||||
part.body.pipe(stream, { end: false });
|
||||
+ part.body.on('error', (err) => {
|
||||
+ stream.destroy(err);
|
||||
+ });
|
||||
part.body.on('end', () => {
|
||||
stream.write('\r\n');
|
||||
stream.write(finale);
|
||||
@@ -184,25 +187,25 @@ function teenyRequest(reqOpts, callback) {
|
||||
// Stream mode
|
||||
const requestStream = streamEvents(new stream_1.PassThrough());
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
- let responseStream;
|
||||
- requestStream.once('reading', () => {
|
||||
- if (responseStream) {
|
||||
- (0, stream_1.pipeline)(responseStream, requestStream, () => { });
|
||||
- }
|
||||
- else {
|
||||
- requestStream.once('response', () => {
|
||||
- (0, stream_1.pipeline)(responseStream, requestStream, () => { });
|
||||
- });
|
||||
- }
|
||||
- });
|
||||
+ // let responseStream;
|
||||
+ // requestStream.once('reading', () => {
|
||||
+ // if (responseStream) {
|
||||
+ // (0, stream_1.pipeline)(responseStream, requestStream, () => { });
|
||||
+ // }
|
||||
+ // else {
|
||||
+ // requestStream.once('response', () => {
|
||||
+ // (0, stream_1.pipeline)(responseStream, requestStream, () => { });
|
||||
+ // });
|
||||
+ // }
|
||||
+ // });
|
||||
options.compress = false;
|
||||
teenyRequest.stats.requestStarting();
|
||||
- fetch(uri, options).then(res => {
|
||||
+ (0, node_fetch_1.default)(uri, options).then(res => {
|
||||
- teenyRequest.stats.requestFinished();
|
||||
- responseStream = res.body;
|
||||
- responseStream.on('error', (err) => {
|
||||
- requestStream.emit('error', err);
|
||||
- });
|
||||
+ teenyRequest.stats.requestFinished(); (0, stream_1.pipeline)(res.body, requestStream, () => {});
|
||||
+ // responseStream = res.body;
|
||||
+ // responseStream.on('error', (err) => {
|
||||
+ // requestStream.emit('error', err);
|
||||
+ // });
|
||||
const response = fetchToRequestResponse(options, res);
|
||||
requestStream.emit('response', response);
|
||||
}, err => {
|
||||
@@ -1,58 +0,0 @@
|
||||
diff --git a/build/src/index.js b/build/src/index.js
|
||||
index af5d15e260e2a47588c7c536447fe84bd3f86136..2b63d0c0b1eb6595c7a0bb314c1df792454c1a72 100644
|
||||
--- a/build/src/index.js
|
||||
+++ b/build/src/index.js
|
||||
@@ -115,6 +115,9 @@ function createMultipartStream(boundary, multipart) {
|
||||
}
|
||||
else {
|
||||
part.body.pipe(stream, { end: false });
|
||||
+ part.body.on('error', (err) => {
|
||||
+ stream.destroy(err);
|
||||
+ });
|
||||
part.body.on('end', () => {
|
||||
stream.write('\r\n');
|
||||
stream.write(finale);
|
||||
@@ -168,25 +171,27 @@ function teenyRequest(reqOpts, callback) {
|
||||
// Stream mode
|
||||
const requestStream = streamEvents(new stream_1.PassThrough());
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
- let responseStream;
|
||||
- requestStream.once('reading', () => {
|
||||
- if (responseStream) {
|
||||
- (0, stream_1.pipeline)(responseStream, requestStream, () => { });
|
||||
- }
|
||||
- else {
|
||||
- requestStream.once('response', () => {
|
||||
- (0, stream_1.pipeline)(responseStream, requestStream, () => { });
|
||||
- });
|
||||
- }
|
||||
- });
|
||||
+ // let responseStream;
|
||||
+ // requestStream.once('reading', () => {
|
||||
+ // if (responseStream) {
|
||||
+ // (0, stream_1.pipeline)(responseStream, requestStream, () => { });
|
||||
+ // }
|
||||
+ // else {
|
||||
+ // requestStream.once('response', () => {
|
||||
+ // (0, stream_1.pipeline)(responseStream, requestStream, () => { });
|
||||
+ // });
|
||||
+ // }
|
||||
+ // });
|
||||
+
|
||||
+
|
||||
options.compress = false;
|
||||
teenyRequest.stats.requestStarting();
|
||||
(0, node_fetch_1.default)(uri, options).then(res => {
|
||||
- teenyRequest.stats.requestFinished();
|
||||
- responseStream = res.body;
|
||||
- responseStream.on('error', (err) => {
|
||||
- requestStream.emit('error', err);
|
||||
- });
|
||||
+ teenyRequest.stats.requestFinished(); stream_1.pipeline(res.body, requestStream, () => {});
|
||||
+ // responseStream = res.body;
|
||||
+ // responseStream.on('error', (err) => {
|
||||
+ // requestStream.emit('error', err);
|
||||
+ // });
|
||||
const response = fetchToRequestResponse(options, res);
|
||||
requestStream.emit('response', response);
|
||||
}, err => {
|
||||
@@ -1,81 +0,0 @@
|
||||
diff --git a/dist/WorkerPool.js b/dist/WorkerPool.js
|
||||
index 4145779f08eefafd0c18394806b6409c595ac5bb..aa16dd6a0f463804455164493a1e75e80cdf656a 100644
|
||||
--- a/dist/WorkerPool.js
|
||||
+++ b/dist/WorkerPool.js
|
||||
@@ -258,6 +258,19 @@ class PoolWorker {
|
||||
finalCallback();
|
||||
break;
|
||||
}
|
||||
+ case 'logMessage':
|
||||
+ {
|
||||
+ const {
|
||||
+ data: { loggerName, methodName, args }
|
||||
+ } = message;
|
||||
+ const {
|
||||
+ data: jobData
|
||||
+ } = this.jobs[id];
|
||||
+ const logger = jobData.getLogger(loggerName);
|
||||
+ logger[methodName].apply(logger, args);
|
||||
+ finalCallback();
|
||||
+ break;
|
||||
+ }
|
||||
case 'emitWarning':
|
||||
{
|
||||
const {
|
||||
diff --git a/dist/index.js b/dist/index.js
|
||||
index 75cd30fb63dc864057c1afc866f43fc7cc0a8020..d834af6ce1e2cd4473c4ae1b325d63eb1190c17e 100644
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -43,6 +43,7 @@ function pitch() {
|
||||
sourceMap: this.sourceMap,
|
||||
emitError: this.emitError,
|
||||
emitWarning: this.emitWarning,
|
||||
+ getLogger: this.getLogger,
|
||||
loadModule: this.loadModule,
|
||||
resolve: this.resolve,
|
||||
getResolve: this.getResolve,
|
||||
diff --git a/dist/worker.js b/dist/worker.js
|
||||
index 8e67959e4b7c9fd0db116509b1459636ba13a097..aca94f1442906baf179ada8e6a56501bbf71c480 100644
|
||||
--- a/dist/worker.js
|
||||
+++ b/dist/worker.js
|
||||
@@ -90,6 +90,22 @@ function writeJson(data) {
|
||||
writePipeWrite(lengthBuffer);
|
||||
writePipeWrite(messageBuffer);
|
||||
}
|
||||
+const LOGGER_METHODS = ['error', 'warn', 'info', 'log', 'debug', 'trace', 'group', 'groupEnd', 'groupCollapsed', 'status', 'clear', 'profile', 'profileEnd'];
|
||||
+class Logger {
|
||||
+ constructor(id, loggerName) {
|
||||
+ this.id = id
|
||||
+ this.loggerName = loggerName
|
||||
+ for (const methodName of LOGGER_METHODS) {
|
||||
+ this[methodName] = (...args) => {
|
||||
+ writeJson({
|
||||
+ type: 'logMessage',
|
||||
+ id: this.id,
|
||||
+ data: { loggerName, methodName, args }
|
||||
+ })
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
const queue = (0, _queue.default)(({
|
||||
id,
|
||||
data
|
||||
@@ -190,6 +206,7 @@ const queue = (0, _queue.default)(({
|
||||
}
|
||||
return options;
|
||||
},
|
||||
+ getLogger: (name) => new Logger(id, name),
|
||||
emitWarning: warning => {
|
||||
writeJson({
|
||||
type: 'emitWarning',
|
||||
@@ -211,6 +228,9 @@ const queue = (0, _queue.default)(({
|
||||
module._compile(code, filename); // eslint-disable-line no-underscore-dangle
|
||||
return module.exports;
|
||||
},
|
||||
+ addDependency: filename => {
|
||||
+ buildDependencies.push(filename);
|
||||
+ },
|
||||
addBuildDependency: filename => {
|
||||
buildDependencies.push(filename);
|
||||
},
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
approvedGitRepositories:
|
||||
- "**"
|
||||
|
||||
enableGlobalCache: false
|
||||
|
||||
enableScripts: true
|
||||
|
||||
enableTelemetry: false
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
npmMinimalAgeGate: 3d
|
||||
|
||||
supportedArchitectures:
|
||||
cpu:
|
||||
- current
|
||||
- arm64
|
||||
- x64
|
||||
libc:
|
||||
- current
|
||||
- glibc
|
||||
os:
|
||||
- current
|
||||
- darwin
|
||||
- linux
|
||||
+23
-9
@@ -1,29 +1,43 @@
|
||||
# Contributing to Overleaf CE+
|
||||
Contributing to Overleaf
|
||||
==========================
|
||||
|
||||
Thank you for reading this! If you'd like to report a bug or join in the development
|
||||
of Overleaf CE+, then here are some notes on how to do that.
|
||||
of Overleaf, then here are some notes on how to do that.
|
||||
|
||||
## Reporting bugs and opening issues
|
||||
Reporting bugs and opening issues
|
||||
---------------------------------
|
||||
|
||||
If you'd like to report a bug or open an issue, please **[check if there is an existing issue](https://github.com/yu-i-i/overleaf-cep).**
|
||||
If you'd like to report a bug or open an issue, please **[check if there is an existing issue](https://github.com/overleaf/overleaf/issues).**
|
||||
If there is then please add any more information that you have, or give it a 👍.
|
||||
|
||||
When submitting an issue please describe the issue as clearly as possible, including how to
|
||||
reproduce the bug, which situations it appears in, what you expected to happen, and what actually happens.
|
||||
If you can include a screenshot for front end issues that is very helpful.
|
||||
|
||||
**Note**: If you are using [www.overleaf.com](www.overleaf.com) and have a problem, or if you would like to request a new feature, please contact the Support team at support@overleaf.com. Raise an issue here only to report bugs in the Community Edition release of Overleaf.
|
||||
|
||||
## Pull Requests
|
||||
Pull Requests
|
||||
-------------
|
||||
|
||||
See [Overleaf wiki](https://github.com/overleaf/overleaf/wiki)
|
||||
See [our wiki](https://github.com/overleaf/overleaf/wiki)
|
||||
for how to manage the Overleaf development environment and for our developer guidelines.
|
||||
|
||||
We love pull requests, so be bold with them! Don't be afraid of going ahead
|
||||
and changing something, or adding a new feature. We're very happy to work with you
|
||||
to get your changes merged into Overleaf.
|
||||
|
||||
If you're looking for something to work on, have a look at the [open issues](https://github.com/yu-i-i/overleaf-cep/issues).
|
||||
If you're looking for something to work on, have a look at the [open issues](https://github.com/overleaf/overleaf/issues).
|
||||
|
||||
## Security
|
||||
Security
|
||||
--------
|
||||
|
||||
Please see [Overleaf security policy](https://github.com/overleaf/overleaf/security/policy) if you would like to report a potential security vulnerability.
|
||||
Please see [our security policy](https://github.com/overleaf/overleaf/security/policy) if you would like to report a potential security vulnerability.
|
||||
|
||||
Contributor License Agreement
|
||||
-----------------------------
|
||||
|
||||
Before we can accept any contributions of code, we need you to agree to our
|
||||
[Contributor License Agreement](https://docs.google.com/forms/d/e/1FAIpQLSef79XH3mb7yIiMzZw-yALEegS-wyFetvjTiNBfZvf_IHD2KA/viewform?usp=sf_link).
|
||||
This is to ensure that you own the copyright of your contribution, and that you
|
||||
agree to give us a license to use it in both the open source version, and the version
|
||||
of Overleaf running at www.overleaf.com, which may have additional changes.
|
||||
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
# CREDITS
|
||||
|
||||
This project includes ideas and adapted code from several open-source projects.\
|
||||
In most cases the original code has been modified, optimized, or extended.
|
||||
|
||||
## Symbol palette
|
||||
|
||||
The symbol palette feature is based on the
|
||||
[original Overleaf implementation](https://github.com/overleaf/web/tree/master/frontend/js/features/symbol-palette)
|
||||
|
||||
The original code was slightly improved, particularly in the parts related to keyboard input.
|
||||
|
||||
## LDAP authentication
|
||||
|
||||
LDAP authentication was inspired by [this project](https://github.com/smhaller/ldap-overleaf-sl).
|
||||
|
||||
The project provided the idea and part of the implementation for adding LDAP users to the user's contacts.
|
||||
|
||||
## Real-time track changes and comments
|
||||
|
||||
The Track Changes and Comments feature exists largely in the original Overleaf codebase.\
|
||||
The missing parts were implemented based on [this code](https://github.com/ertuil/overleaf).
|
||||
|
||||
The referenced code was fixed, optimized, and extended.
|
||||
|
||||
## Sandboxed compiles
|
||||
|
||||
The Sandboxed Compiles feature was until recently largely present in the original Overleaf codebase.\
|
||||
The missing parts were implemented in this project.
|
||||
|
||||
## Import file from external URL
|
||||
|
||||
The "From External URL" feature exists in the original Overleaf code.\
|
||||
The missing proxy component was implemented in this project.
|
||||
|
||||
## Git integration
|
||||
|
||||
The Git integration feature includes parts of the frontend code
|
||||
from [here](https://github.com/ayaka-notes/overleaf-pro/tree/feat-git-bridge)
|
||||
|
||||
## GitHub synchronization
|
||||
|
||||
The GitHub synchronization feature includes parts of the frontend code and the OAuth2 backend implementation
|
||||
from [here](https://github.com/ayaka-notes/overleaf-pro/commit/06a30fe9a0ed75e5ab40b50a8a4e94f43161cf71).
|
||||
|
||||
## Sign Up page
|
||||
|
||||
The Sign Up page is based on [this code](https://github.com/ayaka-notes/overleaf-pro/tree/feat-public-registeration).
|
||||
|
||||
# Acknowledgments
|
||||
|
||||
Thanks to the users of the project for valuable feedback, suggestions,
|
||||
and help in identifying and fixing bugs.
|
||||
@@ -6,8 +6,10 @@
|
||||
<h4 align="center">An open-source online real-time collaborative LaTeX editor.</h4>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/yu-i-i/overleaf-cep/wiki">Wiki</a> •
|
||||
<a href="https://github.com/overleaf/overleaf/wiki">Wiki</a> •
|
||||
<a href="https://www.overleaf.com/for/enterprises">Server Pro</a> •
|
||||
<a href="#contributing">Contributing</a> •
|
||||
<a href="https://mailchi.mp/overleaf.com/community-edition-and-server-pro">Mailing List</a> •
|
||||
<a href="#authors">Authors</a> •
|
||||
<a href="#license">License</a>
|
||||
</p>
|
||||
@@ -21,32 +23,17 @@
|
||||
|
||||
[Overleaf](https://www.overleaf.com) is an open-source online real-time collaborative LaTeX editor. Overleaf runs a hosted version at [www.overleaf.com](https://www.overleaf.com), but you can also run your own local version, and contribute to the development of Overleaf.
|
||||
|
||||
## Extended Community Edition (CE+)
|
||||
## Extended Community Edition
|
||||
|
||||
The present "extended" version of Overleaf CE includes:
|
||||
|
||||
- Sandboxed compiles with TeX Live image selection
|
||||
- Sign Up page
|
||||
- Sandboxed Compiles with TeX Live image selection
|
||||
- LDAP authentication
|
||||
- SAML authentication
|
||||
- OpenID Connect authentication
|
||||
- Real-time track changes and comments
|
||||
- Symbol palette
|
||||
- Template gallery
|
||||
- Import file from external URL
|
||||
- Git integration
|
||||
- GitHub synchronization
|
||||
- Zotero integration
|
||||
- Reference Search and Pick Tool
|
||||
- Document Import (`.docx`, `.md`) and Export (`.docx`, `.md`, `.html`)
|
||||
- Advanced administrator tools for managing user accounts and projects
|
||||
- Logo tools
|
||||
|
||||
> [!CAUTION]
|
||||
> Overleaf Community Edition is intended for use in environments where **all** users are trusted. Community Edition is **not** appropriate for scenarios where isolation of users is required due to Sandbox Compiles not being available. When not using Sandboxed Compiles, users have full read and write access to the `sharelatex` container resources (filesystem, network, environment variables) when running LaTeX compiles.
|
||||
Therefore, in any environment where not all users can be fully trusted, it is strongly recommended to enable the Sandboxed Compiles feature available in the Extended Community Edition.
|
||||
|
||||
For more information on Sandbox Compiles check out Overleaf [documentation](https://docs.overleaf.com/on-premises/configuration/overleaf-toolkit/server-pro-only-configuration/sandboxed-compiles).
|
||||
- Autocomplete of reference keys
|
||||
- Symbol Palette
|
||||
|
||||
## Enterprise
|
||||
|
||||
@@ -55,16 +42,847 @@ If you want help installing and maintaining Overleaf in your lab or workplace, O
|
||||
## Installation
|
||||
|
||||
Detailed installation instructions can be found in the [Overleaf Toolkit](https://github.com/overleaf/toolkit/).
|
||||
Configuration details and release history for the Extended Community Edition can be found on the [Extended CE Wiki Page](https://github.com/yu-i-i/overleaf-cep/wiki).
|
||||
To run a custom image, add a file named docker-compose.override.yml with the following or similar content into the `overleaf-toolkit/config directory`:
|
||||
|
||||
```yml
|
||||
---
|
||||
version: '2.2'
|
||||
services:
|
||||
sharelatex:
|
||||
image: sharelatex/sharelatex:ext-ce
|
||||
volumes:
|
||||
- ../config/certs:/overleaf/certs
|
||||
```
|
||||
Here, the attached volume provides convenient access for the container to the certificates needed for SAML or LDAP authentication.
|
||||
|
||||
If you want to build a Docker image of the extended CE based on the upstream v5.3.1 codebase, you can check out the corresponding tag by running:
|
||||
```
|
||||
git checkout v5.3.1-ext-v1
|
||||
```
|
||||
After building the image, switch to the latest state of the repository and check the `server-ce/hotfix` directory. If a subdirectory matching your version (e.g., `5.3.1`) exists, build a patched image.
|
||||
Alternatively, you can download a prebuilt image from Docker Hub:
|
||||
```
|
||||
docker pull overleafcep/sharelatex:5.3.1-ext-v1
|
||||
```
|
||||
Make sure to update the image name in overleaf-toolkit/config/docker-compose.override.yml accordingly.
|
||||
|
||||
## Sandboxed Compiles
|
||||
|
||||
To enable sandboxed compiles (also known as "Sibling containers"), set the following configuration options in `overleaf-toolkit/config/overleaf.rc`:
|
||||
|
||||
```
|
||||
SERVER_PRO=true
|
||||
SIBLING_CONTAINERS_ENABLED=true
|
||||
```
|
||||
|
||||
The following environment variables are used to specify which TeX Live images to use for sandboxed compiles:
|
||||
|
||||
- `ALL_TEX_LIVE_DOCKER_IMAGES` **(required)**
|
||||
* A comma-separated list of TeX Live images to use. These images will be downloaded or updated.
|
||||
To skip downloading the images, set `SIBLING_CONTAINERS_PULL=false` in `config/overleaf.rc`.
|
||||
- `ALL_TEX_LIVE_DOCKER_IMAGE_NAMES`
|
||||
* A comma-separated list of friendly names for the images. If omitted, the version name will be used (e.g., `latest-full`).
|
||||
- `TEX_LIVE_DOCKER_IMAGE` **(required)**
|
||||
* The default TeX Live image that will be used for compiling new projects. The environment variable `ALL_TEX_LIVE_DOCKER_IMAGES` must include this image.
|
||||
|
||||
Users can select the image for their project in the project menu.
|
||||
|
||||
Here is an example where the default TeX Live image is `latest-full` from Docker Hub, but the `TL2023-historic` image can be used for older projects:
|
||||
```
|
||||
ALL_TEX_LIVE_DOCKER_IMAGES=texlive/texlive:latest-full, texlive/texlive:TL2023-historic
|
||||
ALL_TEX_LIVE_DOCKER_IMAGE_NAMES=TeXLive 2024, TeXLive 2023
|
||||
TEX_LIVE_DOCKER_IMAGE=texlive/texlive:latest-full
|
||||
```
|
||||
For additional details refer to
|
||||
[Server Pro: Sandboxed Compiles](https://github.com/overleaf/overleaf/wiki/Server-Pro:-Sandboxed-Compiles) and
|
||||
[Toolkit: Sandboxed Compiles](https://github.com/overleaf/toolkit/blob/master/doc/sandboxed-compiles.md).
|
||||
|
||||
When the compilation takes place in a dedicated container, it is relatively safe to permit running external commands from inside the TeX
|
||||
file during compilation. This is required for packages like `minted`. For this purpose, the following environment variable can be used:
|
||||
|
||||
- `TEX_COMPILER_EXTRA_FLAGS`
|
||||
* A list of extra flags for TeX compiler. Example: `-shell-escape -file-line-error`
|
||||
|
||||
<details>
|
||||
<summary><h4>Sample variables.env file</h4></summary>
|
||||
|
||||
```
|
||||
OVERLEAF_APP_NAME="Our Overleaf Instance"
|
||||
|
||||
ENABLED_LINKED_FILE_TYPES=project_file,project_output_file
|
||||
|
||||
# Enables Thumbnail generation using ImageMagick
|
||||
ENABLE_CONVERSIONS=true
|
||||
|
||||
# Disables email confirmation requirement
|
||||
EMAIL_CONFIRMATION_DISABLED=true
|
||||
|
||||
## Nginx
|
||||
# NGINX_WORKER_PROCESSES=4
|
||||
# NGINX_WORKER_CONNECTIONS=768
|
||||
|
||||
## Set for TLS via nginx-proxy
|
||||
# OVERLEAF_BEHIND_PROXY=true
|
||||
# OVERLEAF_SECURE_COOKIE=true
|
||||
|
||||
OVERLEAF_SITE_URL=http://my-overleaf-instance.com
|
||||
OVERLEAF_NAV_TITLE=Our Overleaf Instance
|
||||
# OVERLEAF_HEADER_IMAGE_URL=http://somewhere.com/mylogo.png
|
||||
OVERLEAF_ADMIN_EMAIL=support@example.com
|
||||
|
||||
OVERLEAF_LEFT_FOOTER=[{"text": "Contact your support team", "url": "mailto:support@example.com"}]
|
||||
OVERLEAF_RIGHT_FOOTER=[{"text":"Hello, I am on the Right", "url":"https://github.com/yu-i-i/overleaf-cep"}]
|
||||
|
||||
OVERLEAF_EMAIL_FROM_ADDRESS=team@example.com
|
||||
OVERLEAF_EMAIL_SMTP_HOST=smtp.example.com
|
||||
OVERLEAF_EMAIL_SMTP_PORT=587
|
||||
OVERLEAF_EMAIL_SMTP_SECURE=false
|
||||
# OVERLEAF_EMAIL_SMTP_USER=
|
||||
# OVERLEAF_EMAIL_SMTP_PASS=
|
||||
# OVERLEAF_EMAIL_SMTP_NAME=
|
||||
OVERLEAF_EMAIL_SMTP_LOGGER=false
|
||||
OVERLEAF_EMAIL_SMTP_TLS_REJECT_UNAUTH=true
|
||||
OVERLEAF_EMAIL_SMTP_IGNORE_TLS=false
|
||||
OVERLEAF_CUSTOM_EMAIL_FOOTER=This system is run by department x
|
||||
|
||||
OVERLEAF_PROXY_LEARN=true
|
||||
NAV_HIDE_POWERED_BY=true
|
||||
|
||||
########################
|
||||
## Sandboxed Compiles ##
|
||||
########################
|
||||
|
||||
ALL_TEX_LIVE_DOCKER_IMAGES=texlive/texlive:latest-full, texlive/texlive:TL2023-historic
|
||||
ALL_TEX_LIVE_DOCKER_IMAGE_NAMES=TeXLive 2024, TeXLive 2023
|
||||
TEX_LIVE_DOCKER_IMAGE=texlive/texlive:latest-full
|
||||
TEX_COMPILER_EXTRA_FLAGS=-shell-escape
|
||||
```
|
||||
</details>
|
||||
|
||||
## Authentication Methods
|
||||
|
||||
The following authentication methods are supported: local authentication, LDAP authentication, SAML authentication,
|
||||
and OpenID Connect (OIDC) authentication. Local authentication is always active. The environment variable `EXTERNAL_AUTH`
|
||||
specifies which external authentication methods are activated. The value of this variable is a list. If the list includes `ldap`, `saml`, or `oidc`,
|
||||
then LDAP authentication, SAML authentication, and OIDC authentication will be activated, respectively.
|
||||
|
||||
For example: `EXTERNAL_AUTH=ldap saml oidc`
|
||||
|
||||
This configuration activates all available authentication methods, although this is rarely necessary.
|
||||
|
||||
<details>
|
||||
<summary><h3>Local Authentication</h3></summary>
|
||||
|
||||
Password of local users stored in the MongoDB database. An admin user can create a new local user. For details, visit the
|
||||
[wiki of Overleaf project](https://github.com/overleaf/overleaf/wiki/Creating-and-managing-users).
|
||||
|
||||
It is possible to enforce password restrictions on local users:
|
||||
|
||||
|
||||
* `OVERLEAF_PASSWORD_VALIDATION_MIN_LENGTH`: The minimum length required
|
||||
|
||||
* `OVERLEAF_PASSWORD_VALIDATION_MAX_LENGTH`: The maximum length allowed
|
||||
|
||||
* `OVERLEAF_PASSWORD_VALIDATION_PATTERN`: is used to validate password strength
|
||||
|
||||
- `abc123` – password requires 3 letters and 3 numbers and be at least 6 characters long
|
||||
- `aA` – password requires lower and uppercase letters and be at least 2 characters long
|
||||
- `ab$3` – it must contain letters, digits and symbols and be at least 4 characters long
|
||||
- There are 4 groups of characters: letters, UPPERcase letters, digits, symbols. Anything that is neither a letter nor a digit is considered to be a symbol.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>LDAP Authentication</h3></summary>
|
||||
|
||||
Internally, Overleaf LDAP uses the [passport-ldapauth](https://github.com/vesse/passport-ldapauth) library. Most of these configuration options are passed through to the `server` config object which is used to configure `passport-ldapauth`. If you are having issues configuring LDAP, it is worth reading the README for `passport-ldapauth` to understand the configuration it expects.
|
||||
|
||||
When using Local and LDAP authentication methods, a user enters a `username` and `password` in the login form. If LDAP authentication is enabled, it is attempted first:
|
||||
|
||||
1. An LDAP user is searched for in the LDAP directory using the filter defined by `OVERLEAF_LDAP_SEARCH_FILTER` and authenticated.
|
||||
2. If authentication is successful, the Overleaf users database is checked for a user with the primary email address that matches the email address of the authenticated LDAP user:
|
||||
- If a matching user is found, the `hashedPassword` field for this user is deleted (if it exists). This ensures that the user can only log in via LDAP authentication in the future.
|
||||
- If no matching user is found, a new Overleaf user is created using the email, first name, and last name retrieved from the LDAP server.
|
||||
3. If LDAP authentication fails or is unsuccessful, local authentication is attempted.
|
||||
|
||||
#### Environment Variables
|
||||
|
||||
- `OVERLEAF_LDAP_URL` **(required)**
|
||||
* URL of the LDAP server.
|
||||
|
||||
- Example: `ldaps://ldap.example.com:636` (LDAP over SSL)
|
||||
- Example: `ldap://ldap.example.com:389` (unencrypted or STARTTLS, if configured).
|
||||
|
||||
- `OVERLEAF_LDAP_EMAIL_ATT`
|
||||
* The email attribute returned by the LDAP server, default `mail`. Each LDAP user must have at least one email address.
|
||||
If multiple addresses are provided, only the first one will be used.
|
||||
|
||||
- `OVERLEAF_LDAP_FIRST_NAME_ATT`
|
||||
* The property name holding the first name of the user which is used in the application, usually `givenName`.
|
||||
|
||||
- `OVERLEAF_LDAP_LAST_NAME_ATT`
|
||||
* The property name holding the family name of the user which is used in the application, usually `sn`.
|
||||
|
||||
- `OVERLEAF_LDAP_NAME_ATT`
|
||||
* The property name holding the full name of the user, usually `cn`. If either of the two previous variables is not defined,
|
||||
the first and/or last name of the user is extracted from this variable. Otherwise, it is not used.
|
||||
|
||||
- `OVERLEAF_LDAP_PLACEHOLDER`
|
||||
* The placeholder for the login form, defaults to `Username`.
|
||||
|
||||
- `OVERLEAF_LDAP_UPDATE_USER_DETAILS_ON_LOGIN`
|
||||
* If set to `true`, updates the LDAP user `first_name` and `last_name` field on login, and turn off the user details form on the `/user/settings`
|
||||
page for LDAP users. Otherwise, details will be fetched only on first login.
|
||||
|
||||
- `OVERLEAF_LDAP_BIND_DN`
|
||||
* The distinguished name of the LDAP user that should be used for the LDAP connection
|
||||
(this user should be able to search/list accounts on the LDAP server),
|
||||
e.g., `cn=ldap_reader,dc=example,dc=com`. If not defined, anonymous binding is used.
|
||||
|
||||
- `OVERLEAF_LDAP_BIND_CREDENTIALS`
|
||||
* Password for `OVERLEAF_LDAP_BIND_DN`.
|
||||
|
||||
- `OVERLEAF_LDAP_BIND_PROPERTY`
|
||||
* Property of the user to bind against the client, defaults to `dn`.
|
||||
|
||||
- `OVERLEAF_LDAP_SEARCH_BASE` **(required)**
|
||||
* The base DN from which to search for users. E.g., `ou=people,dc=example,dc=com`.
|
||||
|
||||
- `OVERLEAF_LDAP_SEARCH_FILTER`
|
||||
* LDAP search filter with which to find a user. Use the literal '{{username}}' to have the given username be interpolated in for the LDAP search.
|
||||
|
||||
- Example: `(|(uid={{username}})(mail={{username}}))` (user can login with email or with login name).
|
||||
- Example: `(sAMAccountName={{username}})` (Active Directory).
|
||||
|
||||
- `OVERLEAF_LDAP_SEARCH_SCOPE`
|
||||
* The scope of the search can be `base`, `one`, or `sub` (default).
|
||||
|
||||
- `OVERLEAF_LDAP_SEARCH_ATTRIBUTES`
|
||||
* JSON array of attributes to fetch from the LDAP server, e.g., `["uid", "mail", "givenName", "sn"]`.
|
||||
By default, all attributes are fetched.
|
||||
|
||||
- `OVERLEAF_LDAP_STARTTLS`
|
||||
* If `true`, LDAP over TLS is used.
|
||||
|
||||
- `OVERLEAF_LDAP_TLS_OPTS_CA_PATH`
|
||||
* Path to the file containing the CA certificate used to verify the LDAP server's SSL/TLS certificate. If there are multiple certificates, then
|
||||
it can be a JSON array of paths to the certificates. The files must be accessible to the docker container.
|
||||
|
||||
- Example (one certificate): `/overleaf/certs/ldap_ca_cert.pem`
|
||||
- Example (multiple certificates): `["/overleaf/certs/ldap_ca_cert1.pem", "/overleaf/certs/ldap_ca_cert2.pem"]`
|
||||
|
||||
- `OVERLEAF_LDAP_TLS_OPTS_REJECT_UNAUTH`
|
||||
* If `true`, the server certificate is verified against the list of supplied CAs.
|
||||
|
||||
- `OVERLEAF_LDAP_CACHE`
|
||||
* If `true`, then up to 100 credentials at a time will be cached for 5 minutes.
|
||||
|
||||
- `OVERLEAF_LDAP_TIMEOUT`
|
||||
* How long the client should let operations live for before timing out, ms (Default: Infinity).
|
||||
|
||||
- `OVERLEAF_LDAP_CONNECT_TIMEOUT`
|
||||
* How long the client should wait before timing out on TCP connections, ms (Default: OS default).
|
||||
|
||||
- `OVERLEAF_LDAP_IS_ADMIN_ATT` and `OVERLEAF_LDAP_IS_ADMIN_ATT_VALUE`
|
||||
* When both environment variables are set, the login process updates `user.isAdmin = true` if the LDAP profile contains the attribute specified by
|
||||
`OVERLEAF_LDAP_IS_ADMIN_ATT` and its value either matches `OVERLEAF_LDAP_IS_ADMIN_ATT_VALUE` or is an array containing `OVERLEAF_LDAP_IS_ADMIN_ATT_VALUE`,
|
||||
otherwise `user.isAdmin` is set to `false`. If either of these variables is not set, then the admin status is only set to `true` during admin user
|
||||
creation in Launchpad.
|
||||
|
||||
The following five variables are used to configure how user contacts are retrieved from the LDAP server.
|
||||
|
||||
- `OVERLEAF_LDAP_CONTACTS_FILTER`
|
||||
* The filter used to search for users in the LDAP server to be loaded into contacts. The placeholder '{{userProperty}}' within the filter is replaced with the value of
|
||||
the property specified by `OVERLEAF_LDAP_CONTACTS_PROPERTY` from the LDAP user initiating the search. If not defined, no users are retrieved from the LDAP server into contacts.
|
||||
|
||||
- `OVERLEAF_LDAP_CONTACTS_SEARCH_BASE`
|
||||
* Specifies the base DN from which to start searching for the contacts. Defaults to `OVERLEAF_LDAP_SEARCH_BASE`.
|
||||
|
||||
- `OVERLEAF_LDAP_CONTACTS_SEARCH_SCOPE`
|
||||
* The scope of the search can be `base`, `one`, or `sub` (default).
|
||||
|
||||
- `OVERLEAF_LDAP_CONTACTS_PROPERTY`
|
||||
* Specifies the property of the user object that will replace the '{{userProperty}}' placeholder in the `OVERLEAF_LDAP_CONTACTS_FILTER`.
|
||||
|
||||
- `OVERLEAF_LDAP_CONTACTS_NON_LDAP_VALUE`
|
||||
* Specifies the value of the `OVERLEAF_LDAP_CONTACTS_PROPERTY` if the search is initiated by a non-LDAP user. If this variable is not defined, the resulting filter
|
||||
will match nothing. The value `*` can be used as a wildcard.
|
||||
|
||||
<details>
|
||||
<summary><h5>Example</h5></summary>
|
||||
|
||||
OVERLEAF_LDAP_CONTACTS_FILTER=(gidNumber={{userProperty}})
|
||||
OVERLEAF_LDAP_CONTACTS_PROPERTY=gidNumber
|
||||
OVERLEAF_LDAP_CONTACTS_NON_LDAP_VALUE=1000
|
||||
|
||||
The above example results in loading into the contacts of the current LDAP user all LDAP users who have the same UNIX `gid`. Non-LDAP users will have all LDAP users with UNIX `gid=1000` in their contacts.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary><h4>Sample variables.env file</h4></summary>
|
||||
|
||||
```
|
||||
OVERLEAF_APP_NAME="Our Overleaf Instance"
|
||||
|
||||
ENABLED_LINKED_FILE_TYPES=project_file,project_output_file
|
||||
|
||||
# Enables Thumbnail generation using ImageMagick
|
||||
ENABLE_CONVERSIONS=true
|
||||
|
||||
# Disables email confirmation requirement
|
||||
EMAIL_CONFIRMATION_DISABLED=true
|
||||
|
||||
## Nginx
|
||||
# NGINX_WORKER_PROCESSES=4
|
||||
# NGINX_WORKER_CONNECTIONS=768
|
||||
|
||||
## Set for TLS via nginx-proxy
|
||||
# OVERLEAF_BEHIND_PROXY=true
|
||||
# OVERLEAF_SECURE_COOKIE=true
|
||||
|
||||
OVERLEAF_SITE_URL=http://my-overleaf-instance.com
|
||||
OVERLEAF_NAV_TITLE=Our Overleaf Instance
|
||||
# OVERLEAF_HEADER_IMAGE_URL=http://somewhere.com/mylogo.png
|
||||
OVERLEAF_ADMIN_EMAIL=support@example.com
|
||||
|
||||
OVERLEAF_LEFT_FOOTER=[{"text": "Contact your support team", "url": "mailto:support@example.com"}]
|
||||
OVERLEAF_RIGHT_FOOTER=[{"text":"Hello, I am on the Right", "url":"https://github.com/yu-i-i/overleaf-cep"}]
|
||||
|
||||
OVERLEAF_EMAIL_FROM_ADDRESS=team@example.com
|
||||
OVERLEAF_EMAIL_SMTP_HOST=smtp.example.com
|
||||
OVERLEAF_EMAIL_SMTP_PORT=587
|
||||
OVERLEAF_EMAIL_SMTP_SECURE=false
|
||||
# OVERLEAF_EMAIL_SMTP_USER=
|
||||
# OVERLEAF_EMAIL_SMTP_PASS=
|
||||
# OVERLEAF_EMAIL_SMTP_NAME=
|
||||
OVERLEAF_EMAIL_SMTP_LOGGER=false
|
||||
OVERLEAF_EMAIL_SMTP_TLS_REJECT_UNAUTH=true
|
||||
OVERLEAF_EMAIL_SMTP_IGNORE_TLS=false
|
||||
OVERLEAF_CUSTOM_EMAIL_FOOTER=This system is run by department x
|
||||
|
||||
OVERLEAF_PROXY_LEARN=true
|
||||
NAV_HIDE_POWERED_BY=true
|
||||
|
||||
#################
|
||||
## LDAP for CE ##
|
||||
#################
|
||||
|
||||
EXTERNAL_AUTH=ldap
|
||||
OVERLEAF_LDAP_URL=ldap://ldap.example.com:389
|
||||
OVERLEAF_LDAP_STARTTLS=true
|
||||
OVERLEAF_LDAP_TLS_OPTS_CA_PATH=/overleaf/certs/ldap_ca_cert.pem
|
||||
OVERLEAF_LDAP_SEARCH_BASE=ou=people,dc=example,dc=com
|
||||
OVERLEAF_LDAP_SEARCH_FILTER=(|(uid={{username}})(mail={{username}}))
|
||||
OVERLEAF_LDAP_BIND_DN=cn=ldap_reader,dc=example,dc=com
|
||||
OVERLEAF_LDAP_BIND_CREDENTIALS=GoodNewsEveryone
|
||||
OVERLEAF_LDAP_EMAIL_ATT=mail
|
||||
OVERLEAF_LDAP_FIRST_NAME_ATT=givenName
|
||||
OVERLEAF_LDAP_LAST_NAME_ATT=sn
|
||||
# OVERLEAF_LDAP_NAME_ATT=cn
|
||||
OVERLEAF_LDAP_SEARCH_ATTRIBUTES=["uid", "sn", "givenName", "mail"]
|
||||
|
||||
OVERLEAF_LDAP_UPDATE_USER_DETAILS_ON_LOGIN=true
|
||||
|
||||
OVERLEAF_LDAP_PLACEHOLDER='Username or email address'
|
||||
|
||||
OVERLEAF_LDAP_IS_ADMIN_ATT=mail
|
||||
OVERLEAF_LDAP_IS_ADMIN_ATT_VALUE=admin@example.com
|
||||
|
||||
OVERLEAF_LDAP_CONTACTS_FILTER=(gidNumber={{userProperty}})
|
||||
OVERLEAF_LDAP_CONTACTS_PROPERTY=gidNumber
|
||||
OVERLEAF_LDAP_CONTACTS_NON_LDAP_VALUE='*'
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><i>Deprecated variables</i></summary>
|
||||
|
||||
**These variables will be removed soon**, use `OVERLEAF_LDAP_IS_ADMIN_ATT` and `OVERLEAF_LDAP_IS_ADMIN_ATT_VALUE` instead.
|
||||
|
||||
The following variables are used to determine if the user has admin rights.
|
||||
Please note: the user gains admin status if the search result is not empty, not when the user is explicitly included in the search results.
|
||||
|
||||
- `OVERLEAF_LDAP_ADMIN_SEARCH_BASE`
|
||||
* Specifies the base DN from which to start searching for the admin group. If this variable is defined,
|
||||
`OVERLEAF_LDAP_ADMIN_SEARCH_FILTER` must also be defined for the search to function properly.
|
||||
|
||||
- `OVERLEAF_LDAP_ADMIN_SEARCH_FILTER`
|
||||
* Defines the LDAP search filter used to identify the admin group. The placeholder `{{dn}}` within the filter
|
||||
is replaced with the value of the property specified by `OVERLEAF_LDAP_ADMIN_DN_PROPERTY`. The placeholder `{{username}}` is also supported.
|
||||
|
||||
- `OVERLEAF_LDAP_ADMIN_DN_PROPERTY`
|
||||
* Specifies the property of the user object that will replace the '{{dn}}' placeholder
|
||||
in the `OVERLEAF_LDAP_ADMIN_SEARCH_FILTER`, defaults to `dn`.
|
||||
|
||||
- `OVERLEAF_LDAP_ADMIN_SEARCH_SCOPE`
|
||||
* The scope of the LDAP search can be `base`, `one`, or `sub` (default)
|
||||
|
||||
<details>
|
||||
<summary><h5>Example</h5></summary>
|
||||
|
||||
In the following example admins are members of a group `admins`, the objectClass of the entry `admins` is `groupOfNames`:
|
||||
|
||||
OVERLEAF_LDAP_ADMIN_SEARCH_BASE='cn=admins,ou=group,dc=example,dc=com'
|
||||
OVERLEAF_LDAP_ADMIN_SEARCH_FILTER='(member={{dn}})'
|
||||
|
||||
In the following example admins are members of a group 'admins', the objectClass of the entry `admins` is `posixGroup`:
|
||||
|
||||
OVERLEAF_LDAP_ADMIN_SEARCH_BASE='cn=admins,ou=group,dc=example,dc=com'
|
||||
OVERLEAF_LDAP_ADMIN_SEARCH_FILTER='(memberUid={{username}})'
|
||||
|
||||
In the following example admins are users with UNIX gid=1234:
|
||||
|
||||
OVERLEAF_LDAP_ADMIN_SEARCH_BASE='ou=people,dc=example,dc=com'
|
||||
OVERLEAF_LDAP_ADMIN_SEARCH_FILTER='(&(gidNumber=1234)(uid={{username}}))'
|
||||
|
||||
In the following example admin is the user with `uid=someuser`:
|
||||
|
||||
OVERLEAF_LDAP_ADMIN_SEARCH_BASE='ou=people,dc=example,dc=com'
|
||||
OVERLEAF_LDAP_ADMIN_SEARCH_FILTER='(&(uid=someuser)(uid={{username}}))'
|
||||
|
||||
The filter
|
||||
|
||||
OVERLEAF_LDAP_ADMIN_SEARCH_FILTER='(uid=someuser)'
|
||||
|
||||
where `someuser` is the uid of an existing user, will always produce a non-empty search result.
|
||||
As a result, **every user will be granted admin rights**, not just `someuser`, as one might expect.
|
||||
|
||||
</details>
|
||||
</details>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>SAML Authentication</h3></summary>
|
||||
|
||||
Internally, Overleaf SAML module uses the [passport-saml](https://github.com/node-saml/passport-saml) library, most of the following
|
||||
configuration options are passed through to `passport-saml`. If you are having issues configuring SAML, it is worth reading the README
|
||||
for `passport-saml` to get a feel for the configuration it expects.
|
||||
|
||||
When using the SAML authentication method, a user is redirected to the Identity Provider (IdP) authentication site.
|
||||
If the IdP successfully authenticates the user, the Overleaf users database is checked for a record containing a `samlIdentifiers` field structured as follows:
|
||||
|
||||
```
|
||||
samlIdentifiers: [
|
||||
{
|
||||
externalUserId: "...",
|
||||
providerId: "1",
|
||||
userIdAttribute: "..."
|
||||
}
|
||||
]
|
||||
```
|
||||
The `externalUserId` must match the value of the property specified by `userIdAttribute` in the user profile returned by the IdP server.
|
||||
|
||||
If no matching record is found, the database is searched for a user with the primary email address matching the email in the IdP user profile:
|
||||
|
||||
- If such a user is found, the `hashedPassword` field is deleted to disable local authentication, and the `samlIdentifiers` field is added.
|
||||
- If no matching user is found, a new user is created with the email address and `samlIdentifiers` from the IdP profile.
|
||||
|
||||
**Note:** Currently, only one SAML IdP is supported. The `providerId` field in `samlIdentifiers` is fixed to `'1'`.
|
||||
|
||||
#### Environment Variables
|
||||
|
||||
- `OVERLEAF_SAML_IDENTITY_SERVICE_NAME`
|
||||
* Display name for the identity service, used on the login page (default: `Log in with SAML IdP`).
|
||||
|
||||
- `OVERLEAF_SAML_USER_ID_FIELD`
|
||||
* The value of this attribute will be used by Overleaf as the external user ID, defaults to `nameID`.
|
||||
|
||||
- `OVERLEAF_SAML_EMAIL_FIELD`
|
||||
* Name of the Email field in user profile, defaults to `nameID`.
|
||||
|
||||
- `OVERLEAF_SAML_FIRST_NAME_FIELD`
|
||||
* Name of the firstName field in user profile, defaults to `givenName`.
|
||||
|
||||
- `OVERLEAF_SAML_LAST_NAME_FIELD`
|
||||
* Name of the lastName field in user profile, defaults to `lastName`
|
||||
|
||||
- `OVERLEAF_SAML_UPDATE_USER_DETAILS_ON_LOGIN`
|
||||
* If set to `true`, updates the user `first_name` and `last_name` field on login,
|
||||
and turn off the user details form on `/user/settings` page.
|
||||
|
||||
- `OVERLEAF_SAML_ENTRYPOINT` **(required)**
|
||||
* Entrypoint URL for the SAML identity service.
|
||||
|
||||
- Example: `https://idp.example.com/simplesaml/saml2/idp/SSOService.php`
|
||||
- Azure Example: `https://login.microsoftonline.com/8b26b46a-6dd3-45c7-a104-f883f4db1f6b/saml2`
|
||||
|
||||
- `OVERLEAF_SAML_ISSUER` **(required)**
|
||||
* The Issuer name.
|
||||
|
||||
- `OVERLEAF_SAML_AUDIENCE`
|
||||
* Expected saml response Audience, defaults to value of `OVERLEAF_SAML_ISSUER`.
|
||||
|
||||
- `OVERLEAF_SAML_IDP_CERT` **(required)**
|
||||
* Path to a file containing the Identity Provider's public certificate, used to validate the signatures of incoming SAML responses. If the Identity Provider has multiple valid signing certificates, then
|
||||
it can be a JSON array of paths to the certificates.
|
||||
|
||||
- Example (one certificate): `/overleaf/certs/idp_cert.pem`
|
||||
- Example (multiple certificates): `["/overleaf/certs/idp_cert.pem", "/overleaf/certs/idp_cert_old.pem"]`
|
||||
|
||||
- `OVERLEAF_SAML_PUBLIC_CERT`
|
||||
* Path to a file containing public signing certificate used to embed in auth requests in order for the IdP to validate the signatures of the incoming SAML Request. It's required when setting up the [metadata endpoint](#metadata-for-the-identity-provider)
|
||||
when the strategy is configured with a `OVERLEAF_SAML_PRIVATE_KEY`. A JSON array of paths to certificates can be provided to support certificate rotation. When supplying an array of certificates, the first entry in the array should match the
|
||||
current `OVERLEAF_SAML_PRIVATE_KEY`. Additional entries in the array can be used to publish upcoming certificates to IdPs before changing the `OVERLEAF_SAML_PRIVATE_KEY`.
|
||||
|
||||
- `OVERLEAF_SAML_PRIVATE_KEY`
|
||||
* Path to a file containing a PEM-formatted private key matching the `OVERLEAF_SAML_PUBLIC_CERT` used to sign auth requests sent by passport-saml.
|
||||
|
||||
- `OVERLEAF_SAML_DECRYPTION_CERT`
|
||||
* Path to a file containing public certificate, used for the [metadata endpoint](#metadata-for-the-identity-provider).
|
||||
|
||||
- `OVERLEAF_SAML_DECRYPTION_PVK`
|
||||
* Path to a file containing private key matching the `OVERLEAF_SAML_DECRYPTION_CERT` that will be used to attempt to decrypt any encrypted assertions that are received.
|
||||
|
||||
- `OVERLEAF_SAML_SIGNATURE_ALGORITHM`
|
||||
* Optionally set the signature algorithm for signing requests,
|
||||
valid values are 'sha1' (default), 'sha256' (prefered), 'sha512' (most secure, check if your IdP supports it).
|
||||
|
||||
- `OVERLEAF_SAML_ADDITIONAL_PARAMS`
|
||||
* JSON dictionary of additional query params to add to all requests.
|
||||
|
||||
- `OVERLEAF_SAML_ADDITIONAL_AUTHORIZE_PARAMS`
|
||||
* JSON dictionary of additional query params to add to 'authorize' requests.
|
||||
|
||||
- Example: `{"some_key": "some_value"}`
|
||||
|
||||
- `OVERLEAF_SAML_IDENTIFIER_FORMAT`
|
||||
* Name identifier format to request from the identity provider (default: `urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress`).
|
||||
If using `urn:oasis:names:tc:SAML:2.0:nameid-format:persistent`, ensure the `OVERLEAF_SAML_EMAIL_FIELD` envirionment variable is defined.
|
||||
If `urn:oasis:names:tc:SAML:2.0:nameid-format:transient` is required, you must also define the `OVERLEAF_SAML_ID_FIELD` environment variable,
|
||||
which can, for example, be set to the user's email address.
|
||||
|
||||
- `OVERLEAF_SAML_ACCEPTED_CLOCK_SKEW_MS`
|
||||
* Time in milliseconds of skew that is acceptable between client and server when checking OnBefore and NotOnOrAfter assertion
|
||||
condition validity timestamps. Setting to -1 will disable checking these conditions entirely. Default is 0.
|
||||
|
||||
- `OVERLEAF_SAML_ATTRIBUTE_CONSUMING_SERVICE_INDEX`
|
||||
* `AttributeConsumingServiceIndex` attribute to add to AuthnRequest to instruct the IdP which attribute set to attach
|
||||
to the response ([link](http://blog.aniljohn.com/2014/01/data-minimization-front-channel-saml-attribute-requests.html)).
|
||||
|
||||
- `OVERLEAF_SAML_AUTHN_CONTEXT`
|
||||
* JSON array of name identifier format values to request auth context. Default: `["urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"]`.
|
||||
|
||||
- `OVERLEAF_SAML_FORCE_AUTHN`
|
||||
* If `true`, the initial SAML request from the service provider specifies that the IdP should force re-authentication of the user,
|
||||
even if they possess a valid session.
|
||||
|
||||
- `OVERLEAF_SAML_DISABLE_REQUESTED_AUTHN_CONTEXT`
|
||||
* If `true`, do not request a specific auth context. For example, you can this this to `true` to allow additional contexts such as password-less logins (`urn:oasis:names:tc:SAML:2.0:ac:classes:X509`). Support for additional contexts is dependant on your IdP.
|
||||
|
||||
- `OVERLEAF_SAML_AUTHN_REQUEST_BINDING`
|
||||
* If set to `HTTP-POST`, will request authentication from IdP via HTTP POST binding, otherwise defaults to HTTP-Redirect.
|
||||
|
||||
- `OVERLEAF_SAML_VALIDATE_IN_RESPONSE_TO`
|
||||
* If `always`, then InResponseTo will be validated from incoming SAML responses.
|
||||
* If `never`, then InResponseTo won't be validated (default).
|
||||
* If `ifPresent`, then InResponseTo will only be validated if present in the incoming SAML response.
|
||||
|
||||
- `OVERLEAF_SAML_REQUEST_ID_EXPIRATION_PERIOD_MS`
|
||||
* Defines the expiration time when a Request ID generated for a SAML request will not be valid if seen
|
||||
in a SAML response in the `InResponseTo` field. Default: 28800000 (8 hours).
|
||||
|
||||
- `OVERLEAF_SAML_LOGOUT_URL`
|
||||
* base address to call with logout requests (default: `entryPoint`).
|
||||
|
||||
- Example: `https://idp.example.com/simplesaml/saml2/idp/SingleLogoutService.php`
|
||||
|
||||
- `OVERLEAF_SAML_ADDITIONAL_LOGOUT_PARAMS`
|
||||
* JSON dictionary of additional query params to add to 'logout' requests.
|
||||
|
||||
- `OVERLEAF_SAML_IS_ADMIN_FIELD` and `OVERLEAF_SAML_IS_ADMIN_FIELD_VALUE`
|
||||
* When both environment variables are set, the login process updates `user.isAdmin = true` if the profile returned by the SAML IdP contains the attribute specified by
|
||||
`OVERLEAF_SAML_IS_ADMIN_FIELD` and its value either matches `OVERLEAF_SAML_IS_ADMIN_FIELD_VALUE` or is an array containing `OVERLEAF_SAML_IS_ADMIN_FIELD_VALUE`,
|
||||
otherwise `user.isAdmin` is set to `false`. If either of these variables is not set, then the admin status is only set to `true` during admin user
|
||||
creation in Launchpad.
|
||||
|
||||
#### Metadata for the Identity Provider
|
||||
|
||||
The current version of Overleaf CE includes and endpoint to retrieve Service Provider Metadata: `http://my-overleaf-instance.com/saml/meta`
|
||||
|
||||
The Identity Provider will need to be configured to recognize the Overleaf server as a "Service Provider". Consult the documentation for your SAML server for instructions on how to do this.
|
||||
|
||||
Below is an example of appropriate Service Provider metadata:
|
||||
|
||||
<details>
|
||||
<summary><h5>ol-meta.xml</h5></summary>
|
||||
|
||||
```
|
||||
<?xml version="1.0"?>
|
||||
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata"
|
||||
xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
|
||||
entityID="MyOverleaf"
|
||||
ID="_b508c83b7dda452f5b269383fb391107116f8f57">
|
||||
<SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol" AuthnRequestsSigned="true" WantAssertionsSigned="true">
|
||||
<KeyDescriptor use="signing">
|
||||
<ds:KeyInfo>
|
||||
<ds:X509Data>
|
||||
<ds:X509Certificate>MII...
|
||||
[skipped]
|
||||
</ds:X509Certificate>
|
||||
</ds:X509Data>
|
||||
</ds:KeyInfo>
|
||||
</KeyDescriptor>
|
||||
<KeyDescriptor use="encryption">
|
||||
<ds:KeyInfo>
|
||||
<ds:X509Data>
|
||||
<ds:X509Certificate>MII...
|
||||
[skipped]
|
||||
</ds:X509Certificate>
|
||||
</ds:X509Data>
|
||||
</ds:KeyInfo>
|
||||
<EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes256-gcm"/>
|
||||
<EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/>
|
||||
<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes256-cbc"/>
|
||||
<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes128-cbc"/>
|
||||
</KeyDescriptor>
|
||||
<SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
|
||||
Location="https://my-overleaf-instance.com/saml/logout/callback"/>
|
||||
<NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</NameIDFormat>
|
||||
<AssertionConsumerService index="1"
|
||||
isDefault="true"
|
||||
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
|
||||
Location="https://my-overleaf-instance.com/saml/login/callback"/>
|
||||
</SPSSODescriptor>
|
||||
</EntityDescriptor>
|
||||
|
||||
```
|
||||
</details>
|
||||
|
||||
Note the certificates, `AssertionConsumerService.Location`, `SingleLogoutService.Location` and `EntityDescriptor.entityID`
|
||||
and set as appropriate in your IdP configuration, or send the metadata file to the IdP admin.
|
||||
|
||||
<details>
|
||||
<summary><h4>Sample variables.env file</h4></summary>
|
||||
|
||||
```
|
||||
OVERLEAF_APP_NAME="Our Overleaf Instance"
|
||||
|
||||
ENABLED_LINKED_FILE_TYPES=project_file,project_output_file
|
||||
|
||||
# Enables Thumbnail generation using ImageMagick
|
||||
ENABLE_CONVERSIONS=true
|
||||
|
||||
# Disables email confirmation requirement
|
||||
EMAIL_CONFIRMATION_DISABLED=true
|
||||
|
||||
## Nginx
|
||||
# NGINX_WORKER_PROCESSES=4
|
||||
# NGINX_WORKER_CONNECTIONS=768
|
||||
|
||||
## Set for TLS via nginx-proxy
|
||||
# OVERLEAF_BEHIND_PROXY=true
|
||||
# OVERLEAF_SECURE_COOKIE=true
|
||||
|
||||
OVERLEAF_SITE_URL=http://my-overleaf-instance.com
|
||||
OVERLEAF_NAV_TITLE=Our Overleaf Instance
|
||||
# OVERLEAF_HEADER_IMAGE_URL=http://somewhere.com/mylogo.png
|
||||
OVERLEAF_ADMIN_EMAIL=support@example.com
|
||||
|
||||
OVERLEAF_LEFT_FOOTER=[{"text": "Contact your support team", "url": "mailto:support@example.com"}]
|
||||
OVERLEAF_RIGHT_FOOTER=[{"text":"Hello, I am on the Right", "url":"https://github.com/yu-i-i/overleaf-cep"}]
|
||||
|
||||
OVERLEAF_EMAIL_FROM_ADDRESS=team@example.com
|
||||
OVERLEAF_EMAIL_SMTP_HOST=smtp.example.com
|
||||
OVERLEAF_EMAIL_SMTP_PORT=587
|
||||
OVERLEAF_EMAIL_SMTP_SECURE=false
|
||||
# OVERLEAF_EMAIL_SMTP_USER=
|
||||
# OVERLEAF_EMAIL_SMTP_PASS=
|
||||
# OVERLEAF_EMAIL_SMTP_NAME=
|
||||
OVERLEAF_EMAIL_SMTP_LOGGER=false
|
||||
OVERLEAF_EMAIL_SMTP_TLS_REJECT_UNAUTH=true
|
||||
OVERLEAF_EMAIL_SMTP_IGNORE_TLS=false
|
||||
OVERLEAF_CUSTOM_EMAIL_FOOTER=This system is run by department x
|
||||
|
||||
OVERLEAF_PROXY_LEARN=true
|
||||
NAV_HIDE_POWERED_BY=true
|
||||
|
||||
#################
|
||||
## SAML for CE ##
|
||||
#################
|
||||
|
||||
EXTERNAL_AUTH=saml
|
||||
OVERLEAF_SAML_IDENTITY_SERVICE_NAME='Log in with My IdP'
|
||||
OVERLEAF_SAML_EMAIL_FIELD=mail
|
||||
OVERLEAF_SAML_FIRST_NAME_FIELD=givenName
|
||||
OVERLEAF_SAML_LAST_NAME_FIELD=sn
|
||||
OVERLEAF_SAML_ENTRYPOINT=https://idp.example.com/simplesamlphp/saml2/idp/SSOService.php
|
||||
OVERLEAF_SAML_CALLBACK_URL=https://my-overleaf-instance.com/saml/login/callback
|
||||
OVERLEAF_SAML_LOGOUT_URL=https://idp.example.com/simplesamlphp/saml2/idp/SingleLogoutService.php
|
||||
OVERLEAF_SAML_LOGOUT_CALLBACK_URL=https://my-overleaf-instance.com/saml/logout/callback
|
||||
OVERLEAF_SAML_ISSUER=MyOverleaf
|
||||
OVERLEAF_SAML_IDP_CERT=/overleaf/certs/idp_cert.pem
|
||||
OVERLEAF_SAML_PUBLIC_CERT=/overleaf/certs/myol_cert.pem
|
||||
OVERLEAF_SAML_PRIVATE_KEY=/overleaf/certs/myol_key.pem
|
||||
OVERLEAF_SAML_DECRYPTION_CERT=/overleaf/certs/myol_decr_cert.pem
|
||||
OVERLEAF_SAML_DECRYPTION_PVK=/overleaf/certs/myol_decr_key.pem
|
||||
OVERLEAF_SAML_IS_ADMIN_FIELD=mail
|
||||
OVERLEAF_SAML_IS_ADMIN_FIELD_VALUE=overleaf.admin@example.com
|
||||
```
|
||||
</details>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>OIDC Authentication</h3></summary>
|
||||
|
||||
Internally, Overleaf OIDC module uses the [passport-openidconnect](https://github.com/jaredhanson/passport-openidconnect) library.
|
||||
If you are having issues configuring OpenID Connect, it is worth reading the README for `passport-openidconnect` to get a feel for the configuration it expects.
|
||||
|
||||
|
||||
When using the OIDC authentication method, a user is redirected to the Identity Provider (IdP) authentication site.
|
||||
If the IdP successfully authenticates the user, the Overleaf users database is checked for a record containing a `thirdPartyIdentifiers` field structured as follows:
|
||||
|
||||
```
|
||||
thirdPartyIdentifiers: [
|
||||
{
|
||||
externalUserId: "...",
|
||||
externalData: null,
|
||||
providerId: "..."
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
The `externalUserId` must match the user ID in the profile returned by the IdP server (see the `OVERLEAF_OIDC_USER_ID_FIELD` environment variable), and `providerId`
|
||||
must match the ID of the OIDC provider (see the `OVERLEAF_OIDC_PROVIDER_ID`).
|
||||
|
||||
If no matching record is found, the database is searched for a user with the primary email address matching the email in the IdP user profile:
|
||||
- If such a user is found, the `thirdPartyIdentifiers` field is updated.
|
||||
- If no matching user is found, a new user is created with the email address and `thirdPartyIdentifiers` from the IdP profile.
|
||||
|
||||
In both cases, the user is said to be 'linked' to the external OIDC user. The user can be unlinked from the OIDC provider on the `/user/settings` page.
|
||||
|
||||
|
||||
#### Environment Variables
|
||||
|
||||
The values of the following five required variables can be found using `.well-known/openid-configuration` endpoint of your OpenID Provider (OP).
|
||||
|
||||
- `OVERLEAF_OIDC_ISSUER` **(required)**
|
||||
|
||||
- `OVERLEAF_OIDC_AUTHORIZATION_URL` **(required)**
|
||||
|
||||
- `OVERLEAF_OIDC_TOKEN_URL` **(required)**
|
||||
|
||||
- `OVERLEAF_OIDC_USER_INFO_URL` **(required)**
|
||||
|
||||
- `OVERLEAF_OIDC_LOGOUT_URL` **(required)**
|
||||
|
||||
The values of the following two required variables will be provided by the admin of your OP
|
||||
|
||||
- `OVERLEAF_OIDC_CLIENT_ID` **(required)**
|
||||
|
||||
- `OVERLEAF_OIDC_CLIENT_SECRET` **(required)**
|
||||
|
||||
- `OVERLEAF_OIDC_SCOPE`
|
||||
* Default: `openid profile email`
|
||||
|
||||
- `OVERLEAF_OIDC_PROVIDER_ID`
|
||||
* Arbitrary ID of the OP, defaults to `oidc`.
|
||||
|
||||
- `OVERLEAF_OIDC_PROVIDER_NAME`
|
||||
* The name of the OP, used in the `Linked Accounts` section of the `/user/settings` page, defaults to `OIDC Provider`.
|
||||
|
||||
- `OVERLEAF_OIDC_IDENTITY_SERVICE_NAME`
|
||||
* Display name for the identity service, used on the login page (default: `Log in with $OVERLEAF_OIDC_PROVIDER_NAME`).
|
||||
|
||||
- `OVERLEAF_OIDC_PROVIDER_DESCRIPTION`
|
||||
* Description of OP, used in the `Linked Accounts` section (default: `Log in with $OVERLEAF_OIDC_PROVIDER_NAME`).
|
||||
|
||||
- `OVERLEAF_OIDC_PROVIDER_INFO_LINK`
|
||||
* `Learn more` URL in the OP description, default: no `Learn more` link in the description.
|
||||
|
||||
- `OVERLEAF_OIDC_PROVIDER_HIDE_NOT_LINKED`
|
||||
* Do not show OP on the `/user/settings` page, if the user's account is not linked with the OP, default `false`.
|
||||
|
||||
- `OVERLEAF_OIDC_USER_ID_FIELD`
|
||||
* The value of this attribute will be used by Overleaf as the external user ID, defaults to `id`.
|
||||
|
||||
- `OVERLEAF_OIDC_UPDATE_USER_DETAILS_ON_LOGIN`
|
||||
* If set to `true`, updates the user `first_name` and `last_name` field on login,
|
||||
and disables the user details form on `/user/settings` page.
|
||||
|
||||
- `OVERLEAF_OIDC_IS_ADMIN_FIELD` and `OVERLEAF_OIDC_IS_ADMIN_FIELD_VALUE`
|
||||
* When both environment variables are set, the login process updates `user.isAdmin = true` if the profile returned by the OP contains the attribute specified by
|
||||
`OVERLEAF_OIDC_IS_ADMIN_FIELD` and its value matches `OVERLEAF_OIDC_IS_ADMIN_FIELD_VALUE`, otherwise `user.isAdmin` is set to `false`.
|
||||
If `OVERLEAF_OIDC_IS_ADMIN_FIELD` is `email` then the value of the attribute `emails[0].value` is used for match checking.
|
||||
|
||||
<details>
|
||||
<summary><h4>Sample variables.env file</h4></summary>
|
||||
|
||||
```
|
||||
OVERLEAF_APP_NAME="Our Overleaf Instance"
|
||||
|
||||
ENABLED_LINKED_FILE_TYPES=project_file,project_output_file
|
||||
|
||||
# Enables Thumbnail generation using ImageMagick
|
||||
ENABLE_CONVERSIONS=true
|
||||
|
||||
# Disables email confirmation requirement
|
||||
EMAIL_CONFIRMATION_DISABLED=true
|
||||
|
||||
## Nginx
|
||||
# NGINX_WORKER_PROCESSES=4
|
||||
# NGINX_WORKER_CONNECTIONS=768
|
||||
|
||||
## Set for TLS via nginx-proxy
|
||||
# OVERLEAF_BEHIND_PROXY=true
|
||||
# OVERLEAF_SECURE_COOKIE=true
|
||||
|
||||
OVERLEAF_SITE_URL=http://my-overleaf-instance.com
|
||||
OVERLEAF_NAV_TITLE=Our Overleaf Instance
|
||||
# OVERLEAF_HEADER_IMAGE_URL=http://somewhere.com/mylogo.png
|
||||
OVERLEAF_ADMIN_EMAIL=support@example.com
|
||||
|
||||
OVERLEAF_LEFT_FOOTER=[{"text": "Contact your support team", "url": "mailto:support@example.com"}]
|
||||
OVERLEAF_RIGHT_FOOTER=[{"text":"Hello, I am on the Right", "url":"https://github.com/yu-i-i/overleaf-cep"}]
|
||||
|
||||
OVERLEAF_EMAIL_FROM_ADDRESS=team@example.com
|
||||
OVERLEAF_EMAIL_SMTP_HOST=smtp.example.com
|
||||
OVERLEAF_EMAIL_SMTP_PORT=587
|
||||
OVERLEAF_EMAIL_SMTP_SECURE=false
|
||||
# OVERLEAF_EMAIL_SMTP_USER=
|
||||
# OVERLEAF_EMAIL_SMTP_PASS=
|
||||
# OVERLEAF_EMAIL_SMTP_NAME=
|
||||
OVERLEAF_EMAIL_SMTP_LOGGER=false
|
||||
OVERLEAF_EMAIL_SMTP_TLS_REJECT_UNAUTH=true
|
||||
OVERLEAF_EMAIL_SMTP_IGNORE_TLS=false
|
||||
OVERLEAF_CUSTOM_EMAIL_FOOTER=This system is run by department x
|
||||
|
||||
OVERLEAF_PROXY_LEARN=true
|
||||
NAV_HIDE_POWERED_BY=true
|
||||
|
||||
#################
|
||||
## OIDC for CE ##
|
||||
#################
|
||||
|
||||
EXTERNAL_AUTH=oidc
|
||||
|
||||
OVERLEAF_OIDC_PROVIDER_ID=oidc
|
||||
OVERLEAF_OIDC_ISSUER=https://keycloak.provider.com/realms/example
|
||||
OVERLEAF_OIDC_AUTHORIZATION_URL=https://keycloak.provider.com/realms/example/protocol/openid-connect/auth
|
||||
OVERLEAF_OIDC_TOKEN_URL=https://keycloak.provider.com/realms/example/protocol/openid-connect/token
|
||||
OVERLEAF_OIDC_USER_INFO_URL=https://keycloak.provider.com/realms/example/protocol/openid-connect/userinfo
|
||||
OVERLEAF_OIDC_LOGOUT_URL=https://keycloak.provider.com/realms/example/protocol/openid-connect/logout
|
||||
OVERLEAF_OIDC_CLIENT_ID=Overleaf-OIDC
|
||||
OVERLEAF_OIDC_CLIENT_SECRET=DoNotUseThisATGgaAcTgCcATgGATTACAagGtTCaGcGTAG
|
||||
OVERLEAF_OIDC_IDENTITY_SERVICE_NAME='Log in with Keycloak OIDC Provider'
|
||||
OVERLEAF_OIDC_PROVIDER_NAME=OIDC Keycloak Provider
|
||||
OVERLEAF_OIDC_PROVIDER_INFO_LINK=https://openid.net
|
||||
OVERLEAF_OIDC_IS_ADMIN_FIELD=email
|
||||
OVERLEAF_OIDC_IS_ADMIN_FIELD_VALUE=overleaf.admin@example.com
|
||||
OVERLEAF_OIDC_UPDATE_USER_DETAILS_ON_LOGIN=false
|
||||
```
|
||||
</details>
|
||||
</details>
|
||||
|
||||
## Overleaf Docker Image
|
||||
|
||||
This repo contains two dockerfiles, [`Dockerfile-base`](server-ce/Dockerfile-base), which builds the
|
||||
`sharelatex/sharelatex-base:ext-ce` image, and [`Dockerfile`](server-ce/Dockerfile) which builds the
|
||||
`sharelatex/sharelatex:ext-ce` image.
|
||||
`sharelatex/sharelatex-base` image, and [`Dockerfile`](server-ce/Dockerfile) which builds the
|
||||
`sharelatex/sharelatex` (or "community") image.
|
||||
|
||||
The Base image generally contains the basic dependencies like `wget`, plus `texlive`.
|
||||
This is split out because it's a pretty heavy set of
|
||||
We split this out because it's a pretty heavy set of
|
||||
dependencies, and it's nice to not have to rebuild all of that every time.
|
||||
|
||||
The `sharelatex/sharelatex` image extends the base image and adds the actual Overleaf code
|
||||
@@ -72,22 +890,20 @@ and services.
|
||||
|
||||
Use `make build-base` and `make build-community` from `server-ce/` to build these images.
|
||||
|
||||
The [Phusion base-image](https://github.com/phusion/baseimage-docker)
|
||||
(which is extended by the `base` image) provides a VM-like container
|
||||
We use the [Phusion base-image](https://github.com/phusion/baseimage-docker)
|
||||
(which is extended by our `base` image) to provide us with a VM-like container
|
||||
in which to run the Overleaf services. Baseimage uses the `runit` service
|
||||
manager to manage services, and init scripts from the `server-ce/runit`
|
||||
folder are added.
|
||||
manager to manage services, and we add our init-scripts from the `server-ce/runit`
|
||||
folder.
|
||||
|
||||
## Authors
|
||||
|
||||
[The Overleaf Team](https://www.overleaf.com/about)\
|
||||
[yu-i-i](https://github.com/yu-i-i), [davrot](https://github.com/davrot) — CE extensions; references to adapted code are listed in [`CREDITS`](CREDITS.md)
|
||||
[The Overleaf Team](https://www.overleaf.com/about)
|
||||
<br>
|
||||
Extensions for CE by: [yu-i-i](https://github.com/yu-i-i/overleaf-cep)
|
||||
|
||||
## License
|
||||
|
||||
The code in this repository is released under the GNU AFFERO GENERAL PUBLIC LICENSE, version 3. A copy can be found in the [`LICENSE`](LICENSE) file.
|
||||
|
||||
Copyright (c) Overleaf, 2014-2026.\
|
||||
Copyright (c) @yu-i-i, 2024-2026, for CE extensions.
|
||||
|
||||
Portions of the code are derived from other open-source projects; see [`CREDITS`](CREDITS.md).
|
||||
Copyright (c) Overleaf, 2014-2024.
|
||||
|
||||
+20
-2
@@ -11,6 +11,12 @@ bin/build
|
||||
> [!NOTE]
|
||||
> If Docker is running out of RAM while building the services in parallel, create a `.env` file in this directory containing `COMPOSE_PARALLEL_LIMIT=1`.
|
||||
|
||||
Next, initialize the database:
|
||||
|
||||
```shell
|
||||
bin/init
|
||||
```
|
||||
|
||||
Then start the services:
|
||||
|
||||
```shell
|
||||
@@ -19,6 +25,18 @@ bin/up
|
||||
|
||||
Once the services are running, open <http://localhost/launchpad> to create the first admin account.
|
||||
|
||||
## TeX Live
|
||||
|
||||
Compiling a PDF requires building a TeX Live image to handle the compilation inside Docker:
|
||||
|
||||
```shell
|
||||
docker build texlive -t texlive-full
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> To compile on a macOS host, you may need to override the path to the Docker socket by creating a `.env` file in this directory, containing
|
||||
> `DOCKER_SOCKET_PATH=/var/run/docker.sock.raw`
|
||||
|
||||
## Development
|
||||
|
||||
To avoid running `bin/build && bin/up` after every code change, you can run Overleaf
|
||||
@@ -30,7 +48,7 @@ To do this, use the included `bin/dev` script:
|
||||
bin/dev
|
||||
```
|
||||
|
||||
This will start all services using `node --watch`, which will automatically monitor the code and restart the services as necessary.
|
||||
This will start all services using `nodemon`, which will automatically monitor the code and restart the services as necessary.
|
||||
|
||||
To improve performance, you can start only a subset of the services in development mode by providing a space-separated list to the `bin/dev` script:
|
||||
|
||||
@@ -59,6 +77,7 @@ each service:
|
||||
| `web` | 9229 |
|
||||
| `clsi` | 9230 |
|
||||
| `chat` | 9231 |
|
||||
| `contacts` | 9232 |
|
||||
| `docstore` | 9233 |
|
||||
| `document-updater` | 9234 |
|
||||
| `filestore` | 9235 |
|
||||
@@ -66,7 +85,6 @@ each service:
|
||||
| `real-time` | 9237 |
|
||||
| `history-v1` | 9239 |
|
||||
| `project-history` | 9240 |
|
||||
| `linked-url-proxy` | 9241 |
|
||||
|
||||
To attach to a service using Chrome's _remote debugging_, go to
|
||||
<chrome://inspect/> and make sure _Discover network targets_ is checked. Next
|
||||
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
docker compose up --detach mongo
|
||||
curl --max-time 10 --retry 5 --retry-delay 5 --retry-all-errors --silent --output /dev/null localhost:27017
|
||||
docker compose exec mongo mongosh --eval "rs.initiate({ _id: 'overleaf', members: [{ _id: 0, host: 'mongo:27017' }] })"
|
||||
docker compose down mongo
|
||||
+2
-6
@@ -1,24 +1,20 @@
|
||||
CHAT_HOST=chat
|
||||
CLSI_HOST=clsi
|
||||
DOWNLOAD_HOST=clsi-nginx
|
||||
CONTACTS_HOST=contacts
|
||||
DOCSTORE_HOST=docstore
|
||||
DOCUMENT_UPDATER_HOST=document-updater
|
||||
FILESTORE_HOST=filestore
|
||||
GRACEFUL_SHUTDOWN_DELAY_SECONDS=0
|
||||
HISTORY_V1_HOST=history-v1
|
||||
HISTORY_REDIS_HOST=redis
|
||||
LINKED_URL_PROXY_HOST=linked-url-proxy
|
||||
LISTEN_ADDRESS=0.0.0.0
|
||||
MONGO_HOST=mongo
|
||||
MONGO_URL=mongodb://mongo/sharelatex?directConnection=true
|
||||
NOTIFICATIONS_HOST=notifications
|
||||
PROJECT_HISTORY_HOST=project-history
|
||||
QUEUES_REDIS_HOST=redis
|
||||
DSMP_REDIS_HOST=redis
|
||||
REALTIME_HOST=real-time
|
||||
REDIS_HOST=redis
|
||||
REFERENCES_HOST=references
|
||||
SESSION_SECRET=foo
|
||||
V1_HISTORY_HOST=history-v1
|
||||
WEBPACK_HOST=webpack
|
||||
WEB_API_PASSWORD=overleaf
|
||||
WEB_API_USER=overleaf
|
||||
|
||||
@@ -21,6 +21,17 @@ services:
|
||||
- ../services/chat/app.js:/overleaf/services/chat/app.js
|
||||
- ../services/chat/config:/overleaf/services/chat/config
|
||||
|
||||
contacts:
|
||||
command: ["node", "--watch", "app.js"]
|
||||
environment:
|
||||
- NODE_OPTIONS=--inspect=0.0.0.0:9229
|
||||
ports:
|
||||
- "127.0.0.1:9232:9229"
|
||||
volumes:
|
||||
- ../services/contacts/app:/overleaf/services/contacts/app
|
||||
- ../services/contacts/app.js:/overleaf/services/contacts/app.js
|
||||
- ../services/contacts/config:/overleaf/services/contacts/config
|
||||
|
||||
docstore:
|
||||
command: ["node", "--watch", "app.js"]
|
||||
environment:
|
||||
@@ -68,26 +79,15 @@ services:
|
||||
- ../services/history-v1/knexfile.js:/overleaf/services/history-v1/knexfile.js
|
||||
- ../services/history-v1/migrations:/overleaf/services/history-v1/migrations
|
||||
|
||||
linked-url-proxy:
|
||||
command: ["node", "--watch", "app.mjs"]
|
||||
environment:
|
||||
- NODE_OPTIONS=--inspect=0.0.0.0:9229
|
||||
ports:
|
||||
- "127.0.0.1:9241:9229"
|
||||
volumes:
|
||||
- ../services/linked-url-proxy/app:/overleaf/services/linked-url-proxy/app
|
||||
- ../services/linked-url-proxy/config:/overleaf/services/linked-url-proxy/config
|
||||
- ../services/linked-url-proxy/app.mjs:/overleaf/services/linked-url-proxy/app.mjs
|
||||
|
||||
notifications:
|
||||
command: ["node", "--watch", "app.ts"]
|
||||
command: ["node", "--watch", "app.js"]
|
||||
environment:
|
||||
- NODE_OPTIONS=--inspect=0.0.0.0:9229
|
||||
ports:
|
||||
- "127.0.0.1:9236:9229"
|
||||
volumes:
|
||||
- ../services/notifications/app:/overleaf/services/notifications/app
|
||||
- ../services/notifications/app.ts:/overleaf/services/notifications/app.ts
|
||||
- ../services/notifications/app.js:/overleaf/services/notifications/app.js
|
||||
- ../services/notifications/config:/overleaf/services/notifications/config
|
||||
|
||||
project-history:
|
||||
@@ -112,8 +112,19 @@ services:
|
||||
- ../services/real-time/app.js:/overleaf/services/real-time/app.js
|
||||
- ../services/real-time/config:/overleaf/services/real-time/config
|
||||
|
||||
references:
|
||||
command: ["node", "--watch", "app.js"]
|
||||
environment:
|
||||
- NODE_OPTIONS=--inspect=0.0.0.0:9229
|
||||
ports:
|
||||
- "127.0.0.1:9236:9229"
|
||||
volumes:
|
||||
- ../services/references/app:/overleaf/services/references/app
|
||||
- ../services/references/config:/overleaf/services/references/config
|
||||
- ../services/references/app.js:/overleaf/services/references/app.js
|
||||
|
||||
web:
|
||||
command: ["node", "--watch", "app.mjs", "--watch-locales"]
|
||||
command: ["node", "--watch", "app.js", "--watch-locales"]
|
||||
environment:
|
||||
- NODE_OPTIONS=--inspect=0.0.0.0:9229
|
||||
ports:
|
||||
|
||||
+30
-54
@@ -1,5 +1,6 @@
|
||||
volumes:
|
||||
clsi-cache:
|
||||
clsi-output:
|
||||
filestore-public-files:
|
||||
filestore-template-files:
|
||||
filestore-uploads:
|
||||
@@ -22,28 +23,25 @@ services:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: services/clsi/Dockerfile
|
||||
target: with-texlive
|
||||
env_file:
|
||||
- dev.env
|
||||
environment:
|
||||
- SANDBOXED_COMPILES=false
|
||||
- DOCKER_RUNNER=true
|
||||
- TEXLIVE_IMAGE=texlive-full # docker build texlive -t texlive-full
|
||||
- COMPILES_HOST_DIR=${PWD}/compiles
|
||||
user: root
|
||||
volumes:
|
||||
- ${PWD}/compiles:/overleaf/services/clsi/compiles
|
||||
- ${PWD}/output:/overleaf/services/clsi/output
|
||||
- ${DOCKER_SOCKET_PATH:-/var/run/docker.sock}:/var/run/docker.sock
|
||||
- clsi-cache:/overleaf/services/clsi/cache
|
||||
- clsi-output:/overleaf/services/clsi/output
|
||||
|
||||
clsi-nginx:
|
||||
image: nginx:1.28
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
- /var/cache/nginx
|
||||
- /run
|
||||
volumes:
|
||||
- ${PWD}/output:/output:ro
|
||||
- ../services/clsi/nginx.conf:/etc/nginx/conf.d/nginx.conf:ro
|
||||
contacts:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: services/contacts/Dockerfile
|
||||
env_file:
|
||||
- dev.env
|
||||
|
||||
docstore:
|
||||
build:
|
||||
@@ -65,19 +63,20 @@ services:
|
||||
dockerfile: services/filestore/Dockerfile
|
||||
env_file:
|
||||
- dev.env
|
||||
# environment:
|
||||
# - ENABLE_CONVERSIONS=true
|
||||
# environment:
|
||||
# - ENABLE_CONVERSIONS=true
|
||||
volumes:
|
||||
- filestore-public-files:/overleaf/services/filestore/public_files
|
||||
- filestore-template-files:/overleaf/services/filestore/template_files
|
||||
- filestore-uploads:/overleaf/services/filestore/uploads
|
||||
- filestore-user-files:/overleaf/services/filestore/user_files
|
||||
|
||||
history-v1:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: services/history-v1/Dockerfile
|
||||
env_file:
|
||||
- dev.env
|
||||
- dev.env
|
||||
environment:
|
||||
OVERLEAF_EDITOR_ANALYTICS_BUCKET: "/buckets/analytics"
|
||||
OVERLEAF_EDITOR_BLOBS_BUCKET: "/buckets/blobs"
|
||||
@@ -86,30 +85,15 @@ services:
|
||||
OVERLEAF_EDITOR_ZIPS_BUCKET: "/buckets/zips"
|
||||
PERSISTOR_BACKEND: fs
|
||||
volumes:
|
||||
- history-v1-buckets:/buckets
|
||||
|
||||
linked-url-proxy:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: services/linked-url-proxy/Dockerfile
|
||||
env_file:
|
||||
- dev.env
|
||||
- history-v1-buckets:/buckets
|
||||
|
||||
mongo:
|
||||
image: mongo:8
|
||||
image: mongo:5
|
||||
command: --replSet overleaf
|
||||
ports:
|
||||
- "127.0.0.1:27017:27017" # for debugging
|
||||
volumes:
|
||||
- mongo-data:/data/db
|
||||
- ../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js
|
||||
environment:
|
||||
MONGO_INITDB_DATABASE: sharelatex
|
||||
extra_hosts:
|
||||
# Required when using the automatic database setup for initializing the
|
||||
# replica set. This override is not needed when running the setup after
|
||||
# starting up mongo.
|
||||
- mongo:127.0.0.1
|
||||
|
||||
notifications:
|
||||
build:
|
||||
@@ -123,7 +107,7 @@ services:
|
||||
context: ..
|
||||
dockerfile: services/project-history/Dockerfile
|
||||
env_file:
|
||||
- dev.env
|
||||
- dev.env
|
||||
|
||||
real-time:
|
||||
build:
|
||||
@@ -133,12 +117,19 @@ services:
|
||||
- dev.env
|
||||
|
||||
redis:
|
||||
image: redis:7
|
||||
image: redis:5
|
||||
ports:
|
||||
- "127.0.0.1:6379:6379" # for debugging
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
|
||||
references:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: services/references/Dockerfile
|
||||
env_file:
|
||||
- dev.env
|
||||
|
||||
web:
|
||||
build:
|
||||
context: ..
|
||||
@@ -148,7 +139,7 @@ services:
|
||||
- dev.env
|
||||
environment:
|
||||
- APP_NAME=Overleaf Community Edition
|
||||
- ENABLED_LINKED_FILE_TYPES=project_file,project_output_file,zotero
|
||||
- ENABLED_LINKED_FILE_TYPES=project_file,project_output_file
|
||||
- EMAIL_CONFIRMATION_DISABLED=true
|
||||
- NODE_ENV=development
|
||||
- OVERLEAF_ALLOW_PUBLIC_ACCESS=true
|
||||
@@ -161,38 +152,23 @@ services:
|
||||
- redis
|
||||
- chat
|
||||
- clsi
|
||||
- contacts
|
||||
- docstore
|
||||
- document-updater
|
||||
- filestore
|
||||
- history-v1
|
||||
- linked-url-proxy
|
||||
- notifications
|
||||
- project-history
|
||||
- real-time
|
||||
- references
|
||||
|
||||
webpack:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: services/web/Dockerfile
|
||||
target: webpack
|
||||
command:
|
||||
["npx", "webpack", "serve", "--config", "webpack.config.dev-env.js"]
|
||||
command: ["npx", "webpack", "serve", "--config", "webpack.config.dev-env.js"]
|
||||
ports:
|
||||
- "127.0.0.1:80:3808"
|
||||
volumes:
|
||||
- ./webpack.config.dev-env.js:/overleaf/services/web/webpack.config.dev-env.js
|
||||
|
||||
git-bridge:
|
||||
image: quay.io/sharelatex/git-bridge:latest
|
||||
expose:
|
||||
- "8000"
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
GIT_BRIDGE_API_BASE_URL: "http://web:3000/api/v0"
|
||||
GIT_BRIDGE_OAUTH2_SERVER: "http://web:3000"
|
||||
GIT_BRIDGE_POSTBACK_BASE_URL: "http://git-bridge:8000"
|
||||
GIT_BRIDGE_ROOT_DIR: "/data/git-bridge"
|
||||
LOG_LEVEL: "DEBUG"
|
||||
volumes:
|
||||
- ./data/git-bridge:/data/git-bridge
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
FROM debian:testing-slim
|
||||
|
||||
RUN apt-get update
|
||||
RUN apt-cache depends texlive-full | grep "Depends: " | grep -v -- "-doc" | grep -v -- "-lang-" | sed 's/Depends: //' | xargs apt-get install -y --no-install-recommends
|
||||
RUN apt-get install -y --no-install-recommends fontconfig inkscape pandoc python3-pygments
|
||||
|
||||
RUN useradd tex
|
||||
USER tex
|
||||
@@ -14,11 +14,6 @@ module.exports = merge(base, {
|
||||
target: 'http://real-time:3026',
|
||||
ws: true,
|
||||
},
|
||||
{
|
||||
context: '/git/**',
|
||||
target: 'http://git-bridge:8000',
|
||||
pathRewrite: { '^/git': '' }
|
||||
},
|
||||
{
|
||||
context: ['!**/*.js', '!**/*.css', '!**/*.json'],
|
||||
target: 'http://web:3000',
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 71 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 525 KiB After Width: | Height: | Size: 1.0 MiB |
+21
-21
@@ -1,23 +1,23 @@
|
||||
version: "2.2"
|
||||
version: '2.2'
|
||||
services:
|
||||
sharelatex:
|
||||
ports:
|
||||
- 30000:30000
|
||||
- 30150:30150
|
||||
- 30120:30120
|
||||
- 30050:30050
|
||||
- 30420:30420
|
||||
- 30030:30030
|
||||
- 30160:30160
|
||||
- 30360:30360
|
||||
- 30130:30130
|
||||
- 30100:30100
|
||||
- 30540:30540
|
||||
- 30640:30640
|
||||
- 40000:40000
|
||||
sharelatex:
|
||||
ports:
|
||||
- 30000:30000
|
||||
- 30150:30150
|
||||
- 30120:30120
|
||||
- 30050:30050
|
||||
- 30420:30420
|
||||
- 30030:30030
|
||||
- 30160:30160
|
||||
- 30360:30360
|
||||
- 30130:30130
|
||||
- 30100:30100
|
||||
- 30540:30540
|
||||
- 30640:30640
|
||||
- 40000:40000
|
||||
|
||||
# Server Pro
|
||||
- 30070:30070
|
||||
- 30400:30400
|
||||
environment:
|
||||
DEBUG_NODE: "true"
|
||||
# Server Pro
|
||||
- 30070:30070
|
||||
- 30400:30400
|
||||
environment:
|
||||
DEBUG_NODE: 'true'
|
||||
|
||||
+124
-128
@@ -1,152 +1,148 @@
|
||||
version: '2.2'
|
||||
services:
|
||||
sharelatex:
|
||||
restart: always
|
||||
# Server Pro users:
|
||||
# image: quay.io/sharelatex/sharelatex-pro
|
||||
image: sharelatex/sharelatex
|
||||
container_name: sharelatex
|
||||
depends_on:
|
||||
mongo:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
ports:
|
||||
- 80:80
|
||||
stop_grace_period: 60s
|
||||
volumes:
|
||||
- ~/sharelatex_data:/var/lib/overleaf
|
||||
########################################################################
|
||||
#### Server Pro: Uncomment the following line to mount the docker ####
|
||||
#### socket, required for Sibling Containers to work ####
|
||||
########################################################################
|
||||
# - /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
OVERLEAF_APP_NAME: Overleaf Community Edition
|
||||
sharelatex:
|
||||
restart: always
|
||||
# Server Pro users:
|
||||
# image: quay.io/sharelatex/sharelatex-pro
|
||||
image: sharelatex/sharelatex
|
||||
container_name: sharelatex
|
||||
depends_on:
|
||||
mongo:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
ports:
|
||||
- 80:80
|
||||
stop_grace_period: 60s
|
||||
volumes:
|
||||
- ~/sharelatex_data:/var/lib/overleaf
|
||||
########################################################################
|
||||
#### Server Pro: Uncomment the following line to mount the docker ####
|
||||
#### socket, required for Sibling Containers to work ####
|
||||
########################################################################
|
||||
# - /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
|
||||
OVERLEAF_MONGO_URL: mongodb://mongo/sharelatex
|
||||
OVERLEAF_APP_NAME: Overleaf Community Edition
|
||||
|
||||
# Same property, unfortunately with different names in
|
||||
# different locations
|
||||
OVERLEAF_REDIS_HOST: redis
|
||||
REDIS_HOST: redis
|
||||
OVERLEAF_MONGO_URL: mongodb://mongo/sharelatex
|
||||
|
||||
# IMPORTANT: Required for invite token generation
|
||||
# OVERLEAF_INVITE_TOKEN_SECRET: generate with `openssl rand -base64 32`
|
||||
# Same property, unfortunately with different names in
|
||||
# different locations
|
||||
OVERLEAF_REDIS_HOST: redis
|
||||
REDIS_HOST: redis
|
||||
|
||||
ENABLED_LINKED_FILE_TYPES: "project_file,project_output_file,zotero"
|
||||
ENABLED_LINKED_FILE_TYPES: 'project_file,project_output_file'
|
||||
|
||||
# Enables Thumbnail generation using ImageMagick
|
||||
ENABLE_CONVERSIONS: "true"
|
||||
# Enables Thumbnail generation using ImageMagick
|
||||
ENABLE_CONVERSIONS: 'true'
|
||||
|
||||
# Disables email confirmation requirement
|
||||
EMAIL_CONFIRMATION_DISABLED: "true"
|
||||
# Disables email confirmation requirement
|
||||
EMAIL_CONFIRMATION_DISABLED: 'true'
|
||||
|
||||
## Set for SSL via nginx-proxy
|
||||
#VIRTUAL_HOST: 103.112.212.22
|
||||
# temporary fix for LuaLaTex compiles
|
||||
# see https://github.com/overleaf/overleaf/issues/695
|
||||
TEXMFVAR: /var/lib/overleaf/tmp/texmf-var
|
||||
|
||||
# OVERLEAF_SITE_URL: http://overleaf.example.com
|
||||
# OVERLEAF_NAV_TITLE: Overleaf Community Edition
|
||||
# OVERLEAF_HEADER_IMAGE_URL: http://example.com/mylogo.png
|
||||
# OVERLEAF_ADMIN_EMAIL: support@it.com
|
||||
## Set for SSL via nginx-proxy
|
||||
#VIRTUAL_HOST: 103.112.212.22
|
||||
|
||||
# OVERLEAF_LEFT_FOOTER: '[{"text": "Another page I want to link to can be found <a href=\"here\">here</a>"} ]'
|
||||
# OVERLEAF_RIGHT_FOOTER: '[{"text": "Hello I am on the Right"} ]'
|
||||
# OVERLEAF_SITE_URL: http://overleaf.example.com
|
||||
# OVERLEAF_NAV_TITLE: Overleaf Community Edition
|
||||
# OVERLEAF_HEADER_IMAGE_URL: http://example.com/mylogo.png
|
||||
# OVERLEAF_ADMIN_EMAIL: support@it.com
|
||||
|
||||
# OVERLEAF_EMAIL_FROM_ADDRESS: "hello@example.com"
|
||||
# OVERLEAF_LEFT_FOOTER: '[{"text": "Another page I want to link to can be found <a href=\"here\">here</a>"} ]'
|
||||
# OVERLEAF_RIGHT_FOOTER: '[{"text": "Hello I am on the Right"} ]'
|
||||
|
||||
# OVERLEAF_EMAIL_AWS_SES_ACCESS_KEY_ID:
|
||||
# OVERLEAF_EMAIL_AWS_SES_SECRET_KEY:
|
||||
# OVERLEAF_EMAIL_FROM_ADDRESS: "hello@example.com"
|
||||
|
||||
# OVERLEAF_EMAIL_SMTP_HOST: smtp.example.com
|
||||
# OVERLEAF_EMAIL_SMTP_PORT: 587
|
||||
# OVERLEAF_EMAIL_SMTP_SECURE: false
|
||||
# OVERLEAF_EMAIL_SMTP_USER:
|
||||
# OVERLEAF_EMAIL_SMTP_PASS:
|
||||
# OVERLEAF_EMAIL_SMTP_TLS_REJECT_UNAUTH: true
|
||||
# OVERLEAF_EMAIL_SMTP_IGNORE_TLS: false
|
||||
# OVERLEAF_EMAIL_SMTP_NAME: '127.0.0.1'
|
||||
# OVERLEAF_EMAIL_SMTP_LOGGER: true
|
||||
# OVERLEAF_CUSTOM_EMAIL_FOOTER: "This system is run by department x"
|
||||
# OVERLEAF_EMAIL_AWS_SES_ACCESS_KEY_ID:
|
||||
# OVERLEAF_EMAIL_AWS_SES_SECRET_KEY:
|
||||
|
||||
# ENABLE_CRON_RESOURCE_DELETION: true
|
||||
# OVERLEAF_EMAIL_SMTP_HOST: smtp.example.com
|
||||
# OVERLEAF_EMAIL_SMTP_PORT: 587
|
||||
# OVERLEAF_EMAIL_SMTP_SECURE: false
|
||||
# OVERLEAF_EMAIL_SMTP_USER:
|
||||
# OVERLEAF_EMAIL_SMTP_PASS:
|
||||
# OVERLEAF_EMAIL_SMTP_TLS_REJECT_UNAUTH: true
|
||||
# OVERLEAF_EMAIL_SMTP_IGNORE_TLS: false
|
||||
# OVERLEAF_EMAIL_SMTP_NAME: '127.0.0.1'
|
||||
# OVERLEAF_EMAIL_SMTP_LOGGER: true
|
||||
# OVERLEAF_CUSTOM_EMAIL_FOOTER: "This system is run by department x"
|
||||
|
||||
################
|
||||
## Server Pro ##
|
||||
################
|
||||
# ENABLE_CRON_RESOURCE_DELETION: true
|
||||
|
||||
## The Community Edition is intended for use in environments where all users are trusted and is not appropriate for
|
||||
## scenarios where isolation of users is required. Sandboxed Compiles are not available in the Community Edition,
|
||||
## so the following environment variables must be commented out to avoid compile issues.
|
||||
##
|
||||
## Sandboxed Compiles: https://docs.overleaf.com/on-premises/configuration/overleaf-toolkit/server-pro-only-configuration/sandboxed-compiles
|
||||
SANDBOXED_COMPILES: "true"
|
||||
### Bind-mount source for /var/lib/overleaf/data/compiles inside the container.
|
||||
SANDBOXED_COMPILES_HOST_DIR_COMPILES: "/home/user/sharelatex_data/data/compiles"
|
||||
### Bind-mount source for /var/lib/overleaf/data/output inside the container.
|
||||
SANDBOXED_COMPILES_HOST_DIR_OUTPUT: "/home/user/sharelatex_data/data/output"
|
||||
### Backwards compatibility (before Server Pro 5.5)
|
||||
DOCKER_RUNNER: "true"
|
||||
SANDBOXED_COMPILES_SIBLING_CONTAINERS: "true"
|
||||
################
|
||||
## Server Pro ##
|
||||
################
|
||||
|
||||
## Works with test LDAP server shown at bottom of docker compose
|
||||
# OVERLEAF_LDAP_URL: 'ldap://ldap:389'
|
||||
# OVERLEAF_LDAP_SEARCH_BASE: 'ou=people,dc=planetexpress,dc=com'
|
||||
# OVERLEAF_LDAP_SEARCH_FILTER: '(uid={{username}})'
|
||||
# OVERLEAF_LDAP_BIND_DN: 'cn=admin,dc=planetexpress,dc=com'
|
||||
# OVERLEAF_LDAP_BIND_CREDENTIALS: 'GoodNewsEveryone'
|
||||
# OVERLEAF_LDAP_EMAIL_ATT: 'mail'
|
||||
# OVERLEAF_LDAP_NAME_ATT: 'cn'
|
||||
# OVERLEAF_LDAP_LAST_NAME_ATT: 'sn'
|
||||
# OVERLEAF_LDAP_UPDATE_USER_DETAILS_ON_LOGIN: 'true'
|
||||
## Sandboxed Compiles: https://github.com/overleaf/overleaf/wiki/Server-Pro:-Sandboxed-Compiles
|
||||
SANDBOXED_COMPILES: 'true'
|
||||
SANDBOXED_COMPILES_SIBLING_CONTAINERS: 'true'
|
||||
### Bind-mount source for /var/lib/overleaf/data/compiles inside the container.
|
||||
SANDBOXED_COMPILES_HOST_DIR: '/home/user/sharelatex_data/data/compiles'
|
||||
|
||||
# OVERLEAF_TEMPLATES_USER_ID: "578773160210479700917ee5"
|
||||
# OVERLEAF_NEW_PROJECT_TEMPLATE_LINKS: '[ {"name":"All Templates","url":"/templates/all"}]'
|
||||
## Works with test LDAP server shown at bottom of docker compose
|
||||
# OVERLEAF_LDAP_URL: 'ldap://ldap:389'
|
||||
# OVERLEAF_LDAP_SEARCH_BASE: 'ou=people,dc=planetexpress,dc=com'
|
||||
# OVERLEAF_LDAP_SEARCH_FILTER: '(uid={{username}})'
|
||||
# OVERLEAF_LDAP_BIND_DN: 'cn=admin,dc=planetexpress,dc=com'
|
||||
# OVERLEAF_LDAP_BIND_CREDENTIALS: 'GoodNewsEveryone'
|
||||
# OVERLEAF_LDAP_EMAIL_ATT: 'mail'
|
||||
# OVERLEAF_LDAP_NAME_ATT: 'cn'
|
||||
# OVERLEAF_LDAP_LAST_NAME_ATT: 'sn'
|
||||
# OVERLEAF_LDAP_UPDATE_USER_DETAILS_ON_LOGIN: 'true'
|
||||
|
||||
# OVERLEAF_PROXY_LEARN: "true"
|
||||
# OVERLEAF_TEMPLATES_USER_ID: "578773160210479700917ee5"
|
||||
# OVERLEAF_NEW_PROJECT_TEMPLATE_LINKS: '[ {"name":"All Templates","url":"/templates/all"}]'
|
||||
|
||||
mongo:
|
||||
restart: always
|
||||
image: mongo:8.0
|
||||
container_name: mongo
|
||||
command: "--replSet overleaf"
|
||||
volumes:
|
||||
- ~/mongo_data:/data/db
|
||||
- ./bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js
|
||||
environment:
|
||||
MONGO_INITDB_DATABASE: sharelatex
|
||||
extra_hosts:
|
||||
# Required when using the automatic database setup for initializing the replica set.
|
||||
# This override is not needed when running the setup after starting up mongo.
|
||||
- mongo:127.0.0.1
|
||||
healthcheck:
|
||||
test: echo 'db.stats().ok' | mongosh localhost:27017/test --quiet
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
restart: always
|
||||
image: redis:6.2
|
||||
container_name: redis
|
||||
volumes:
|
||||
- ~/redis_data:/data
|
||||
# OVERLEAF_PROXY_LEARN: "true"
|
||||
|
||||
# ldap:
|
||||
# restart: always
|
||||
# image: rroemhild/test-openldap
|
||||
# container_name: ldap
|
||||
mongo:
|
||||
restart: always
|
||||
image: mongo:5.0
|
||||
container_name: mongo
|
||||
command: '--replSet overleaf'
|
||||
volumes:
|
||||
- ~/mongo_data:/data/db
|
||||
- ./mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js
|
||||
environment:
|
||||
MONGO_INITDB_DATABASE: sharelatex
|
||||
extra_hosts:
|
||||
# Required when using the automatic database setup for initializing the replica set.
|
||||
# This override is not needed when running the setup after starting up mongo.
|
||||
- mongo:127.0.0.1
|
||||
healthcheck:
|
||||
test: echo 'db.stats().ok' | mongo localhost:27017/test --quiet
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
|
||||
# See https://github.com/jwilder/nginx-proxy for documentation on how to configure the nginx-proxy container,
|
||||
# and https://github.com/overleaf/overleaf/wiki/HTTPS-reverse-proxy-using-Nginx for an example of some recommended
|
||||
# settings. We recommend using a properly managed nginx instance outside of the Overleaf Server Pro setup,
|
||||
# but the example here can be used if you'd prefer to run everything with docker-compose
|
||||
redis:
|
||||
restart: always
|
||||
image: redis:6.2
|
||||
container_name: redis
|
||||
volumes:
|
||||
- ~/redis_data:/data
|
||||
|
||||
# nginx-proxy:
|
||||
# image: jwilder/nginx-proxy
|
||||
# container_name: nginx-proxy
|
||||
# ports:
|
||||
# - "80:80"
|
||||
# - "443:443"
|
||||
# volumes:
|
||||
# - /var/run/docker.sock:/tmp/docker.sock:ro
|
||||
# - /home/overleaf/tmp:/etc/nginx/certs
|
||||
# ldap:
|
||||
# restart: always
|
||||
# image: rroemhild/test-openldap
|
||||
# container_name: ldap
|
||||
|
||||
# See https://github.com/jwilder/nginx-proxy for documentation on how to configure the nginx-proxy container,
|
||||
# and https://github.com/overleaf/overleaf/wiki/HTTPS-reverse-proxy-using-Nginx for an example of some recommended
|
||||
# settings. We recommend using a properly managed nginx instance outside of the Overleaf Server Pro setup,
|
||||
# but the example here can be used if you'd prefer to run everything with docker-compose
|
||||
|
||||
# nginx-proxy:
|
||||
# image: jwilder/nginx-proxy
|
||||
# container_name: nginx-proxy
|
||||
# ports:
|
||||
# - "80:80"
|
||||
# - "443:443"
|
||||
# volumes:
|
||||
# - /var/run/docker.sock:/tmp/docker.sock:ro
|
||||
# - /home/overleaf/tmp:/etc/nginx/certs
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
FROM cypress/included:15.12.0
|
||||
ARG USER_UID=1000
|
||||
ARG USER_GID=1000
|
||||
|
||||
# Corepack setup, shared between all the images.
|
||||
ENV PATH="/overleaf/node_modules/.bin:$PATH"
|
||||
ENV COREPACK_HOME=/opt/corepack
|
||||
RUN corepack enable && corepack install -g yarn@4.14.1
|
||||
ENV COREPACK_ENABLE_NETWORK=0
|
||||
|
||||
WORKDIR /overleaf
|
||||
|
||||
RUN sed -i s/node:x:1000:/node:x:${USER_GID}:/ /etc/group \
|
||||
&& sed -i s_node:x:1000:1000::/home/node:/bin/bash_node:x:${USER_UID}:${USER_GID}::/home/node:/bin/bash_ /etc/passwd \
|
||||
&& chown -R node:node /home/node \
|
||||
&& chown node:node /overleaf
|
||||
|
||||
USER node
|
||||
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -0,0 +1,46 @@
|
||||
compileFolder
|
||||
|
||||
Compiled source #
|
||||
###################
|
||||
*.com
|
||||
*.class
|
||||
*.dll
|
||||
*.exe
|
||||
*.o
|
||||
*.so
|
||||
|
||||
# Packages #
|
||||
############
|
||||
# it's better to unpack these files and commit the raw source
|
||||
# git has its own built in compression methods
|
||||
*.7z
|
||||
*.dmg
|
||||
*.gz
|
||||
*.iso
|
||||
*.jar
|
||||
*.rar
|
||||
*.tar
|
||||
*.zip
|
||||
|
||||
# Logs and databases #
|
||||
######################
|
||||
*.log
|
||||
*.sql
|
||||
*.sqlite
|
||||
|
||||
# OS generated files #
|
||||
######################
|
||||
.DS_Store?
|
||||
ehthumbs.db
|
||||
Icon?
|
||||
Thumbs.db
|
||||
|
||||
/node_modules/*
|
||||
data/*/*
|
||||
|
||||
**.swp
|
||||
|
||||
/log.json
|
||||
hash_folder
|
||||
|
||||
.npmrc
|
||||
@@ -1,13 +0,0 @@
|
||||
let reporterOptions = {}
|
||||
if (process.env.CI) {
|
||||
reporterOptions = {
|
||||
reporter: require.resolve('mocha-multi-reporters'),
|
||||
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
||||
}
|
||||
}
|
||||
const all = {
|
||||
require: 'test/setup.js',
|
||||
...reporterOptions,
|
||||
}
|
||||
|
||||
module.exports = all
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ui": "bdd",
|
||||
"recursive": "true",
|
||||
"reporter": "spec",
|
||||
"require": "test/setup.js"
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
24.14.1
|
||||
20.18.0
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
access-token-encryptor
|
||||
--dependencies=None
|
||||
--docker-repos=gcr.io/overleaf-ops
|
||||
--env-add=
|
||||
--env-pass-through=
|
||||
--esmock-loader=False
|
||||
--is-library=True
|
||||
--node-version=24.14.1
|
||||
--package-name=@overleaf/access-token-encryptor
|
||||
--pipeline-owner=32
|
||||
--node-version=20.18.0
|
||||
--public-repo=False
|
||||
--script-version=4.5.0
|
||||
|
||||
@@ -4,24 +4,24 @@
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "yarn run lint && yarn run types:check && yarn run test:unit",
|
||||
"lint": "eslint --cache --cache-location ../../.cache/eslint/ --ext .cjs,.js,.jsx,.mjs,.ts --max-warnings 0 --format unix .",
|
||||
"lint:fix": "eslint --cache --cache-location ../../.cache/eslint/ --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
|
||||
"test:ci": "yarn run test:unit",
|
||||
"test": "npm run lint && npm run format && npm run types:check && npm run test:unit",
|
||||
"lint": "eslint --ext .js --ext .cjs --ext .ts --max-warnings 0 --format unix .",
|
||||
"lint:fix": "eslint --fix --ext .js --ext .cjs --ext .ts .",
|
||||
"format": "prettier --list-different $PWD/'**/*.{js,cjs,ts}'",
|
||||
"format:fix": "prettier --write $PWD/'**/*.{js,cjs,ts}'",
|
||||
"test:ci": "npm run test:unit",
|
||||
"test:unit": "mocha --exit test/**/*.{js,cjs}",
|
||||
"types:check": "tsc --noEmit"
|
||||
},
|
||||
"author": "",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"lodash": "^4.18.1"
|
||||
"lodash": "^4.17.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
"chai": "^4.3.6",
|
||||
"chai-as-promised": "^7.1.1",
|
||||
"mocha": "^11.1.0",
|
||||
"mocha-junit-reporter": "^2.2.1",
|
||||
"mocha-multi-reporters": "^1.5.1",
|
||||
"mocha": "^10.2.0",
|
||||
"sandboxed-module": "^2.0.4",
|
||||
"typescript": "^5.0.4"
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
module.exports = {
|
||||
reporterEnabled: 'spec, mocha-junit-reporter',
|
||||
mochaJunitReporterReporterOptions: {
|
||||
mochaFile: `reports/junit-mocha-${process.env.MOCHA_GREP}.xml`,
|
||||
includePending: true,
|
||||
jenkinsMode: true,
|
||||
output: true,
|
||||
},
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.backend.json",
|
||||
"include": ["**/*.js", "**/*.cjs", "**/*.ts"]
|
||||
"include": [
|
||||
"**/*.js",
|
||||
"**/*.cjs"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
// French typographic spacing rules for JSON string values.
|
||||
// Per https://typographisme.net/post/Les-espaces-typographiques-et-le-web
|
||||
// - NNBSP before ? ! ;
|
||||
// - NBSP before :
|
||||
// - NBSP inside guillemets (after «, before ») — keeps the
|
||||
// guillemet attached to its content so it cannot be orphaned at a
|
||||
// line break
|
||||
// - NNBSP between digit groups (thousands separator)
|
||||
// - NBSP between a number and a following word/unit (50 Mo, 5 jours)
|
||||
// - NBSP before %
|
||||
// - point médian "·" instead of parenthetical (e) for inclusive writing
|
||||
|
||||
const NBSP = ' '
|
||||
const NNBSP = ' '
|
||||
|
||||
function autoFix(value) {
|
||||
let v = value
|
||||
// Normalize whitespace before ? ! ; to NNBSP
|
||||
v = v.replace(/\s*([?!;])/g, `${NNBSP}$1`)
|
||||
v = v.replace(/([^\s])([?!;])/g, `$1${NNBSP}$2`)
|
||||
// Normalize whitespace before : (after a letter, not before /) to NBSP
|
||||
v = v.replace(/(\p{L})\s*:(?!\/)/gu, `$1${NBSP}:`)
|
||||
// NBSP after «
|
||||
v = v.replace(/«\s*/g, `«${NBSP}`)
|
||||
// NBSP before »
|
||||
v = v.replace(/\s*»/g, `${NBSP}»`)
|
||||
// NNBSP between digits (thousands separator)
|
||||
v = v.replace(/(\d)\s+(\d)/g, `$1${NNBSP}$2`)
|
||||
// NBSP between digit and following letter (number + unit/word)
|
||||
v = v.replace(/(\d)\s+(\p{L})/gu, `$1${NBSP}$2`)
|
||||
// NBSP before %
|
||||
v = v.replace(/(\d)\s*%/g, `$1${NBSP}%`)
|
||||
return v
|
||||
}
|
||||
|
||||
function lint(value) {
|
||||
const violations = []
|
||||
for (const m of value.matchAll(/(.)([?!;])/g)) {
|
||||
if (m[1] !== NNBSP) {
|
||||
violations.push({
|
||||
message: `expected NNBSP before "${m[2]}", got ${JSON.stringify(m[1])}`,
|
||||
fixable: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const m of value.matchAll(/(\p{L})(\s*):(?!\/)/gu)) {
|
||||
if (m[2] !== NBSP) {
|
||||
violations.push({
|
||||
message: `expected NBSP before ":" after letter ${JSON.stringify(m[1])}, got ${JSON.stringify(m[2])}`,
|
||||
fixable: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const m of value.matchAll(/«(.)/g)) {
|
||||
if (m[1] !== NBSP) {
|
||||
violations.push({
|
||||
message: `expected NBSP after "«", got ${JSON.stringify(m[1])}`,
|
||||
fixable: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const m of value.matchAll(/(.)»/g)) {
|
||||
if (m[1] !== NBSP) {
|
||||
violations.push({
|
||||
message: `expected NBSP before "»", got ${JSON.stringify(m[1])}`,
|
||||
fixable: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const m of value.matchAll(/(\d)(\s)(\d)/g)) {
|
||||
if (m[2] !== NNBSP) {
|
||||
violations.push({
|
||||
message: `expected NNBSP between digits "${m[1]}${m[3]}", got ${JSON.stringify(m[2])}`,
|
||||
fixable: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const m of value.matchAll(/(\d)(\s)(\p{L})/gu)) {
|
||||
if (m[2] !== NBSP) {
|
||||
violations.push({
|
||||
message: `expected NBSP between digit "${m[1]}" and "${m[3]}", got ${JSON.stringify(m[2])}`,
|
||||
fixable: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const m of value.matchAll(/(\d)(\s*)%/g)) {
|
||||
if (m[2] !== NBSP) {
|
||||
violations.push({
|
||||
message: `expected NBSP before "%" after "${m[1]}", got ${JSON.stringify(m[2])}`,
|
||||
fixable: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (value.includes('(e)')) {
|
||||
violations.push({
|
||||
message:
|
||||
'expected point médian "·" instead of "(e)" for inclusive writing',
|
||||
fixable: false, // suggestion-only
|
||||
})
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
fixable: 'code',
|
||||
hasSuggestions: true,
|
||||
docs: {
|
||||
description:
|
||||
'Enforce French typographic spacing in JSON string values (NNBSP before ? ! ;, NBSP before : and inside « », etc.).',
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
Member(node) {
|
||||
if (node.value.type !== 'String') return
|
||||
const original = node.value.value
|
||||
const violations = lint(original)
|
||||
if (violations.length === 0) return
|
||||
|
||||
const fixed = autoFix(original)
|
||||
const replacement = JSON.stringify(fixed)
|
||||
const inclusiveSuggestion = original.includes('(e)')
|
||||
? JSON.stringify(original.replace(/\(e\)/g, '·e'))
|
||||
: null
|
||||
|
||||
for (const v of violations) {
|
||||
context.report({
|
||||
node: node.value,
|
||||
message: v.message,
|
||||
...(v.fixable && fixed !== original
|
||||
? {
|
||||
fix(fixer) {
|
||||
return fixer.replaceText(node.value, replacement)
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(!v.fixable && inclusiveSuggestion
|
||||
? {
|
||||
suggest: [
|
||||
{
|
||||
desc: 'Replace "(e)" with "·e"',
|
||||
fix(fixer) {
|
||||
return fixer.replaceText(
|
||||
node.value,
|
||||
inclusiveSuggestion
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
const pkg = require('./package.json')
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
name: pkg.name,
|
||||
version: pkg.version,
|
||||
},
|
||||
rules: {
|
||||
'no-unnecessary-trans': require('./no-unnecessary-trans'),
|
||||
'prefer-kebab-url': require('./prefer-kebab-url'),
|
||||
'should-unescape-trans': require('./should-unescape-trans'),
|
||||
'no-generated-editor-themes': require('./no-generated-editor-themes'),
|
||||
'require-script-runner': require('./require-script-runner'),
|
||||
'require-vi-doMock-valid-path': require('./require-vi-doMock-valid-path'),
|
||||
'require-loading-label': require('./require-loading-label'),
|
||||
'require-cio-snake-case-properties': require('./require-cio-snake-case-properties'),
|
||||
'no-throw-in-callback': require('./no-throw-in-callback'),
|
||||
'no-consecutive-spaces-in-locales': require('./no-consecutive-spaces-in-locales'),
|
||||
'no-straight-apostrophes-in-locales': require('./no-straight-apostrophes-in-locales'),
|
||||
'french-typography-in-locales': require('./french-typography-in-locales'),
|
||||
'sorted-keys-in-locales': require('./sorted-keys-in-locales'),
|
||||
'locale-variables-match-en': require('./locale-variables-match-en'),
|
||||
'no-orphan-locale-keys': require('./no-orphan-locale-keys'),
|
||||
},
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
// Ensure variable placeholders (`__name__`) in non-en locale values match
|
||||
// those in the corresponding en.json key.
|
||||
//
|
||||
// Apply this rule to all non-en locale JSON files.
|
||||
|
||||
const fs = require('node:fs')
|
||||
const Path = require('node:path')
|
||||
|
||||
// Placeholders always available in translations (injected by the i18n layer
|
||||
// or globally defined as defaults).
|
||||
const GLOBALS = new Set(['__appName__'])
|
||||
|
||||
// Keys whose translations are allowed to omit specific base placeholders.
|
||||
const IGNORE_NESTING_FOR = {
|
||||
over_x_templates_easy_getting_started: ['__templates__'],
|
||||
all_packages_and_templates: ['__templatesLink__'],
|
||||
}
|
||||
|
||||
function extractPlaceholders(str) {
|
||||
return Array.from(str.matchAll(/__.*?__/g), m => m[0])
|
||||
}
|
||||
|
||||
const enCache = new Map()
|
||||
function loadEn(localesDir) {
|
||||
if (enCache.has(localesDir)) return enCache.get(localesDir)
|
||||
const path = Path.join(localesDir, 'en.json')
|
||||
const data = JSON.parse(fs.readFileSync(path, 'utf8'))
|
||||
enCache.set(localesDir, data)
|
||||
return data
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Ensure variable placeholders in non-en locale values match the en.json base.',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
missing:
|
||||
'Translation for key "{{key}}" is missing placeholder(s) {{placeholders}} present in en.json.',
|
||||
extra:
|
||||
'Translation for key "{{key}}" has placeholder(s) {{placeholders}} not present in en.json.',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const filename = context.filename
|
||||
const localesDir = Path.dirname(filename)
|
||||
if (Path.basename(filename) === 'en.json') return {}
|
||||
|
||||
let base
|
||||
try {
|
||||
base = loadEn(localesDir)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
Member(node) {
|
||||
if (node.value.type !== 'String') return
|
||||
const key = node.name.value
|
||||
if (!(key in base)) return // orphan key — covered by no-orphan-locale-keys
|
||||
|
||||
const basePlaceholders = extractPlaceholders(base[key])
|
||||
const targetPlaceholders = extractPlaceholders(node.value.value)
|
||||
const ignored = IGNORE_NESTING_FOR[key] || []
|
||||
|
||||
const missing = basePlaceholders.filter(
|
||||
p => !targetPlaceholders.includes(p) && !ignored.includes(p)
|
||||
)
|
||||
const extra = targetPlaceholders.filter(
|
||||
p => !basePlaceholders.includes(p) && !GLOBALS.has(p)
|
||||
)
|
||||
|
||||
if (missing.length > 0) {
|
||||
context.report({
|
||||
node: node.value,
|
||||
messageId: 'missing',
|
||||
data: { key, placeholders: missing.join(', ') },
|
||||
})
|
||||
}
|
||||
if (extra.length > 0) {
|
||||
context.report({
|
||||
node: node.value,
|
||||
messageId: 'extra',
|
||||
data: { key, placeholders: extra.join(', ') },
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
// Reject runs of two or more consecutive whitespace characters in JSON string
|
||||
// values. Catches ASCII spaces, NBSP, NNBSP, tabs, and other Unicode whitespace.
|
||||
|
||||
const CONSECUTIVE_WHITESPACE = /\s{2,}/
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
fixable: 'code',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow runs of two or more consecutive whitespace characters in JSON string values (typically locale files).',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
consecutiveSpaces:
|
||||
'Locale value contains a run of consecutive whitespace. Collapse to a single space.',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
Member(node) {
|
||||
if (node.value.type !== 'String') return
|
||||
const original = node.value.value
|
||||
if (!CONSECUTIVE_WHITESPACE.test(original)) return
|
||||
const fixed = original.replace(/\s{2,}/g, ' ')
|
||||
context.report({
|
||||
node: node.value,
|
||||
messageId: 'consecutiveSpaces',
|
||||
fix(fixer) {
|
||||
return fixer.replaceText(node.value, JSON.stringify(fixed))
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'error',
|
||||
docs: {
|
||||
description:
|
||||
'Prohibit CodeMirror themes that are generated in a function',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
':matches(ArrowFunctionExpression, FunctionDeclaration, FunctionExpression) CallExpression > MemberExpression[object.name="EditorView"]:matches([property.name="theme"],[property.name="baseTheme"])'(
|
||||
node
|
||||
) {
|
||||
context.report({
|
||||
node,
|
||||
message: `EditorView.theme and EditorView.baseTheme each add CSS to the page for every instance of the theme. Store the theme in a variable and reuse it instead.`,
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
// Detect translation keys in non-en locale files that don't exist in
|
||||
// en.json (orphans).
|
||||
|
||||
const fs = require('node:fs')
|
||||
const Path = require('node:path')
|
||||
|
||||
const enCache = new Map()
|
||||
function loadEnKeys(localesDir) {
|
||||
if (enCache.has(localesDir)) return enCache.get(localesDir)
|
||||
const path = Path.join(localesDir, 'en.json')
|
||||
const data = JSON.parse(fs.readFileSync(path, 'utf8'))
|
||||
const keys = new Set(Object.keys(data))
|
||||
enCache.set(localesDir, keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
fixable: 'code',
|
||||
docs: {
|
||||
description:
|
||||
'Detect translation keys in non-en locale files that are not present in en.json.',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
orphan:
|
||||
'Translation key "{{key}}" is not present in en.json (orphan key).',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const filename = context.filename
|
||||
const localesDir = Path.dirname(filename)
|
||||
if (Path.basename(filename) === 'en.json') return {}
|
||||
|
||||
let enKeys
|
||||
try {
|
||||
enKeys = loadEnKeys(localesDir)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
'Document > Object'(node) {
|
||||
const orphans = node.members.filter(m => !enKeys.has(m.name.value))
|
||||
if (orphans.length === 0) return
|
||||
|
||||
const text = context.sourceCode.text
|
||||
const parsed = JSON.parse(text)
|
||||
for (const member of orphans) {
|
||||
delete parsed[member.name.value]
|
||||
}
|
||||
const sortedRemaining = Object.keys(parsed).sort()
|
||||
const cleaned = JSON.stringify(parsed, sortedRemaining, 2) + '\n'
|
||||
|
||||
for (const member of orphans) {
|
||||
context.report({
|
||||
node: member,
|
||||
messageId: 'orphan',
|
||||
data: { key: member.name.value },
|
||||
fix(fixer) {
|
||||
return fixer.replaceTextRange([0, text.length], cleaned)
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
// Reject straight apostrophes (') in JSON string values. Straight apostrophes
|
||||
// in Angular templates can lead to XSS; use the right single quotation mark
|
||||
// ’ (U+2019) instead. See https://github.com/overleaf/issues/issues/4478
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
fixable: 'code',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow straight apostrophes in JSON string values (typically locale files).',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
straightApostrophe:
|
||||
"Locale value contains a straight apostrophe ('). Use the right single quotation mark ’ (U+2019) instead.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
Member(node) {
|
||||
if (node.value.type !== 'String') return
|
||||
const original = node.value.value
|
||||
if (!original.includes("'")) return
|
||||
const fixed = original.replace(/'/g, '’')
|
||||
context.report({
|
||||
node: node.value,
|
||||
messageId: 'straightApostrophe',
|
||||
fix(fixer) {
|
||||
return fixer.replaceText(node.value, JSON.stringify(fixed))
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
const CALLBACK_PARAM_NAMES = new Set(['cb', 'callback', 'done', 'next'])
|
||||
|
||||
function isCallbackParam(param) {
|
||||
return (
|
||||
param && param.type === 'Identifier' && CALLBACK_PARAM_NAMES.has(param.name)
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'error',
|
||||
docs: {
|
||||
description: 'Disallow throw statements inside callback-based functions',
|
||||
},
|
||||
messages: {
|
||||
noThrowInCallback:
|
||||
'Pass the error to the callback instead of throwing in callback-based code.',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
// Stack tracks whether each enclosing function is a callback-style function.
|
||||
// A callback-style function is non-async and has a last param named cb/callback/done/next.
|
||||
const stack = []
|
||||
|
||||
function enterFunction(node) {
|
||||
const params = node.params
|
||||
const isCallback =
|
||||
!node.async &&
|
||||
params.length > 0 &&
|
||||
isCallbackParam(params[params.length - 1])
|
||||
stack.push(isCallback)
|
||||
}
|
||||
|
||||
function exitFunction() {
|
||||
stack.pop()
|
||||
}
|
||||
|
||||
return {
|
||||
FunctionDeclaration: enterFunction,
|
||||
'FunctionDeclaration:exit': exitFunction,
|
||||
FunctionExpression: enterFunction,
|
||||
'FunctionExpression:exit': exitFunction,
|
||||
ArrowFunctionExpression: enterFunction,
|
||||
'ArrowFunctionExpression:exit': exitFunction,
|
||||
ThrowStatement(node) {
|
||||
if (stack[stack.length - 1]) {
|
||||
context.report({ node, messageId: 'noThrowInCallback' })
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
fixable: 'code',
|
||||
docs: {
|
||||
description: 'Prohibit Trans with no components or values',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
'JSXOpeningElement[name.name="Trans"]'(node) {
|
||||
const attributes = new Map(
|
||||
node.attributes.map(attr => [attr.name.name, attr])
|
||||
)
|
||||
|
||||
if (!attributes.has('components')) {
|
||||
if (node.parent.children.length > 0) {
|
||||
context.report({
|
||||
node,
|
||||
message: `Trans components must not have child elements`,
|
||||
})
|
||||
} else if (attributes.has('values')) {
|
||||
context.report({
|
||||
node,
|
||||
message: `Use t('…') when there are no components`,
|
||||
})
|
||||
} else {
|
||||
context.report({
|
||||
node,
|
||||
message: `Use t('…') when there are no components`,
|
||||
fix(fixer) {
|
||||
const i18nKey = attributes.get('i18nKey').value.value
|
||||
|
||||
// Note: Prettier can fix indentation
|
||||
return fixer.replaceText(node.parent, `{t('${i18nKey}')}`)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "@overleaf/eslint-plugin",
|
||||
"version": "0.1.0",
|
||||
"author": "Overleaf (https://www.overleaf.com)",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"lodash": "^4.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/json": "^2.0.0",
|
||||
"@typescript-eslint/parser": "^8.59.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^10.4.0"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node rules.test.js"
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
// URL parts should be kebab-case, but we didn't have this rule in the past.
|
||||
// The ESLint rule `prefer-kebab-url` will ignore these "legacy" URL parts.
|
||||
|
||||
const ignoreWords = {
|
||||
snake: new Set([
|
||||
'clear_saml_data',
|
||||
'confirm_link',
|
||||
'confirm_university_domain',
|
||||
'create_recurly_account',
|
||||
'current_history_content',
|
||||
'current_user',
|
||||
'default_email',
|
||||
'disable_managed_users',
|
||||
'doc_snapshot',
|
||||
'enable_history_ranges_support',
|
||||
'features_override',
|
||||
'generate_password_reset_url',
|
||||
'get_assignment',
|
||||
'get_clone',
|
||||
'health_check',
|
||||
'institutional_emails',
|
||||
'latest_template',
|
||||
'link_after_saml_response',
|
||||
'linked_file',
|
||||
'metrics_segmentation',
|
||||
'new_users',
|
||||
'no_autostart_post_gateway',
|
||||
'personal_info',
|
||||
'planned_maintenance',
|
||||
'refresh_features',
|
||||
'register_admin',
|
||||
'register_ldap_admin',
|
||||
'register_saml_admin',
|
||||
'restore_file',
|
||||
'revert_file',
|
||||
'saved_vers',
|
||||
'send_test_email',
|
||||
'session_maintenance',
|
||||
'set_in_session',
|
||||
'sign_in_to_link',
|
||||
'split_test',
|
||||
'sso_configuration_test',
|
||||
'sso_email',
|
||||
'sso_enrollment',
|
||||
'track_changes',
|
||||
'update_admin',
|
||||
'user_details',
|
||||
]),
|
||||
camel: new Set([
|
||||
'addWorkflowScope',
|
||||
'aiErrorAssistant',
|
||||
'aiFeatureUsage',
|
||||
'beginAuth',
|
||||
'brandVariationId',
|
||||
'closeEditor',
|
||||
'completeRegistration',
|
||||
'deactivateOldProjects',
|
||||
'deletedSubscription',
|
||||
'disconnectAllUsers',
|
||||
'editingSession',
|
||||
'emailSubscription',
|
||||
'enableManagedUsers',
|
||||
'externalCollaboration',
|
||||
'flushProjectToTpds',
|
||||
'indexAll',
|
||||
'offboardManagedUser',
|
||||
'openEditor',
|
||||
'perfTest',
|
||||
'pollDropboxForUser',
|
||||
'resendInvite',
|
||||
'resendManagedUserInvite',
|
||||
'salesContactForm',
|
||||
'showSupport',
|
||||
]),
|
||||
other: new Set([
|
||||
'Project',
|
||||
'disableSSO',
|
||||
'enableSSO',
|
||||
'resendSSOLinkInvite',
|
||||
'usersCSV',
|
||||
]),
|
||||
}
|
||||
|
||||
module.exports = { ignoreWords }
|
||||
@@ -1,91 +0,0 @@
|
||||
const _ = require('lodash')
|
||||
const { ignoreWords } = require('./prefer-kebab-url-ignore')
|
||||
|
||||
const removeTextBetweenBrackets = text => {
|
||||
while (text.includes('[') || text.includes('(')) {
|
||||
text = text.replaceAll(/\[[^[\]]*]/g, '')
|
||||
text = text.replaceAll(/\([^()]*\)/g, '')
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
const shouldIgnoreWord = str =>
|
||||
str.includes(':') ||
|
||||
str.includes('(') ||
|
||||
str === '*' ||
|
||||
str.match(/^[a-z0-9.]+$/) ||
|
||||
ignoreWords.snake.has(str) ||
|
||||
ignoreWords.camel.has(str) ||
|
||||
ignoreWords.other.has(str)
|
||||
|
||||
const getSuggestion = routePath => {
|
||||
if (typeof routePath === 'string') {
|
||||
const kebabed = routePath
|
||||
.split('/')
|
||||
.map(word => (shouldIgnoreWord(word) ? word : _.kebabCase(word)))
|
||||
.join('/')
|
||||
return kebabed === routePath ? null : `'${kebabed}'`
|
||||
}
|
||||
|
||||
if (routePath instanceof RegExp) {
|
||||
const words = removeTextBetweenBrackets(routePath.source).match(/[\w-]+/g)
|
||||
if (!words) return routePath
|
||||
|
||||
let newSource = routePath.source
|
||||
for (const word of words) {
|
||||
if (!shouldIgnoreWord(word)) {
|
||||
newSource = newSource.replaceAll(
|
||||
new RegExp(`\\b${word}\\b`, 'g'),
|
||||
_.kebabCase(word)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const kebabed = new RegExp(newSource, routePath.flags)
|
||||
return kebabed.source.toString() === routePath.source.toString()
|
||||
? null
|
||||
: kebabed
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
fixable: 'code',
|
||||
hasSuggestions: true,
|
||||
docs: {
|
||||
description: 'Enforce using kebab-case for URL paths',
|
||||
},
|
||||
},
|
||||
create: context => ({
|
||||
CallExpression(node) {
|
||||
if (
|
||||
node.callee.type === 'MemberExpression' &&
|
||||
node.arguments[0]?.type === 'Literal' &&
|
||||
[/app/i, /router/i].some(callee =>
|
||||
typeof callee === 'string'
|
||||
? node.callee.object.name === callee
|
||||
: callee.test(node.callee.object.name)
|
||||
) &&
|
||||
['get', 'post', 'put', 'delete'].includes(node.callee.property.name)
|
||||
) {
|
||||
const routePath = node.arguments[0].value
|
||||
|
||||
const suggestion = getSuggestion(routePath)
|
||||
|
||||
if (suggestion) {
|
||||
context.report({
|
||||
node: node.arguments[0],
|
||||
message: 'Route path should be in kebab-case.',
|
||||
suggest: [
|
||||
{
|
||||
desc: `Change to kebab-case: ${suggestion}`,
|
||||
fix: fixer => fixer.replaceText(node.arguments[0], suggestion),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
const SNAKE_CASE_RE = /^[a-z][a-z0-9]*(_[a-z0-9]+)*$/
|
||||
|
||||
function isSnakeCase(name) {
|
||||
return SNAKE_CASE_RE.test(name)
|
||||
}
|
||||
|
||||
function getStaticKeyName(property) {
|
||||
if (property.computed) return null
|
||||
if (property.key.type === 'Identifier') return property.key.name
|
||||
if (property.key.type === 'Literal' && typeof property.key.value === 'string')
|
||||
return property.key.value
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node is a call to CustomerIoHandler.updateUserAttributes()
|
||||
* and return the attributes argument (2nd argument)
|
||||
*/
|
||||
function getUpdateUserAttributesArg(node) {
|
||||
if (
|
||||
node.callee.type === 'MemberExpression' &&
|
||||
node.callee.object.type === 'Identifier' &&
|
||||
node.callee.object.name === 'CustomerIoHandler' &&
|
||||
node.callee.property.name === 'updateUserAttributes' &&
|
||||
node.arguments[1]?.type === 'ObjectExpression'
|
||||
) {
|
||||
return node.arguments[1]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node is a call to Modules[.promises].hooks.fire('setUserProperties', ...)
|
||||
* and return the attributes argument (3rd argument)
|
||||
*/
|
||||
function getSetUserPropertiesArg(node) {
|
||||
const callee = node.callee
|
||||
if (callee.type !== 'MemberExpression' || callee.property.name !== 'fire') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Check first argument is 'setUserProperties'
|
||||
if (
|
||||
!node.arguments[0] ||
|
||||
node.arguments[0].type !== 'Literal' ||
|
||||
node.arguments[0].value !== 'setUserProperties'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Match: Modules.hooks.fire or Modules.promises.hooks.fire
|
||||
const obj = callee.object
|
||||
if (obj.type === 'MemberExpression' && obj.property.name === 'hooks') {
|
||||
const parent = obj.object
|
||||
// Modules.hooks
|
||||
if (parent.type === 'Identifier' && parent.name === 'Modules') {
|
||||
if (node.arguments[2]?.type === 'ObjectExpression') {
|
||||
return node.arguments[2]
|
||||
}
|
||||
}
|
||||
// Modules.promises.hooks
|
||||
if (
|
||||
parent.type === 'MemberExpression' &&
|
||||
parent.property.name === 'promises' &&
|
||||
parent.object.type === 'Identifier' &&
|
||||
parent.object.name === 'Modules'
|
||||
) {
|
||||
if (node.arguments[2]?.type === 'ObjectExpression') {
|
||||
return node.arguments[2]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Enforce snake_case for Customer.io user property attribute names',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
CallExpression(node) {
|
||||
const attrsNode =
|
||||
getUpdateUserAttributesArg(node) || getSetUserPropertiesArg(node)
|
||||
if (!attrsNode) return
|
||||
|
||||
for (const property of attrsNode.properties) {
|
||||
if (property.type === 'SpreadElement') continue
|
||||
|
||||
const keyName = getStaticKeyName(property)
|
||||
if (keyName === null) continue // skip computed/dynamic keys
|
||||
|
||||
if (!isSnakeCase(keyName)) {
|
||||
context.report({
|
||||
node: property.key,
|
||||
message: `Customer.io attribute '{{name}}' must be in snake_case.`,
|
||||
data: { name: keyName },
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
fixable: null,
|
||||
docs: {
|
||||
description:
|
||||
'Require loadingLabel prop when isLoading is specified on OLButton',
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
'JSXOpeningElement[name.name="OLButton"]'(node) {
|
||||
const attributes = new Map(
|
||||
node.attributes.map(attr => [attr.name?.name, attr])
|
||||
)
|
||||
|
||||
const isLoadingAttr = attributes.get('isLoading')
|
||||
const loadingLabelAttr = attributes.get('loadingLabel')
|
||||
|
||||
if (isLoadingAttr && !loadingLabelAttr) {
|
||||
const isLoadingValue = isLoadingAttr.value
|
||||
|
||||
if (
|
||||
!isLoadingValue ||
|
||||
(isLoadingValue.type === 'JSXExpressionContainer' &&
|
||||
isLoadingValue.expression.type === 'Literal' &&
|
||||
isLoadingValue.expression.value === true)
|
||||
) {
|
||||
context.report({
|
||||
node: isLoadingAttr,
|
||||
message:
|
||||
'Button with isLoading prop must also specify loadingLabel',
|
||||
})
|
||||
} else if (
|
||||
isLoadingValue.type === 'JSXExpressionContainer' &&
|
||||
isLoadingValue.expression.type !== 'Literal'
|
||||
) {
|
||||
context.report({
|
||||
node: isLoadingAttr,
|
||||
message:
|
||||
'Button with isLoading prop must also specify loadingLabel',
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
docs: {
|
||||
description: 'Require Script Runner for scripts',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
let hasImport = false
|
||||
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
if (node.source.value.endsWith('lib/ScriptRunner.mjs')) {
|
||||
hasImport = true
|
||||
}
|
||||
},
|
||||
'Program:exit'() {
|
||||
if (!hasImport) {
|
||||
context.report({
|
||||
loc: { line: 1, column: 0 },
|
||||
message:
|
||||
'Please use Script Runner for scripts. Refer to the developer manual (https://manual.dev-overleaf.com/development/code/web_scripts/#monitor-script-execution-and-usage-with-script-runner) for more information.',
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
const path = require('node:path')
|
||||
const fs = require('node:fs')
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Ensure vi.doMock first argument is a resolvable path.',
|
||||
category: 'Best Practices',
|
||||
recommended: false,
|
||||
url: '',
|
||||
},
|
||||
fixable: 'code',
|
||||
hasSuggestions: true,
|
||||
schema: [],
|
||||
messages: {
|
||||
unresolvablePath:
|
||||
'The path "{{pathValue}}" in vi.doMock() cannot be resolved relative to the current file.',
|
||||
notAStringLiteral:
|
||||
'The first argument of vi.doMock() must be (or resolve to) a string literal representing a path.',
|
||||
noArguments: 'vi.doMock() called with no arguments.',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const currentFilePath = context.filename
|
||||
// ESLint can sometimes pass <text> or <input> for snippets not in a file
|
||||
if (currentFilePath === '<text>' || currentFilePath === '<input>') {
|
||||
return {}
|
||||
}
|
||||
const currentDirectory = path.dirname(currentFilePath)
|
||||
|
||||
function canResolve(modulePath) {
|
||||
try {
|
||||
require.resolve(path.resolve(currentDirectory, modulePath))
|
||||
return true
|
||||
} catch (e) {
|
||||
const absolutePath = path.resolve(currentDirectory, modulePath)
|
||||
const extensions = [
|
||||
'',
|
||||
'.js',
|
||||
'.mjs',
|
||||
'.ts',
|
||||
'.jsx',
|
||||
'.tsx',
|
||||
'.json',
|
||||
'.node',
|
||||
'/index.js',
|
||||
'/index.ts',
|
||||
] // Add common extensions
|
||||
for (const ext of extensions) {
|
||||
if (fs.existsSync(absolutePath + ext)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
CallExpression(node) {
|
||||
if (
|
||||
node.callee.type === 'MemberExpression' &&
|
||||
node.callee.object.type === 'Identifier' &&
|
||||
node.callee.object.name === 'vi' &&
|
||||
node.callee.property.type === 'Identifier' &&
|
||||
node.callee.property.name === 'doMock'
|
||||
) {
|
||||
if (node.arguments.length === 0) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'noArguments',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const firstArg = node.arguments[0]
|
||||
let pathValue = firstArg.value
|
||||
|
||||
if (
|
||||
firstArg.type !== 'Literal' ||
|
||||
typeof firstArg.value !== 'string'
|
||||
) {
|
||||
if (firstArg.type === 'Identifier') {
|
||||
const scope = context.sourceCode.getScope(node)
|
||||
const variable = scope.variables.find(
|
||||
v => v.name === firstArg.name
|
||||
)
|
||||
if (
|
||||
variable &&
|
||||
variable.defs.length > 0 &&
|
||||
variable.defs[0].node.init &&
|
||||
variable.defs[0].node.init.type === 'Literal' &&
|
||||
typeof variable.defs[0].node.init.value === 'string'
|
||||
) {
|
||||
pathValue = variable.defs[0].node.init.value
|
||||
if (canResolve(pathValue)) {
|
||||
return
|
||||
}
|
||||
// If the first argument was a variable that didn't resolve then we can't auto-fix it
|
||||
}
|
||||
}
|
||||
context.report({
|
||||
node: firstArg,
|
||||
messageId: 'notAStringLiteral',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!pathValue.startsWith('.')) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!canResolve(pathValue)) {
|
||||
const mjsPath = pathValue.replace('.js', '.mjs')
|
||||
const additionalReportOptions = {}
|
||||
if (canResolve(mjsPath)) {
|
||||
additionalReportOptions.fix = fixer =>
|
||||
fixer.replaceText(firstArg, `'${mjsPath}'`)
|
||||
additionalReportOptions.suggest = [
|
||||
{
|
||||
desc: `Replace with "${pathValue.replace('.js', '.mjs')}"`,
|
||||
fix: fixer => fixer.replaceText(firstArg, `'${mjsPath}'`),
|
||||
},
|
||||
]
|
||||
}
|
||||
context.report({
|
||||
node: firstArg,
|
||||
messageId: 'unresolvablePath',
|
||||
data: {
|
||||
pathValue,
|
||||
},
|
||||
...additionalReportOptions,
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,455 +0,0 @@
|
||||
const { RuleTester } = require('eslint')
|
||||
const tsParser = require('@typescript-eslint/parser')
|
||||
const json = require('@eslint/json').default
|
||||
const noThrowInCallback = require('./no-throw-in-callback')
|
||||
const preferKebabUrl = require('./prefer-kebab-url')
|
||||
const noUnnecessaryTrans = require('./no-unnecessary-trans')
|
||||
const shouldUnescapeTrans = require('./should-unescape-trans')
|
||||
const noGeneratedEditorThemes = require('./no-generated-editor-themes')
|
||||
const viDoMockValidPath = require('./require-vi-doMock-valid-path')
|
||||
const requireCioSnakeCaseProperties = require('./require-cio-snake-case-properties')
|
||||
const noConsecutiveSpacesInLocales = require('./no-consecutive-spaces-in-locales')
|
||||
const noStraightApostrophesInLocales = require('./no-straight-apostrophes-in-locales')
|
||||
const frenchTypographyInLocales = require('./french-typography-in-locales')
|
||||
const sortedKeysInLocales = require('./sorted-keys-in-locales')
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
ecmaVersion: 'latest',
|
||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||
},
|
||||
})
|
||||
|
||||
const jsonRuleTester = new RuleTester({
|
||||
plugins: { json },
|
||||
language: 'json/json',
|
||||
})
|
||||
|
||||
ruleTester.run('prefer-kebab-url', preferKebabUrl, {
|
||||
valid: [
|
||||
{ code: `app.get('/foo-bar')` },
|
||||
{ code: `app.get('/foo-bar/:id')` },
|
||||
{ code: `router.post('/foo-bar')` },
|
||||
{ code: `router.get('/foo-bar/:id/:name/:age')` },
|
||||
{ code: `webRouter.get('/foo-bar/:user_id/(ProjectName)/get-info')` },
|
||||
{ code: `webApp.post('/foo-bar/:user_id/(ProjectName)/get-info')` },
|
||||
{
|
||||
code: `router.get(/^\\/download\\/project\\/([^/]*)\\/output\\/output\\.pdf$/)`,
|
||||
},
|
||||
{
|
||||
code: `webRouter.get(/^\\/project\\/([^/]*)\\/user\\/([0-9a-f]+)\\/build\\/([0-9a-f-]+)\\/output\\/(.*)$/)`,
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: `app.get('/fooBar')`,
|
||||
errors: [
|
||||
{ message: 'Route path should be in kebab-case.', suggestions: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
code: `app.get('/fooBar/:id')`,
|
||||
errors: [
|
||||
{ message: 'Route path should be in kebab-case.', suggestions: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
code: `webRouter.get('/foo_bar/:id/FooBar/:name/fooBar')`,
|
||||
errors: [
|
||||
{ message: 'Route path should be in kebab-case.', suggestions: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
code: `router.get(/^\\/downLoad\\/pro-ject\\/([^/]*)\\/OutPut\\/out-put\\.pdf$/)`,
|
||||
errors: [
|
||||
{ message: 'Route path should be in kebab-case.', suggestions: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
ruleTester.run('no-unnecessary-trans', noUnnecessaryTrans, {
|
||||
valid: [
|
||||
{ code: `<Trans i18nKey="test" components={{ strong: <strong/> }}/>` },
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: `<Trans i18nKey="test" values={{ test: 'foo '}}/>`,
|
||||
errors: [{ message: `Use t('…') when there are no components` }],
|
||||
},
|
||||
{
|
||||
code: `<Trans i18nKey="test" />`,
|
||||
errors: [{ message: `Use t('…') when there are no components` }],
|
||||
output: `{t('test')}`,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
ruleTester.run('should-unescape-trans', shouldUnescapeTrans, {
|
||||
valid: [
|
||||
{
|
||||
code: `<Trans i18nKey="test" components={{ strong: <strong/> }}/>`,
|
||||
},
|
||||
{
|
||||
code: `<Trans i18nKey="test" values={{ foo: 'bar' }} components={{ strong: <strong/> }} shouldUnescape tOptions={{ interpolation: { escapeValue: true } }}/>`,
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: `<Trans i18nKey="test" values={{ foo: 'bar' }} components={{ strong: <strong/> }} />`,
|
||||
errors: [{ message: 'Trans with values must have shouldUnescape' }],
|
||||
output: `<Trans i18nKey="test" values={{ foo: 'bar' }}\nshouldUnescape components={{ strong: <strong/> }} />`,
|
||||
},
|
||||
{
|
||||
code: `<Trans i18nKey="test" values={{ foo: 'bar' }} components={{ strong: <strong/> }} shouldUnescape />`,
|
||||
errors: [
|
||||
{
|
||||
message:
|
||||
'Trans with shouldUnescape must have tOptions.interpolation.escapeValue',
|
||||
},
|
||||
],
|
||||
output: `<Trans i18nKey="test" values={{ foo: 'bar' }} components={{ strong: <strong/> }} shouldUnescape\ntOptions={{ interpolation: { escapeValue: true } }} />`,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const noGeneratedEditorThemesError =
|
||||
'EditorView.theme and EditorView.baseTheme each add CSS to the page for every instance of the theme. Store the theme in a variable and reuse it instead.'
|
||||
ruleTester.run('no-generated-editor-themes', noGeneratedEditorThemes, {
|
||||
valid: [
|
||||
{
|
||||
code: `EditorView.theme({ '.cm-editor': { color: 'black' } })`,
|
||||
},
|
||||
{
|
||||
code: `const theme = EditorView.theme({ '.cm-editor': { color: 'black' } })`,
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: `function createTheme() { return EditorView.theme({ '.cm-editor': { color: 'black' } }) }`,
|
||||
errors: [
|
||||
{
|
||||
message: noGeneratedEditorThemesError,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
code: `() => EditorView.theme({ '.cm-editor': { color: 'black' } })`,
|
||||
errors: [
|
||||
{
|
||||
message: noGeneratedEditorThemesError,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
code: `class Foo { createTheme() { return EditorView.theme({ '.cm-editor': { color: 'black' } }) } }`,
|
||||
errors: [
|
||||
{
|
||||
message: noGeneratedEditorThemesError,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
ruleTester.run('domock-require-valid-path', viDoMockValidPath, {
|
||||
valid: [
|
||||
{
|
||||
code: 'vi.doMock("./require-vi-doMock-valid-path.js")',
|
||||
filename: __filename,
|
||||
},
|
||||
{
|
||||
code: 'const filename = "./require-vi-doMock-valid-path.js"; vi.doMock(filename);',
|
||||
filename: __filename,
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: "vi.doMock('./require-vi-doMock-valid-path2')",
|
||||
filename: __filename,
|
||||
errors: [
|
||||
{
|
||||
message:
|
||||
'The path "./require-vi-doMock-valid-path2" in vi.doMock() cannot be resolved relative to the current file.',
|
||||
suggestions: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'const filename = "./require-vi-doMock-valid-path2.js"; vi.doMock(filename);',
|
||||
filename: __filename,
|
||||
errors: [
|
||||
{
|
||||
message:
|
||||
'The first argument of vi.doMock() must be (or resolve to) a string literal representing a path.',
|
||||
suggestions: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
ruleTester.run(
|
||||
'require-cio-snake-case-properties',
|
||||
requireCioSnakeCaseProperties,
|
||||
{
|
||||
valid: [
|
||||
// updateUserAttributes with snake_case keys
|
||||
{
|
||||
code: `CustomerIoHandler.updateUserAttributes(userId, { plan_type: 'free', group_size: 10 })`,
|
||||
},
|
||||
// Modules.promises.hooks.fire with snake_case keys
|
||||
{
|
||||
code: `Modules.promises.hooks.fire('setUserProperties', userId, { plan_type: 'free', last_active: 123 })`,
|
||||
},
|
||||
// Modules.hooks.fire with snake_case keys
|
||||
{
|
||||
code: `Modules.hooks.fire('setUserProperties', userId, { plan_type: 'free' })`,
|
||||
},
|
||||
// Single-word keys are valid snake_case
|
||||
{
|
||||
code: `CustomerIoHandler.updateUserAttributes(userId, { email: 'a@b.com', role: 'admin' })`,
|
||||
},
|
||||
// Computed/dynamic keys are skipped
|
||||
{
|
||||
code: `CustomerIoHandler.updateUserAttributes(userId, { [dynamicKey]: true })`,
|
||||
},
|
||||
// Spread elements are skipped
|
||||
{
|
||||
code: `CustomerIoHandler.updateUserAttributes(userId, { ...existingAttrs })`,
|
||||
},
|
||||
// Unrelated function calls are not checked
|
||||
{
|
||||
code: `SomeOtherHandler.updateUserAttributes(userId, { camelCase: true })`,
|
||||
},
|
||||
// fire() with a different event name is not checked
|
||||
{
|
||||
code: `Modules.promises.hooks.fire('someOtherEvent', userId, { camelCase: true })`,
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
// camelCase key in updateUserAttributes
|
||||
{
|
||||
code: `CustomerIoHandler.updateUserAttributes(userId, { planType: 'free' })`,
|
||||
errors: [
|
||||
{
|
||||
message: `Customer.io attribute 'planType' must be in snake_case.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
// kebab-case string key
|
||||
{
|
||||
code: `CustomerIoHandler.updateUserAttributes(userId, { 'plan-type': 'free' })`,
|
||||
errors: [
|
||||
{
|
||||
message: `Customer.io attribute 'plan-type' must be in snake_case.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
// PascalCase key
|
||||
{
|
||||
code: `CustomerIoHandler.updateUserAttributes(userId, { PlanType: 'free' })`,
|
||||
errors: [
|
||||
{
|
||||
message: `Customer.io attribute 'PlanType' must be in snake_case.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
// camelCase in Modules.promises.hooks.fire
|
||||
{
|
||||
code: `Modules.promises.hooks.fire('setUserProperties', userId, { planType: 'free' })`,
|
||||
errors: [
|
||||
{
|
||||
message: `Customer.io attribute 'planType' must be in snake_case.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
// camelCase in Modules.hooks.fire
|
||||
{
|
||||
code: `Modules.hooks.fire('setUserProperties', userId, { planType: 'free' })`,
|
||||
errors: [
|
||||
{
|
||||
message: `Customer.io attribute 'planType' must be in snake_case.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
// Multiple invalid keys report multiple errors
|
||||
{
|
||||
code: `CustomerIoHandler.updateUserAttributes(userId, { planType: 'free', groupSize: 10, plan_term: 'annual' })`,
|
||||
errors: [
|
||||
{
|
||||
message: `Customer.io attribute 'planType' must be in snake_case.`,
|
||||
},
|
||||
{
|
||||
message: `Customer.io attribute 'groupSize' must be in snake_case.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
const noThrowInCallbackMessage =
|
||||
'Pass the error to the callback instead of throwing in callback-based code.'
|
||||
ruleTester.run('no-throw-in-callback', noThrowInCallback, {
|
||||
valid: [
|
||||
// Calling the callback with an error is fine
|
||||
{ code: `function foo(cb) { cb(new Error()) }` },
|
||||
// async functions may throw (they return a rejected promise)
|
||||
{ code: `async function foo(cb) { throw new Error() }` },
|
||||
// Last param not a callback name — not a callback-style function
|
||||
{ code: `function foo(data) { throw new Error() }` },
|
||||
// No params at all
|
||||
{ code: `function foo() { throw new Error() }` },
|
||||
// throw inside a nested non-callback function is fine
|
||||
{ code: `function foo(cb) { [1].map(function() { throw new Error() }) }` },
|
||||
// throw inside a nested async arrow is fine
|
||||
{ code: `function foo(cb) { [1].map(async () => { throw new Error() }) }` },
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: `function foo(cb) { throw new Error() }`,
|
||||
errors: [{ message: noThrowInCallbackMessage }],
|
||||
},
|
||||
{
|
||||
code: `function foo(callback) { throw new Error() }`,
|
||||
errors: [{ message: noThrowInCallbackMessage }],
|
||||
},
|
||||
{
|
||||
code: `function foo(done) { throw new Error() }`,
|
||||
errors: [{ message: noThrowInCallbackMessage }],
|
||||
},
|
||||
{
|
||||
code: `function foo(next) { throw new Error() }`,
|
||||
errors: [{ message: noThrowInCallbackMessage }],
|
||||
},
|
||||
{
|
||||
code: `function foo(data, cb) { throw new Error() }`,
|
||||
errors: [{ message: noThrowInCallbackMessage }],
|
||||
},
|
||||
{
|
||||
code: `const foo = (cb) => { throw new Error() }`,
|
||||
errors: [{ message: noThrowInCallbackMessage }],
|
||||
},
|
||||
// throw in a nested callback-style function inside another callback function
|
||||
{
|
||||
code: `function foo(cb) { bar(function(done) { throw new Error() }) }`,
|
||||
errors: [{ message: noThrowInCallbackMessage }],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
jsonRuleTester.run(
|
||||
'no-consecutive-spaces-in-locales',
|
||||
noConsecutiveSpacesInLocales,
|
||||
{
|
||||
valid: [
|
||||
{ code: '{ "key": "one space" }' },
|
||||
{ code: '{ "key": "no whitespace" }' },
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: '{ "key": "two spaces" }',
|
||||
errors: [{ messageId: 'consecutiveSpaces' }],
|
||||
output: '{ "key": "two spaces" }',
|
||||
},
|
||||
{
|
||||
code: '{ "key": "three spaces" }',
|
||||
errors: [{ messageId: 'consecutiveSpaces' }],
|
||||
output: '{ "key": "three spaces" }',
|
||||
},
|
||||
{
|
||||
// \t then space → two consecutive whitespace chars
|
||||
code: '{ "key": "tab\\t and" }',
|
||||
errors: [{ messageId: 'consecutiveSpaces' }],
|
||||
output: '{ "key": "tab and" }',
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
jsonRuleTester.run(
|
||||
'no-straight-apostrophes-in-locales',
|
||||
noStraightApostrophesInLocales,
|
||||
{
|
||||
valid: [
|
||||
{ code: '{ "key": "no apostrophe" }' },
|
||||
{ code: '{ "key": "it’s curly" }' },
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: `{ "key": "it's straight" }`,
|
||||
errors: [{ messageId: 'straightApostrophe' }],
|
||||
output: '{ "key": "it’s straight" }',
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
jsonRuleTester.run('sorted-keys-in-locales', sortedKeysInLocales, {
|
||||
valid: [
|
||||
{ code: '{\n "a": "1",\n "b": "2"\n}\n' },
|
||||
{ code: '{}' },
|
||||
{ code: '{ "only": "one" }' },
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: '{\n "b": "2",\n "a": "1"\n}\n',
|
||||
errors: [{ messageId: 'unsorted' }],
|
||||
output: '{\n "a": "1",\n "b": "2"\n}\n',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
jsonRuleTester.run('french-typography-in-locales', frenchTypographyInLocales, {
|
||||
valid: [
|
||||
{ code: '{ "key": "Bonjour ?" }' },
|
||||
{ code: '{ "key": "Liste :" }' },
|
||||
{ code: '{ "key": "« contenu »" }' },
|
||||
{ code: '{ "key": "abc 123" }' },
|
||||
{ code: '{ "key": "10 %" }' },
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: '{ "key": "Bonjour?" }',
|
||||
errors: 1,
|
||||
output: '{ "key": "Bonjour ?" }',
|
||||
},
|
||||
{
|
||||
code: '{ "key": "Bonjour ?" }',
|
||||
errors: 1,
|
||||
output: '{ "key": "Bonjour ?" }',
|
||||
},
|
||||
{
|
||||
code: '{ "key": "Liste:" }',
|
||||
errors: 1,
|
||||
output: '{ "key": "Liste :" }',
|
||||
},
|
||||
{
|
||||
code: '{ "key": "«contenu»" }',
|
||||
errors: 2,
|
||||
output: '{ "key": "« contenu »" }',
|
||||
},
|
||||
{
|
||||
code: '{ "key": "10%" }',
|
||||
errors: 1,
|
||||
output: '{ "key": "10 %" }',
|
||||
},
|
||||
{
|
||||
code: '{ "key": "sûr(e)" }',
|
||||
errors: [
|
||||
{
|
||||
message:
|
||||
'expected point médian "·" instead of "(e)" for inclusive writing',
|
||||
suggestions: [
|
||||
{
|
||||
desc: 'Replace "(e)" with "·e"',
|
||||
output: '{ "key": "sûr·e" }',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -1,60 +0,0 @@
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
fixable: 'code',
|
||||
docs: {
|
||||
description: 'Ensure that Trans with values has shouldUnescape',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
'JSXOpeningElement[name.name="Trans"]'(node) {
|
||||
const attributes = new Map(
|
||||
node.attributes.map(attr => [attr.name.name, attr])
|
||||
)
|
||||
|
||||
if (attributes.has('values') && !attributes.has('shouldUnescape')) {
|
||||
context.report({
|
||||
node,
|
||||
message: 'Trans with values must have shouldUnescape',
|
||||
fix(fixer) {
|
||||
return fixer.insertTextAfter(
|
||||
attributes.get('values'),
|
||||
'\nshouldUnescape' // Note: Prettier can fix indentation
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (attributes.has('values') && attributes.has('shouldUnescape')) {
|
||||
const tOptions = attributes.get('tOptions')
|
||||
if (!tOptions) {
|
||||
context.report({
|
||||
node,
|
||||
message:
|
||||
'Trans with shouldUnescape must have tOptions.interpolation.escapeValue',
|
||||
fix(fixer) {
|
||||
return fixer.insertTextAfter(
|
||||
attributes.get('shouldUnescape'),
|
||||
'\ntOptions={{ interpolation: { escapeValue: true } }}' // Note: Prettier can fix indentation
|
||||
)
|
||||
},
|
||||
})
|
||||
} else {
|
||||
const property = tOptions.value.expression.properties
|
||||
.find(p => p.key.name === 'interpolation')
|
||||
?.value.properties.find(p => p.key.name === 'escapeValue')
|
||||
|
||||
if (property?.value.value !== true) {
|
||||
context.report({
|
||||
node,
|
||||
message:
|
||||
'Trans with shouldUnescape must have tOptions.interpolation.escapeValue set to true',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
// Enforce alphabetically sorted top-level keys in JSON files.
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
fixable: 'code',
|
||||
docs: {
|
||||
description:
|
||||
'Require top-level keys to be sorted alphabetically in JSON files (typically locale files).',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
unsorted:
|
||||
'Top-level keys are not sorted alphabetically. Key "{{key}}" should come before "{{predecessor}}".',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
'Document > Object'(node) {
|
||||
const members = node.members
|
||||
for (let i = 1; i < members.length; i++) {
|
||||
const prev = members[i - 1].name.value
|
||||
const curr = members[i].name.value
|
||||
if (curr < prev) {
|
||||
const text = context.sourceCode.text
|
||||
const parsed = JSON.parse(text)
|
||||
const sortedKeys = Object.keys(parsed).sort()
|
||||
const sorted = JSON.stringify(parsed, sortedKeys, 2) + '\n'
|
||||
context.report({
|
||||
node: members[i],
|
||||
messageId: 'unsorted',
|
||||
data: { key: curr, predecessor: prev },
|
||||
fix(fixer) {
|
||||
return fixer.replaceTextRange([0, text.length], sorted)
|
||||
},
|
||||
})
|
||||
return // one violation per file is enough; the fix re-sorts everything
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.backend.json",
|
||||
"include": ["**/*.js"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -0,0 +1,3 @@
|
||||
|
||||
# managed by monorepo$ bin/update_build_scripts
|
||||
.npmrc
|
||||
@@ -1,13 +0,0 @@
|
||||
let reporterOptions = {}
|
||||
if (process.env.CI) {
|
||||
reporterOptions = {
|
||||
reporter: require.resolve('mocha-multi-reporters'),
|
||||
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
||||
}
|
||||
}
|
||||
const all = {
|
||||
require: 'test/setup.js',
|
||||
...reporterOptions,
|
||||
}
|
||||
|
||||
module.exports = all
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ui": "bdd",
|
||||
"recursive": "true",
|
||||
"reporter": "spec",
|
||||
"require": "test/setup.js"
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
24.14.1
|
||||
20.18.0
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
fetch-utils
|
||||
--dependencies=None
|
||||
--docker-repos=gcr.io/overleaf-ops
|
||||
--env-add=
|
||||
--env-pass-through=
|
||||
--esmock-loader=False
|
||||
--is-library=True
|
||||
--node-version=24.14.1
|
||||
--package-name=@overleaf/fetch-utils
|
||||
--pipeline-owner=32
|
||||
--node-version=20.18.0
|
||||
--public-repo=False
|
||||
--script-version=4.5.0
|
||||
|
||||
@@ -5,12 +5,6 @@ const fetch = require('node-fetch')
|
||||
const http = require('node:http')
|
||||
const https = require('node:https')
|
||||
|
||||
let logger
|
||||
|
||||
function setLogger(loggerInstance) {
|
||||
logger = loggerInstance
|
||||
}
|
||||
|
||||
/**
|
||||
* @import { Response } from 'node-fetch'
|
||||
*/
|
||||
@@ -29,11 +23,11 @@ async function fetchJson(url, opts = {}) {
|
||||
}
|
||||
|
||||
async function fetchJsonWithResponse(url, opts = {}) {
|
||||
const { fetchOpts, detachSignal } = parseOpts(opts, url)
|
||||
const { fetchOpts } = parseOpts(opts)
|
||||
fetchOpts.headers = fetchOpts.headers ?? {}
|
||||
fetchOpts.headers.Accept = fetchOpts.headers.Accept ?? 'application/json'
|
||||
|
||||
const response = await performRequest(url, fetchOpts, detachSignal)
|
||||
const response = await performRequest(url, fetchOpts)
|
||||
if (!response.ok) {
|
||||
const body = await maybeGetResponseBody(response)
|
||||
throw new RequestFailedError(url, opts, response, body)
|
||||
@@ -59,8 +53,8 @@ async function fetchStream(url, opts = {}) {
|
||||
}
|
||||
|
||||
async function fetchStreamWithResponse(url, opts = {}) {
|
||||
const { fetchOpts, abortController, detachSignal } = parseOpts(opts, url)
|
||||
const response = await performRequest(url, fetchOpts, detachSignal)
|
||||
const { fetchOpts, abortController } = parseOpts(opts)
|
||||
const response = await performRequest(url, fetchOpts)
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await maybeGetResponseBody(response)
|
||||
@@ -82,8 +76,8 @@ async function fetchStreamWithResponse(url, opts = {}) {
|
||||
* @throws {RequestFailedError} if the response has a failure status code
|
||||
*/
|
||||
async function fetchNothing(url, opts = {}) {
|
||||
const { fetchOpts, detachSignal } = parseOpts(opts, url)
|
||||
const response = await performRequest(url, fetchOpts, detachSignal)
|
||||
const { fetchOpts } = parseOpts(opts)
|
||||
const response = await performRequest(url, fetchOpts)
|
||||
if (!response.ok) {
|
||||
const body = await maybeGetResponseBody(response)
|
||||
throw new RequestFailedError(url, opts, response, body)
|
||||
@@ -101,22 +95,9 @@ async function fetchNothing(url, opts = {}) {
|
||||
* @throws {RequestFailedError} if the response has a non redirect status code or missing Location header
|
||||
*/
|
||||
async function fetchRedirect(url, opts = {}) {
|
||||
const { location } = await fetchRedirectWithResponse(url, opts)
|
||||
return location
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a request and extract the redirect from the response.
|
||||
*
|
||||
* @param {string | URL} url - request URL
|
||||
* @param {object} opts - fetch options
|
||||
* @return {Promise<{location: string, response: Response}>}
|
||||
* @throws {RequestFailedError} if the response has a non redirect status code or missing Location header
|
||||
*/
|
||||
async function fetchRedirectWithResponse(url, opts = {}) {
|
||||
const { fetchOpts, detachSignal } = parseOpts(opts, url)
|
||||
const { fetchOpts } = parseOpts(opts)
|
||||
fetchOpts.redirect = 'manual'
|
||||
const response = await performRequest(url, fetchOpts, detachSignal)
|
||||
const response = await performRequest(url, fetchOpts)
|
||||
if (response.status < 300 || response.status >= 400) {
|
||||
const body = await maybeGetResponseBody(response)
|
||||
throw new RequestFailedError(url, opts, response, body)
|
||||
@@ -131,7 +112,7 @@ async function fetchRedirectWithResponse(url, opts = {}) {
|
||||
)
|
||||
}
|
||||
await discardResponseBody(response)
|
||||
return { location, response }
|
||||
return location
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -148,8 +129,8 @@ async function fetchString(url, opts = {}) {
|
||||
}
|
||||
|
||||
async function fetchStringWithResponse(url, opts = {}) {
|
||||
const { fetchOpts, detachSignal } = parseOpts(opts, url)
|
||||
const response = await performRequest(url, fetchOpts, detachSignal)
|
||||
const { fetchOpts } = parseOpts(opts)
|
||||
const response = await performRequest(url, fetchOpts)
|
||||
if (!response.ok) {
|
||||
const body = await maybeGetResponseBody(response)
|
||||
throw new RequestFailedError(url, opts, response, body)
|
||||
@@ -173,7 +154,7 @@ class RequestFailedError extends OError {
|
||||
}
|
||||
}
|
||||
|
||||
function parseOpts(opts, url) {
|
||||
function parseOpts(opts) {
|
||||
const fetchOpts = _.omit(opts, ['json', 'signal', 'basicAuth'])
|
||||
if (opts.json) {
|
||||
setupJsonBody(fetchOpts, opts.json)
|
||||
@@ -184,35 +165,13 @@ function parseOpts(opts, url) {
|
||||
|
||||
const abortController = new AbortController()
|
||||
fetchOpts.signal = abortController.signal
|
||||
let detachSignal
|
||||
if (opts.signal) {
|
||||
detachSignal = abortOnSignal(abortController, opts.signal)
|
||||
} else {
|
||||
let overTimeoutStart
|
||||
const stack = new Error().stack
|
||||
const timeout = setTimeout(() => {
|
||||
overTimeoutStart = process.hrtime.bigint()
|
||||
}, 120000)
|
||||
detachSignal = () => {
|
||||
clearTimeout(timeout)
|
||||
if (overTimeoutStart && logger) {
|
||||
logger.warn(
|
||||
{
|
||||
url,
|
||||
method: opts.method ?? 'GET',
|
||||
overTimeoutMs:
|
||||
Number(process.hrtime.bigint() - overTimeoutStart) / 1e6,
|
||||
stack,
|
||||
},
|
||||
'Fetch request did not complete within 120 seconds'
|
||||
)
|
||||
}
|
||||
}
|
||||
abortOnSignal(abortController, opts.signal)
|
||||
}
|
||||
if (opts.body instanceof Readable) {
|
||||
abortOnDestroyedRequest(abortController, fetchOpts.body)
|
||||
}
|
||||
return { fetchOpts, abortController, detachSignal }
|
||||
return { fetchOpts, abortController }
|
||||
}
|
||||
|
||||
function setupJsonBody(fetchOpts, json) {
|
||||
@@ -236,9 +195,6 @@ function abortOnSignal(abortController, signal) {
|
||||
abortController.abort(signal.reason)
|
||||
}
|
||||
signal.addEventListener('abort', listener)
|
||||
return () => {
|
||||
signal.removeEventListener('abort', listener)
|
||||
}
|
||||
}
|
||||
|
||||
function abortOnDestroyedRequest(abortController, stream) {
|
||||
@@ -257,12 +213,11 @@ function abortOnDestroyedResponse(abortController, response) {
|
||||
})
|
||||
}
|
||||
|
||||
async function performRequest(url, fetchOpts, detachSignal) {
|
||||
async function performRequest(url, fetchOpts) {
|
||||
let response
|
||||
try {
|
||||
response = await fetch(url, fetchOpts)
|
||||
} catch (err) {
|
||||
detachSignal()
|
||||
if (fetchOpts.body instanceof Readable) {
|
||||
fetchOpts.body.destroy()
|
||||
}
|
||||
@@ -271,7 +226,6 @@ async function performRequest(url, fetchOpts, detachSignal) {
|
||||
method: fetchOpts.method ?? 'GET',
|
||||
})
|
||||
}
|
||||
response.body.on('close', detachSignal)
|
||||
if (fetchOpts.body instanceof Readable) {
|
||||
response.body.on('close', () => {
|
||||
if (!fetchOpts.body.readableEnded) {
|
||||
@@ -343,12 +297,10 @@ module.exports = {
|
||||
fetchStreamWithResponse,
|
||||
fetchNothing,
|
||||
fetchRedirect,
|
||||
fetchRedirectWithResponse,
|
||||
fetchString,
|
||||
fetchStringWithResponse,
|
||||
RequestFailedError,
|
||||
ConnectTimeoutError,
|
||||
CustomHttpAgent,
|
||||
CustomHttpsAgent,
|
||||
setLogger,
|
||||
}
|
||||
|
||||
@@ -4,30 +4,30 @@
|
||||
"description": "utilities for node-fetch",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "yarn run lint && yarn run types:check && yarn run test:unit",
|
||||
"lint": "eslint --cache --cache-location ../../.cache/eslint/ --ext .cjs,.js,.jsx,.mjs,.ts --max-warnings 0 --format unix .",
|
||||
"lint:fix": "eslint --cache --cache-location ../../.cache/eslint/ --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
|
||||
"test:ci": "yarn run test:unit",
|
||||
"test": "npm run lint && npm run format && npm run types:check && npm run test:unit",
|
||||
"lint": "eslint --ext .js --ext .cjs --ext .ts --max-warnings 0 --format unix .",
|
||||
"lint:fix": "eslint --fix --ext .js --ext .cjs --ext .ts .",
|
||||
"format": "prettier --list-different $PWD/'**/*.{js,cjs,ts}'",
|
||||
"format:fix": "prettier --write $PWD/'**/*.{js,cjs,ts}'",
|
||||
"test:ci": "npm run test:unit",
|
||||
"test:unit": "mocha --exit test/**/*.{js,cjs}",
|
||||
"types:check": "tsc --noEmit"
|
||||
},
|
||||
"author": "Overleaf (https://www.overleaf.com)",
|
||||
"license": "AGPL-3.0-only",
|
||||
"devDependencies": {
|
||||
"@types/node-fetch": "^2.6.13",
|
||||
"body-parser": "1.20.4",
|
||||
"@types/node-fetch": "^2.6.11",
|
||||
"body-parser": "^1.20.3",
|
||||
"chai": "^4.3.6",
|
||||
"chai-as-promised": "^7.1.1",
|
||||
"express": "4.22.1",
|
||||
"mocha": "^11.1.0",
|
||||
"mocha-junit-reporter": "^2.2.1",
|
||||
"mocha-multi-reporters": "^1.5.1",
|
||||
"selfsigned": "^5.5.0",
|
||||
"express": "^4.21.0",
|
||||
"mocha": "^10.2.0",
|
||||
"typescript": "^5.0.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@overleaf/o-error": "workspace:*",
|
||||
"lodash": "^4.18.1",
|
||||
"node-fetch": "^2.7.0"
|
||||
"@overleaf/o-error": "*",
|
||||
"lodash": "^4.17.21",
|
||||
"node-fetch": "^2.7.0",
|
||||
"selfsigned": "^2.4.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
module.exports = {
|
||||
reporterEnabled: 'spec, mocha-junit-reporter',
|
||||
mochaJunitReporterReporterOptions: {
|
||||
mochaFile: `reports/junit-mocha-${process.env.MOCHA_GREP}.xml`,
|
||||
includePending: true,
|
||||
jenkinsMode: true,
|
||||
output: true,
|
||||
},
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
const { expect } = require('chai')
|
||||
const fs = require('node:fs')
|
||||
const events = require('node:events')
|
||||
const { FetchError, AbortError } = require('node-fetch')
|
||||
const { Readable } = require('node:stream')
|
||||
const { pipeline } = require('node:stream/promises')
|
||||
const { once } = require('node:events')
|
||||
const { TestServer } = require('./helpers/TestServer')
|
||||
const selfsigned = require('selfsigned')
|
||||
@@ -21,6 +18,12 @@ const {
|
||||
const HTTP_PORT = 30001
|
||||
const HTTPS_PORT = 30002
|
||||
|
||||
const attrs = [{ name: 'commonName', value: 'example.com' }]
|
||||
const pems = selfsigned.generate(attrs, { days: 365 })
|
||||
|
||||
const PRIVATE_KEY = pems.private
|
||||
const PUBLIC_CERT = pems.cert
|
||||
|
||||
const dns = require('node:dns')
|
||||
const _originalLookup = dns.lookup
|
||||
// Custom DNS resolver function
|
||||
@@ -39,15 +42,8 @@ dns.lookup = (hostname, options, callback) => {
|
||||
}
|
||||
|
||||
describe('fetch-utils', function () {
|
||||
let PUBLIC_CERT
|
||||
|
||||
before(async function () {
|
||||
this.server = new TestServer()
|
||||
const attrs = [{ name: 'commonName', value: 'example.com' }]
|
||||
const pems = await selfsigned.generate(attrs, { days: 365, keySize: 2048 })
|
||||
|
||||
const PRIVATE_KEY = pems.private
|
||||
PUBLIC_CERT = pems.cert
|
||||
await this.server.start(HTTP_PORT, HTTPS_PORT, {
|
||||
key: PRIVATE_KEY,
|
||||
cert: PUBLIC_CERT,
|
||||
@@ -207,31 +203,6 @@ describe('fetch-utils', function () {
|
||||
).to.be.rejectedWith(AbortError)
|
||||
expect(stream.destroyed).to.be.true
|
||||
})
|
||||
|
||||
it('detaches from signal on success', async function () {
|
||||
const signal = AbortSignal.timeout(10_000)
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const s = await fetchStream(this.url('/hello'), { signal })
|
||||
expect(events.getEventListeners(signal, 'abort')).to.have.length(1)
|
||||
await pipeline(s, fs.createWriteStream('/dev/null'))
|
||||
expect(events.getEventListeners(signal, 'abort')).to.have.length(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('detaches from signal on error', async function () {
|
||||
const signal = AbortSignal.timeout(10_000)
|
||||
for (let i = 0; i < 20; i++) {
|
||||
try {
|
||||
await fetchStream(this.url('/500'), { signal })
|
||||
} catch (err) {
|
||||
if (err instanceof RequestFailedError && err.response.status === 500)
|
||||
continue
|
||||
throw err
|
||||
} finally {
|
||||
expect(events.getEventListeners(signal, 'abort')).to.have.length(0)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchNothing', function () {
|
||||
@@ -420,16 +391,9 @@ async function* infiniteIterator() {
|
||||
async function abortOnceReceived(func, server) {
|
||||
const controller = new AbortController()
|
||||
const promise = func(controller.signal)
|
||||
expect(events.getEventListeners(controller.signal, 'abort')).to.have.length(1)
|
||||
await once(server.events, 'request-received')
|
||||
controller.abort()
|
||||
try {
|
||||
return await promise
|
||||
} finally {
|
||||
expect(events.getEventListeners(controller.signal, 'abort')).to.have.length(
|
||||
0
|
||||
)
|
||||
}
|
||||
return await promise
|
||||
}
|
||||
|
||||
async function expectRequestAborted(req) {
|
||||
|
||||
@@ -119,20 +119,11 @@ class TestServer {
|
||||
}
|
||||
|
||||
stop() {
|
||||
const promises = []
|
||||
if (this.server) {
|
||||
const stopHttp = promisify(this.server.close).bind(this.server)
|
||||
this.server.closeAllConnections()
|
||||
promises.push(stopHttp())
|
||||
}
|
||||
if (this.https_server) {
|
||||
const stopHttps = promisify(this.https_server.close).bind(
|
||||
this.https_server
|
||||
)
|
||||
this.https_server.closeAllConnections()
|
||||
promises.push(stopHttps())
|
||||
}
|
||||
return Promise.all(promises)
|
||||
const stopHttp = promisify(this.server.close).bind(this.server)
|
||||
const stopHttps = promisify(this.https_server.close).bind(this.https_server)
|
||||
this.server.closeAllConnections()
|
||||
this.https_server.closeAllConnections()
|
||||
return Promise.all([stopHttp(), stopHttps()])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.backend.json",
|
||||
"include": ["**/*.js", "**/*.cjs", "**/*.ts"]
|
||||
"include": [
|
||||
"**/*.js",
|
||||
"**/*.cjs"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
|
||||
.npmrc
|
||||
@@ -1,13 +0,0 @@
|
||||
let reporterOptions = {}
|
||||
if (process.env.CI) {
|
||||
reporterOptions = {
|
||||
reporter: require.resolve('mocha-multi-reporters'),
|
||||
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
||||
}
|
||||
}
|
||||
const all = {
|
||||
require: 'test/setup.js',
|
||||
...reporterOptions,
|
||||
}
|
||||
|
||||
module.exports = all
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"require": "test/setup.js"
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
24.14.1
|
||||
20.18.0
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
## v3.1.1
|
||||
|
||||
- Handle malformed requests in the req serializer
|
||||
* Handle malformed requests in the req serializer
|
||||
|
||||
## v3.0.0
|
||||
|
||||
- Improve logging in Google Cloud Platform. Set environment variable `GCP_LOGGING=true` to enable.
|
||||
* Improve logging in Google Cloud Platform. Set environment variable `GCP_LOGGING=true` to enable.
|
||||
|
||||
This version of the metrics module only works with versions of the `@overleaf/metrics` module greater than v4.0.0
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
logger
|
||||
--dependencies=None
|
||||
--docker-repos=gcr.io/overleaf-ops
|
||||
--env-add=
|
||||
--env-pass-through=
|
||||
--esmock-loader=False
|
||||
--is-library=True
|
||||
--node-version=24.14.1
|
||||
--package-name=@overleaf/logger
|
||||
--pipeline-owner=32
|
||||
--node-version=20.18.0
|
||||
--public-repo=False
|
||||
--script-version=4.5.0
|
||||
|
||||
@@ -6,19 +6,16 @@ const {
|
||||
FileLogLevelChecker,
|
||||
GCEMetadataLogLevelChecker,
|
||||
} = require('./log-level-checker')
|
||||
const { setLogger } = require('@overleaf/fetch-utils')
|
||||
|
||||
const LoggingManager = {
|
||||
/**
|
||||
* @param {string} name - The name of the logger
|
||||
*/
|
||||
initialize(name, options = {}) {
|
||||
initialize(name) {
|
||||
this.isProduction =
|
||||
(process.env.NODE_ENV || '').toLowerCase() === 'production'
|
||||
const isTest = (process.env.NODE_ENV || '').toLowerCase() === 'test'
|
||||
this.defaultLevel =
|
||||
process.env.LOG_LEVEL ||
|
||||
(this.isProduction ? 'info' : isTest ? 'fatal' : 'debug')
|
||||
process.env.LOG_LEVEL || (this.isProduction ? 'info' : 'debug')
|
||||
this.loggerName = name
|
||||
this.logger = bunyan.createLogger({
|
||||
name,
|
||||
@@ -28,11 +25,10 @@ const LoggingManager = {
|
||||
req: Serializers.req,
|
||||
res: Serializers.res,
|
||||
},
|
||||
streams: options.streams ?? [this._getOutputStreamConfig()],
|
||||
streams: [this._getOutputStreamConfig()],
|
||||
})
|
||||
this._setupRingBuffer()
|
||||
this._setupLogLevelChecker()
|
||||
setLogger(this)
|
||||
return this
|
||||
},
|
||||
|
||||
|
||||
@@ -10,30 +10,30 @@
|
||||
"license": "AGPL-3.0-only",
|
||||
"version": "3.1.1",
|
||||
"scripts": {
|
||||
"test": "yarn run lint && yarn run types:check && yarn run test:unit",
|
||||
"lint": "eslint --cache --cache-location ../../.cache/eslint/ --ext .cjs,.js,.jsx,.mjs,.ts --max-warnings 0 --format unix .",
|
||||
"lint:fix": "eslint --cache --cache-location ../../.cache/eslint/ --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
|
||||
"test:ci": "yarn run test:unit",
|
||||
"test": "npm run lint && npm run format && npm run types:check && npm run test:unit",
|
||||
"format": "prettier --list-different $PWD/'**/*.{js,cjs,ts}'",
|
||||
"format:fix": "prettier --write $PWD/'**/*.{js,cjs,ts}'",
|
||||
"lint": "eslint --ext .js --ext .cjs --ext .ts --max-warnings 0 --format unix .",
|
||||
"lint:fix": "eslint --fix --ext .js --ext .cjs --ext .ts .",
|
||||
"test:ci": "npm run test:unit",
|
||||
"test:unit": "mocha --exit test/**/*.{js,cjs}",
|
||||
"types:check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google-cloud/logging-bunyan": "^5.1.0",
|
||||
"@overleaf/fetch-utils": "workspace:*",
|
||||
"@overleaf/o-error": "workspace:*",
|
||||
"@overleaf/fetch-utils": "*",
|
||||
"@overleaf/o-error": "*",
|
||||
"bunyan": "^1.8.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"chai": "^4.3.6",
|
||||
"mocha": "^11.1.0",
|
||||
"mocha-junit-reporter": "^2.2.1",
|
||||
"mocha-multi-reporters": "^1.5.1",
|
||||
"mocha": "^10.2.0",
|
||||
"sandboxed-module": "^2.0.4",
|
||||
"sinon": "^9.2.4",
|
||||
"sinon-chai": "^3.7.0",
|
||||
"typescript": "^5.0.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@overleaf/metrics": "workspace:*"
|
||||
"@overleaf/metrics": "*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,9 +80,4 @@ function getRemoteIp(req) {
|
||||
return null
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
err: errSerializer,
|
||||
error: errSerializer,
|
||||
req: reqSerializer,
|
||||
res: resSerializer,
|
||||
}
|
||||
module.exports = { err: errSerializer, req: reqSerializer, res: resSerializer }
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
const logger = require('./')
|
||||
const bunyan = require('bunyan')
|
||||
const serializers = require('./serializers')
|
||||
|
||||
function testLogRecorder() {
|
||||
const currentTest = this.currentTest
|
||||
for (const level of ['error', 'fatal']) {
|
||||
logger[level] = (info, msg) => {
|
||||
const entry = { level, ...info, msg }
|
||||
for (const [name, fn] of Object.entries(serializers)) {
|
||||
if (name in entry) entry[name] = fn(entry[name])
|
||||
}
|
||||
currentTest.consoleErrors = (currentTest.consoleErrors || []).concat(
|
||||
JSON.stringify(entry, bunyan.safeCycles())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = testLogRecorder
|
||||
@@ -1,9 +0,0 @@
|
||||
module.exports = {
|
||||
reporterEnabled: 'spec, mocha-junit-reporter',
|
||||
mochaJunitReporterReporterOptions: {
|
||||
mochaFile: `reports/junit-mocha-${process.env.MOCHA_GREP}.xml`,
|
||||
includePending: true,
|
||||
jenkinsMode: true,
|
||||
output: true,
|
||||
},
|
||||
}
|
||||
@@ -43,13 +43,9 @@ describe('LoggingManager', function () {
|
||||
.stub()
|
||||
.returns(this.GCEMetadataLogLevelChecker),
|
||||
}
|
||||
this.fetchUtils = {
|
||||
setLogger: sinon.stub(),
|
||||
}
|
||||
this.LoggingManager = SandboxedModule.require(MODULE_PATH, {
|
||||
requires: {
|
||||
bunyan: this.Bunyan,
|
||||
'@overleaf/fetch-utils': this.fetchUtils,
|
||||
'./log-level-checker': this.LogLevelChecker,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.backend.json",
|
||||
"include": ["**/*.js", "**/*.cjs", "**/*.ts"]
|
||||
"include": [
|
||||
"**/*.js",
|
||||
"**/*.cjs"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
|
||||
.npmrc
|
||||
@@ -1,13 +0,0 @@
|
||||
let reporterOptions = {}
|
||||
if (process.env.CI) {
|
||||
reporterOptions = {
|
||||
reporter: require.resolve('mocha-multi-reporters'),
|
||||
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
||||
}
|
||||
}
|
||||
const all = {
|
||||
require: 'test/setup.js',
|
||||
...reporterOptions,
|
||||
}
|
||||
|
||||
module.exports = all
|
||||
@@ -1 +1 @@
|
||||
24.14.1
|
||||
20.18.0
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user